From 6ab05edab2c24e10c0406cd6917f41bc60973850 Mon Sep 17 00:00:00 2001 From: bridge Date: Fri, 21 Nov 2025 23:11:35 +0800 Subject: [PATCH] update map --- src/server/main.py | 115 ++++++- web/src/App.vue | 100 +++++- web/src/components/SystemMenu.vue | 295 ++++++++++++++++++ web/src/components/game/GameCanvas.vue | 14 +- web/src/components/game/MapLayer.vue | 2 + .../game/composables/useTextures.ts | 5 +- web/src/services/gameApi.ts | 35 +++ web/src/stores/game.ts | 30 +- 8 files changed, 575 insertions(+), 21 deletions(-) create mode 100644 web/src/components/SystemMenu.vue diff --git a/src/server/main.py b/src/server/main.py index 2f89828..ea28272 100644 --- a/src/server/main.py +++ b/src/server/main.py @@ -2,7 +2,7 @@ import sys import os import asyncio from contextlib import asynccontextmanager -from typing import List +from typing import List, Optional from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -22,12 +22,15 @@ from src.classes.sect import sects_by_id from src.classes.color import serialize_hover_lines from src.classes.event import Event from src.classes.long_term_objective import set_user_long_term_objective, clear_user_long_term_objective +from src.sim.save.save_game import save_game, list_saves +from src.sim.load.load_game import load_game import random # 全局游戏实例 game_instance = { "world": None, - "sim": None + "sim": None, + "is_paused": False # 新增暂停标记 } class ConnectionManager: @@ -130,6 +133,10 @@ async def game_loop(): await asyncio.sleep(1.0) try: + # 检查暂停状态 + if game_instance.get("is_paused", False): + continue + sim = game_instance.get("sim") world = game_instance.get("world") @@ -270,7 +277,8 @@ def get_state(): "month": m, "avatar_count": len(world.avatar_manager.avatars), "avatars": av_list, - "events": recent_events + "events": recent_events, + "is_paused": game_instance.get("is_paused", False) } except Exception as e: @@ -338,6 +346,18 @@ async def step_world(): "events_sample": [str(e) for e in events[:5]] } +@app.post("/api/control/pause") +def pause_game(): + """暂停游戏循环""" + game_instance["is_paused"] = True + return {"status": "ok", "message": "Game paused"} + +@app.post("/api/control/resume") +def resume_game(): + """恢复游戏循环""" + game_instance["is_paused"] = False + return {"status": "ok", "message": "Game resumed"} + @app.get("/api/hover") def get_hover_info( target_type: str = Query(alias="type"), @@ -411,6 +431,95 @@ def clear_long_term_objective(req: ClearObjectiveRequest): "message": "Objective cleared" if cleared else "No user objective to clear" } +# --- 存档系统 API --- + +class SaveGameRequest(BaseModel): + filename: Optional[str] = None + +class LoadGameRequest(BaseModel): + filename: str + +@app.get("/api/saves") +def get_saves(): + """获取存档列表""" + saves_list = list_saves() + # 转换 Path 为 str,并整理格式 + result = [] + for path, meta in saves_list: + result.append({ + "filename": path.name, + "save_time": meta.get("save_time", ""), + "game_time": meta.get("game_time", ""), + "version": meta.get("version", "") + }) + return {"saves": result} + +@app.post("/api/game/save") +def api_save_game(req: SaveGameRequest): + """保存游戏""" + world = game_instance.get("world") + sim = game_instance.get("sim") + if not world or not sim: + raise HTTPException(status_code=503, detail="Game not initialized") + + # 这里的 existed_sects 需要从 world 或者 sim 中获取,目前简单起见, + # 我们可以遍历地图上的宗门总部,或者如果全局有保存最好。 + # 由于 init_game 只有一次,我们需要从 world 中反推 active sects + # 但 save_game 签名里的 existed_sects 主要是为了记录 id。 + # 实际上 world.map.regions 中包含了宗门总部信息。 + # 或者更简单的:直接从 sects_by_id 取所有? 不太对。 + # 让我们看看 save_game 实现:它主要是存 id。 + # 我们可以传入空列表,如果在 load 时能容忍的话。 + # 实际上 load_game 里:existed_sects = [sects_by_id[sid] for sid in existed_sect_ids] + # 所以 save 时如果不传,load 时就拿不到。 + # 临时方案:遍历所有宗门,如果它有领地或者有人,就算存在。 + # 或者更粗暴:CONFIG.game.sect_num 如果没变,可以不管。 + # 最好是 world 对象上能挂载 existed_sects。 + # 暂时方案:传入所有宗门作为 existed_sects (全集),虽然有点浪费,但不丢数据。 + # 更好的方案:修改 init_game,把 existed_sects 挂载到 world 上。 + + # 尝试从 world 属性获取(如果以后添加了) + existed_sects = getattr(world, "existed_sects", []) + if not existed_sects: + # fallback: 所有 sects + existed_sects = list(sects_by_id.values()) + + success, filename = save_game(world, sim, existed_sects, save_path=None) # save_path=None 会自动生成时间戳文件名 + if success: + return {"status": "ok", "filename": filename} + else: + raise HTTPException(status_code=500, detail="Save failed") + +@app.post("/api/game/load") +def api_load_game(req: LoadGameRequest): + """加载游戏""" + # 安全检查:只允许加载 saves 目录下的文件 + if ".." in req.filename or "/" in req.filename or "\\" in req.filename: + raise HTTPException(status_code=400, detail="Invalid filename") + + try: + saves_dir = CONFIG.paths.saves + target_path = saves_dir / req.filename + + if not target_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + + # 加载 + new_world, new_sim, new_sects = load_game(target_path) + + # 确保挂载 existed_sects 以便下次保存 + new_world.existed_sects = new_sects + + # 替换全局实例 + game_instance["world"] = new_world + game_instance["sim"] = new_sim + + return {"status": "ok", "message": "Game loaded"} + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Load failed: {str(e)}") + def start(): """启动服务的入口函数""" # 改为 8002 端口 diff --git a/web/src/App.vue b/web/src/App.vue index 4d85988..c2564b1 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,43 +1,90 @@ @@ -50,6 +97,7 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?: background: #000; color: #eee; overflow: hidden; + position: relative; } .main-content { @@ -66,6 +114,28 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?: overflow: hidden; } +.menu-toggle { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + background: rgba(0,0,0,0.5); + border: 1px solid #444; + color: #ddd; + width: 40px; + height: 40px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.menu-toggle:hover { + background: rgba(0,0,0,0.8); + border-color: #666; +} + .sidebar { width: 400px; background: #181818; diff --git a/web/src/components/SystemMenu.vue b/web/src/components/SystemMenu.vue new file mode 100644 index 0000000..ef00ae4 --- /dev/null +++ b/web/src/components/SystemMenu.vue @@ -0,0 +1,295 @@ + + + + + + diff --git a/web/src/components/game/GameCanvas.vue b/web/src/components/game/GameCanvas.vue index 9aca2e0..f598930 100644 --- a/web/src/components/game/GameCanvas.vue +++ b/web/src/components/game/GameCanvas.vue @@ -2,6 +2,7 @@ import { Application } from 'vue3-pixi' import { ref, onMounted } from 'vue' import { useElementSize } from '@vueuse/core' +import { useGameStore } from '../../stores/game' // 引入 store import Viewport from './Viewport.vue' import MapLayer from './MapLayer.vue' import EntityLayer from './EntityLayer.vue' @@ -11,6 +12,8 @@ const container = ref() const { width, height } = useElementSize(container) const { loadBaseTextures, isLoaded } = useTextures() +const store = useGameStore() // 使用 store + const mapSize = ref({ width: 2000, height: 2000 }) const emit = defineEmits<{ @@ -59,7 +62,16 @@ onMounted(() => { :world-width="mapSize.width" :world-height="mapSize.height" > - + + diff --git a/web/src/components/game/MapLayer.vue b/web/src/components/game/MapLayer.vue index 534cf9d..0fc1172 100644 --- a/web/src/components/game/MapLayer.vue +++ b/web/src/components/game/MapLayer.vue @@ -61,6 +61,8 @@ async function renderMap() { const sprite = new Sprite(tex) sprite.x = x * TILE_SIZE sprite.y = y * TILE_SIZE + // 开启像素取整,消除 Tile 之间的黑边缝隙 + sprite.roundPixels = true if (['SECT', 'CITY', 'CAVE', 'RUINS'].includes(type)) { sprite.width = TILE_SIZE * 2 diff --git a/web/src/components/game/composables/useTextures.ts b/web/src/components/game/composables/useTextures.ts index b1acc2f..a7eae9e 100644 --- a/web/src/components/game/composables/useTextures.ts +++ b/web/src/components/game/composables/useTextures.ts @@ -1,5 +1,8 @@ import { ref } from 'vue' -import { Assets, Texture } from 'pixi.js' +import { Assets, Texture, TextureStyle } from 'pixi.js' + +// 设置全局纹理缩放模式为 nearest (像素风) +TextureStyle.defaultOptions.scaleMode = 'nearest' // 全局纹理缓存,避免重复加载 const textures = ref>({}) diff --git a/web/src/services/gameApi.ts b/web/src/services/gameApi.ts index fc327d1..132e55d 100644 --- a/web/src/services/gameApi.ts +++ b/web/src/services/gameApi.ts @@ -14,6 +14,13 @@ function buildHoverQuery(target: HoverTarget) { return `/api/hover?${query.toString()}` } +export interface SaveFile { + filename: string + save_time: string + game_time: string + version: string +} + export const gameApi = { getInitialState() { return apiGet('/api/state') @@ -38,5 +45,33 @@ export const gameApi = { return apiPost<{ status: string; message: string }>('/api/action/clear_long_term_objective', { avatar_id: avatarId }) + }, + + // --- 游戏控制 API --- + + pauseGame() { + return apiPost<{ status: string; message: string }>('/api/control/pause', {}) + }, + + resumeGame() { + return apiPost<{ status: string; message: string }>('/api/control/resume', {}) + }, + + // --- 存读档 API --- + + getSaves() { + return apiGet<{ saves: SaveFile[] }>('/api/saves') + }, + + saveGame(filename?: string) { + return apiPost<{ status: string; filename: string }>('/api/game/save', { + filename + }) + }, + + loadGame(filename: string) { + return apiPost<{ status: string; message: string }>('/api/game/load', { + filename + }) } } diff --git a/web/src/stores/game.ts b/web/src/stores/game.ts index a0c6efa..6c77440 100644 --- a/web/src/stores/game.ts +++ b/web/src/stores/game.ts @@ -44,6 +44,9 @@ export const useGameStore = defineStore('game', () => { const infoLoading = ref(false) const infoError = ref(null) const hoverCache = new Map() + + // 新增:用于标记世界是否被重置(读档) + const worldVersion = ref(0) const avatarList = computed(() => Object.values(avatars.value)) @@ -105,6 +108,9 @@ export const useGameStore = defineStore('game', () => { }) avatars.value = nextAvatars } + + // 重置事件列表,而不是追加,因为是全新状态 + events.value = [] appendEvents(data.events) } catch (error) { console.error('Fetch State Error', error) @@ -164,6 +170,26 @@ export const useGameStore = defineStore('game', () => { } } + async function reloadGame(filename: string) { + // 1. 调用加载接口 + await gameApi.loadGame(filename) + + // 2. 清空前端状态 + avatars.value = {} + events.value = [] + hoverCache.clear() + hoverInfo.value = [] + selectedTarget.value = null + + // 3. 重新获取初始状态 + await fetchInitialState() + + // 4. 更新世界版本,触发特定组件重绘 + worldVersion.value++ + + return true + } + function connect() { gateway.connect() } @@ -195,12 +221,14 @@ export const useGameStore = defineStore('game', () => { hoverInfo, infoLoading, infoError, + worldVersion, // 导出 connect, disconnect, fetchInitialState, openInfoPanel, closeInfoPanel, setLongTermObjective, - clearLongTermObjective + clearLongTermObjective, + reloadGame } })