add save and load func
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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的类。
|
||||
包含了这个角色的一切信息。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
空事件单例类
|
||||
|
||||
@@ -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,
|
||||
|
||||
198
src/front/app.py
198
src/front/app.py
@@ -22,6 +22,7 @@ from .rendering import (
|
||||
)
|
||||
from .events_panel import draw_sidebar
|
||||
from .menu import PauseMenu
|
||||
from .toast import Toast
|
||||
from .layout import calculate_layout, get_fullscreen_resolution
|
||||
|
||||
|
||||
@@ -33,12 +34,14 @@ class Front:
|
||||
step_interval_ms: int = 400,
|
||||
window_title: str = "Cultivation World Simulator",
|
||||
font_path: Optional[str] = None,
|
||||
existed_sects: Optional[List] = None,
|
||||
):
|
||||
self.world = simulator.world
|
||||
self.simulator = simulator
|
||||
self.step_interval_ms = step_interval_ms
|
||||
self.window_title = window_title
|
||||
self.font_path = font_path
|
||||
self.existed_sects = existed_sects or [] # 保存本局启用的宗门列表
|
||||
|
||||
self._last_step_ms = 0
|
||||
self.events: List[Event] = []
|
||||
@@ -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)
|
||||
|
||||
@@ -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
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,
|
||||
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
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:
|
||||
# 填入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: 祥子
|
||||
|
||||
@@ -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}的身份。
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user