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

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)