update map

This commit is contained in:
bridge
2025-11-21 23:11:35 +08:00
parent a234e621b7
commit 6ab05edab2
8 changed files with 575 additions and 21 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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