add save and load func
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -143,6 +143,10 @@ logs/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Save files (ignore all files in saves directory but not the directory itself)
|
||||||
|
assets/saves/*
|
||||||
|
!assets/saves/README.md
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
local_config.yml
|
local_config.yml
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,29 @@ class ActionPlan:
|
|||||||
max_retries: int = 0
|
max_retries: int = 0
|
||||||
attempted: 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
|
@dataclass
|
||||||
class ActionInstance:
|
class ActionInstance:
|
||||||
|
|||||||
@@ -121,3 +121,17 @@ class Age:
|
|||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""返回年龄的详细字符串表示"""
|
"""返回年龄的详细字符串表示"""
|
||||||
return f"Age({self.age})"
|
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
|
||||||
@@ -13,6 +13,8 @@ from src.classes.action import Action
|
|||||||
from src.classes.action_runtime import ActionStatus, ActionResult
|
from src.classes.action_runtime import ActionStatus, ActionResult
|
||||||
from src.classes.action.registry import ActionRegistry
|
from src.classes.action.registry import ActionRegistry
|
||||||
from src.classes.world import World
|
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.tile import Tile
|
||||||
from src.classes.region import Region
|
from src.classes.region import Region
|
||||||
from src.classes.cultivation import CultivationProgress
|
from src.classes.cultivation import CultivationProgress
|
||||||
@@ -59,7 +61,7 @@ gender_strs = {
|
|||||||
MAX_HISTORY_EVENTS = 10
|
MAX_HISTORY_EVENTS = 10
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Avatar:
|
class Avatar(AvatarSaveMixin, AvatarLoadMixin):
|
||||||
"""
|
"""
|
||||||
NPC的类。
|
NPC的类。
|
||||||
包含了这个角色的一切信息。
|
包含了这个角色的一切信息。
|
||||||
|
|||||||
@@ -247,6 +247,20 @@ class CultivationProgress:
|
|||||||
def get_breakthrough_fail_reduce_lifespan(self) -> int:
|
def get_breakthrough_fail_reduce_lifespan(self) -> int:
|
||||||
return breakthrough_fail_reduce_lifespan_by_realm[self.realm]
|
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 = {
|
breakthrough_success_rate_by_realm = {
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ class Event:
|
|||||||
month = self.month_stamp.get_month()
|
month = self.month_stamp.get_month()
|
||||||
return f"{year}年{month}月: {self.content}"
|
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:
|
class NullEvent:
|
||||||
"""
|
"""
|
||||||
空事件单例类
|
空事件单例类
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ class HP:
|
|||||||
return self.cur >= other.cur
|
return self.cur >= other.cur
|
||||||
return self.cur >= other
|
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 = {
|
HP_MAX_BY_REALM = {
|
||||||
Realm.Qi_Refinement: 100,
|
Realm.Qi_Refinement: 100,
|
||||||
Realm.Foundation_Establishment: 200,
|
Realm.Foundation_Establishment: 200,
|
||||||
@@ -142,6 +151,15 @@ class MP:
|
|||||||
self.max += value_2_add
|
self.max += value_2_add
|
||||||
return True
|
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 = {
|
MP_MAX_BY_REALM = {
|
||||||
Realm.Qi_Refinement: 100,
|
Realm.Qi_Refinement: 100,
|
||||||
Realm.Foundation_Establishment: 200,
|
Realm.Foundation_Establishment: 200,
|
||||||
|
|||||||
198
src/front/app.py
198
src/front/app.py
@@ -22,6 +22,7 @@ from .rendering import (
|
|||||||
)
|
)
|
||||||
from .events_panel import draw_sidebar
|
from .events_panel import draw_sidebar
|
||||||
from .menu import PauseMenu
|
from .menu import PauseMenu
|
||||||
|
from .toast import Toast
|
||||||
from .layout import calculate_layout, get_fullscreen_resolution
|
from .layout import calculate_layout, get_fullscreen_resolution
|
||||||
|
|
||||||
|
|
||||||
@@ -33,12 +34,14 @@ class Front:
|
|||||||
step_interval_ms: int = 400,
|
step_interval_ms: int = 400,
|
||||||
window_title: str = "Cultivation World Simulator",
|
window_title: str = "Cultivation World Simulator",
|
||||||
font_path: Optional[str] = None,
|
font_path: Optional[str] = None,
|
||||||
|
existed_sects: Optional[List] = None,
|
||||||
):
|
):
|
||||||
self.world = simulator.world
|
self.world = simulator.world
|
||||||
self.simulator = simulator
|
self.simulator = simulator
|
||||||
self.step_interval_ms = step_interval_ms
|
self.step_interval_ms = step_interval_ms
|
||||||
self.window_title = window_title
|
self.window_title = window_title
|
||||||
self.font_path = font_path
|
self.font_path = font_path
|
||||||
|
self.existed_sects = existed_sects or [] # 保存本局启用的宗门列表
|
||||||
|
|
||||||
self._last_step_ms = 0
|
self._last_step_ms = 0
|
||||||
self.events: List[Event] = []
|
self.events: List[Event] = []
|
||||||
@@ -111,6 +114,12 @@ class Front:
|
|||||||
# 暂停菜单
|
# 暂停菜单
|
||||||
self.pause_menu = PauseMenu(pygame)
|
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}
|
# 渲染插值状态:avatar_id -> {start_px, start_py, target_px, target_py, start_ms, duration_ms}
|
||||||
self._avatar_display_states: Dict[str, Dict[str, float]] = {}
|
self._avatar_display_states: Dict[str, Dict[str, float]] = {}
|
||||||
self._init_avatar_display_states()
|
self._init_avatar_display_states()
|
||||||
@@ -135,7 +144,16 @@ class Front:
|
|||||||
self.events = self.events[-1000:]
|
self.events = self.events[-1000:]
|
||||||
|
|
||||||
async def _step_once_async(self):
|
async def _step_once_async(self):
|
||||||
|
# 捕获当前world_id,用于检测是否已经加载了新世界
|
||||||
|
current_world_id = self._world_id
|
||||||
|
|
||||||
events = await self.simulator.step()
|
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:
|
if events:
|
||||||
self.add_events(events)
|
self.add_events(events)
|
||||||
self._last_step_ms = 0
|
self._last_step_ms = 0
|
||||||
@@ -162,12 +180,13 @@ class Front:
|
|||||||
if event.key == pygame.K_ESCAPE:
|
if event.key == pygame.K_ESCAPE:
|
||||||
self.pause_menu.toggle()
|
self.pause_menu.toggle()
|
||||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||||
# 处理菜单点击
|
# 处理菜单点击(菜单可见时阻止其他所有交互)
|
||||||
if self.pause_menu.is_visible:
|
if self.pause_menu.is_visible:
|
||||||
action = self._handle_menu_click()
|
action = self._handle_menu_click()
|
||||||
if action == "quit":
|
if action == "quit":
|
||||||
running = False
|
running = False
|
||||||
else:
|
else:
|
||||||
|
# 只有菜单不可见时才处理地图交互
|
||||||
self._handle_mouse_click()
|
self._handle_mouse_click()
|
||||||
# 兼容旧版滚轮为 MOUSEBUTTON 4/5
|
# 兼容旧版滚轮为 MOUSEBUTTON 4/5
|
||||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button in (4, 5):
|
elif event.type == pygame.MOUSEBUTTONDOWN and event.button in (4, 5):
|
||||||
@@ -213,31 +232,38 @@ class Front:
|
|||||||
# 底图后叠加小区域整图(2x2/3x3),再绘制宗门总部,避免被覆盖
|
# 底图后叠加小区域整图(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_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)
|
draw_sect_headquarters(pygame, self.screen, self.world, self.sect_images, self.tile_size, self.margin, status_bar_height)
|
||||||
hovered_region = draw_region_labels(
|
# 如果菜单可见,不显示任何hover(避免穿透)
|
||||||
pygame,
|
if not self.pause_menu.is_visible:
|
||||||
self.screen,
|
hovered_region = draw_region_labels(
|
||||||
self.colors,
|
pygame,
|
||||||
self.world,
|
self.screen,
|
||||||
self._get_region_font,
|
self.colors,
|
||||||
self.tile_size,
|
self.world,
|
||||||
self.margin,
|
self._get_region_font,
|
||||||
status_bar_height,
|
self.tile_size,
|
||||||
)
|
self.margin,
|
||||||
self._assign_avatar_images()
|
status_bar_height,
|
||||||
hovered_default, hover_candidates = draw_avatars_and_pick_hover(
|
)
|
||||||
pygame,
|
self._assign_avatar_images()
|
||||||
self.screen,
|
hovered_default, hover_candidates = draw_avatars_and_pick_hover(
|
||||||
self.colors,
|
pygame,
|
||||||
self.simulator,
|
self.screen,
|
||||||
self.avatar_images,
|
self.colors,
|
||||||
self.tile_size,
|
self.simulator,
|
||||||
self.margin,
|
self.avatar_images,
|
||||||
self._get_display_center,
|
self.tile_size,
|
||||||
status_bar_height,
|
self.margin,
|
||||||
self.name_font,
|
self._get_display_center,
|
||||||
self._sidebar_filter_avatar_id,
|
status_bar_height,
|
||||||
)
|
self.name_font,
|
||||||
hovered_avatar = self._pick_hover_with_scroll(hovered_default, hover_candidates)
|
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 在最上层
|
# 先绘制状态栏和侧边栏,再绘制 tooltip 保证 tooltip 在最上层
|
||||||
draw_status_bar(pygame, self.screen, self.colors, self.status_font, self.margin, self.world, status_bar_height)
|
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)
|
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()
|
pygame.display.flip()
|
||||||
|
|
||||||
def _handle_mouse_click(self) -> None:
|
def _handle_mouse_click(self) -> None:
|
||||||
@@ -309,7 +339,121 @@ class Front:
|
|||||||
"""处理菜单点击,返回动作"""
|
"""处理菜单点击,返回动作"""
|
||||||
mouse_pos = self.pygame.mouse.get_pos()
|
mouse_pos = self.pygame.mouse.get_pos()
|
||||||
option_rects = getattr(self, "_menu_option_rects", [])
|
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):
|
def _get_region_font(self, size: int):
|
||||||
return _get_region_font_cached(self.pygame, self._region_font_cache, size, self.font_path)
|
return _get_region_font_cached(self.pygame, self._region_font_cache, size, self.font_path)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class PauseMenu:
|
|||||||
self.pygame = pygame_mod
|
self.pygame = pygame_mod
|
||||||
self.is_visible = False
|
self.is_visible = False
|
||||||
self.options = [
|
self.options = [
|
||||||
|
MenuOption("保存游戏", "save"),
|
||||||
|
MenuOption("加载游戏", "load"),
|
||||||
MenuOption("退出游戏", "quit")
|
MenuOption("退出游戏", "quit")
|
||||||
]
|
]
|
||||||
self.selected_index = 0
|
self.selected_index = 0
|
||||||
@@ -50,9 +52,9 @@ class PauseMenu:
|
|||||||
pygame = self.pygame
|
pygame = self.pygame
|
||||||
screen_w, screen_h = screen.get_size()
|
screen_w, screen_h = screen.get_size()
|
||||||
|
|
||||||
# 绘制半透明黑色背景(模糊效果)
|
# 绘制全屏半透明黑色背景(作为mask,阻止背后交互)
|
||||||
overlay = pygame.Surface((screen_w, screen_h), pygame.SRCALPHA)
|
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))
|
screen.blit(overlay, (0, 0))
|
||||||
|
|
||||||
# 计算菜单尺寸
|
# 计算菜单尺寸
|
||||||
|
|||||||
149
src/front/toast.py
Normal file
149
src/front/toast.py
Normal 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"]
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ async def main():
|
|||||||
simulator=sim,
|
simulator=sim,
|
||||||
step_interval_ms=750,
|
step_interval_ms=750,
|
||||||
window_title="Cultivation World — Front Demo",
|
window_title="Cultivation World — Front Demo",
|
||||||
|
existed_sects=existed_sects,
|
||||||
)
|
)
|
||||||
await front.run_async()
|
await front.run_async()
|
||||||
|
|
||||||
|
|||||||
17
src/sim/load/__init__.py
Normal file
17
src/sim/load/__init__.py
Normal 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"]
|
||||||
|
|
||||||
172
src/sim/load/avatar_load_mixin.py
Normal file
172
src/sim/load/avatar_load_mixin.py
Normal file
@@ -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
|
||||||
|
|
||||||
165
src/sim/load/load_game.py
Normal file
165
src/sim/load/load_game.py
Normal 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
138
src/sim/load_game.py
Normal 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
20
src/sim/save/__init__.py
Normal 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"]
|
||||||
|
|
||||||
99
src/sim/save/avatar_save_mixin.py
Normal file
99
src/sim/save/avatar_save_mixin.py
Normal 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
171
src/sim/save/save_game.py
Normal 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
139
src/sim/save_game.py
Normal 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
|
||||||
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
meta:
|
||||||
|
version: "1.0.2"
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
# 填入litellm支持的model name和key
|
# 填入litellm支持的model name和key
|
||||||
key: "your-api-key" # 目前需要的是阿里的qwen api
|
key: "your-api-key" # 目前需要的是阿里的qwen api
|
||||||
@@ -8,6 +11,7 @@ llm:
|
|||||||
paths:
|
paths:
|
||||||
templates: static/templates/
|
templates: static/templates/
|
||||||
game_configs: static/game_configs/
|
game_configs: static/game_configs/
|
||||||
|
saves: assets/saves/
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
max_decide_num: 4
|
max_decide_num: 4
|
||||||
@@ -28,6 +32,9 @@ avatar:
|
|||||||
social:
|
social:
|
||||||
event_context_num: 8
|
event_context_num: 8
|
||||||
|
|
||||||
|
save:
|
||||||
|
max_events_to_save: 1000
|
||||||
|
|
||||||
# defined_avatar:
|
# defined_avatar:
|
||||||
# surname: 丰川
|
# surname: 丰川
|
||||||
# given_name: 祥子
|
# given_name: 祥子
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{{
|
{{
|
||||||
"{avatar_name_2}": {{
|
"{avatar_name_2}": {{
|
||||||
"thinking": ..., // 简单思考对话的情况
|
"thinking": ..., // 简单思考对话的情况
|
||||||
"conversation_content": ... // 对话双方均为第三人称视角的,对话的主题和情况概括,约100字。注意不是对话的口语内容,仙侠语言风格。
|
"conversation_content": ... // 对话双方均为第三人称视角的对话,100~150字,仙侠语言风格。可以是聊天也可以是对话概括。
|
||||||
"new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。
|
"new_relation": ... // 如果你认为可以让两者产生某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。
|
||||||
"cancal_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。
|
"cancal_relation": ... // 可选,如果你认为可以让两者取消某种身份关系,则返回关系的中文名,否则返回空str。注意这是{avatar_name_2}相对于{avatar_name_1}的身份。
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user