diff --git a/src/classes/action/assassinate.py b/src/classes/action/assassinate.py index 918fa13..24d59a0 100644 --- a/src/classes/action/assassinate.py +++ b/src/classes/action/assassinate.py @@ -9,6 +9,7 @@ from src.classes.battle import decide_battle, get_assassination_success_rate from src.classes.story_teller import StoryTeller from src.classes.normalize import normalize_avatar_name from src.classes.death import handle_death +from src.classes.death_reason import DeathReason from src.classes.kill_and_grab import kill_and_grab if TYPE_CHECKING: @@ -116,7 +117,7 @@ class Assassinate(InstantAction): story_event = Event(self.world.month_stamp, story, related_avatars=rel_ids, is_story=True) # 死亡清理 - handle_death(self.world, target) + handle_death(self.world, target, DeathReason.BATTLE) return [result_event, story_event] @@ -153,7 +154,7 @@ class Assassinate(InstantAction): story_event = Event(self.world.month_stamp, story, related_avatars=rel_ids, is_story=True) if is_fatal: - handle_death(self.world, loser) + handle_death(self.world, loser, DeathReason.BATTLE) return [result_event, story_event] diff --git a/src/classes/action/attack.py b/src/classes/action/attack.py index d4b65dc..26c8029 100644 --- a/src/classes/action/attack.py +++ b/src/classes/action/attack.py @@ -7,6 +7,7 @@ from src.classes.battle import decide_battle, get_effective_strength_pair from src.classes.story_teller import StoryTeller from src.classes.normalize import normalize_avatar_name from src.classes.death import handle_death +from src.classes.death_reason import DeathReason from src.classes.kill_and_grab import kill_and_grab class Attack(InstantAction): @@ -109,6 +110,6 @@ class Attack(InstantAction): # 如果死亡,执行死亡清理(在故事生成后,保证关系数据可用) if is_fatal: - handle_death(self.world, loser) + handle_death(self.world, loser, DeathReason.BATTLE) return [result_event, story_event] diff --git a/src/classes/age.py b/src/classes/age.py index be8a8e6..e70c328 100644 --- a/src/classes/age.py +++ b/src/classes/age.py @@ -84,7 +84,8 @@ class Age: years_over_lifespan = self.age - expected # 基础概率:每超过1年增加0.01的概率 - death_probability = min(years_over_lifespan * 0.01, 0.1) + prob_add = 0.01 + death_probability = min(years_over_lifespan * prob_add, 0.1) return death_probability diff --git a/src/classes/avatar.py b/src/classes/avatar.py index e9514ce..491f2a9 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -114,6 +114,12 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): nickname: Optional[Nickname] = None # 自定义头像ID:如果设置,优先使用此ID显示头像 custom_pic_id: Optional[int] = None + + # 死亡状态 + is_dead: bool = False + # 死亡信息:{ "time": MonthStamp, "reason": str, "location": (x, y) } + death_info: Optional[dict] = None + # 当月/当步新设动作标记:在 commit_next_plan 设为 True,首次 tick_action 后清为 False _new_action_set_this_step: bool = False # 动作冷却:记录动作类名 -> 上次完成月戳 @@ -122,6 +128,8 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): def join_sect(self, sect: Sect, rank: "SectRank") -> None: """加入宗门""" + if self.is_dead: + return if self.sect: self.leave_sect() self.sect = sect @@ -137,6 +145,38 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): self.sect = None self.sect_rank = None + def set_dead(self, reason: str, time: MonthStamp) -> None: + """ + 设置角色死亡状态。 + + Args: + reason: 死亡原因 + time: 死亡时间 + """ + if self.is_dead: + return + + self.is_dead = True + self.death_info = { + "time": int(time), + "reason": reason, + "location": (self.pos_x, self.pos_y) + } + + # 清空所有计划和当前动作 + self.planned_actions.clear() + self.current_action = None + self._pending_events.clear() + self.thinking = "" + self.short_term_objective = "" + + # 退出宗门(保留职位记录还是清除?通常死人不再担任职位) + # 但为了历史记录,也许可以保留 sect 字段,但从宗门成员列表中移除 + if self.sect: + self.sect.remove_member(self) + # 不清除 self.sect 和 self.sect_rank,作为生平记录保留 + + def __post_init__(self): """ 在Avatar创建后自动初始化tile和HP @@ -319,6 +359,8 @@ class Avatar(AvatarSaveMixin, AvatarLoadMixin): "long_term_objective": self.long_term_objective.content if self.long_term_objective else "", "nickname": self.nickname.value if self.nickname else None, "nickname_reason": self.nickname.reason if self.nickname else None, + "is_dead": self.is_dead, + "death_info": self.death_info, } # 复杂对象结构化 diff --git a/src/classes/avatar_manager.py b/src/classes/avatar_manager.py index b16bcf9..06f5f04 100644 --- a/src/classes/avatar_manager.py +++ b/src/classes/avatar_manager.py @@ -26,6 +26,12 @@ class AvatarManager: same_region.append(other) return same_region + def get_living_avatars(self) -> List["Avatar"]: + """ + 返回所有存活的角色列表。 + """ + return [avatar for avatar in self.avatars.values() if not avatar.is_dead] + def get_observable_avatars(self, avatar: "Avatar") -> List["Avatar"]: """ 返回处于 avatar 交互范围内的其他角色列表(不含自己)。 diff --git a/src/classes/death.py b/src/classes/death.py index ac86ba6..216c44c 100644 --- a/src/classes/death.py +++ b/src/classes/death.py @@ -1,16 +1,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union +from src.classes.death_reason import DeathReason if TYPE_CHECKING: from src.classes.world import World from src.classes.avatar import Avatar -def handle_death(world: World, avatar: Avatar) -> None: +def handle_death(world: World, avatar: Avatar, reason: Union[str, DeathReason] = DeathReason.UNKNOWN) -> None: """ 处理角色死亡的统一入口。 - 负责将角色从世界管理器中移除,并处理相关的清理工作(如关系解除已在 remove_avatar 中实现)。 - 注意:本函数不负责生成死亡事件文本,调用者应在调用前生成相应的 Event。 + 负责将角色标记为死亡,清理行动队列,但保留角色数据。 + + Args: + world: 世界对象 + avatar: 死亡的角色 + reason: 死亡原因(DeathReason枚举或字符串) """ - # 从管理器中移除角色(remove_avatar 内部会自动清理双向关系) - world.avatar_manager.remove_avatar(avatar.id) + # 如果传入的是枚举,转为字符串值 + reason_str = reason.value if isinstance(reason, DeathReason) else str(reason) + + # 标记为死亡(软删除) + avatar.set_dead(reason_str, world.month_stamp) + + # 可以在这里触发其他逻辑,比如检查是否有继承人等 + diff --git a/src/classes/death_reason.py b/src/classes/death_reason.py new file mode 100644 index 0000000..92fa9f1 --- /dev/null +++ b/src/classes/death_reason.py @@ -0,0 +1,11 @@ +from enum import Enum + +class DeathReason(Enum): + OLD_AGE = "老死" + BATTLE = "战死" + SERIOUS_INJURY = "重伤" + UNKNOWN = "未知" + + def __str__(self) -> str: + return self.value + diff --git a/src/server/main.py b/src/server/main.py index e7356d9..83c903a 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -255,16 +255,19 @@ async def game_loop(): # 执行一步 events = await sim.step() - # 找出新诞生的角色 ID + # 找出新诞生的角色 ID 和 刚死亡的角色 ID newly_born_ids = set() + newly_dead_ids = set() for e in events: if "晋升为修士" in e.content and e.related_avatars: newly_born_ids.update(e.related_avatars) + if ("身亡" in e.content or "老死" in e.content) and e.related_avatars: + newly_dead_ids.update(e.related_avatars) avatar_updates = [] # 为了避免重复发送大量数据,我们区分处理: - # - 新角色:发送完整数据 + # - 新角色/刚死角色:发送完整数据(或关键状态更新) # - 旧角色:只发送位置 (x, y)(限制数量) # 1. 发送新角色的完整信息 @@ -276,15 +279,29 @@ async def game_loop(): "name": a.name, "x": int(getattr(a, "pos_x", 0)), "y": int(getattr(a, "pos_y", 0)), - "gender": a.gender.value, # 使用 value (male/female) 而不是 str (中文) + "gender": a.gender.value, "pic_id": resolve_avatar_pic_id(a), - "action": getattr(a, "current_action", {}).get("name", "发呆") if hasattr(a, "current_action") and a.current_action else "发呆" + "action": getattr(a, "current_action", {}).get("name", "发呆") if hasattr(a, "current_action") and a.current_action else "发呆", + "is_dead": False }) - # 2. 常规位置更新(暂时只发前 50 个旧角色,减少数据量) + # 2. 发送刚死角色的状态更新 + for aid in newly_dead_ids: + # 注意:死人可能不在 get_living_avatars() 里,但还在 avatars 里 + a = world.avatar_manager.avatars.get(aid) + if a: + avatar_updates.append({ + "id": str(a.id), + "name": a.name, # 名字也带上,防止前端没数据 + "is_dead": True, + "action": "已故" + }) + + # 3. 常规位置更新(暂时只发前 50 个旧角色,减少数据量) limit = 50 count = 0 - for a in world.avatar_manager.avatars.values(): + # 只遍历活人更新位置 + for a in world.avatar_manager.get_living_avatars(): # 如果是新角色,已经在上面处理过了,跳过 if a.id in newly_born_ids: continue diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py index 4def9ed..6b2ef60 100644 --- a/src/sim/load/avatar_load_mixin.py +++ b/src/sim/load/avatar_load_mixin.py @@ -148,6 +148,10 @@ class AvatarLoadMixin: # 恢复绰号 from src.classes.nickname_data import Nickname avatar.nickname = Nickname.from_dict(data.get("nickname")) + + # 恢复死亡状态 + avatar.is_dead = data.get("is_dead", False) + avatar.death_info = data.get("death_info") # 设置行动与AI avatar.thinking = data.get("thinking", "") diff --git a/src/sim/new_avatar.py b/src/sim/new_avatar.py index 36b56e0..f1e2fef 100644 --- a/src/sim/new_avatar.py +++ b/src/sim/new_avatar.py @@ -251,7 +251,10 @@ class MortalPlanner: plan.pos_y = random.randint(0, world.map.height - 1) if existing_avatars is None: - existing_avatars = list(world.avatar_manager.avatars.values()) + existing_avatars = world.avatar_manager.get_living_avatars() + else: + existing_avatars = [av for av in existing_avatars if not av.is_dead] + if existed_sects is None: try: from src.classes.sect import sects_by_id as _sects_by_id diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py index 8db12ef..dbc7914 100644 --- a/src/sim/save/avatar_save_mixin.py +++ b/src/sim/save/avatar_save_mixin.py @@ -90,6 +90,8 @@ class AvatarSaveMixin: "persona_ids": [p.id for p in self.personas] if self.personas else [], "appearance": self.appearance.level, "nickname": self.nickname.to_dict() if self.nickname else None, + "is_dead": self.is_dead, + "death_info": self.death_info, # 行动与AI "current_action": current_action_dict, diff --git a/src/sim/simulator.py b/src/sim/simulator.py index 5fdef2e..0525752 100644 --- a/src/sim/simulator.py +++ b/src/sim/simulator.py @@ -16,6 +16,7 @@ from src.classes.fortune import try_trigger_fortune from src.classes.celestial_phenomenon import get_random_celestial_phenomenon from src.classes.long_term_objective import process_avatar_long_term_objective from src.classes.death import handle_death +from src.classes.death_reason import DeathReason class Simulator: def __init__(self, world: World): @@ -28,7 +29,7 @@ class Simulator: 将 AI 的决策结果加载为角色的计划链。 """ avatars_to_decide = [] - for avatar in list(self.world.avatar_manager.avatars.values()): + for avatar in self.world.avatar_manager.get_living_avatars(): if avatar.current_action is None and not avatar.has_plans(): avatars_to_decide.append(avatar) if not avatars_to_decide: @@ -45,7 +46,7 @@ class Simulator: 提交阶段:为空闲角色提交计划中的下一个可执行动作,返回开始事件集合。 """ events = [] - for avatar in list(self.world.avatar_manager.avatars.values()): + for avatar in self.world.avatar_manager.get_living_avatars(): if avatar.current_action is None: start_event = avatar.commit_next_plan() if start_event is not None and not is_null_event(start_event): @@ -60,7 +61,7 @@ class Simulator: MAX_LOCAL_ROUNDS = 3 for _ in range(MAX_LOCAL_ROUNDS): new_action_happened = False - for avatar_id, avatar in list(self.world.avatar_manager.avatars.items()): + for avatar in self.world.avatar_manager.get_living_avatars(): # 本轮执行前若标记为新设,则清理,执行后由 Avatar 再统一清除 if getattr(avatar, "_new_action_set_this_step", False): new_action_happened = True @@ -78,28 +79,30 @@ class Simulator: def _phase_resolve_death(self): """ 结算死亡: - - 战斗死亡已在 Action 中结算,此处不再重复(因为已从 avatars 中移除) + - 战斗死亡已在 Action 中结算 - 此时剩下的 avatars 都是存活的,只需检查非战斗因素(如老死、被动掉血) """ events = [] - # 遍历时可能修改字典,使用 list() 复制 - for avatar_id, avatar in list(self.world.avatar_manager.avatars.items()): + for avatar in self.world.avatar_manager.get_living_avatars(): is_dead = False - reason = "" + reason_str = "" + death_reason = DeathReason.UNKNOWN # 优先判定重伤(可能是被动效果导致) - if avatar.hp <= 0: + if avatar.hp.cur <= 0: # 注意:这里应该是 avatar.hp.cur 或者 avatar.hp <= 0 取决于 HP 类的实现,原代码是 avatar.hp <= 0 is_dead = True - reason = f"{avatar.name} 因重伤不治身亡" + reason_str = f"{avatar.name} 因重伤不治身亡" + death_reason = DeathReason.SERIOUS_INJURY # 其次判定寿元 elif avatar.death_by_old_age(): is_dead = True - reason = f"{avatar.name} 老死了,时年{avatar.age.get_age()}岁" + reason_str = f"{avatar.name} 老死了,时年{avatar.age.get_age()}岁" + death_reason = DeathReason.OLD_AGE if is_dead: - event = Event(self.world.month_stamp, reason, related_avatars=[avatar.id]) + event = Event(self.world.month_stamp, reason_str, related_avatars=[avatar.id]) events.append(event) - handle_death(self.world, avatar) + handle_death(self.world, avatar, death_reason) return events @@ -108,12 +111,13 @@ class Simulator: 更新存活角色年龄,并以一定概率生成新修士,返回期间产生的事件集合。 """ events = [] - for avatar_id, avatar in self.world.avatar_manager.avatars.items(): + for avatar in self.world.avatar_manager.get_living_avatars(): avatar.update_age(self.world.month_stamp) if random.random() < self.birth_rate: age = random.randint(16, 60) gender = random.choice(list(Gender)) name = get_random_name(gender) + # create_random_mortal 内部会获取 existing_avatars,需要确保它处理活人 new_avatar = create_random_mortal(self.world, self.world.month_stamp, name, Age(age, Realm.Qi_Refinement)) self.world.avatar_manager.avatars[new_avatar.id] = new_avatar event = Event(self.world.month_stamp, f"{new_avatar.name}晋升为修士了。", related_avatars=[new_avatar.id]) @@ -127,11 +131,12 @@ class Simulator: - 触发奇遇(非动作) """ events = [] - for avatar in self.world.avatar_manager.avatars.values(): + living_avatars = self.world.avatar_manager.get_living_avatars() + for avatar in living_avatars: avatar.update_time_effect() # 使用 gather 并行触发奇遇 - tasks = [try_trigger_fortune(avatar) for avatar in self.world.avatar_manager.avatars.values()] + tasks = [try_trigger_fortune(avatar) for avatar in living_avatars] results = await asyncio.gather(*tasks) for res in results: if res: @@ -146,7 +151,8 @@ class Simulator: from src.classes.nickname import process_avatar_nickname # 并发执行 - tasks = [process_avatar_nickname(avatar) for avatar in self.world.avatar_manager.avatars.values()] + living_avatars = self.world.avatar_manager.get_living_avatars() + tasks = [process_avatar_nickname(avatar) for avatar in living_avatars] results = await asyncio.gather(*tasks) events = [e for e in results if e] @@ -158,7 +164,8 @@ class Simulator: 检查角色是否需要生成/更新长期目标 """ # 并发执行 - tasks = [process_avatar_long_term_objective(avatar) for avatar in self.world.avatar_manager.avatars.values()] + living_avatars = self.world.avatar_manager.get_living_avatars() + tasks = [process_avatar_long_term_objective(avatar) for avatar in living_avatars] results = await asyncio.gather(*tasks) events = [e for e in results if e] diff --git a/static/config.yml b/static/config.yml index 41de6c5..d52b4ab 100644 --- a/static/config.yml +++ b/static/config.yml @@ -34,8 +34,8 @@ social: minor_event_context_num: 10 # 小事(短期记忆)展示数量 nickname: - major_event_threshold: 3 # 获得绰号需要的长期事件数量 - minor_event_threshold: 20 # 获得绰号需要的短期事件数量 + major_event_threshold: 5 # 获得绰号需要的长期事件数量 + minor_event_threshold: 25 # 获得绰号需要的短期事件数量 save: max_events_to_save: 1000 \ No newline at end of file diff --git a/web/src/components/game/EntityLayer.vue b/web/src/components/game/EntityLayer.vue index 019af21..c11ac3b 100644 --- a/web/src/components/game/EntityLayer.vue +++ b/web/src/components/game/EntityLayer.vue @@ -11,8 +11,12 @@ const emit = defineEmits<{ (e: 'avatarSelected', payload: { type: 'avatar'; id: string; name?: string }): void }>() +const visibleAvatars = computed(() => { + return worldStore.avatarList.filter(a => !a.is_dead) +}) + const avatarOffsets = computed(() => { - return calculateVisualOffsets(worldStore.avatarList) + return calculateVisualOffsets(visibleAvatars.value) }) function handleAvatarSelect(payload: { type: 'avatar'; id: string; name?: string }) { @@ -23,7 +27,7 @@ function handleAvatarSelect(payload: { type: 'avatar'; id: string; name?: string