refactor frontend (not done)

This commit is contained in:
bridge
2026-01-11 22:08:01 +08:00
parent 488758764e
commit f33cfab0d5
9 changed files with 440 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'
import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui'
import { NConfigProvider, darkTheme, NMessageProvider, createDiscreteApi } from 'naive-ui'
import { useWorldStore } from './stores/world'
import { useUiStore } from './stores/ui'
import { useSocketStore } from './stores/socket'
@@ -23,14 +23,22 @@ const initStatus = ref<InitStatusDTO | null>(null)
const gameInitialized = ref(false)
const mapPreloaded = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
const canCloseMenu = ref(true)
const { message } = createDiscreteApi(['message'], {
configProviderProps: {
theme: darkTheme
}
})
// 根据 spec: showLoading = initStatus !== 'ready'
// 注意:
// 1. initStatus 为 null 时显示加载界面(还没获取到状态)
// 2. initStatus 不是 ready 时显示加载界面
// 3. 前端还没初始化完成时也要显示加载界面
// 2. initStatus 不是 ready 且不是 idle 时显示加载界面
// 3. 前端还没初始化完成时也要显示加载界面(如果后端 ready
const showLoading = computed(() => {
if (initStatus.value === null) return true
// idle 状态下,不显示全屏 loading而是显示菜单供用户配置
if (initStatus.value.status === 'idle') return false
if (initStatus.value.status !== 'ready') return true
if (!gameInitialized.value) return true
return false
@@ -38,7 +46,7 @@ const showLoading = computed(() => {
const showMenu = ref(false)
const isManualPaused = ref(true)
const menuDefaultTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm'>('load')
const menuDefaultTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm' | 'start'>('load')
// 可以提前加载地图的阶段(宗门初始化后地图数据就 ready 了)。
const MAP_READY_PHASES = ['initializing_sects', 'generating_avatars', 'checking_llm', 'generating_initial_events']
@@ -54,6 +62,12 @@ async function pollInitStatus() {
const prevStatus = initStatus.value?.status
initStatus.value = res
// 如果是从 null -> idle或者一直保持 idle 但菜单没打开
// 这里我们只在第一次检测到 idle 时执行启动检查
if (res.status === 'idle' && !showMenu.value && prevStatus !== 'idle') {
performStartupCheck()
}
// 提前加载地图:当进入特定阶段且还没预加载过时。
if (!mapPreloaded.value && MAP_READY_PHASES.includes(res.phase_name)) {
mapPreloaded.value = true
@@ -72,12 +86,60 @@ async function pollInitStatus() {
await initializeGame()
// ready 后停止轮询
stopPolling()
// 游戏准备就绪,关闭菜单
showMenu.value = false
}
} catch (e) {
console.error('Failed to fetch init status:', e)
}
}
async function performStartupCheck() {
try {
const res = await gameApi.fetchLLMStatus()
if (!res.configured) {
// 未配置 -> 强制进入 LLM 配置,禁止关闭
showMenu.value = true
menuDefaultTab.value = 'llm'
canCloseMenu.value = false
message.warning('检测到 LLM 未配置,请先完成设置')
} else {
// 已配置 -> 验证连通性
try {
const configRes = await gameApi.fetchLLMConfig()
await gameApi.testLLMConnection(configRes)
// 测试通过 -> 允许进入开始游戏
menuDefaultTab.value = 'start'
canCloseMenu.value = true
// 确保菜单显示在 Start 页
showMenu.value = true
} catch (connErr) {
// 连接失败 -> 强制进入配置
console.error('LLM Connection check failed:', connErr)
showMenu.value = true
menuDefaultTab.value = 'llm'
canCloseMenu.value = false
message.error('LLM 连接测试失败,请重新配置')
}
}
} catch (e) {
console.error('Failed to check LLM status:', e)
// Fallback
showMenu.value = true
menuDefaultTab.value = 'llm'
canCloseMenu.value = false
message.error('无法获取系统状态')
}
}
function handleLLMReady() {
canCloseMenu.value = true
menuDefaultTab.value = 'start'
message.success('LLM 配置成功,请开始游戏')
}
async function initializeGame() {
if (gameInitialized.value) {
// 重新加载存档时,重新初始化
@@ -141,6 +203,10 @@ function handleKeydown(e: KeyboardEvent) {
uiStore.clearSelection()
} else {
showMenu.value = !showMenu.value
// 如果打开菜单,默认切到 load (或者保持上一次的状态,这里暂定 load)
if (showMenu.value) {
menuDefaultTab.value = 'load'
}
}
}
}
@@ -226,7 +292,10 @@ watch([showMenu, isManualPaused], ([menuVisible, manualPaused]) => {
<SystemMenu
:visible="showMenu"
:default-tab="menuDefaultTab"
:game-initialized="gameInitialized"
:closable="canCloseMenu"
@close="handleMenuClose"
@llm-ready="handleLLMReady"
/>
</div>
</n-message-provider>
@@ -316,4 +385,4 @@ watch([showMenu, isManualPaused], ([menuVisible, manualPaused]) => {
flex-direction: column;
z-index: 20;
}
</style>
</style>

View File

@@ -66,6 +66,24 @@ export interface LLMConfigDTO {
mode: string;
}
export interface GameStartConfigDTO {
init_npc_num: number;
sect_num: number;
protagonist: string;
npc_awakening_rate_per_month: number;
}
export interface CurrentConfigDTO {
game: {
init_npc_num: number;
sect_num: number;
npc_awakening_rate_per_month: number;
};
avatar: {
protagonist: string;
};
}
// --- Events Pagination ---
export interface EventDTO {
@@ -117,7 +135,8 @@ export const gameApi = {
},
fetchAvatarMeta() {
return httpClient.get<{ males: number[]; females: number[] }>('/api/meta/avatars');
// Add timestamp to prevent caching
return httpClient.get<{ males: number[]; females: number[] }>(`/api/meta/avatars?t=${Date.now()}`);
},
fetchPhenomenaList() {
@@ -205,6 +224,10 @@ export const gameApi = {
saveLLMConfig(config: LLMConfigDTO) {
return httpClient.post<{ status: string; message: string }>('/api/config/llm/save', config);
},
fetchLLMStatus() {
return httpClient.get<{ configured: boolean }>('/api/config/llm/status');
},
// --- Events Pagination ---
@@ -233,10 +256,21 @@ export const gameApi = {
},
startNewGame() {
// Legacy: replaced by startGame logic usually, but kept for compatibility if needed
return httpClient.post<{ status: string; message: string }>('/api/game/new', {});
},
reinitGame() {
return httpClient.post<{ status: string; message: string }>('/api/control/reinit', {});
},
// --- Game Start Config ---
fetchCurrentConfig() {
return httpClient.get<CurrentConfigDTO>('/api/config/current');
},
startGame(config: GameStartConfigDTO) {
return httpClient.post<{ status: string; message: string }>('/api/game/start', config);
}
};

View File

@@ -42,17 +42,19 @@ const tips = [
'天灵根角色在任何洞府修行,都事半功倍',
'改变天地灵机,不仅会影响加成,还会微妙调整角色行事风格',
'偶尔会有修仙小说中的主角穿越进此方世界',
'每个角色都有自己的思考和情绪',
'每个角色都有自己的真实思考和情绪',
'除了修炼,炼丹和练气也很重要',
'参加拍卖会可能捡漏,但要小心恶人的衔尾追杀',
'江湖同道会根据你的行为取一个绰号',
'双修虽好,还请克制',
'在宗门驻地回血可以回满HP',
'境界之间,战力差距极大,越阶挑战难于登天',
'不同境界之间,战力差距极大,越阶挑战难于登天',
'天命之子特质的角色,好运连连,奇遇不断',
'现代世界的穿越者,只想回到现实世界,但这是不可能的',
'丹药有生效的时间限制',
'由于大模型需要思考,游戏启动可能耗时较久',
'模拟世界对大模型token消耗较大请注意',
'设定开局历史,世界也会随之而改变',
]
const currentTip = ref(tips[Math.floor(Math.random() * tips.length)])

View File

@@ -4,17 +4,21 @@ import SaveLoadPanel from './game/panels/system/SaveLoadPanel.vue'
import CreateAvatarPanel from './game/panels/system/CreateAvatarPanel.vue'
import DeleteAvatarPanel from './game/panels/system/DeleteAvatarPanel.vue'
import LLMConfigPanel from './game/panels/system/LLMConfigPanel.vue'
import GameStartPanel from './game/panels/system/GameStartPanel.vue'
const props = defineProps<{
visible: boolean
defaultTab?: 'save' | 'load' | 'create' | 'delete' | 'llm'
defaultTab?: 'save' | 'load' | 'create' | 'delete' | 'llm' | 'start'
gameInitialized: boolean
closable?: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'llm-ready'): void
}>()
const activeTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm'>(props.defaultTab || 'load')
const activeTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm' | 'start'>(props.defaultTab || 'load')
function switchTab(tab: typeof activeTab.value) {
activeTab.value = tab
@@ -40,10 +44,18 @@ watch(() => props.visible, (val) => {
<div class="system-menu">
<div class="menu-header">
<h2>系统菜单</h2>
<button class="close-btn" @click="emit('close')">×</button>
<!-- 只有在游戏未开始且处于 start/llm 界面时才可能无法关闭如果是强制引导 -->
<!-- 但为了用户体验通常保留关闭按钮用户如果没配置好就关闭也只是回到 idle 状态的空界面 -->
<button class="close-btn" @click="emit('close')" v-if="closable !== false">×</button>
</div>
<div class="menu-tabs">
<button
:class="{ active: activeTab === 'start' }"
@click="switchTab('start')"
>
开始游戏
</button>
<button
:class="{ active: activeTab === 'load' }"
@click="switchTab('load')"
@@ -53,18 +65,21 @@ watch(() => props.visible, (val) => {
<button
:class="{ active: activeTab === 'save' }"
@click="switchTab('save')"
:disabled="!gameInitialized"
>
保存游戏
</button>
<button
:class="{ active: activeTab === 'create' }"
@click="switchTab('create')"
:disabled="!gameInitialized"
>
新建角色
</button>
<button
:class="{ active: activeTab === 'delete' }"
@click="switchTab('delete')"
:disabled="!gameInitialized"
>
删除角色
</button>
@@ -77,8 +92,13 @@ watch(() => props.visible, (val) => {
</div>
<div class="menu-content">
<GameStartPanel
v-if="activeTab === 'start'"
:readonly="gameInitialized"
/>
<SaveLoadPanel
v-if="activeTab === 'save' || activeTab === 'load'"
v-else-if="activeTab === 'save' || activeTab === 'load'"
:mode="activeTab === 'save' ? 'save' : 'load'"
@close="emit('close')"
/>
@@ -87,12 +107,13 @@ watch(() => props.visible, (val) => {
v-else-if="activeTab === 'create'"
@created="switchTab('create')"
/>
<!-- Note: @created callback could switch to list or stay,
currently it stays to allow creating more or just refreshes internal list -->
<DeleteAvatarPanel v-else-if="activeTab === 'delete'" />
<LLMConfigPanel v-else-if="activeTab === 'llm'" />
<LLMConfigPanel
v-else-if="activeTab === 'llm'"
@config-saved="emit('llm-ready')"
/>
</div>
</div>
</div>
@@ -165,10 +186,15 @@ watch(() => props.visible, (val) => {
font-size: 1em;
}
.menu-tabs button:hover {
.menu-tabs button:hover:not(:disabled) {
background: #2a2a2a;
}
.menu-tabs button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-tabs button.active {
background: #1a1a1a;
color: #fff;

View File

@@ -32,19 +32,46 @@ export function useTextures() {
// 基础纹理加载(地图块、角色)
const loadBaseTextures = async () => {
if (isLoaded.value) return
// Load Avatar Meta first
// 1. 获取最新的 Avatar Meta 并检查是否有变化
let metaChanged = false
try {
const meta = await gameApi.fetchAvatarMeta()
if (meta.males) availableAvatars.value.males = meta.males
if (meta.females) availableAvatars.value.females = meta.females
console.log('Avatar meta loaded:', availableAvatars.value)
// 对比当前缓存的列表和新获取的列表
const newMalesStr = JSON.stringify(meta.males || [])
const curMalesStr = JSON.stringify(availableAvatars.value.males)
if (meta.males && newMalesStr !== curMalesStr) {
availableAvatars.value.males = meta.males
metaChanged = true
}
const newFemalesStr = JSON.stringify(meta.females || [])
const curFemalesStr = JSON.stringify(availableAvatars.value.females)
if (meta.females && newFemalesStr !== curFemalesStr) {
availableAvatars.value.females = meta.females
metaChanged = true
}
if (metaChanged) {
console.log('Avatar meta updated:', availableAvatars.value)
}
} catch (e) {
console.warn('Failed to load avatar meta, using default range', e)
// Fallback
availableAvatars.value.males = Array.from({length: 47}, (_, i) => i + 1)
availableAvatars.value.females = Array.from({length: 41}, (_, i) => i + 1)
// Fallback: 只有在列表为空时才使用默认值
if (availableAvatars.value.males.length === 0) {
availableAvatars.value.males = Array.from({length: 47}, (_, i) => i + 1)
availableAvatars.value.females = Array.from({length: 41}, (_, i) => i + 1)
metaChanged = true
}
}
// 2. 如果已经加载过,且元数据没有变化,则跳过
// 注意:如果 metaChanged 为 true即使 isLoaded 为 true 也要重新执行加载逻辑Pixi Assets 会处理去重)
if (isLoaded.value && !metaChanged) {
// Double check if textures are actually loaded for current avatars
// This handles the case where meta didn't change (e.g. was fallback) but textures weren't loaded
const missingTexture = availableAvatars.value.males.some(id => !textures.value[`male_${id}`])
if (!missingTexture) return
}
const manifest: Record<string, string> = {

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NForm, NFormItem, NInputNumber, NSelect, NButton, useMessage } from 'naive-ui'
import { gameApi } from '../../../../api/game'
const props = defineProps<{
readonly: boolean
}>()
const message = useMessage()
// 配置表单数据
const config = ref({
init_npc_num: 12,
sect_num: 3,
protagonist: 'none',
npc_awakening_rate_per_month: 0.01
})
const loading = ref(false)
// 选项
const protagonistOptions = [
{ label: '不引入主角', value: 'none' },
{ label: '随机引入主角', value: 'random' },
{ label: '全部引入主角', value: 'all' }
]
async function fetchConfig() {
try {
loading.value = true
const res = await gameApi.fetchCurrentConfig()
config.value = {
init_npc_num: res.game.init_npc_num,
sect_num: res.game.sect_num,
protagonist: res.avatar.protagonist,
npc_awakening_rate_per_month: res.game.npc_awakening_rate_per_month
}
} catch (e) {
message.error('加载配置失败')
console.error(e)
} finally {
loading.value = false
}
}
async function startGame() {
try {
loading.value = true
await gameApi.startGame(config.value)
message.success('配置已保存,正在初始化世界...')
// 父组件会通过 polling 检测到状态变化,从而自动关闭菜单并显示 loading
} catch (e) {
message.error('开始游戏失败')
console.error(e)
loading.value = false
}
}
onMounted(() => {
fetchConfig()
})
</script>
<template>
<div class="game-start-panel">
<div class="panel-header">
<h3>开始游戏</h3>
<p class="description">设定世界的初始状态注意游戏开始后这些设定将无法更改</p>
</div>
<n-form
label-placement="left"
label-width="160"
require-mark-placement="right-hanging"
:disabled="readonly"
>
<n-form-item label="初始修士数量" path="init_npc_num">
<n-input-number v-model:value="config.init_npc_num" :min="0" :max="100" />
</n-form-item>
<n-form-item label="活跃宗门数量" path="sect_num">
<n-input-number v-model:value="config.sect_num" :min="0" :max="10" />
</n-form-item>
<div class="tip-text" style="margin-top: -12px;">
宗门数量建议少于角色数量的一半
</div>
<n-form-item label="主角引入模式" path="protagonist">
<n-select v-model:value="config.protagonist" :options="protagonistOptions" />
</n-form-item>
<div class="tip-text" v-if="config.protagonist !== 'none'">
<span v-if="config.protagonist === 'random'">
随机引入每次生成角色时 5% 的概率使用预设的小说主角模板
</span>
<span v-if="config.protagonist === 'all'">
全部引入开局时强制生成所有预设的小说主角
</span>
</div>
<n-form-item label="每月新生修士概率" path="npc_awakening_rate_per_month">
<n-input-number
v-model:value="config.npc_awakening_rate_per_month"
:min="0"
:max="1"
:step="0.001"
:format="(val: number) => `${(val * 100).toFixed(1)}%`"
:parse="(val: string) => parseFloat(val) / 100"
/>
</n-form-item>
<div class="actions" v-if="!readonly">
<n-button type="primary" size="large" @click="startGame" :loading="loading">
开始
</n-button>
</div>
</n-form>
</div>
</template>
<style scoped>
.game-start-panel {
color: #eee;
max-width: 600px;
margin: 0 auto;
}
.panel-header {
margin-bottom: 2em;
text-align: center;
}
.description {
color: #888;
font-size: 0.9em;
}
.tip-text {
margin-left: 160px; /* offset label width */
margin-bottom: 24px;
color: #aaa;
font-size: 0.85em;
line-height: 1.5;
}
.actions {
display: flex;
justify-content: center;
margin-top: 2em;
}
</style>

View File

@@ -69,6 +69,10 @@ function applyPreset(preset: typeof presets[0]) {
message.info(`已应用 ${preset.name} 预设 (请填写 API Key)`)
}
const emit = defineEmits<{
(e: 'config-saved'): void
}>()
async function handleTestAndSave() {
if (!config.value.api_key) {
message.warning('请填写 API Key')
@@ -88,6 +92,7 @@ async function handleTestAndSave() {
// 2. 保存配置
await gameApi.saveLLMConfig(config.value)
message.success('配置已保存')
emit('config-saved')
} catch (e: any) {
message.error('测试或保存失败: ' + (e.response?.data?.detail || e.message))
} finally {