Files
cultivation-world-simulator/web/src/components/LoadingOverlay.vue
2026-01-10 00:50:30 +08:00

481 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { gameApi, type InitStatusDTO } from '../api/game'
const props = defineProps<{
status: InitStatusDTO | null
}>()
// 阶段文案(鬼谷八荒风格)
const phaseTexts: Record<string, string | string[]> = {
'scanning_assets': '扫描天地资源',
'loading_map': '构建洪荒山川',
'initializing_sects': '宗门入世',
'generating_avatars': '众修士降临',
'checking_llm': '连通天道意志',
'generating_initial_events': [
'天道轮转,命运初显',
'因果交织,机缘暗涌',
'气运流转,风云将起',
'众生沉浮,天机莫测',
'劫数将至,各凭造化',
'红尘万丈,道心初定',
'缘起缘灭,皆是天意',
'大道无形,万法归一',
],
'loading_save': '读取前世因果',
'parsing_data': '解析天地法则',
'restoring_state': '恢复时空位面',
'finalizing': '万象归位',
'complete': '天地初开',
'': '混沌初始',
}
// 用于 generating_initial_events 阶段的轮换文案。
const eventPhaseTextIndex = ref(0)
// Tips 列表
const tips = [
'修改角色目标,可以改变该角色的行事风格',
'角色的性格特质,会极大影响角色的行事风格',
'在符合角色灵根的洞府修行,事半功倍',
'天灵根角色在任何洞府修行,都事半功倍',
'改变天地灵机,不仅会影响加成,还会微妙调整角色行事风格',
'偶尔会有修仙小说中的主角穿越进此方世界',
'每个角色都有自己的思考和情绪',
'除了修炼,炼丹和练气也很重要',
'参加拍卖会可能捡漏,但要小心恶人的衔尾追杀',
'江湖同道会根据你的行为取一个绰号',
'双修虽好,还请克制',
'在宗门驻地回血可以回满HP',
'境界之间,战力差距极大,越阶挑战难于登天',
'天命之子特质的角色,好运连连,奇遇不断',
'现代世界的穿越者,只想回到现实世界,但这是不可能的',
'丹药有生效的时间限制',
'由于大模型需要思考,游戏启动可能耗时较久',
]
const currentTip = ref(tips[Math.floor(Math.random() * tips.length)])
const displayProgress = ref(0) // 实际显示的进度
const localElapsed = ref(0)
let tipInterval: ReturnType<typeof setInterval> | null = null
let elapsedInterval: ReturnType<typeof setInterval> | null = null
const progress = computed(() => displayProgress.value)
const phaseText = computed(() => {
const phaseName = props.status?.phase_name || ''
const text = phaseTexts[phaseName] || phaseTexts['']
if (Array.isArray(text)) {
return text[eventPhaseTextIndex.value % text.length]
}
return text
})
const isError = computed(() => props.status?.status === 'error')
const errorMessage = computed(() => props.status?.error || '未知错误')
// 监听后端进度,如果后端进度领先,则同步
watch(() => props.status?.progress, (newVal) => {
if (newVal !== undefined && newVal !== null) {
if (newVal > displayProgress.value) {
displayProgress.value = newVal
}
}
}, { immediate: true })
// 根据时间计算背景透明度前5秒保持不透明5-20秒逐渐透明到0.8。
// 只影响背景,不影响内容亮度。
const bgOpacity = computed(() => {
const elapsed = localElapsed.value
if (elapsed <= 5) return 1
if (elapsed >= 20) return 0.8
// 5秒 -> 1.0, 20秒 -> 0.8 (线性插值)。
return 1 - (elapsed - 5) / 15 * 0.2
})
// SVG 圆环参数
const radius = 90
const circumference = 2 * Math.PI * radius
const strokeDashoffset = computed(() => {
return circumference - (progress.value / 100) * circumference
})
async function handleRetry() {
localElapsed.value = 0
displayProgress.value = 0
try {
await gameApi.reinitGame()
} catch (e: any) {
console.error('Reinit failed:', e)
}
}
function startTimers() {
// Tips 切换
tipInterval = setInterval(() => {
const idx = Math.floor(Math.random() * tips.length)
currentTip.value = tips[idx]
}, 5000)
// 本地计时器 + 阶段文案轮换 + 伪进度自增
elapsedInterval = setInterval(() => {
localElapsed.value++
// 伪进度逻辑
if (props.status?.status === 'in_progress' && displayProgress.value < 99) {
const currentPhase = props.status?.phase ?? 0
// 后端定义的进度节点: {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83}
const progressMap: Record<number, number> = { 0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83 }
const nextPhaseStart = progressMap[currentPhase + 1] ?? 100
// 每1秒增加 1%
if (localElapsed.value % 1 === 0) {
// 如果还没达到下一阶段的起点前 1%,就继续自增
if (displayProgress.value < nextPhaseStart - 1) {
displayProgress.value++
} else if (currentPhase === 5 && displayProgress.value < 99) {
// 最后一个阶段5阶段允许一直增加到 99%
displayProgress.value++
}
}
}
// 每 5 秒切换一次 generating_initial_events 的文案。
if (localElapsed.value % 5 === 0) {
eventPhaseTextIndex.value++
}
}, 1000)
}
function stopTimers() {
if (tipInterval) {
clearInterval(tipInterval)
tipInterval = null
}
if (elapsedInterval) {
clearInterval(elapsedInterval)
elapsedInterval = null
}
}
// 当状态从 ready 变成其他时,重置
watch(() => props.status?.status, (newStatus, oldStatus) => {
if (oldStatus === 'ready' && newStatus !== 'ready') {
localElapsed.value = 0
displayProgress.value = 0
}
})
onMounted(() => {
startTimers()
})
onUnmounted(() => {
stopTimers()
})
</script>
<template>
<div class="loading-overlay">
<!-- 背景层 - 只有这层透明度变化 -->
<div
class="bg-layer"
:style="{ opacity: bgOpacity }"
></div>
<!-- 背景装饰 -->
<div class="bg-decoration" :style="{ opacity: bgOpacity }">
<div class="glow glow-1"></div>
<div class="glow glow-2"></div>
</div>
<!-- 主内容 -->
<div class="content">
<!-- 标题 -->
<h1 class="title">AI 修仙世界模拟器</h1>
<p class="subtitle">AI Cultivation World Simulator</p>
<!-- 进度圆环 -->
<div class="progress-ring">
<svg width="220" height="220" viewBox="0 0 220 220">
<defs>
<linearGradient id="progress-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00d4ff;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00ffa3;stop-opacity:1" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- 背景圆环 -->
<circle
class="track"
cx="110"
cy="110"
:r="radius"
/>
<!-- 进度圆环 -->
<circle
class="progress"
:class="{ error: isError }"
cx="110"
cy="110"
:r="radius"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
filter="url(#glow)"
/>
</svg>
<!-- 圆环内容 -->
<div class="ring-content">
<div class="percentage" :class="{ error: isError }">
{{ isError ? '!' : progress + '%' }}
</div>
<div class="phase-text">{{ isError ? '初始化失败' : phaseText }}</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="isError" class="error-section">
<p class="error-message">{{ errorMessage }}</p>
<button class="retry-btn" @click="handleRetry">
重新初始化
</button>
</div>
<!-- Tips -->
<div v-else class="tips-section">
<div class="tips-label">修行小贴士</div>
<div class="tips">{{ currentTip }}</div>
</div>
</div>
<!-- 底部信息 -->
<div class="footer">
<div class="elapsed">已等待 {{ localElapsed }} </div>
<div class="version">v1.1.0</div>
</div>
</div>
</template>
<style scoped>
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* 背景层 - 只有这层会变透明,带模糊效果 */
.bg-layer {
position: absolute;
inset: 0;
background: rgba(10, 10, 18, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: opacity 0.5s ease;
}
/* 背景装饰 */
.bg-decoration {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.glow {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.15;
}
.glow-1 {
width: 400px;
height: 400px;
background: #00d4ff;
top: 10%;
left: 20%;
animation: float 8s ease-in-out infinite;
}
.glow-2 {
width: 300px;
height: 300px;
background: #00ffa3;
bottom: 20%;
right: 15%;
animation: float 6s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(30px, -20px); }
}
/* 主内容 */
.content {
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
}
.title {
font-size: 42px;
font-weight: 300;
letter-spacing: 16px;
margin: 0 0 8px 16px;
background: linear-gradient(135deg, #fff 0%, rgba(255,255,255,0.8) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 12px;
letter-spacing: 4px;
color: rgba(255, 255, 255, 0.3);
margin: 0 0 50px 0;
text-transform: uppercase;
}
/* 进度圆环 */
.progress-ring {
width: 220px;
height: 220px;
position: relative;
}
.progress-ring svg {
transform: rotate(-90deg);
}
.progress-ring circle.track {
fill: none;
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 4;
}
.progress-ring circle.progress {
fill: none;
stroke: url(#progress-gradient);
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s ease;
}
.progress-ring circle.progress.error {
stroke: #ff6b6b;
}
.ring-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.percentage {
font-size: 48px;
font-weight: 200;
color: #fff;
letter-spacing: 2px;
}
.percentage.error {
color: #ff6b6b;
font-size: 56px;
}
.phase-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin-top: 8px;
letter-spacing: 2px;
}
/* 错误区域 */
.error-section {
margin-top: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.error-message {
color: rgba(255, 107, 107, 0.9);
font-size: 14px;
max-width: 300px;
text-align: center;
margin: 0;
}
.retry-btn {
padding: 12px 32px;
background: transparent;
border: 1px solid rgba(255, 107, 107, 0.4);
border-radius: 24px;
color: rgba(255, 107, 107, 0.9);
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 1px;
}
.retry-btn:hover {
background: rgba(255, 107, 107, 0.1);
border-color: rgba(255, 107, 107, 0.6);
}
/* Tips 区域 */
.tips-section {
margin-top: 50px;
text-align: center;
}
.tips-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.25);
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 12px;
}
.tips {
font-size: 14px;
color: rgba(255, 255, 255, 0.45);
max-width: 300px;
line-height: 1.6;
transition: opacity 0.3s ease;
}
/* 底部 */
.footer {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 32px;
}
.elapsed, .version {
font-size: 11px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 1px;
}
</style>