diff --git a/.gitignore b/.gitignore index 832e61d..130a4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,10 @@ logs/ *.tmp *.temp +# Save files (ignore all files in saves directory but not the directory itself) +assets/saves/* +!assets/saves/README.md + TODO local_config.yml diff --git a/src/classes/action_runtime.py b/src/classes/action_runtime.py index 5887af4..0c65170 100644 --- a/src/classes/action_runtime.py +++ b/src/classes/action_runtime.py @@ -52,6 +52,29 @@ class ActionPlan: expiry_month: Optional[int] = None # 到期月戳;None 为不过期 max_retries: int = 0 attempted: int = 0 + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return { + "action_name": self.action_name, + "params": self.params, + "priority": self.priority, + "expiry_month": self.expiry_month, + "max_retries": self.max_retries, + "attempted": self.attempted + } + + @classmethod + def from_dict(cls, data: dict) -> "ActionPlan": + """从字典重建ActionPlan""" + return cls( + action_name=data["action_name"], + params=data["params"], + priority=data.get("priority", 0), + expiry_month=data.get("expiry_month"), + max_retries=data.get("max_retries", 0), + attempted=data.get("attempted", 0) + ) @dataclass diff --git a/src/classes/age.py b/src/classes/age.py index dd20176..d6e6eba 100644 --- a/src/classes/age.py +++ b/src/classes/age.py @@ -121,3 +121,17 @@ class Age: def __repr__(self) -> str: """返回年龄的详细字符串表示""" return f"Age({self.age})" + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return { + "age": self.age, + "max_lifespan": self.max_lifespan + } + + @classmethod + def from_dict(cls, data: dict, realm: Realm) -> "Age": + """从字典重建Age""" + age_obj = cls(data["age"], realm) + age_obj.max_lifespan = data["max_lifespan"] + return age_obj \ No newline at end of file diff --git a/src/classes/avatar.py b/src/classes/avatar.py index 857af13..2412da2 100644 --- a/src/classes/avatar.py +++ b/src/classes/avatar.py @@ -13,6 +13,8 @@ from src.classes.action import Action from src.classes.action_runtime import ActionStatus, ActionResult from src.classes.action.registry import ActionRegistry from src.classes.world import World +from src.sim.save.avatar_save_mixin import AvatarSaveMixin +from src.sim.load.avatar_load_mixin import AvatarLoadMixin from src.classes.tile import Tile from src.classes.region import Region from src.classes.cultivation import CultivationProgress @@ -59,7 +61,7 @@ gender_strs = { MAX_HISTORY_EVENTS = 10 @dataclass -class Avatar: +class Avatar(AvatarSaveMixin, AvatarLoadMixin): """ NPC的类。 包含了这个角色的一切信息。 @@ -697,5 +699,5 @@ class Avatar: 获取角色的移动步长 """ return self.cultivation_progress.get_move_step() - + diff --git a/src/classes/cultivation.py b/src/classes/cultivation.py index d5861fc..364fa50 100644 --- a/src/classes/cultivation.py +++ b/src/classes/cultivation.py @@ -246,6 +246,20 @@ class CultivationProgress: def get_breakthrough_fail_reduce_lifespan(self) -> int: return breakthrough_fail_reduce_lifespan_by_realm[self.realm] + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return { + "level": self.level, + "exp": self.exp, + "realm": self.realm.name, # 保存枚举的name + "stage": self.stage.name + } + + @classmethod + def from_dict(cls, data: dict) -> "CultivationProgress": + """从字典重建CultivationProgress""" + return cls(level=data["level"], exp=data["exp"]) diff --git a/src/classes/event.py b/src/classes/event.py index 5be455f..a42f21e 100644 --- a/src/classes/event.py +++ b/src/classes/event.py @@ -17,6 +17,23 @@ class Event: year = self.month_stamp.get_year() month = self.month_stamp.get_month() return f"{year}年{month}月: {self.content}" + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return { + "month_stamp": int(self.month_stamp), + "content": self.content, + "related_avatars": self.related_avatars + } + + @classmethod + def from_dict(cls, data: dict) -> "Event": + """从字典重建Event""" + return cls( + month_stamp=MonthStamp(data["month_stamp"]), + content=data["content"], + related_avatars=data.get("related_avatars") + ) class NullEvent: """ diff --git a/src/classes/hp_and_mp.py b/src/classes/hp_and_mp.py index 8aaed85..7738186 100644 --- a/src/classes/hp_and_mp.py +++ b/src/classes/hp_and_mp.py @@ -66,6 +66,15 @@ class HP: if isinstance(other, HP): return self.cur >= other.cur return self.cur >= other + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return {"max": self.max, "cur": self.cur} + + @classmethod + def from_dict(cls, data: dict) -> "HP": + """从字典重建HP""" + return cls(max=data["max"], cur=data["cur"]) HP_MAX_BY_REALM = { Realm.Qi_Refinement: 100, @@ -141,6 +150,15 @@ class MP: def add_max(self, value_2_add:int) -> bool: self.max += value_2_add return True + + def to_dict(self) -> dict: + """转换为可序列化的字典""" + return {"max": self.max, "cur": self.cur} + + @classmethod + def from_dict(cls, data: dict) -> "MP": + """从字典重建MP""" + return cls(max=data["max"], cur=data["cur"]) MP_MAX_BY_REALM = { Realm.Qi_Refinement: 100, diff --git a/src/front/app.py b/src/front/app.py index 910d7aa..dbfe005 100644 --- a/src/front/app.py +++ b/src/front/app.py @@ -22,6 +22,7 @@ from .rendering import ( ) from .events_panel import draw_sidebar from .menu import PauseMenu +from .toast import Toast from .layout import calculate_layout, get_fullscreen_resolution @@ -33,12 +34,14 @@ class Front: step_interval_ms: int = 400, window_title: str = "Cultivation World Simulator", font_path: Optional[str] = None, + existed_sects: Optional[List] = None, ): self.world = simulator.world self.simulator = simulator self.step_interval_ms = step_interval_ms self.window_title = window_title self.font_path = font_path + self.existed_sects = existed_sects or [] # 保存本局启用的宗门列表 self._last_step_ms = 0 self.events: List[Event] = [] @@ -110,6 +113,12 @@ class Front: # 暂停菜单 self.pause_menu = PauseMenu(pygame) + + # Toast提示 + self.toast = Toast(pygame) + + # 世界ID标记(用于取消过期的异步任务) + self._world_id = 0 # 渲染插值状态:avatar_id -> {start_px, start_py, target_px, target_py, start_ms, duration_ms} self._avatar_display_states: Dict[str, Dict[str, float]] = {} @@ -135,7 +144,16 @@ class Front: self.events = self.events[-1000:] async def _step_once_async(self): + # 捕获当前world_id,用于检测是否已经加载了新世界 + current_world_id = self._world_id + events = await self.simulator.step() + + # 如果world_id已改变,说明加载了新存档,丢弃这次结果 + if self._world_id != current_world_id: + print(f"丢弃过期的异步任务结果(world_id: {current_world_id} -> {self._world_id})") + return + if events: self.add_events(events) self._last_step_ms = 0 @@ -162,12 +180,13 @@ class Front: if event.key == pygame.K_ESCAPE: self.pause_menu.toggle() elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: - # 处理菜单点击 + # 处理菜单点击(菜单可见时阻止其他所有交互) if self.pause_menu.is_visible: action = self._handle_menu_click() if action == "quit": running = False else: + # 只有菜单不可见时才处理地图交互 self._handle_mouse_click() # 兼容旧版滚轮为 MOUSEBUTTON 4/5 elif event.type == pygame.MOUSEBUTTONDOWN and event.button in (4, 5): @@ -213,31 +232,38 @@ class Front: # 底图后叠加小区域整图(2x2/3x3),再绘制宗门总部,避免被覆盖 draw_small_regions(pygame, self.screen, self.world, self.region_images, self.tile_images, self.tile_size, self.margin, status_bar_height, self.tile_originals) draw_sect_headquarters(pygame, self.screen, self.world, self.sect_images, self.tile_size, self.margin, status_bar_height) - hovered_region = draw_region_labels( - pygame, - self.screen, - self.colors, - self.world, - self._get_region_font, - self.tile_size, - self.margin, - status_bar_height, - ) - self._assign_avatar_images() - hovered_default, hover_candidates = draw_avatars_and_pick_hover( - pygame, - self.screen, - self.colors, - self.simulator, - self.avatar_images, - self.tile_size, - self.margin, - self._get_display_center, - status_bar_height, - self.name_font, - self._sidebar_filter_avatar_id, - ) - hovered_avatar = self._pick_hover_with_scroll(hovered_default, hover_candidates) + # 如果菜单可见,不显示任何hover(避免穿透) + if not self.pause_menu.is_visible: + hovered_region = draw_region_labels( + pygame, + self.screen, + self.colors, + self.world, + self._get_region_font, + self.tile_size, + self.margin, + status_bar_height, + ) + self._assign_avatar_images() + hovered_default, hover_candidates = draw_avatars_and_pick_hover( + pygame, + self.screen, + self.colors, + self.simulator, + self.avatar_images, + self.tile_size, + self.margin, + self._get_display_center, + status_bar_height, + self.name_font, + self._sidebar_filter_avatar_id, + ) + hovered_avatar = self._pick_hover_with_scroll(hovered_default, hover_candidates) + else: + # 菜单可见时,清空所有hover状态 + hovered_region = None + hovered_avatar = None + hover_candidates = [] # 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层 draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world, status_bar_height) @@ -286,6 +312,10 @@ class Front: # 绘制暂停菜单(在最上层) self._menu_option_rects = self.pause_menu.draw(self.screen, self.colors, self.status_font) + # 更新并绘制Toast(在最上层) + self.toast.update() + self.toast.draw(self.screen, self.sidebar_font) + pygame.display.flip() def _handle_mouse_click(self) -> None: @@ -309,7 +339,121 @@ class Front: """处理菜单点击,返回动作""" mouse_pos = self.pygame.mouse.get_pos() option_rects = getattr(self, "_menu_option_rects", []) - return self.pause_menu.handle_click(mouse_pos, option_rects) + action = self.pause_menu.handle_click(mouse_pos, option_rects) + + # 处理保存和加载操作 + if action == "save": + self._save_game() + self.pause_menu.hide() + return None + elif action == "load": + success = self._load_game() + if success: + self.pause_menu.hide() + return None + + return action + + def _save_game(self) -> bool: + """保存游戏""" + try: + from src.sim.save.save_game import save_game + success, filename = save_game(self.world, self.simulator, self.existed_sects) + if success and filename: + self.toast.show(f"保存成功!\n{filename}", Toast.SUCCESS, duration_ms=4000) + print(f"游戏保存成功!文件:{filename}") + else: + self.toast.show("游戏保存失败", Toast.ERROR) + return success + except Exception as e: + self.toast.show(f"保存失败: {str(e)[:30]}", Toast.ERROR) + print(f"保存游戏时出错: {e}") + import traceback + traceback.print_exc() + return False + + def _load_game(self) -> bool: + """加载游戏 - 打开文件选择对话框""" + try: + import tkinter as tk + from tkinter import filedialog + from pathlib import Path + from src.utils.config import CONFIG + from src.sim.load.load_game import load_game + + # 创建临时的tkinter根窗口(隐藏) + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + + # 获取saves目录 + saves_dir = CONFIG.paths.saves + saves_dir.mkdir(parents=True, exist_ok=True) + + # 打开文件选择对话框 + save_path = filedialog.askopenfilename( + title="选择存档文件", + initialdir=str(saves_dir), + filetypes=[("JSON文件", "*.json"), ("所有文件", "*.*")] + ) + + # 销毁tkinter根窗口 + root.destroy() + + # 如果用户取消 + if not save_path: + self.toast.show("取消加载", Toast.INFO, duration_ms=2000) + return False + + save_path = Path(save_path) + if not save_path.exists(): + self.toast.show("存档文件不存在", Toast.ERROR) + return False + + # 显示加载提示 + self.toast.show("正在加载存档...", Toast.INFO, duration_ms=10000) + # 强制刷新一次屏幕,让toast显示出来 + self._render() + + # 加载游戏数据 + world, simulator, existed_sects = load_game(save_path) + + # 增加world_id,使所有正在进行的异步任务失效 + self._world_id += 1 + + # 替换当前的world和simulator + self.world = world + self.simulator = simulator + self.existed_sects = existed_sects + + # 从event_manager恢复事件到侧边栏显示列表 + self.events.clear() + recent_events = world.event_manager.get_recent_events(limit=1000) + self.events.extend(recent_events) + + # 重新初始化头像图像分配 + self.avatar_images.clear() + self._assign_avatar_images() + + # 重新初始化插值状态 + self._avatar_display_states.clear() + self._init_avatar_display_states() + + # 标记侧栏选项为脏(需要重建角色列表) + self._sidebar_options_dirty = True + self._sidebar_filter_avatar_id = None + + # 立即显示成功toast,覆盖"正在加载"的toast + filename = save_path.name + self.toast.show(f"加载成功!\n{filename}", Toast.SUCCESS, duration_ms=3000) + print(f"游戏加载成功!文件:{filename}") + return True + except Exception as e: + self.toast.show(f"加载失败: {str(e)[:30]}", Toast.ERROR) + print(f"加载游戏时出错: {e}") + import traceback + traceback.print_exc() + return False def _get_region_font(self, size: int): return _get_region_font_cached(self.pygame, self._region_font_cache, size, self.font_path) diff --git a/src/front/menu.py b/src/front/menu.py index df3640a..b999a21 100644 --- a/src/front/menu.py +++ b/src/front/menu.py @@ -15,6 +15,8 @@ class PauseMenu: self.pygame = pygame_mod self.is_visible = False self.options = [ + MenuOption("保存游戏", "save"), + MenuOption("加载游戏", "load"), MenuOption("退出游戏", "quit") ] self.selected_index = 0 @@ -50,9 +52,9 @@ class PauseMenu: pygame = self.pygame screen_w, screen_h = screen.get_size() - # 绘制半透明黑色背景(模糊效果) + # 绘制全屏半透明黑色背景(作为mask,阻止背后交互) overlay = pygame.Surface((screen_w, screen_h), pygame.SRCALPHA) - overlay.fill((0, 0, 0, 160)) + overlay.fill((0, 0, 0, 180)) screen.blit(overlay, (0, 0)) # 计算菜单尺寸 diff --git a/src/front/toast.py b/src/front/toast.py new file mode 100644 index 0000000..b4b25f5 --- /dev/null +++ b/src/front/toast.py @@ -0,0 +1,149 @@ +"""Toast提示组件 + +用于显示临时的成功/失败/信息提示 +""" +from typing import Optional + + +class Toast: + """Toast提示 + + 显示短暂的提示信息,自动消失 + """ + + # Toast类型 + SUCCESS = "success" + ERROR = "error" + INFO = "info" + + def __init__(self, pygame_mod): + self.pygame = pygame_mod + self.message: Optional[str] = None + self.toast_type: str = Toast.INFO + self.start_time: int = 0 + self.duration_ms: int = 3000 # 默认显示3秒 + self.is_visible: bool = False + + def show(self, message: str, toast_type: str = INFO, duration_ms: int = 3000): + """显示Toast提示 + + Args: + message: 提示信息 + toast_type: 类型(success/error/info) + duration_ms: 显示时长(毫秒) + """ + self.message = message + self.toast_type = toast_type + self.duration_ms = duration_ms + self.start_time = self.pygame.time.get_ticks() + self.is_visible = True + + def update(self): + """更新Toast状态,检查是否应该隐藏""" + if not self.is_visible: + return + + current_time = self.pygame.time.get_ticks() + if current_time - self.start_time >= self.duration_ms: + self.is_visible = False + self.message = None + + def draw(self, screen, font): + """绘制Toast + + Args: + screen: pygame屏幕对象 + font: pygame字体对象 + """ + if not self.is_visible or not self.message: + return + + pygame = self.pygame + screen_w, screen_h = screen.get_size() + + # 根据类型选择颜色 + if self.toast_type == Toast.SUCCESS: + bg_color = (34, 139, 34) # 绿色 + border_color = (46, 184, 46) + elif self.toast_type == Toast.ERROR: + bg_color = (178, 34, 34) # 红色 + border_color = (220, 50, 50) + else: # INFO + bg_color = (70, 130, 180) # 蓝色 + border_color = (100, 150, 200) + + # 计算淡入淡出效果 + elapsed = self.pygame.time.get_ticks() - self.start_time + fade_in_duration = 200 # 淡入200ms + fade_out_duration = 500 # 淡出500ms + + if elapsed < fade_in_duration: + # 淡入阶段 + alpha = int(255 * (elapsed / fade_in_duration)) + elif elapsed > self.duration_ms - fade_out_duration: + # 淡出阶段 + remaining = self.duration_ms - elapsed + alpha = int(255 * (remaining / fade_out_duration)) + else: + # 完全显示 + alpha = 255 + + # 创建更大的字体用于Toast + from .fonts import create_font + toast_font = create_font(pygame, 24, None) # 使用24号字体,更大更清晰 + + # 处理多行文本 + lines = self.message.split('\n') + text_surfaces = [] + max_text_w = 0 + total_text_h = 0 + line_spacing = 5 # 行间距 + + for line in lines: + text_surf = toast_font.render(line, True, (255, 255, 255)) + text_surfaces.append(text_surf) + w, h = text_surf.get_size() + max_text_w = max(max_text_w, w) + total_text_h += h + + # 加上行间距 + total_text_h += line_spacing * (len(lines) - 1) + + # Toast尺寸(增大padding) + padding_x = 40 # 水平padding增大 + padding_y = 25 # 垂直padding增大 + toast_w = max(max_text_w + padding_x * 2, 300) # 最小宽度300 + toast_h = total_text_h + padding_y * 2 + + # 位置:屏幕上方中央 + toast_x = (screen_w - toast_w) // 2 + toast_y = 100 # 稍微下移一点 + + # 创建带透明度的surface + toast_surface = pygame.Surface((toast_w, toast_h), pygame.SRCALPHA) + + # 绘制背景(带圆角和透明度) + bg_with_alpha = (*bg_color, alpha) + pygame.draw.rect(toast_surface, bg_with_alpha, (0, 0, toast_w, toast_h), border_radius=8) + + # 绘制边框 + border_with_alpha = (*border_color, alpha) + pygame.draw.rect(toast_surface, border_with_alpha, (0, 0, toast_w, toast_h), 2, border_radius=8) + + # 绘制多行文本(应用透明度,居中显示) + current_y = (toast_h - total_text_h) // 2 # 垂直居中起点 + for text_surf in text_surfaces: + w, h = text_surf.get_size() + text_with_alpha = pygame.Surface((w, h), pygame.SRCALPHA) + text_with_alpha.blit(text_surf, (0, 0)) + text_with_alpha.set_alpha(alpha) + text_x = (toast_w - w) // 2 # 每行水平居中 + toast_surface.blit(text_with_alpha, (text_x, current_y)) + current_y += h + line_spacing + + # 绘制到屏幕 + screen.blit(toast_surface, (toast_x, toast_y)) + + +__all__ = ["Toast"] + diff --git a/src/run/run.py b/src/run/run.py index 20f4594..9469005 100644 --- a/src/run/run.py +++ b/src/run/run.py @@ -103,6 +103,7 @@ async def main(): simulator=sim, step_interval_ms=750, window_title="Cultivation World — Front Demo", + existed_sects=existed_sects, ) await front.run_async() diff --git a/src/sim/load/__init__.py b/src/sim/load/__init__.py new file mode 100644 index 0000000..dd344ba --- /dev/null +++ b/src/sim/load/__init__.py @@ -0,0 +1,17 @@ +"""读档功能模块 + +延迟导入以避免循环依赖 +""" + +def __getattr__(name): + """延迟导入,避免在模块级别触发循环依赖""" + if name == "load_game": + from .load_game import load_game + return load_game + elif name == "check_save_compatibility": + from .load_game import check_save_compatibility + return check_save_compatibility + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = ["load_game", "check_save_compatibility"] + diff --git a/src/sim/load/avatar_load_mixin.py b/src/sim/load/avatar_load_mixin.py new file mode 100644 index 0000000..6ec0eb6 --- /dev/null +++ b/src/sim/load/avatar_load_mixin.py @@ -0,0 +1,172 @@ +""" +Avatar读档反序列化Mixin + +将Avatar的反序列化逻辑从avatar.py分离出来。 + +读档策略: +- 两阶段加载:先加载所有Avatar(relations留空),再重建relations网络 +- 引用对象:通过id从全局字典获取(如techniques_by_id) +- treasure:深拷贝后恢复devoured_souls +- 错误容错:缺失的引用对象会跳过而不是崩溃 +""" +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.classes.world import World + + +class AvatarLoadMixin: + """Avatar读档反序列化Mixin + + 提供from_save_dict类方法,从字典重建Avatar对象 + """ + + @classmethod + def from_save_dict(cls, data: dict, world: "World") -> "AvatarLoadMixin": + """从字典重建Avatar(用于读档) + + 注意:relations需要在所有Avatar加载完成后单独重建 + + Args: + data: 存档数据字典 + world: 世界对象引用 + + Returns: + 重建的Avatar对象(relations为空,需要外部第二阶段填充) + """ + from src.classes.avatar import Gender + from src.classes.calendar import MonthStamp + from src.classes.cultivation import Realm, CultivationProgress + from src.classes.age import Age + from src.classes.hp_and_mp import HP, MP + from src.classes.technique import techniques_by_id + from src.classes.item import items_by_id + from src.classes.treasure import treasures_by_id + from src.classes.sect import sects_by_id + from src.classes.sect_ranks import SectRank + from src.classes.root import Root + from src.classes.alignment import Alignment + from src.classes.persona import personas_by_id + from src.classes.trait import traits_by_id + from src.classes.appearance import get_appearance_by_level + from src.classes.magic_stone import MagicStone + from src.classes.action_runtime import ActionPlan + + # 重建基本对象 + gender = Gender(data["gender"]) + birth_month_stamp = MonthStamp(data["birth_month_stamp"]) + + # 重建修炼进度 + cultivation_progress = CultivationProgress.from_dict(data["cultivation_progress"]) + realm = cultivation_progress.realm + + # 重建age + age = Age.from_dict(data["age"], realm) + + # 创建Avatar(不完整,需要后续填充) + avatar = cls( + world=world, + name=data["name"], + id=data["id"], + birth_month_stamp=birth_month_stamp, + age=age, + gender=gender, + cultivation_progress=cultivation_progress, + pos_x=data["pos_x"], + pos_y=data["pos_y"], + ) + + # 设置灵根 + avatar.root = Root[data["root"]] + + # 设置功法 + technique_id = data.get("technique_id") + if technique_id is not None: + avatar.technique = techniques_by_id.get(technique_id) + + # 设置HP/MP + avatar.hp = HP.from_dict(data["hp"]) + avatar.mp = MP.from_dict(data["mp"]) + + # 设置物品与资源 + avatar.magic_stone = MagicStone(data.get("magic_stone", 0)) + + # 重建items + items_dict = data.get("items", {}) + avatar.items = {} + for item_id_str, quantity in items_dict.items(): + item_id = int(item_id_str) + if item_id in items_by_id: + avatar.items[items_by_id[item_id]] = quantity + + # 重建treasure(深拷贝因为devoured_souls是实例特有的) + treasure_id = data.get("treasure_id") + if treasure_id is not None and treasure_id in treasures_by_id: + import copy + avatar.treasure = copy.deepcopy(treasures_by_id[treasure_id]) + avatar.treasure.devoured_souls = data.get("treasure_devoured_souls", 0) + + # 重建spirit_animal + spirit_animal_data = data.get("spirit_animal") + if spirit_animal_data is not None: + from src.classes.spirit_animal import SpiritAnimal + spirit_realm = Realm[spirit_animal_data["realm"]] + avatar.spirit_animal = SpiritAnimal( + name=spirit_animal_data["name"], + realm=spirit_realm + ) + + # 设置社交与状态 + sect_id = data.get("sect_id") + if sect_id is not None: + avatar.sect = sects_by_id.get(sect_id) + + sect_rank_value = data.get("sect_rank") + if sect_rank_value is not None: + avatar.sect_rank = SectRank(sect_rank_value) + + alignment_name = data.get("alignment") + if alignment_name is not None: + avatar.alignment = Alignment[alignment_name] + + # 重建personas + persona_ids = data.get("persona_ids", []) + avatar.personas = [personas_by_id[pid] for pid in persona_ids if pid in personas_by_id] + + # 重建trait + trait_id = data.get("trait_id") + if trait_id is not None and trait_id in traits_by_id: + avatar.trait = traits_by_id[trait_id] + + # 设置外貌(通过level获取完整的Appearance对象) + avatar.appearance = get_appearance_by_level(data.get("appearance", 5)) + + # 设置行动与AI + avatar.thinking = data.get("thinking", "") + avatar.objective = data.get("objective", "") + avatar._action_cd_last_months = data.get("_action_cd_last_months", {}) + + # 重建planned_actions + planned_actions_data = data.get("planned_actions", []) + avatar.planned_actions = [ActionPlan.from_dict(plan_data) for plan_data in planned_actions_data] + + # 重建current_action(如果有) + current_action_data = data.get("current_action") + if current_action_data is not None: + try: + action = avatar.create_action(current_action_data["action_name"]) + from src.classes.action_runtime import ActionInstance + avatar.current_action = ActionInstance( + action=action, + params=current_action_data["params"], + status=current_action_data["status"] + ) + except Exception: + # 如果动作无法重建,跳过(容错) + avatar.current_action = None + + # relations需要在外部单独重建(因为需要所有avatar都加载完成) + avatar.relations = {} + + return avatar + diff --git a/src/sim/load/load_game.py b/src/sim/load/load_game.py new file mode 100644 index 0000000..6ce9ea8 --- /dev/null +++ b/src/sim/load/load_game.py @@ -0,0 +1,165 @@ +""" +读档功能模块 + +主要功能: +- load_game: 从JSON文件加载游戏完整状态 +- check_save_compatibility: 检查存档版本兼容性(当前未实现严格检查) + +加载流程(两阶段): +1. 第一阶段:加载所有Avatar对象(relations留空) + - 通过AvatarLoadMixin.from_save_dict反序列化 + - 配表对象(Technique, Item等)通过id从全局字典获取 +2. 第二阶段:重建Avatar之间的relations网络 + - 必须在所有Avatar加载完成后才能建立引用关系 + +错误容错: +- 缺失的配表对象引用会被跳过(如删除的Item) +- 无法重建的动作会被置为None +- 不存在的Avatar引用会被忽略 + +注意事项: +- 读档后会重置前端UI状态(头像图像、插值等) +- 事件历史完整恢复(受限于保存时的数量) +- 地图从头重建(因为地图是固定的),但会恢复宗门总部位置 +""" +import json +from pathlib import Path +from typing import Tuple, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from src.classes.world import World + from src.sim.simulator import Simulator + from src.classes.sect import Sect + +from src.classes.calendar import MonthStamp +from src.classes.event import Event +from src.classes.relation import Relation +from src.utils.config import CONFIG + + +def load_game(save_path: Optional[Path] = None) -> Tuple["World", "Simulator", List["Sect"]]: + """ + 从文件加载游戏状态 + + Args: + save_path: 存档路径,默认为saves/save.json + + Returns: + (world, simulator, existed_sects) + + Raises: + FileNotFoundError: 如果存档文件不存在 + Exception: 如果加载失败 + """ + # 确定加载路径 + if save_path is None: + saves_dir = CONFIG.paths.saves + save_path = saves_dir / "save.json" + else: + save_path = Path(save_path) + + if not save_path.exists(): + raise FileNotFoundError(f"存档文件不存在: {save_path}") + + try: + # 运行时导入,避免循环依赖 + from src.classes.world import World + from src.classes.avatar import Avatar + from src.classes.sect import sects_by_id + from src.sim.simulator import Simulator + from src.run.create_map import create_cultivation_world_map, add_sect_headquarters + + # 读取存档文件 + with open(save_path, "r", encoding="utf-8") as f: + save_data = json.load(f) + + # 读取元信息 + meta = save_data.get("meta", {}) + print(f"正在加载存档 (版本: {meta.get('version', 'unknown')}, " + f"游戏时间: {meta.get('game_time', 'unknown')})") + + # 重建地图(地图本身不变,只需重建宗门总部位置) + game_map = create_cultivation_world_map() + + # 读取世界数据 + world_data = save_data.get("world", {}) + month_stamp = MonthStamp(world_data["month_stamp"]) + + # 重建World对象 + world = World(map=game_map, month_stamp=month_stamp) + + # 获取本局启用的宗门 + existed_sect_ids = world_data.get("existed_sect_ids", []) + existed_sects = [sects_by_id[sid] for sid in existed_sect_ids if sid in sects_by_id] + + # 在地图上添加宗门总部 + add_sect_headquarters(game_map, existed_sects) + + # 第一阶段:重建所有Avatar(不含relations) + avatars_data = save_data.get("avatars", []) + all_avatars = {} + for avatar_data in avatars_data: + avatar = Avatar.from_save_dict(avatar_data, world) + all_avatars[avatar.id] = avatar + + # 第二阶段:重建relations(需要所有avatar都已加载) + for avatar_data in avatars_data: + avatar_id = avatar_data["id"] + avatar = all_avatars[avatar_id] + relations_dict = avatar_data.get("relations", {}) + + for other_id, relation_value in relations_dict.items(): + if other_id in all_avatars: + other_avatar = all_avatars[other_id] + relation = Relation(relation_value) + avatar.relations[other_avatar] = relation + + # 将所有avatar添加到world + world.avatar_manager.avatars = all_avatars + + # 重建事件历史 + events_data = save_data.get("events", []) + for event_data in events_data: + event = Event.from_dict(event_data) + world.event_manager.add_event(event) + + # 重建Simulator + simulator_data = save_data.get("simulator", {}) + simulator = Simulator(world) + simulator.birth_rate = simulator_data.get("birth_rate", CONFIG.game.npc_birth_rate_per_month) + + print(f"存档加载成功!共加载 {len(all_avatars)} 个角色,{len(events_data)} 条事件") + return world, simulator, existed_sects + + except Exception as e: + print(f"加载游戏失败: {e}") + import traceback + traceback.print_exc() + raise + + +def check_save_compatibility(save_path: Path) -> Tuple[bool, str]: + """ + 检查存档兼容性 + + Args: + save_path: 存档路径 + + Returns: + (是否兼容, 错误信息) + """ + try: + with open(save_path, "r", encoding="utf-8") as f: + save_data = json.load(f) + + meta = save_data.get("meta", {}) + save_version = meta.get("version", "unknown") + current_version = CONFIG.meta.version + + # 当前不做版本兼容性检查,直接返回兼容 + # 未来可以在这里添加版本比较逻辑 + return True, "" + + except Exception as e: + return False, f"无法读取存档文件: {e}" + diff --git a/src/sim/load_game.py b/src/sim/load_game.py new file mode 100644 index 0000000..1eb0b3f --- /dev/null +++ b/src/sim/load_game.py @@ -0,0 +1,138 @@ +""" +读档功能模块 +""" +import json +from pathlib import Path +from typing import Tuple, List, Optional + +from src.classes.world import World +from src.classes.map import Map +from src.classes.calendar import MonthStamp +from src.classes.avatar import Avatar +from src.classes.event import Event +from src.classes.sect import sects_by_id, Sect +from src.classes.relation import Relation +from src.sim.simulator import Simulator +from src.run.create_map import create_cultivation_world_map, add_sect_headquarters +from src.utils.config import CONFIG + + +def load_game(save_path: Optional[Path] = None) -> Tuple[World, Simulator, List[Sect]]: + """ + 从文件加载游戏状态 + + Args: + save_path: 存档路径,默认为saves/save.json + + Returns: + (world, simulator, existed_sects) + + Raises: + FileNotFoundError: 如果存档文件不存在 + Exception: 如果加载失败 + """ + # 确定加载路径 + if save_path is None: + saves_dir = CONFIG.paths.saves + save_path = saves_dir / "save.json" + else: + save_path = Path(save_path) + + if not save_path.exists(): + raise FileNotFoundError(f"存档文件不存在: {save_path}") + + try: + # 读取存档文件 + with open(save_path, "r", encoding="utf-8") as f: + save_data = json.load(f) + + # 读取元信息 + meta = save_data.get("meta", {}) + print(f"正在加载存档 (版本: {meta.get('version', 'unknown')}, " + f"游戏时间: {meta.get('game_time', 'unknown')})") + + # 重建地图(地图本身不变,只需重建宗门总部位置) + game_map = create_cultivation_world_map() + + # 读取世界数据 + world_data = save_data.get("world", {}) + month_stamp = MonthStamp(world_data["month_stamp"]) + + # 重建World对象 + world = World(map=game_map, month_stamp=month_stamp) + + # 获取本局启用的宗门 + existed_sect_ids = world_data.get("existed_sect_ids", []) + existed_sects = [sects_by_id[sid] for sid in existed_sect_ids if sid in sects_by_id] + + # 在地图上添加宗门总部 + add_sect_headquarters(game_map, existed_sects) + + # 第一阶段:重建所有Avatar(不含relations) + avatars_data = save_data.get("avatars", []) + all_avatars = {} + for avatar_data in avatars_data: + avatar = Avatar.from_save_dict(avatar_data, world) + all_avatars[avatar.id] = avatar + + # 第二阶段:重建relations(需要所有avatar都已加载) + for avatar_data in avatars_data: + avatar_id = avatar_data["id"] + avatar = all_avatars[avatar_id] + relations_dict = avatar_data.get("relations", {}) + + for other_id, relation_value in relations_dict.items(): + if other_id in all_avatars: + other_avatar = all_avatars[other_id] + relation = Relation(relation_value) + avatar.relations[other_avatar] = relation + + # 将所有avatar添加到world + world.avatar_manager.avatars = all_avatars + + # 重建事件历史 + events_data = save_data.get("events", []) + for event_data in events_data: + event = Event.from_dict(event_data) + world.event_manager.add_event(event) + + # 重建Simulator + simulator_data = save_data.get("simulator", {}) + simulator = Simulator(world) + simulator.birth_rate = simulator_data.get("birth_rate", CONFIG.game.npc_birth_rate_per_month) + + print(f"存档加载成功!共加载 {len(all_avatars)} 个角色,{len(events_data)} 条事件") + return world, simulator, existed_sects + + except Exception as e: + print(f"加载游戏失败: {e}") + import traceback + traceback.print_exc() + raise + + +def check_save_compatibility(save_path: Path) -> Tuple[bool, str]: + """ + 检查存档兼容性 + + Args: + save_path: 存档路径 + + Returns: + (是否兼容, 错误信息) + """ + try: + with open(save_path, "r", encoding="utf-8") as f: + save_data = json.load(f) + + meta = save_data.get("meta", {}) + save_version = meta.get("version", "unknown") + current_version = CONFIG.meta.version + + # 当前不做版本兼容性检查,直接返回兼容 + # 未来可以在这里添加版本比较逻辑 + return True, "" + + except Exception as e: + return False, f"无法读取存档文件: {e}" + diff --git a/src/sim/save/__init__.py b/src/sim/save/__init__.py new file mode 100644 index 0000000..36fbb94 --- /dev/null +++ b/src/sim/save/__init__.py @@ -0,0 +1,20 @@ +"""存档功能模块 + +延迟导入以避免循环依赖 +""" + +def __getattr__(name): + """延迟导入,避免在模块级别触发循环依赖""" + if name == "save_game": + from .save_game import save_game + return save_game + elif name == "get_save_info": + from .save_game import get_save_info + return get_save_info + elif name == "list_saves": + from .save_game import list_saves + return list_saves + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +__all__ = ["save_game", "get_save_info", "list_saves"] + diff --git a/src/sim/save/avatar_save_mixin.py b/src/sim/save/avatar_save_mixin.py new file mode 100644 index 0000000..d3beed9 --- /dev/null +++ b/src/sim/save/avatar_save_mixin.py @@ -0,0 +1,99 @@ +""" +Avatar存档序列化Mixin + +将Avatar的序列化逻辑从avatar.py分离出来,保持核心类的清晰性。 + +存档策略: +- 引用对象(Technique, Item等):保存id,加载时从全局字典获取 +- relations:转换为dict[str, str](avatar_id -> relation_value) +- items:转换为dict[int, int](item_id -> quantity) +- current_action:保存动作类名和参数 +- treasure:需要深拷贝(因为devoured_souls是实例特有的) +""" + + +class AvatarSaveMixin: + """Avatar存档序列化Mixin + + 提供to_save_dict方法,将Avatar转换为可JSON序列化的字典 + """ + + def to_save_dict(self) -> dict: + """转换为可序列化的字典(用于存档) + + Returns: + 包含Avatar完整状态的字典,可直接JSON序列化 + """ + # 序列化relations: dict[Avatar, Relation] -> dict[str, str] + relations_dict = { + other.id: relation.value + for other, relation in self.relations.items() + } + + # 序列化items: dict[Item, int] -> dict[int, int] + items_dict = { + item.id: quantity + for item, quantity in self.items.items() + } + + # 序列化current_action + current_action_dict = None + if self.current_action is not None: + current_action_dict = { + "action_name": self.current_action.action.__class__.__name__, + "params": self.current_action.params, + "status": self.current_action.status + } + + # 序列化planned_actions + planned_actions_list = [plan.to_dict() for plan in self.planned_actions] + + # 序列化spirit_animal + spirit_animal_dict = None + if self.spirit_animal is not None: + spirit_animal_dict = { + "name": self.spirit_animal.name, + "realm": self.spirit_animal.realm.name + } + + return { + # 基础信息 + "id": self.id, + "name": self.name, + "birth_month_stamp": int(self.birth_month_stamp), + "gender": self.gender.value, + "pos_x": self.pos_x, + "pos_y": self.pos_y, + + # 修炼相关 + "age": self.age.to_dict(), + "cultivation_progress": self.cultivation_progress.to_dict(), + "root": self.root.name, + "technique_id": self.technique.id if self.technique else None, + "hp": self.hp.to_dict(), + "mp": self.mp.to_dict(), + + # 物品与资源 + "magic_stone": self.magic_stone.value, + "items": items_dict, + "treasure_id": self.treasure.id if self.treasure else None, + "treasure_devoured_souls": self.treasure.devoured_souls if self.treasure else 0, + "spirit_animal": spirit_animal_dict, + + # 社交与状态 + "relations": relations_dict, + "sect_id": self.sect.id if self.sect else None, + "sect_rank": self.sect_rank.value if self.sect_rank else None, + "alignment": self.alignment.name if self.alignment else None, + "persona_ids": [p.id for p in self.personas] if self.personas else [], + "trait_id": self.trait.id if self.trait else None, + "appearance": self.appearance.level, + + # 行动与AI + "current_action": current_action_dict, + "planned_actions": planned_actions_list, + "thinking": self.thinking, + "objective": self.objective, + "_action_cd_last_months": self._action_cd_last_months, + } + diff --git a/src/sim/save/save_game.py b/src/sim/save/save_game.py new file mode 100644 index 0000000..3ca7ad9 --- /dev/null +++ b/src/sim/save/save_game.py @@ -0,0 +1,171 @@ +""" +存档功能模块 + +主要功能: +- save_game: 保存游戏完整状态到JSON文件 +- get_save_info: 读取存档的元信息(不加载完整数据) +- list_saves: 列出所有存档文件 + +存档内容: +- meta: 版本号、保存时间、游戏时间 +- world: 游戏时间戳、本局启用的宗门列表 +- avatars: 所有角色的完整状态(通过AvatarSaveMixin.to_save_dict序列化) +- events: 最近N条事件历史(N在config.yml中配置) +- simulator: 模拟器配置(如出生率) + +存档格式:JSON(明文,易于调试) +存档位置:assets/saves/ (配置在config.yml中) + +注意事项: +- 当前版本只支持单一存档槽位(save.json) +- 不支持跨版本兼容(版本号仅记录,不做检查) +- 地图本身不保存(因为地图是固定的,只保存宗门总部位置) +- relations在Avatar中已转换为id映射,避免循环引用 +""" +import json +from pathlib import Path +from datetime import datetime +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from src.classes.world import World + from src.sim.simulator import Simulator + from src.classes.sect import Sect + +from src.utils.config import CONFIG + + +def save_game( + world: "World", + simulator: "Simulator", + existed_sects: List["Sect"], + save_path: Optional[Path] = None +) -> tuple[bool, Optional[str]]: + """ + 保存游戏状态到文件 + + Args: + world: 世界对象 + simulator: 模拟器对象 + existed_sects: 本局启用的宗门列表 + save_path: 保存路径,默认为saves/时间戳_游戏时间.json + + Returns: + (保存是否成功, 保存的文件名) + """ + try: + # 确定保存路径 + if save_path is None: + saves_dir = CONFIG.paths.saves + saves_dir.mkdir(parents=True, exist_ok=True) + + # 生成友好的文件名:20251111_193000_Y100M1.json + now = datetime.now() + time_str = now.strftime("%Y%m%d_%H%M%S") + year = world.month_stamp.get_year() + month = world.month_stamp.get_month().value + game_time_str = f"Y{year}M{month}" + + filename = f"{time_str}_{game_time_str}.json" + save_path = saves_dir / filename + else: + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + # 构建元信息 + meta = { + "version": CONFIG.meta.version, + "save_time": datetime.now().isoformat(), + "game_time": f"{world.month_stamp.get_year()}年{world.month_stamp.get_month().value}月" + } + + # 构建世界数据 + world_data = { + "month_stamp": int(world.month_stamp), + "existed_sect_ids": [sect.id for sect in existed_sects] + } + + # 保存所有Avatar(第一阶段:不含relations) + avatars_data = [] + for avatar in world.avatar_manager.avatars.values(): + avatars_data.append(avatar.to_save_dict()) + + # 保存事件历史(限制数量) + max_events = CONFIG.save.max_events_to_save + events_data = [] + recent_events = world.event_manager.get_recent_events(limit=max_events) + for event in recent_events: + events_data.append(event.to_dict()) + + # 保存模拟器数据 + simulator_data = { + "birth_rate": simulator.birth_rate + } + + # 组装完整的存档数据 + save_data = { + "meta": meta, + "world": world_data, + "avatars": avatars_data, + "events": events_data, + "simulator": simulator_data + } + + # 写入文件 + with open(save_path, "w", encoding="utf-8") as f: + json.dump(save_data, f, ensure_ascii=False, indent=2) + + print(f"游戏已保存到: {save_path}") + return True, save_path.name + + except Exception as e: + print(f"保存游戏失败: {e}") + import traceback + traceback.print_exc() + return False, None + + +def get_save_info(save_path: Path) -> Optional[dict]: + """ + 读取存档文件的元信息(不加载完整数据) + + Args: + save_path: 存档路径 + + Returns: + 存档元信息字典,如果读取失败返回None + """ + try: + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("meta", {}) + except Exception: + return None + + +def list_saves(saves_dir: Optional[Path] = None) -> List[tuple[Path, dict]]: + """ + 列出所有存档文件及其元信息 + + Args: + saves_dir: 存档目录,默认为config中的saves目录 + + Returns: + [(存档路径, 元信息字典), ...] + """ + if saves_dir is None: + saves_dir = CONFIG.paths.saves + + if not saves_dir.exists(): + return [] + + saves = [] + for save_file in saves_dir.glob("*.json"): + info = get_save_info(save_file) + if info is not None: + saves.append((save_file, info)) + + # 按保存时间倒序排列 + saves.sort(key=lambda x: x[1].get("save_time", ""), reverse=True) + return saves + diff --git a/src/sim/save_game.py b/src/sim/save_game.py new file mode 100644 index 0000000..f405be8 --- /dev/null +++ b/src/sim/save_game.py @@ -0,0 +1,139 @@ +""" +存档功能模块 +""" +import json +from pathlib import Path +from datetime import datetime +from typing import List, Optional + +from src.classes.world import World +from src.sim.simulator import Simulator +from src.classes.sect import Sect +from src.utils.config import CONFIG + + +def save_game( + world: World, + simulator: Simulator, + existed_sects: List[Sect], + save_path: Optional[Path] = None +) -> bool: + """ + 保存游戏状态到文件 + + Args: + world: 世界对象 + simulator: 模拟器对象 + existed_sects: 本局启用的宗门列表 + save_path: 保存路径,默认为saves/save.json + + Returns: + 保存是否成功 + """ + try: + # 确定保存路径 + if save_path is None: + saves_dir = CONFIG.paths.saves + saves_dir.mkdir(parents=True, exist_ok=True) + save_path = saves_dir / "save.json" + else: + save_path = Path(save_path) + save_path.parent.mkdir(parents=True, exist_ok=True) + + # 构建元信息 + meta = { + "version": CONFIG.meta.version, + "save_time": datetime.now().isoformat(), + "game_time": f"{world.month_stamp.get_year()}年{world.month_stamp.get_month().value}月" + } + + # 构建世界数据 + world_data = { + "month_stamp": int(world.month_stamp), + "existed_sect_ids": [sect.id for sect in existed_sects] + } + + # 保存所有Avatar(第一阶段:不含relations) + avatars_data = [] + for avatar in world.avatar_manager.avatars.values(): + avatars_data.append(avatar.to_save_dict()) + + # 保存事件历史(限制数量) + max_events = CONFIG.save.max_events_to_save + events_data = [] + recent_events = world.event_manager.get_recent_events(limit=max_events) + for event in recent_events: + events_data.append(event.to_dict()) + + # 保存模拟器数据 + simulator_data = { + "birth_rate": simulator.birth_rate + } + + # 组装完整的存档数据 + save_data = { + "meta": meta, + "world": world_data, + "avatars": avatars_data, + "events": events_data, + "simulator": simulator_data + } + + # 写入文件 + with open(save_path, "w", encoding="utf-8") as f: + json.dump(save_data, f, ensure_ascii=False, indent=2) + + print(f"游戏已保存到: {save_path}") + return True + + except Exception as e: + print(f"保存游戏失败: {e}") + import traceback + traceback.print_exc() + return False + + +def get_save_info(save_path: Path) -> Optional[dict]: + """ + 读取存档文件的元信息(不加载完整数据) + + Args: + save_path: 存档路径 + + Returns: + 存档元信息字典,如果读取失败返回None + """ + try: + with open(save_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("meta", {}) + except Exception: + return None + + +def list_saves(saves_dir: Optional[Path] = None) -> List[tuple[Path, dict]]: + """ + 列出所有存档文件及其元信息 + + Args: + saves_dir: 存档目录,默认为config中的saves目录 + + Returns: + [(存档路径, 元信息字典), ...] + """ + if saves_dir is None: + saves_dir = CONFIG.paths.saves + + if not saves_dir.exists(): + return [] + + saves = [] + for save_file in saves_dir.glob("*.json"): + info = get_save_info(save_file) + if info is not None: + saves.append((save_file, info)) + + # 按保存时间倒序排列 + saves.sort(key=lambda x: x[1].get("save_time", ""), reverse=True) + return saves + diff --git a/static/config.yml b/static/config.yml index a5b4fec..402b46e 100644 --- a/static/config.yml +++ b/static/config.yml @@ -1,3 +1,6 @@ +meta: + version: "1.0.2" + llm: # 填入litellm支持的model name和key key: "your-api-key" # 目前需要的是阿里的qwen api @@ -8,6 +11,7 @@ llm: paths: templates: static/templates/ game_configs: static/game_configs/ + saves: assets/saves/ ai: max_decide_num: 4 @@ -28,6 +32,9 @@ avatar: social: event_context_num: 8 +save: + max_events_to_save: 1000 + # defined_avatar: # surname: 丰川 # given_name: 祥子 diff --git a/static/templates/conversation.txt b/static/templates/conversation.txt index 65cb9ca..f632982 100644 --- a/static/templates/conversation.txt +++ b/static/templates/conversation.txt @@ -16,7 +16,7 @@ {{ "{avatar_name_2}": {{ "thinking": ..., // 简单思考对话的情况 - "conversation_content": ... // 对话双方均为第三人称视角的,对话的主题和情况概括,约100字。注意不是对话的口语内容,仙侠语言风格。 + "conversation_content": ... // 对话双方均为第三人称视角的对话,100~150字,仙侠语言风格。可以是聊天也可以是对话概括。 "new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 "cancal_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。 }}