Feat: Add splash layer (#29)

Add splash layer, support game start, settings, exit
Modify settings layer, add "go back to splash" and "exit"
Add character threshold for history input
Closes #28
This commit is contained in:
4thfever
2026-01-13 22:00:23 +08:00
committed by GitHub
parent 224e3e76f0
commit 0d34b27fff
9 changed files with 460 additions and 9 deletions

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 MiB

View File

@@ -4,7 +4,10 @@ import asyncio
import webbrowser
import subprocess
import time
import threading
import signal
from contextlib import asynccontextmanager
from typing import List, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
@@ -883,6 +886,18 @@ def get_map():
}
@app.post("/api/control/reset")
def reset_game():
"""重置游戏到 Idle 状态(回到主菜单)"""
game_instance["world"] = None
game_instance["sim"] = None
game_instance["is_paused"] = True
game_instance["init_status"] = "idle"
game_instance["init_phase"] = 0
game_instance["init_progress"] = 0
game_instance["init_error"] = None
return {"status": "ok", "message": "Game reset to idle"}
@app.post("/api/control/pause")
def pause_game():
"""暂停游戏循环"""
@@ -895,6 +910,17 @@ def resume_game():
game_instance["is_paused"] = False
return {"status": "ok", "message": "Game resumed"}
@app.post("/api/control/shutdown")
async def shutdown_server():
def _shutdown():
time.sleep(1) # 给前端一点时间接收 200 OK 响应
# 这种方式适用于 uvicorn 运行环境,或者直接杀进程
os.kill(os.getpid(), signal.SIGINT)
# 异步执行关闭,确保先返回响应
threading.Thread(target=_shutdown).start()
return {"status": "shutting_down", "message": "Server is shutting down..."}
# --- 初始化状态 API ---
@@ -1551,6 +1577,11 @@ async def api_load_game(req: LoadGameRequest):
game_instance["init_start_time"] = time.time()
game_instance["init_error"] = None
game_instance["init_phase"] = 0
# 0. 扫描资源 (修复读取存档不加载头像的问题)
game_instance["init_phase_name"] = "scanning_assets"
await asyncio.to_thread(scan_avatar_assets)
game_instance["init_phase_name"] = "loading_save"
game_instance["init_progress"] = 10

View File

@@ -61,6 +61,8 @@ def dummy_avatar(base_world):
# 强制清空特质(因为 __post_init__ 会在 personas 为空时自动随机生成)
av.personas = []
# 强制清空功法,防止随机出的功法带有移动步长加成(如逍遥游)
av.technique = None
av.recalc_effects()
return av

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { onMounted, onUnmounted, watch, ref } from 'vue'
import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui'
import { useUiStore } from './stores/ui'
import { systemApi } from './api/modules/system'
// Components
import SplashLayer from './components/SplashLayer.vue'
import GameCanvas from './components/game/GameCanvas.vue'
import InfoPanelContainer from './components/game/panels/info/InfoPanelContainer.vue'
import StatusBar from './components/layout/StatusBar.vue'
@@ -18,6 +20,9 @@ import { useGameControl } from './composables/useGameControl'
// Stores
const uiStore = useUiStore()
const showSplash = ref(true)
const openedFromSplash = ref(false)
// 1. 游戏初始化逻辑
const {
initStatus,
@@ -59,13 +64,20 @@ watch(initStatus, (newVal, oldVal) => {
// 自动取消暂停:当游戏初始化完成后,自动开始运行
watch(gameInitialized, (val) => {
if (val) {
// 如果游戏已初始化完成(可能是刷新页面后恢复),确保关闭 Splash
if (showSplash.value) {
showSplash.value = false
}
isManualPaused.value = false
openedFromSplash.value = false // 游戏开始,清除 Splash 来源标记
}
})
// 事件处理
function onKeydown(e: KeyboardEvent) {
if (showLoading.value) return
if (showSplash.value) return
controlHandleKeydown(e)
}
@@ -73,6 +85,61 @@ function handleSelection(target: { type: 'avatar' | 'region'; id: string; name?:
uiStore.select(target.type, target.id)
}
async function handleSplashAction(key: string) {
if (key === 'exit') {
try {
await systemApi.shutdown()
window.close()
document.body.innerHTML = '<div style="color:white; display:flex; justify-content:center; align-items:center; height:100vh; background:black; font-size:24px;">游戏已关闭,您可以安全关闭此标签页。</div>'
} catch (e) {
console.error('Shutdown failed', e)
}
return
}
openedFromSplash.value = true // 标记来源
// 关闭 Splash
showSplash.value = false
// 确保系统菜单是打开的
showMenu.value = true
// 根据按键跳转到对应 Tab
if (key === 'start') {
menuDefaultTab.value = 'start'
} else if (key === 'load') {
menuDefaultTab.value = 'load'
}
}
function handleMenuCloseWrapper() {
// 如果是从 Splash 打开的,关闭菜单时应回到 Splash
if (openedFromSplash.value) {
showMenu.value = false
showSplash.value = true
// 保持 openedFromSplash 为 true 或 false?
// 如果回到 Splash下次点击 Start 又是重新流程。
// 这里不需要重置,因为下次点击 handleSplashAction 会再次设置。
} else {
// 正常游戏内关闭
handleMenuClose()
}
}
async function handleReturnToMain() {
try {
await systemApi.resetGame()
// 关闭菜单
showMenu.value = false
// 显示 Splash
showSplash.value = true
// 重置来源标记虽然显示Splash后点击按钮会重新设置但这里为了逻辑清晰先重置
openedFromSplash.value = false
} catch (e) {
console.error('Reset game failed', e)
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
@@ -85,14 +152,19 @@ onUnmounted(() => {
<template>
<n-config-provider :theme="darkTheme">
<n-message-provider>
<SplashLayer
v-if="showSplash"
@action="handleSplashAction"
/>
<!-- Loading Overlay - 盖在游戏上面 -->
<LoadingOverlay
v-if="showLoading"
v-if="!showSplash && showLoading"
:status="initStatus"
/>
<!-- Game UI - 始终渲染 -->
<div class="app-layout">
<div v-if="!showSplash" class="app-layout">
<StatusBar />
<div class="main-content">
@@ -140,8 +212,10 @@ onUnmounted(() => {
:default-tab="menuDefaultTab"
:game-initialized="gameInitialized"
:closable="canCloseMenu"
@close="handleMenuClose"
@close="handleMenuCloseWrapper"
@llm-ready="handleLLMReady"
@return-to-main="handleReturnToMain"
@exit-game="() => handleSplashAction('exit')"
/>
</div>
</n-message-provider>

View File

@@ -45,5 +45,13 @@ export const systemApi = {
startGame(config: GameStartConfigDTO) {
return httpClient.post<{ status: string; message: string }>('/api/game/start', config);
},
shutdown() {
return httpClient.post<{ status: string; message: string }>('/api/control/shutdown', {});
},
resetGame() {
return httpClient.post<{ status: string; message: string }>('/api/control/reset', {});
}
};

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import { NButton, NSpace } from 'naive-ui'
// 定义事件
const emit = defineEmits<{
(e: 'action', key: string): void
}>()
// 定义按钮列表
const menuOptions = [
{ label: '开始游戏', subLabel: 'Start Game', key: 'start', disabled: false },
{ label: '加载游戏', subLabel: 'Load Game', key: 'load', disabled: false },
{ label: '成就', subLabel: 'Achievements', key: 'achievements', disabled: true },
{ label: '设置', subLabel: 'Settings', key: 'settings', disabled: true },
{ label: '离开', subLabel: 'Exit', key: 'exit', disabled: false }
]
function handleClick(key: string) {
emit('action', key)
}
</script>
<template>
<div class="splash-container">
<!-- 左侧模糊层 -->
<div class="glass-panel">
<div class="title-area">
<h1>修仙模拟器</h1>
<p>Cultivation World Simulator</p>
</div>
<div class="menu-area">
<n-space vertical size="large">
<n-button
v-for="opt in menuOptions"
:key="opt.key"
size="large"
block
color="#ffffff20"
text-color="#fff"
class="menu-btn"
:disabled="opt.disabled"
@click="handleClick(opt.key)"
>
<div class="btn-content">
<span class="btn-label">{{ opt.label }}</span>
<span class="btn-sub">{{ opt.subLabel }}</span>
</div>
</n-button>
</n-space>
</div>
</div>
</div>
</template>
<style scoped>
.splash-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
/* 背景图设置 */
background-image: url('/assets/splash.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 9999;
display: flex;
align-items: center;
background-color: #000; /* 图片加载前的底色 */
}
/* 左侧毛玻璃面板 */
.glass-panel {
width: 400px;
height: 100%;
background: rgba(0, 0, 0, 0.4); /* 半透明黑底 */
backdrop-filter: blur(20px); /* 核心模糊效果 */
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 60px;
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
}
.title-area {
margin-bottom: 80px;
color: #fff;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.title-area h1 {
font-size: 3rem;
margin-bottom: 10px;
font-weight: bold;
letter-spacing: 4px;
}
.title-area p {
font-size: 1.2rem;
opacity: 0.8;
letter-spacing: 2px;
}
/* 按钮样式微调 */
.menu-btn {
height: 60px; /* 稍微加大一点按钮高度 */
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
/* 核心修复:强制内容左对齐 */
justify-content: flex-start;
text-align: left;
padding-left: 32px; /* 统一的左侧留白 */
}
/* 修复 Naive UI 按钮内容可能默认居中的问题 */
.menu-btn :deep(.n-button__content) {
justify-content: flex-start;
width: 100%;
}
.btn-content {
display: flex;
flex-direction: column;
align-items: flex-start; /* 左对齐 */
width: 100%;
}
.btn-label {
font-size: 20px;
letter-spacing: 4px;
line-height: 1.2;
}
.btn-sub {
font-size: 10px;
opacity: 0.6;
letter-spacing: 1px;
text-transform: uppercase;
}
.menu-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
transform: translateX(10px);
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
}
/* 针对移动端的简单适配(虽然这种游戏一般是桌面端) */
@media (max-width: 768px) {
.glass-panel {
width: 100%;
border-right: none;
background: rgba(0, 0, 0, 0.6);
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NButton } from 'naive-ui'
import SaveLoadPanel from './game/panels/system/SaveLoadPanel.vue'
import CreateAvatarPanel from './game/panels/system/CreateAvatarPanel.vue'
import DeleteAvatarPanel from './game/panels/system/DeleteAvatarPanel.vue'
@@ -16,9 +17,11 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'close'): void
(e: 'llm-ready'): void
(e: 'return-to-main'): void
(e: 'exit-game'): void
}>()
const activeTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm' | 'start'>(props.defaultTab || 'load')
const activeTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm' | 'start' | 'other'>(props.defaultTab || 'load')
function switchTab(tab: typeof activeTab.value) {
activeTab.value = tab
@@ -89,6 +92,12 @@ watch(() => props.visible, (val) => {
>
LLM设置
</button>
<button
:class="{ active: activeTab === 'other' }"
@click="switchTab('other')"
>
其他
</button>
</div>
<div class="menu-content">
@@ -114,12 +123,165 @@ watch(() => props.visible, (val) => {
v-else-if="activeTab === 'llm'"
@config-saved="emit('llm-ready')"
/>
<div v-else-if="activeTab === 'other'" class="other-panel-container">
<div class="panel-header">
<h3>其他选项</h3>
<p class="description">管理游戏进程和退出</p>
</div>
<div class="other-actions">
<button class="custom-action-btn" @click="emit('return-to-main')">
<div class="btn-content">
<div class="btn-icon">🏠</div>
<div class="btn-text-group">
<span class="btn-title">回到主菜单</span>
<span class="btn-desc">返回标题画面未保存的进度将丢失</span>
</div>
</div>
<div class="btn-arrow"></div>
</button>
<button class="custom-action-btn danger-hover" @click="emit('exit-game')">
<div class="btn-content">
<div class="btn-icon">🚪</div>
<div class="btn-text-group">
<span class="btn-title">结束游戏</span>
<span class="btn-desc">关闭程序并退出到桌面</span>
</div>
</div>
<div class="btn-arrow"></div>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.other-panel-container {
max-width: 600px;
margin: 0 auto;
padding-top: 2em;
}
.panel-header {
margin-bottom: 3em;
text-align: center;
}
.panel-header h3 {
margin: 0 0 0.5em 0;
font-size: 1.5em;
color: #eee;
}
.description {
color: #888;
font-size: 0.9em;
margin: 0;
}
.other-actions {
display: flex;
flex-direction: column;
gap: 20px; /* 间距调整 */
width: 100%;
padding: 0 40px;
}
/* 新的自定义按钮样式 */
.custom-action-btn {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 20px 24px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
color: #eee;
text-align: left;
position: relative;
overflow: hidden;
}
.custom-action-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.btn-content {
display: flex;
align-items: center;
gap: 20px;
}
.btn-icon {
font-size: 24px;
opacity: 0.8;
width: 32px;
text-align: center;
}
.btn-text-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.btn-title {
font-size: 18px;
font-weight: 500;
letter-spacing: 1px;
}
.btn-desc {
font-size: 12px;
color: #888;
transition: color 0.3s;
}
.btn-arrow {
font-size: 18px;
opacity: 0.3;
transform: translateX(0);
transition: all 0.3s ease;
}
.custom-action-btn:hover .btn-arrow {
opacity: 0.8;
transform: translateX(5px);
}
.custom-action-btn:hover .btn-desc {
color: #aaa;
}
/* 危险操作(结束游戏)的微调 - 只有在 Hover 时才显露一点红色 */
.custom-action-btn.danger-hover:hover {
border-color: rgba(255, 80, 80, 0.4);
background: linear-gradient(90deg, rgba(255, 80, 80, 0.05), rgba(255, 255, 255, 0.05));
}
.custom-action-btn.danger-hover:hover .btn-title {
color: #ff6666;
}
.custom-action-btn.danger-hover:hover .btn-icon {
color: #ff6666;
}
.icon {
font-size: 1.2em;
margin-right: 4px;
}
.system-menu-overlay {
position: fixed;
top: 0;
@@ -206,4 +368,4 @@ watch(() => props.visible, (val) => {
padding: 1.5em;
overflow-y: auto;
}
</style>
</style>

View File

@@ -118,6 +118,8 @@ onMounted(() => {
type="textarea"
placeholder="请输入修仙界历史背景(可选)。"
:autosize="{ minRows: 3, maxRows: 6 }"
maxlength="800"
show-count
/>
</n-form-item>
<div class="tip-text" style="margin-top: -12px;">

View File

@@ -56,8 +56,18 @@ export function useGameInit(options: UseGameInitOptions = {}) {
if (!res) return
// Idle check
if (res.status === 'idle' && prevStatus !== 'idle') {
options.onIdle?.()
if (res.status === 'idle') {
if (prevStatus !== 'idle') {
options.onIdle?.()
}
// 如果后端是 idle确保前端状态也是重置的
if (isInitialized.value) {
systemStore.setInitialized(false)
worldStore.reset()
}
// 重置预加载标记
mapPreloaded.value = false
avatarsPreloaded.value = false
}
// 提前加载地图
@@ -75,7 +85,8 @@ export function useGameInit(options: UseGameInitOptions = {}) {
// 状态跃迁:非 Ready -> Ready
if (prevStatus !== 'ready' && res.status === 'ready') {
await initializeGame()
stopPolling()
// 不要停止轮询,否则 reset 之后无法检测到状态变化
// stopPolling()
}
} catch (e) {
console.error('Failed to fetch init status:', e)