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:
@@ -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>
|
||||
|
||||
@@ -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', {});
|
||||
}
|
||||
};
|
||||
|
||||
161
web/src/components/SplashLayer.vue
Normal file
161
web/src/components/SplashLayer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user