add save and load func

This commit is contained in:
bridge
2025-11-11 19:48:18 +08:00
parent 0cb7eacee7
commit 9b870475bf
21 changed files with 1348 additions and 32 deletions

4
.gitignore vendored
View File

@@ -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

View File

@@ -53,6 +53,29 @@ class ActionPlan:
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
class ActionInstance:

View File

@@ -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

View File

@@ -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的类。
包含了这个角色的一切信息。

View File

@@ -247,6 +247,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"])
breakthrough_success_rate_by_realm = {

View File

@@ -18,6 +18,23 @@ class Event:
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:
"""
空事件单例类

View File

@@ -67,6 +67,15 @@ class 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,
Realm.Foundation_Establishment: 200,
@@ -142,6 +151,15 @@ class MP:
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,
Realm.Foundation_Establishment: 200,

View File

@@ -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] = []
@@ -111,6 +114,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]] = {}
self._init_avatar_display_states()
@@ -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)

View File

@@ -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))
# 计算菜单尺寸

149
src/front/toast.py Normal file
View File

@@ -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"]

View File

@@ -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()

17
src/sim/load/__init__.py Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,172 @@
"""
Avatar读档反序列化Mixin
将Avatar的反序列化逻辑从avatar.py分离出来。
读档策略:
- 两阶段加载先加载所有Avatarrelations留空再重建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

165
src/sim/load/load_game.py Normal file
View File

@@ -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}"

138
src/sim/load_game.py Normal file
View File

@@ -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}"

20
src/sim/save/__init__.py Normal file
View File

@@ -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"]

View File

@@ -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,
}

171
src/sim/save/save_game.py Normal file
View File

@@ -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

139
src/sim/save_game.py Normal file
View File

@@ -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

View File

@@ -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: 祥子

View File

@@ -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}的身份。
}}