feat: add loading screen with progress tracking

- Add async initialization with 6 phases: scanning_assets, loading_map,
  initializing_sects, generating_avatars, checking_llm, generating_initial_events
- Add /api/init-status endpoint for frontend polling
- Add /api/control/reinit endpoint for error recovery
- Add LoadingOverlay.vue component with:
  - Progress ring with gradient
  - Phase text in xianxia style (rotating messages for LLM phase)
  - Tips that rotate every 5 seconds
  - Time-based background transparency (fades to 80% over 20s)
  - Backdrop blur effect
  - Error state with retry button
- Preload map and avatars during LLM initialization for smoother UX
- Add comprehensive tests for init status API
This commit is contained in:
Zihao Xu
2026-01-08 00:57:21 -08:00
committed by bridge
parent 8631be501b
commit 9485b62cfd
7 changed files with 1305 additions and 36 deletions

View File

@@ -3,6 +3,7 @@ import os
import asyncio
import webbrowser
import subprocess
import time
from contextlib import asynccontextmanager
from typing import List, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Query
@@ -43,7 +44,14 @@ from src.utils.llm.config import LLMConfig, LLMMode
game_instance = {
"world": None,
"sim": None,
"is_paused": True # 默认启动为暂停状态,等待前端连接唤醒
"is_paused": True, # 默认启动为暂停状态,等待前端连接唤醒
# 初始化状态字段
"init_status": "idle", # idle | pending | in_progress | ready | error
"init_phase": 0, # 当前阶段 (0-5)
"init_phase_name": "", # 当前阶段名称
"init_progress": 0, # 总体进度 (0-100)
"init_error": None, # 错误信息
"init_start_time": None, # 初始化开始时间戳
}
# Cache for avatar IDs
@@ -273,12 +281,138 @@ def check_llm_connectivity() -> tuple[bool, str]:
except Exception as e:
return False, f"连通性检测异常:{str(e)}"
def init_game():
"""初始化游戏世界,逻辑复用自 src/run/run.py"""
# 初始化阶段名称映射(用于前端显示)
INIT_PHASE_NAMES = {
0: "scanning_assets",
1: "loading_map",
2: "initializing_sects",
3: "generating_avatars",
4: "checking_llm",
5: "generating_initial_events",
}
def update_init_progress(phase: int, phase_name: str = ""):
"""更新初始化进度。"""
game_instance["init_phase"] = phase
game_instance["init_phase_name"] = phase_name or INIT_PHASE_NAMES.get(phase, "")
# 每阶段占约 16.7%(共 6 阶段),最后一阶段到 100%
progress_map = {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83}
game_instance["init_progress"] = progress_map.get(phase, phase * 17)
print(f"[Init] Phase {phase}: {game_instance['init_phase_name']} ({game_instance['init_progress']}%)")
async def init_game_async():
"""异步初始化游戏世界,带进度更新。"""
game_instance["init_status"] = "in_progress"
game_instance["init_start_time"] = time.time()
game_instance["init_error"] = None
try:
# 阶段 0: 资源扫描
update_init_progress(0, "scanning_assets")
await asyncio.to_thread(scan_avatar_assets)
# 阶段 1: 地图加载
update_init_progress(1, "loading_map")
game_map = await asyncio.to_thread(load_cultivation_world_map)
world = World(map=game_map, month_stamp=create_month_stamp(Year(100), Month.JANUARY))
sim = Simulator(world)
# 阶段 2: 宗门初始化
update_init_progress(2, "initializing_sects")
all_sects = list(sects_by_id.values())
needed_sects = int(getattr(CONFIG.game, "sect_num", 0) or 0)
existed_sects = []
if needed_sects > 0 and all_sects:
pool = list(all_sects)
random.shuffle(pool)
existed_sects = pool[:needed_sects]
# 阶段 3: 角色生成
update_init_progress(3, "generating_avatars")
protagonist_mode = getattr(CONFIG.avatar, "protagonist", "none")
target_total_count = int(getattr(CONFIG.game, "init_npc_num", 12))
final_avatars = {}
spawned_protagonists_count = 0
if protagonist_mode in ["all", "random"]:
prob = 1.0 if protagonist_mode == "all" else 0.05
def _spawn_protagonists_sync():
return prot_utils.spawn_protagonists(world, world.month_stamp, probability=prob)
prot_avatars = await asyncio.to_thread(_spawn_protagonists_sync)
final_avatars.update(prot_avatars)
spawned_protagonists_count = len(prot_avatars)
print(f"生成了 {spawned_protagonists_count} 位主角 (Mode: {protagonist_mode})")
remaining_count = 0
if protagonist_mode == "all":
remaining_count = 0
else:
remaining_count = max(0, target_total_count - spawned_protagonists_count)
if remaining_count > 0:
def _make_random_sync():
return _new_make_random(
world,
count=remaining_count,
current_month_stamp=world.month_stamp,
existed_sects=existed_sects
)
random_avatars = await asyncio.to_thread(_make_random_sync)
final_avatars.update(random_avatars)
print(f"生成了 {len(random_avatars)} 位随机路人")
world.avatar_manager.avatars.update(final_avatars)
game_instance["world"] = world
game_instance["sim"] = sim
# 阶段 4: LLM 连通性检测
update_init_progress(4, "checking_llm")
print("正在检测 LLM 连通性...")
# 使用线程池执行,避免阻塞事件循环,让 /api/init-status 可以响应
success, error_msg = await asyncio.to_thread(check_llm_connectivity)
if not success:
print(f"[警告] LLM 连通性检测失败: {error_msg}")
game_instance["llm_check_failed"] = True
game_instance["llm_error_message"] = error_msg
else:
print("LLM 连通性检测通过 ✓")
game_instance["llm_check_failed"] = False
game_instance["llm_error_message"] = ""
# 阶段 5: 生成初始事件(第一次 sim.step
update_init_progress(5, "generating_initial_events")
print("正在生成初始事件...")
# 取消暂停,执行第一步来生成初始事件
game_instance["is_paused"] = False
try:
await sim.step()
print("初始事件生成完成 ✓")
except Exception as e:
print(f"[警告] 初始事件生成失败: {e}")
finally:
# 执行完后重新暂停,等待前端准备好
game_instance["is_paused"] = True
# 完成
game_instance["init_status"] = "ready"
game_instance["init_progress"] = 100
print("游戏世界初始化完成!")
except Exception as e:
import traceback
traceback.print_exc()
game_instance["init_status"] = "error"
game_instance["init_error"] = str(e)
print(f"[Error] 初始化失败: {e}")
"""初始化游戏世界,逻辑复用自 src/run/run.py (同步版本,保留用于向后兼容)"""
from datetime import datetime
from src.sim.load_game import get_events_db_path
print("正在初始化游戏世界...")
scan_avatar_assets()
game_map = load_cultivation_world_map()
# 生成时间戳命名的存档路径
@@ -373,18 +507,37 @@ def init_game():
# 这样可以避免在用户加载存档前就生成初始化事件(如长期目标)。
game_instance["is_paused"] = True
# ===== LLM 检测结束 =====
# 更新初始化状态为就绪
game_instance["init_status"] = "ready"
game_instance["init_progress"] = 100
async def game_loop():
"""后台自动运行游戏循环"""
print("后台游戏循环已启动...")
"""后台自动运行游戏循环"""
print("后台游戏循环已启动,等待初始化完成...")
# 等待初始化完成
while game_instance.get("init_status") not in ("ready", "error"):
await asyncio.sleep(0.5)
if game_instance.get("init_status") == "error":
print("[game_loop] 初始化失败,游戏循环退出。")
return
print("[game_loop] 初始化完成,开始游戏循环。")
while True:
# 控制游戏速度,例如每秒 1 次更新
await asyncio.sleep(1.0)
await asyncio.sleep(1.0)
try:
# 检查暂停状态
if game_instance.get("is_paused", False):
continue
# 再次检查初始化状态(可能被重新初始化)
if game_instance.get("init_status") != "ready":
continue
sim = game_instance.get("sim")
world = game_instance.get("world")
@@ -466,10 +619,12 @@ async def game_loop():
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时初始化
scan_avatar_assets()
init_game()
# 启动后台任务
# 启动时自动开始异步初始化游戏
# 前端会轮询 /api/init-status 来显示加载进度
print("服务器启动,开始异步初始化游戏...")
asyncio.create_task(init_game_async())
# 启动后台游戏循环(会自动等待初始化完成)
asyncio.create_task(game_loop())
npm_process = None
@@ -805,6 +960,73 @@ def resume_game():
game_instance["is_paused"] = False
return {"status": "ok", "message": "Game resumed"}
# --- 初始化状态 API ---
@app.get("/api/init-status")
def get_init_status():
"""获取初始化状态。"""
status = game_instance.get("init_status", "idle")
start_time = game_instance.get("init_start_time")
elapsed = time.time() - start_time if start_time else 0
return {
"status": status,
"phase": game_instance.get("init_phase", 0),
"phase_name": game_instance.get("init_phase_name", ""),
"progress": game_instance.get("init_progress", 0),
"elapsed_seconds": round(elapsed, 1),
"error": game_instance.get("init_error"),
# 额外信息LLM 状态
"llm_check_failed": game_instance.get("llm_check_failed", False),
"llm_error_message": game_instance.get("llm_error_message", ""),
}
@app.post("/api/game/new")
async def start_new_game():
"""开始新游戏(异步初始化)。"""
current_status = game_instance.get("init_status", "idle")
# 如果已经在初始化中,返回错误
if current_status == "in_progress":
raise HTTPException(status_code=400, detail="Game is already initializing")
# 如果已经初始化完成,需要重置
if current_status == "ready":
# 清理旧的游戏状态
game_instance["world"] = None
game_instance["sim"] = None
# 重置初始化状态
game_instance["init_status"] = "pending"
game_instance["init_phase"] = 0
game_instance["init_progress"] = 0
game_instance["init_error"] = None
# 启动异步初始化任务
asyncio.create_task(init_game_async())
return {"status": "ok", "message": "New game initialization started"}
@app.post("/api/control/reinit")
async def reinit_game():
"""重新初始化游戏(用于错误恢复)。"""
# 清理旧的游戏状态
game_instance["world"] = None
game_instance["sim"] = None
game_instance["init_status"] = "pending"
game_instance["init_phase"] = 0
game_instance["init_progress"] = 0
game_instance["init_error"] = None
# 启动异步初始化任务
asyncio.create_task(init_game_async())
return {"status": "ok", "message": "Reinitialization started"}
@app.get("/api/detail")
def get_detail_info(
target_type: str = Query(alias="type"),
@@ -1298,8 +1520,8 @@ def api_save_game(req: SaveGameRequest):
raise HTTPException(status_code=500, detail="Save failed")
@app.post("/api/game/load")
def api_load_game(req: LoadGameRequest):
"""加载游戏"""
async def api_load_game(req: LoadGameRequest):
"""加载游戏(异步,支持进度更新)。"""
# 安全检查:只允许加载 saves 目录下的文件
if ".." in req.filename or "/" in req.filename or "\\" in req.filename:
raise HTTPException(status_code=400, detail="Invalid filename")
@@ -1311,8 +1533,22 @@ def api_load_game(req: LoadGameRequest):
if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found")
# 设置加载状态
game_instance["init_status"] = "in_progress"
game_instance["init_start_time"] = time.time()
game_instance["init_error"] = None
game_instance["init_phase"] = 0
game_instance["init_phase_name"] = "loading_save"
game_instance["init_progress"] = 10
# 暂停游戏,防止 game_loop 在加载过程中使用旧 world 生成事件。
game_instance["is_paused"] = True
await asyncio.sleep(0) # 让出控制权
# 更新进度
game_instance["init_progress"] = 30
game_instance["init_phase_name"] = "parsing_data"
await asyncio.sleep(0)
# 关闭旧 World 的 EventManager释放 SQLite 连接。
old_world = game_instance.get("world")
@@ -1321,6 +1557,11 @@ def api_load_game(req: LoadGameRequest):
# 加载
new_world, new_sim, new_sects = load_game(target_path)
# 更新进度
game_instance["init_progress"] = 70
game_instance["init_phase_name"] = "restoring_state"
await asyncio.sleep(0)
# 确保挂载 existed_sects 以便下次保存
new_world.existed_sects = new_sects
@@ -1330,6 +1571,16 @@ def api_load_game(req: LoadGameRequest):
game_instance["sim"] = new_sim
game_instance["current_save_path"] = target_path
# 更新进度
game_instance["init_progress"] = 90
game_instance["init_phase_name"] = "finalizing"
await asyncio.sleep(0)
# 加载完成
game_instance["init_status"] = "ready"
game_instance["init_progress"] = 100
game_instance["init_phase_name"] = "complete"
# 加载完成后保持暂停状态,让用户决定何时恢复。
# 这也给前端时间来刷新状态。
@@ -1337,6 +1588,8 @@ def api_load_game(req: LoadGameRequest):
except Exception as e:
import traceback
traceback.print_exc()
game_instance["init_status"] = "error"
game_instance["init_error"] = str(e)
raise HTTPException(status_code=500, detail=f"Load failed: {str(e)}")
# --- 静态文件挂载 (必须放在最后) ---

View File

@@ -0,0 +1,405 @@
"""
Tests for the initialization status API endpoints.
These tests verify the loading screen backend functionality:
- /api/init-status endpoint
- /api/game/new endpoint
- /api/control/reinit endpoint
- Initialization phases and progress tracking
"""
import pytest
import asyncio
import time
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi.testclient import TestClient
from src.server import main
from src.server.main import app, game_instance, update_init_progress, INIT_PHASE_NAMES
@pytest.fixture
def client():
"""Create a test client for the FastAPI app."""
return TestClient(app)
@pytest.fixture
def reset_game_instance():
"""Reset game_instance to initial state before each test."""
original_state = dict(game_instance)
game_instance.clear()
game_instance.update({
"world": None,
"sim": None,
"is_paused": True,
"init_status": "idle",
"init_phase": 0,
"init_phase_name": "",
"init_progress": 0,
"init_start_time": None,
"init_error": None,
"llm_check_failed": False,
"llm_error_message": "",
})
yield
game_instance.clear()
game_instance.update(original_state)
class TestInitStatusEndpoint:
"""Tests for /api/init-status endpoint."""
def test_init_status_idle(self, client, reset_game_instance):
"""Test init-status returns idle state correctly."""
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "idle"
assert data["phase"] == 0
assert data["phase_name"] == ""
assert data["progress"] == 0
assert data["error"] is None
assert data["llm_check_failed"] is False
assert data["llm_error_message"] == ""
def test_init_status_in_progress(self, client, reset_game_instance):
"""Test init-status during initialization."""
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 2
game_instance["init_phase_name"] = "initializing_sects"
game_instance["init_progress"] = 33
game_instance["init_start_time"] = time.time() - 5 # 5 seconds ago
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "in_progress"
assert data["phase"] == 2
assert data["phase_name"] == "initializing_sects"
assert data["progress"] == 33
assert data["elapsed_seconds"] >= 5
def test_init_status_ready(self, client, reset_game_instance):
"""Test init-status when initialization is complete."""
game_instance["init_status"] = "ready"
game_instance["init_phase"] = 5
game_instance["init_phase_name"] = "generating_initial_events"
game_instance["init_progress"] = 100
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["progress"] == 100
def test_init_status_error(self, client, reset_game_instance):
"""Test init-status when initialization failed."""
game_instance["init_status"] = "error"
game_instance["init_error"] = "LLM connection failed"
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
assert data["error"] == "LLM connection failed"
def test_init_status_llm_check_failed(self, client, reset_game_instance):
"""Test init-status includes LLM check status."""
game_instance["init_status"] = "ready"
game_instance["llm_check_failed"] = True
game_instance["llm_error_message"] = "API key invalid"
response = client.get("/api/init-status")
assert response.status_code == 200
data = response.json()
assert data["llm_check_failed"] is True
assert data["llm_error_message"] == "API key invalid"
class TestUpdateInitProgress:
"""Tests for update_init_progress function."""
def test_update_progress_with_phase_name(self, reset_game_instance):
"""Test updating progress with explicit phase name."""
update_init_progress(2, "initializing_sects")
assert game_instance["init_phase"] == 2
assert game_instance["init_phase_name"] == "initializing_sects"
assert game_instance["init_progress"] == 33
def test_update_progress_without_phase_name(self, reset_game_instance):
"""Test updating progress uses default phase name from mapping."""
update_init_progress(3)
assert game_instance["init_phase"] == 3
assert game_instance["init_phase_name"] == "generating_avatars"
assert game_instance["init_progress"] == 50
def test_all_phase_names_mapped(self):
"""Test all phases have corresponding names."""
expected_phases = {
0: "scanning_assets",
1: "loading_map",
2: "initializing_sects",
3: "generating_avatars",
4: "checking_llm",
5: "generating_initial_events",
}
assert INIT_PHASE_NAMES == expected_phases
def test_progress_percentages(self, reset_game_instance):
"""Test progress percentages for each phase."""
expected_progress = {0: 0, 1: 17, 2: 33, 3: 50, 4: 67, 5: 83}
for phase, expected in expected_progress.items():
update_init_progress(phase)
assert game_instance["init_progress"] == expected, f"Phase {phase} should have progress {expected}"
class TestNewGameEndpoint:
"""Tests for /api/game/new endpoint."""
def test_new_game_starts_initialization(self, client, reset_game_instance):
"""Test /api/game/new starts initialization process."""
with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init:
response = client.post("/api/game/new")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "started" in data["message"].lower()
assert game_instance["init_status"] == "pending"
def test_new_game_rejects_when_in_progress(self, client, reset_game_instance):
"""Test /api/game/new rejects request when already initializing."""
game_instance["init_status"] = "in_progress"
response = client.post("/api/game/new")
assert response.status_code == 400
assert "already initializing" in response.json()["detail"].lower()
def test_new_game_clears_existing_state(self, client, reset_game_instance):
"""Test /api/game/new clears existing game state when ready."""
mock_world = MagicMock()
mock_sim = MagicMock()
game_instance["world"] = mock_world
game_instance["sim"] = mock_sim
game_instance["init_status"] = "ready"
with patch.object(main, 'init_game_async', new_callable=AsyncMock):
response = client.post("/api/game/new")
assert response.status_code == 200
assert game_instance["world"] is None
assert game_instance["sim"] is None
class TestReinitEndpoint:
"""Tests for /api/control/reinit endpoint."""
def test_reinit_clears_state(self, client, reset_game_instance):
"""Test /api/control/reinit clears all game state."""
game_instance["world"] = MagicMock()
game_instance["sim"] = MagicMock()
game_instance["init_status"] = "error"
game_instance["init_error"] = "Some error"
game_instance["init_phase"] = 3
game_instance["init_progress"] = 50
with patch.object(main, 'init_game_async', new_callable=AsyncMock):
response = client.post("/api/control/reinit")
assert response.status_code == 200
assert game_instance["world"] is None
assert game_instance["sim"] is None
assert game_instance["init_status"] == "pending"
assert game_instance["init_phase"] == 0
assert game_instance["init_progress"] == 0
assert game_instance["init_error"] is None
def test_reinit_starts_new_initialization(self, client, reset_game_instance):
"""Test /api/control/reinit starts new initialization task."""
with patch.object(main, 'init_game_async', new_callable=AsyncMock) as mock_init:
response = client.post("/api/control/reinit")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "reinitialization" in data["message"].lower()
class TestMapAndStateAPIDuringInit:
"""Tests to verify /api/map and /api/state availability during initialization phases."""
def test_map_available_during_checking_llm(self, client, reset_game_instance):
"""Test /api/map is available when world exists (checking_llm phase)."""
# Simulate world being created but LLM check in progress.
mock_world = MagicMock()
mock_map = MagicMock()
mock_map.width = 100
mock_map.height = 100
mock_map.tiles = {}
mock_map.regions = {}
mock_world.map = mock_map
game_instance["world"] = mock_world
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 4
game_instance["init_phase_name"] = "checking_llm"
# The /api/map endpoint should work.
response = client.get("/api/map")
# It may return data or empty, but should not error with 503.
assert response.status_code == 200
def test_state_available_during_generating_events(self, client, reset_game_instance):
"""Test /api/state is available during generating_initial_events phase."""
mock_world = MagicMock()
mock_world.month_stamp.get_year.return_value = 100
mock_world.month_stamp.get_month.return_value = MagicMock(value=1)
mock_world.avatar_manager.avatars = {}
mock_world.event_manager = None
game_instance["world"] = mock_world
game_instance["init_status"] = "in_progress"
game_instance["init_phase"] = 5
game_instance["init_phase_name"] = "generating_initial_events"
response = client.get("/api/state")
assert response.status_code == 200
class TestInitGameAsync:
"""Tests for the async initialization flow."""
@pytest.mark.asyncio
async def test_init_sets_status_to_in_progress(self, reset_game_instance):
"""Test initialization sets status to in_progress immediately."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class:
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
# Start init but check status immediately.
task = asyncio.create_task(main.init_game_async())
await asyncio.sleep(0.01) # Let it start.
assert game_instance["init_status"] in ["in_progress", "ready"]
await task # Let it complete.
@pytest.mark.asyncio
async def test_init_error_sets_error_status(self, reset_game_instance):
"""Test initialization error sets status to error."""
with patch.object(main, 'scan_avatar_assets', side_effect=Exception("Test error")):
await main.init_game_async()
assert game_instance["init_status"] == "error"
assert "Test error" in game_instance["init_error"]
@pytest.mark.asyncio
async def test_init_completes_with_ready_status(self, reset_game_instance):
"""Test successful initialization sets status to ready."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
assert game_instance["init_status"] == "ready"
assert game_instance["init_progress"] == 100
@pytest.mark.asyncio
async def test_init_records_llm_failure(self, reset_game_instance):
"""Test LLM check failure is recorded but doesn't stop initialization."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(False, "API key invalid")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
# Should still complete successfully.
assert game_instance["init_status"] == "ready"
# But LLM failure should be recorded.
assert game_instance["llm_check_failed"] is True
assert game_instance["llm_error_message"] == "API key invalid"
@pytest.mark.asyncio
async def test_init_pauses_after_initial_events(self, reset_game_instance):
"""Test game is paused after generating initial events."""
with patch.object(main, 'scan_avatar_assets'), \
patch.object(main, 'load_cultivation_world_map') as mock_load_map, \
patch.object(main, 'check_llm_connectivity', return_value=(True, "")), \
patch('src.server.main.World') as mock_world_class, \
patch('src.server.main.Simulator') as mock_sim_class, \
patch('src.server.main.sects_by_id', {}), \
patch('src.server.main.CONFIG') as mock_config:
mock_config.game.sect_num = 0
mock_config.game.init_npc_num = 0
mock_config.avatar.protagonist = "none"
mock_map = MagicMock()
mock_load_map.return_value = mock_map
mock_world = MagicMock()
mock_world.avatar_manager.avatars = {}
mock_world_class.return_value = mock_world
mock_sim = MagicMock()
mock_sim.step = AsyncMock()
mock_sim_class.return_value = mock_sim
await main.init_game_async()
# Game should be paused after initialization.
assert game_instance["is_paused"] is True

View File

@@ -1,33 +1,120 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, watch, computed } from 'vue'
import { NConfigProvider, darkTheme, NMessageProvider } from 'naive-ui'
import { useWorldStore } from './stores/world'
import { useUiStore } from './stores/ui'
import { useSocketStore } from './stores/socket'
import { gameApi } from './api/game'
import { gameApi, type InitStatusDTO } from './api/game'
import GameCanvas from './components/game/GameCanvas.vue'
import InfoPanelContainer from './components/game/panels/info/InfoPanelContainer.vue'
import StatusBar from './components/layout/StatusBar.vue'
import EventPanel from './components/panels/EventPanel.vue'
import SystemMenu from './components/SystemMenu.vue'
import LoadingOverlay from './components/LoadingOverlay.vue'
// Stores
const worldStore = useWorldStore()
const uiStore = useUiStore()
const socketStore = useSocketStore()
// 初始化状态 - 持续轮询
const initStatus = ref<InitStatusDTO | null>(null)
const gameInitialized = ref(false)
const mapPreloaded = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
// 根据 spec: showLoading = initStatus !== 'ready'
// 注意:
// 1. initStatus 为 null 时显示加载界面(还没获取到状态)
// 2. initStatus 不是 ready 时显示加载界面
// 3. 前端还没初始化完成时也要显示加载界面
const showLoading = computed(() => {
if (initStatus.value === null) return true
if (initStatus.value.status !== 'ready') return true
if (!gameInitialized.value) return true
return false
})
const showMenu = ref(false)
// 启动时默认暂停,让用户选择"新游戏"或"加载存档"后再继续。
const isManualPaused = ref(true)
const menuDefaultTab = ref<'save' | 'load' | 'create' | 'delete' | 'llm'>('load')
onMounted(async () => {
// 初始化 Socket 连接
socketStore.init()
// 初始化世界状态
// 可以提前加载地图的阶段(宗门初始化后地图数据就 ready 了)。
const MAP_READY_PHASES = ['initializing_sects', 'generating_avatars', 'checking_llm', 'generating_initial_events']
// 可以提前加载角色的阶段world 创建后)。
const AVATAR_READY_PHASES = ['checking_llm', 'generating_initial_events']
const avatarsPreloaded = ref(false)
// 轮询初始化状态
async function pollInitStatus() {
try {
const res = await gameApi.fetchInitStatus()
const prevStatus = initStatus.value?.status
initStatus.value = res
// 提前加载地图:当进入特定阶段且还没预加载过时。
if (!mapPreloaded.value && MAP_READY_PHASES.includes(res.phase_name)) {
mapPreloaded.value = true
worldStore.preloadMap()
}
// 提前加载角色:当进入 checking_llm 或之后阶段。
if (!avatarsPreloaded.value && AVATAR_READY_PHASES.includes(res.phase_name)) {
avatarsPreloaded.value = true
worldStore.preloadAvatars()
}
// 从非 ready 变为 ready 时,初始化前端
// 注意prevStatus 为 undefined 时也算"非 ready"
if (prevStatus !== 'ready' && res.status === 'ready') {
await initializeGame()
// ready 后停止轮询
stopPolling()
}
} catch (e) {
console.error('Failed to fetch init status:', e)
}
}
async function initializeGame() {
if (gameInitialized.value) {
// 重新加载存档时,重新初始化
worldStore.reset()
uiStore.clearSelection()
}
// 初始化 Socket 连接(如果未连接)
if (!socketStore.isConnected) {
socketStore.init()
}
// 初始化世界状态(获取地图、角色等数据)
await worldStore.initialize()
gameInitialized.value = true
// 自动取消暂停,让游戏开始运行
isManualPaused.value = false
console.log('[App] Game initialized.')
}
function startPolling() {
// 立即获取一次
pollInitStatus()
// 每秒轮询
pollInterval = setInterval(pollInitStatus, 1000)
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
startPolling()
})
// 导出方法供 socket store 调用
@@ -42,18 +129,19 @@ function openLLMConfig() {
onUnmounted(() => {
socketStore.disconnect()
window.removeEventListener('keydown', handleKeydown)
stopPolling()
})
function handleKeydown(e: KeyboardEvent) {
// 只在游戏界面响应键盘事件
if (showLoading.value) return
if (e.key === 'Escape') {
if (uiStore.selectedTarget) {
uiStore.clearSelection()
} else {
showMenu.value = !showMenu.value
}
} else if (e.key === ' ') {
// Space to toggle pause? Optional but good UX
// toggleManualPause()
}
}
@@ -71,6 +159,9 @@ function toggleManualPause() {
// 监听菜单状态和手动暂停状态,控制游戏暂停/继续
watch([showMenu, isManualPaused], ([menuVisible, manualPaused]) => {
// 只在游戏已准备好时控制暂停
if (!gameInitialized.value) return
if (menuVisible || manualPaused) {
gameApi.pauseGame().catch(console.error)
} else {
@@ -82,6 +173,13 @@ watch([showMenu, isManualPaused], ([menuVisible, manualPaused]) => {
<template>
<n-config-provider :theme="darkTheme">
<n-message-provider>
<!-- Loading Overlay - 盖在游戏上面 -->
<LoadingOverlay
v-if="showLoading"
:status="initStatus"
/>
<!-- Game UI - 始终渲染 -->
<div class="app-layout">
<StatusBar />

View File

@@ -94,6 +94,17 @@ export interface FetchEventsParams {
limit?: number;
}
export interface InitStatusDTO {
status: 'idle' | 'pending' | 'in_progress' | 'ready' | 'error';
phase: number;
phase_name: string;
progress: number;
elapsed_seconds: number;
error: string | null;
llm_check_failed: boolean;
llm_error_message: string;
}
export const gameApi = {
// --- World State ---
@@ -213,5 +224,19 @@ export const gameApi = {
query.set('keep_major', String(keepMajor));
if (beforeMonthStamp !== undefined) query.set('before_month_stamp', String(beforeMonthStamp));
return httpClient.delete<{ deleted: number }>(`/api/events/cleanup?${query}`);
},
// --- Init Status ---
fetchInitStatus() {
return httpClient.get<InitStatusDTO>('/api/init-status');
},
startNewGame() {
return httpClient.post<{ status: string; message: string }>('/api/game/new', {});
},
reinitGame() {
return httpClient.post<{ status: string; message: string }>('/api/control/reinit', {});
}
};

View File

@@ -0,0 +1,441 @@
<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 = [
'修行之路,贵在坚持。',
'灵根虽重要,但心境更为关键。',
'结交道友,可获意外之机缘。',
'善用洞府,方能事半功倍。',
'大道三千,殊途同归。',
'天地灵气,时刻流转。',
'心魔缠身,难成大道。',
'筑基之前,切莫急躁。',
'功法相克,知己知彼。',
'渡劫之时,需借天时地利。',
]
const currentTip = ref(tips[Math.floor(Math.random() * tips.length)])
const localElapsed = ref(0)
let tipInterval: ReturnType<typeof setInterval> | null = null
let elapsedInterval: ReturnType<typeof setInterval> | null = null
const progress = computed(() => props.status?.progress ?? 0)
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 || '未知错误')
// 根据时间计算背景透明度前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
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++
// 每 3 秒切换一次 generating_initial_events 的文案。
if (localElapsed.value % 3 === 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
}
})
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>

View File

@@ -49,17 +49,16 @@ async function handleLoad(filename: string) {
loading.value = true
try {
// 调用后端加载存档,后端会设置 init_status = "in_progress"
// App.vue 的轮询会检测到状态变化,显示加载界面,并在 ready 后重新初始化前端
await gameApi.loadGame(filename)
worldStore.reset()
uiStore.clearSelection()
await worldStore.initialize()
message.success('读档成功')
// 关闭菜单,让加载界面显示出来
emit('close')
} catch (e) {
message.error('读档失败')
} finally {
loading.value = false
}
// 注意:不在这里设置 loading.value = false因为菜单会关闭
}
watch(() => props.mode, () => {

View File

@@ -128,13 +128,10 @@ export const useWorldStore = defineStore('world', () => {
isLoaded.value = true;
}
async function initialize() {
// 提前加载地图数据(在 LLM 初始化期间可用)。
async function preloadMap() {
try {
const [stateRes, mapRes] = await Promise.all([
gameApi.fetchInitialState(),
gameApi.fetchMap()
]);
const mapRes = await gameApi.fetchMap();
mapData.value = mapRes.data;
if (mapRes.config) {
frontendConfig.value = mapRes.config;
@@ -142,11 +139,60 @@ export const useWorldStore = defineStore('world', () => {
const regionMap = new Map();
mapRes.regions.forEach(r => regionMap.set(r.id, r));
regions.value = regionMap;
// 标记地图已加载,让 MapLayer 可以渲染。
isLoaded.value = true;
console.log('[WorldStore] Map preloaded');
} catch (e) {
console.warn('[WorldStore] Failed to preload map, will retry on initialize', e);
}
}
applyStateSnapshot(stateRes);
// 提前加载角色数据(在 checking_llm 阶段 world 已创建)。
async function preloadAvatars() {
try {
const stateRes = await gameApi.fetchInitialState();
// 只更新角色,不标记完全初始化。
const avatarMap = new Map();
if (stateRes.avatars) {
stateRes.avatars.forEach(av => avatarMap.set(av.id, av));
}
avatars.value = avatarMap;
setTime(stateRes.year, stateRes.month);
console.log('[WorldStore] Avatars preloaded:', avatarMap.size);
} catch (e) {
console.warn('[WorldStore] Failed to preload avatars, will retry on initialize', e);
}
}
async function initialize() {
try {
// 如果地图还没加载,一起加载。
const needMapLoad = mapData.value.length === 0;
if (needMapLoad) {
const [stateRes, mapRes] = await Promise.all([
gameApi.fetchInitialState(),
gameApi.fetchMap()
]);
mapData.value = mapRes.data;
if (mapRes.config) {
frontendConfig.value = mapRes.config;
}
const regionMap = new Map();
mapRes.regions.forEach(r => regionMap.set(r.id, r));
regions.value = regionMap;
applyStateSnapshot(stateRes);
} else {
// 地图已预加载,只需获取状态。
const stateRes = await gameApi.fetchInitialState();
applyStateSnapshot(stateRes);
}
// 从分页 API 加载事件。
await resetEvents({});
} catch (e) {
console.error('Failed to initialize world', e);
}
@@ -274,7 +320,9 @@ export const useWorldStore = defineStore('world', () => {
frontendConfig,
currentPhenomenon,
phenomenaList,
// Functions.
preloadMap,
preloadAvatars,
initialize,
fetchState,
handleTick,