update map
This commit is contained in:
@@ -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 端口
|
||||
|
||||
100
web/src/App.vue
100
web/src/App.vue
@@ -1,43 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useGameStore } from './stores/game'
|
||||
import { NConfigProvider, darkTheme } from 'naive-ui'
|
||||
import { gameApi } from './services/gameApi'
|
||||
import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui'
|
||||
import GameCanvas from './components/game/GameCanvas.vue'
|
||||
import InfoPanel from './components/InfoPanel.vue'
|
||||
import StatusBar from './components/layout/StatusBar.vue'
|
||||
import EventPanel from './components/panels/EventPanel.vue'
|
||||
import SystemMenu from './components/SystemMenu.vue'
|
||||
|
||||
const store = useGameStore()
|
||||
const showMenu = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchInitialState().catch((error) => {
|
||||
console.error('初始化失败', error)
|
||||
})
|
||||
store.connect()
|
||||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (store.selectedTarget) {
|
||||
store.closeInfoPanel()
|
||||
} else {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?: string }) {
|
||||
store.openInfoPanel(target)
|
||||
}
|
||||
|
||||
function handleMenuClose() {
|
||||
showMenu.value = false
|
||||
}
|
||||
|
||||
// 监听菜单状态,控制游戏暂停/继续
|
||||
watch(showMenu, (visible) => {
|
||||
if (visible) {
|
||||
gameApi.pauseGame().catch(console.error)
|
||||
} else {
|
||||
gameApi.resumeGame().catch(console.error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<div class="app-layout">
|
||||
<StatusBar />
|
||||
<div class="main-content">
|
||||
<div class="map-container">
|
||||
<GameCanvas
|
||||
@avatarSelected="handleSelection"
|
||||
@regionSelected="handleSelection"
|
||||
/>
|
||||
<InfoPanel />
|
||||
<n-message-provider>
|
||||
<div class="app-layout">
|
||||
<StatusBar />
|
||||
|
||||
<div class="main-content">
|
||||
<div class="map-container">
|
||||
<!-- 菜单按钮移动到这里,相对于地图区域定位 -->
|
||||
<button class="menu-toggle" @click="showMenu = true">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||
<path fill="currentColor" d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<GameCanvas
|
||||
@avatarSelected="handleSelection"
|
||||
@regionSelected="handleSelection"
|
||||
/>
|
||||
<InfoPanel />
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<EventPanel />
|
||||
</aside>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<EventPanel />
|
||||
</aside>
|
||||
|
||||
<SystemMenu
|
||||
:visible="showMenu"
|
||||
@close="handleMenuClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
295
web/src/components/SystemMenu.vue
Normal file
295
web/src/components/SystemMenu.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { gameApi, type SaveFile } from '../services/gameApi'
|
||||
import { useGameStore } from '../stores/game'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const store = useGameStore()
|
||||
const message = useMessage()
|
||||
|
||||
const activeTab = ref<'save' | 'load'>('load')
|
||||
const saves = ref<SaveFile[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchSaves() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await gameApi.getSaves()
|
||||
saves.value = res.saves
|
||||
} catch (e) {
|
||||
message.error('获取存档列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await gameApi.saveGame()
|
||||
message.success(`存档成功: ${res.filename}`)
|
||||
await fetchSaves() // 刷新列表
|
||||
} catch (e) {
|
||||
message.error('存档失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoad(filename: string) {
|
||||
if (!confirm(`确定要加载存档 ${filename} 吗?当前未保存的进度将丢失。`)) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await store.reloadGame(filename)
|
||||
message.success('读档成功')
|
||||
emit('close')
|
||||
} catch (e) {
|
||||
message.error('读档失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
fetchSaves()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="system-menu-overlay" @click.self="emit('close')">
|
||||
<div class="system-menu">
|
||||
<div class="menu-header">
|
||||
<h2>系统菜单</h2>
|
||||
<button class="close-btn" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="menu-tabs">
|
||||
<button
|
||||
:class="{ active: activeTab === 'save' }"
|
||||
@click="activeTab = 'save'; fetchSaves()"
|
||||
>
|
||||
保存游戏
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'load' }"
|
||||
@click="activeTab = 'load'; fetchSaves()"
|
||||
>
|
||||
加载游戏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="menu-content">
|
||||
<div v-if="loading" class="loading">处理中...</div>
|
||||
|
||||
<div v-else-if="activeTab === 'save'" class="save-panel">
|
||||
<div class="new-save-card" @click="handleSave">
|
||||
<div class="icon">+</div>
|
||||
<div>新建存档</div>
|
||||
<div class="sub">点击创建一个新的存档文件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="load-panel">
|
||||
<div v-if="saves.length === 0" class="empty">暂无存档</div>
|
||||
<div
|
||||
v-for="save in saves"
|
||||
:key="save.filename"
|
||||
class="save-item"
|
||||
@click="handleLoad(save.filename)"
|
||||
>
|
||||
<div class="save-info">
|
||||
<div class="save-time">{{ save.save_time }}</div>
|
||||
<div class="game-time">游戏时间: {{ save.game_time }}</div>
|
||||
<div class="filename">{{ save.filename }}</div>
|
||||
</div>
|
||||
<div class="load-btn">加载</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.system-menu {
|
||||
background: #1a1a1a;
|
||||
width: 600px;
|
||||
height: 500px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.menu-tabs button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
background: #222;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-tabs button:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.menu-tabs button.active {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-bottom: 2px solid #4a9eff;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.save-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.new-save-card {
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
border: 2px dashed #444;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.new-save-card:hover {
|
||||
border-color: #666;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.new-save-card .icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.new-save-card .sub {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.save-item {
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.save-item:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.save-info .save-time {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.save-info .game-time {
|
||||
color: #4a9eff;
|
||||
font-size: 13px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.save-info .filename {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.load-btn {
|
||||
background: #333;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<HTMLElement>()
|
||||
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"
|
||||
>
|
||||
<MapLayer @mapLoaded="onMapLoaded" @regionSelected="handleRegionSelected" />
|
||||
<!--
|
||||
使用 store.worldVersion 作为 key
|
||||
当读档时,MapLayer 会被重新创建,从而重新加载地图数据
|
||||
但 Application 和 WebGL 上下文保持不变,避免崩溃
|
||||
-->
|
||||
<MapLayer
|
||||
:key="store.worldVersion"
|
||||
@mapLoaded="onMapLoaded"
|
||||
@regionSelected="handleRegionSelected"
|
||||
/>
|
||||
<EntityLayer @avatarSelected="handleAvatarSelected" />
|
||||
</Viewport>
|
||||
</Application>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, Texture>>({})
|
||||
|
||||
@@ -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<InitialStateResponse>('/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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ export const useGameStore = defineStore('game', () => {
|
||||
const infoLoading = ref(false)
|
||||
const infoError = ref<string | null>(null)
|
||||
const hoverCache = new Map<string, HoverLine[]>()
|
||||
|
||||
// 新增:用于标记世界是否被重置(读档)
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user