From 303bffe413b6f0f6997d59797a6a5f0c5c3f976f Mon Sep 17 00:00:00 2001 From: bridge Date: Mon, 8 Dec 2025 22:08:53 +0800 Subject: [PATCH] decide only one avatar each time --- src/classes/ai.py | 39 +++++++++++++++++--------- src/classes/avatar.py | 26 +++++++++++++++++ src/classes/long_term_objective.py | 4 +-- src/classes/map.py | 8 ++++-- src/classes/world.py | 5 ++-- src/sim/load/avatar_load_mixin.py | 1 + src/sim/save/avatar_save_mixin.py | 1 + static/templates/ai.txt | 6 ++-- web/src/components/game/CloudLayer.vue | 36 ++++++++++++++++++------ 9 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/classes/ai.py b/src/classes/ai.py index 331820b..0b03f13 100644 --- a/src/classes/ai.py +++ b/src/classes/ai.py @@ -66,22 +66,33 @@ class LLMAI(AI): async def _decide(self, world: World, avatars_to_decide: list[Avatar]) -> dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]]: """ 异步决策逻辑:通过LLM决定执行什么动作和参数 + 改动:支持每个角色仅获取其已知区域的世界信息,并发调用 LLM。 """ - world_info = world.get_info() - # 在提示中包含处于角色观测范围内的其他角色 - avatar_infos = {} - for avatar in avatars_to_decide: - observed = world.get_observable_avatars(avatar) - avatar_infos[avatar.name] = avatar.get_expanded_info(observed) general_action_infos = ACTION_INFOS_STR - info = { - "avatar_infos": avatar_infos, - "world_info": world_info, - "general_action_infos": general_action_infos, - } - res = await call_ai_action(info) + async def decide_one(avatar: Avatar): + # 获取基于该角色已知区域的世界信息 + world_info = world.get_info(known_region_ids=avatar.known_regions) + + # 在提示中包含处于角色观测范围内的其他角色 + observed = world.get_observable_avatars(avatar) + avatar_info = avatar.get_expanded_info(co_region_avatars=observed) + + info = { + "avatar_info": avatar_info, + "world_info": world_info, + "general_action_infos": general_action_infos, + } + res = await call_ai_action(info) + return avatar, res + + tasks = [decide_one(avatar) for avatar in avatars_to_decide] + results_list = await asyncio.gather(*tasks) + results: dict[Avatar, tuple[ACTION_NAME_PARAMS_PAIRS, str, str]] = {} - for avatar in avatars_to_decide: + for avatar, res in results_list: + if not res or avatar.name not in res: + continue + r = res[avatar.name] # 仅接受 action_name_params_pairs,不再支持单个 action_name/action_params raw_pairs = r["action_name_params_pairs"] @@ -94,6 +105,7 @@ class LLMAI(AI): else: # 跳过无法解析的项 continue + # 至少有一个 if not pairs: raise ValueError(f"LLM未返回有效的action_name_params_pairs: {r}") @@ -101,6 +113,7 @@ class LLMAI(AI): avatar_thinking = r.get("avatar_thinking", r.get("thinking", "")) short_term_objective = r.get("short_term_objective", "") results[avatar] = (pairs, avatar_thinking, short_term_objective) + return results llm_ai = LLMAI() \ No newline at end of file diff --git a/src/classes/avatar.py b/src/classes/avatar.py index 491f2a9..7047f35 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -125,6 +125,9 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): # 动作冷却:记录动作类名 -> 上次完成月戳 _action_cd_last_months: dict[str, int] = field(default_factory=dict) # 不缓存 effects;实时从宗门与功法合并 + + # 知道的区域 ID 集合 + known_regions: set[int] = field(default_factory=set) def join_sect(self, sect: Sect, rank: "SectRank") -> None: """加入宗门""" @@ -216,6 +219,24 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): # 初始化时计算所有长期效果(HP等) self.recalc_effects() + # 初始化已知区域 + self._init_known_regions() + + def _init_known_regions(self): + """初始化已知区域:当前位置 + 宗门驻地""" + # 1. 当前位置 + if self.tile and self.tile.region: + self.known_regions.add(self.tile.region.id) + + # 2. 宗门驻地 + if self.sect: + # 遍历地图寻找该宗门的驻地 + # map.sect_regions 是 {region_id: SectRegion} + for r in self.world.map.sect_regions.values(): + if r.sect_id == self.sect.id: + self.known_regions.add(r.id) + break + @property def effects(self) -> dict[str, object]: merged: dict[str, object] = defaultdict(str) @@ -571,6 +592,11 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): if self.current_action is None: # 当前无动作时才清除标记,避免清除新提交动作的标记 self._new_action_set_this_step = False + + # 每次执行动作后,更新已知区域 + if self.tile and self.tile.region: + self.known_regions.add(self.tile.region.id) + return events def update_cultivation(self, new_level: int): diff --git a/src/classes/long_term_objective.py b/src/classes/long_term_objective.py index 7ae490c..88f4514 100644 --- a/src/classes/long_term_objective.py +++ b/src/classes/long_term_objective.py @@ -78,8 +78,8 @@ async def generate_long_term_objective(avatar: "Avatar") -> Optional[LongTermObj Returns: 生成的LongTermObjective对象,失败则返回None """ - # 准备世界信息 - world_info = avatar.world.get_info() + # 准备世界信息(仅获取已知区域) + world_info = avatar.world.get_info(known_region_ids=avatar.known_regions) # 获取 expanded_info(包含详细信息和事件历史) expanded_info = avatar.get_expanded_info(detailed=True) diff --git a/src/classes/map.py b/src/classes/map.py index 1375b69..681a819 100644 --- a/src/classes/map.py +++ b/src/classes/map.py @@ -80,9 +80,10 @@ class Map(): """ return self.tiles[(x, y)].region - def get_info(self, detailed: bool = False) -> dict: + def get_info(self, detailed: bool = False, known_region_ids: Optional[set[int]] = None) -> dict: """ 返回地图信息(dict)。 + known_region_ids: 如果提供,仅返回这些ID对应的区域信息。 """ # 动态分类(因为现在没有自动分类字典了) # 或者我们简单点,不分类返回,只返回总览? @@ -91,7 +92,10 @@ class Map(): from src.classes.region import NormalRegion, CultivateRegion, CityRegion def filter_regions(cls): - return {rid: r for rid, r in self.regions.items() if isinstance(r, cls)} + return { + rid: r for rid, r in self.regions.items() + if rid in known_region_ids + } def build_regions_info(regions_dict) -> list[str]: if detailed: diff --git a/src/classes/world.py b/src/classes/world.py index 2c0ca20..f1ad27e 100644 --- a/src/classes/world.py +++ b/src/classes/world.py @@ -23,12 +23,13 @@ class World(): # 天地灵机开始年份(用于计算持续时间) phenomenon_start_year: int = 0 - def get_info(self, detailed: bool = False) -> dict: + def get_info(self, detailed: bool = False, known_region_ids: Optional[set[int]] = None) -> dict: """ 返回世界信息(dict),其中包含地图信息(dict)。 + 如果指定了 known_region_ids,则只返回这些 ID 对应的区域信息。 """ static_info = self.static_info - map_info = self.map.get_info(detailed=detailed) + map_info = self.map.get_info(detailed=detailed, known_region_ids=known_region_ids) world_info = {**map_info, **static_info} if self.current_phenomenon: diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index 6b2ef60..3b27669 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -157,6 +157,7 @@ class AvatarLoadMixin: avatar.thinking = data.get("thinking", "") avatar.short_term_objective = data.get("short_term_objective", "") avatar._action_cd_last_months = data.get("_action_cd_last_months", {}) + avatar.known_regions = set(data.get("known_regions", [])) # 加载长期目标 long_term_objective_data = data.get("long_term_objective") diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py index dbc7914..5c00512 100644 --- a/src/sim/save/avatar_save_mixin.py +++ b/src/sim/save/avatar_save_mixin.py @@ -104,5 +104,6 @@ class AvatarSaveMixin: "set_year": self.long_term_objective.set_year } if self.long_term_objective else None, "_action_cd_last_months": self._action_cd_last_months, + "known_regions": list(self.known_regions), } diff --git a/static/templates/ai.txt b/static/templates/ai.txt index 6790ce4..692757d 100644 --- a/static/templates/ai.txt +++ b/static/templates/ai.txt @@ -1,9 +1,9 @@ -你是一个决策者,这是一个仙侠世界,你负责来决定一些角色之后的动作行为。 +你是一个决策者,这是一个仙侠世界,你负责来决定某角色之后的动作行为。 {world_info} 全部可执行的动作有: {general_action_infos} -你需要进行决策的NPC的dict[AvatarName, info]为 -{avatar_infos} +你需要进行决策的NPC的info为 +{avatar_info} 注意,只返回json格式结果。 diff --git a/web/src/components/game/CloudLayer.vue b/web/src/components/game/CloudLayer.vue index c8cceed..6f8cb5b 100644 --- a/web/src/components/game/CloudLayer.vue +++ b/web/src/components/game/CloudLayer.vue @@ -77,7 +77,6 @@ function spawnCloud(initial: boolean = false) { const speedY = (Math.random() - 0.5) * 0.1 // Slight vertical drift let x, y - const margin = 200 if (initial) { // Random anywhere @@ -85,7 +84,17 @@ function spawnCloud(initial: boolean = false) { y = Math.random() * props.height } else { // Start from Left (off-screen) - x = -margin - Math.random() * 100 + // We need to ensure the cloud is fully off-screen. + // Shadow is offset to the right, so the rightmost point is shadow.x + halfWidth + // shadow.x = x + offset + // rightmost = x + offset + halfWidth + // We want rightmost < 0 -> x < -(offset + halfWidth) + + const shadowOffsetX = 40 * scale + const halfWidth = (tex.width * scale) / 2 + const safeMargin = shadowOffsetX + halfWidth + 50 + + x = -safeMargin - Math.random() * 200 y = Math.random() * props.height } @@ -112,7 +121,7 @@ function spawnCloud(initial: boolean = false) { function updateClouds(dt: number) { const bounds = { w: props.width, h: props.height } - const margin = 300 // Wider margin for cleanup + const verticalMargin = 300 // For Y-axis removal for (let i = activeClouds.value.length - 1; i >= 0; i--) { const cloud = activeClouds.value[i] @@ -122,14 +131,24 @@ function updateClouds(dt: number) { cloud.sprite.y += cloud.speedY * dt // Move Shadow (Keep offset) - // Re-calculate offset based on scale to keep it simple, or just apply delta cloud.shadow.x += cloud.speedX * dt cloud.shadow.y += cloud.speedY * dt - // Boundary Check (Only check Right side since we move Right) - if (cloud.sprite.x > bounds.w + margin || - cloud.sprite.y > bounds.h + margin || - cloud.sprite.y < -margin) { + // Calculate removal boundary + // Use sprite width to determine if fully off-screen + // sprite.width is the scaled width. Anchor is 0.5. + const halfWidth = cloud.sprite.width / 2 + + // Check if Cloud is fully off-screen to the right + // We check the Leftmost edge of the cloud/shadow complex. + // Since shadow is to the right, the leftmost edge is the cloud's left edge. + // Left edge = cloud.sprite.x - halfWidth + const isOffScreenRight = (cloud.sprite.x - halfWidth) > bounds.w + + // Vertical check (standard margin) + const isOffScreenVertical = cloud.sprite.y > bounds.h + verticalMargin || cloud.sprite.y < -verticalMargin + + if (isOffScreenRight || isOffScreenVertical) { // Remove if (container.value) { @@ -204,3 +223,4 @@ onUnmounted(() => { +