Merge pull request 'fix/scene-auth-websocket-regressions' (#18) from fix/scene-auth-websocket-regressions into main

Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-03-10 16:38:35 +08:00
23 changed files with 3232 additions and 110 deletions

View File

@@ -0,0 +1,105 @@
---
name: godot-cli-test-runner
description: Run Godot CLI commands for this project with emphasis on headless test execution, script runs, scene runs, and export/debug operations. Use when the user asks to run Godot tests or commands (for example “Godot 跑测试”, “执行 Godot 命令”, “检查 Godot 参数”), troubleshoot CLI failures, or request reusable terminal/CI command templates.
---
# Godot CLI Test Runner
## Overview
Use deterministic Godot CLI workflows for Windows terminal and CI-style execution. Prefer `--headless`, explicit `--path`, and `--log-file` for reproducible diagnostics.
## Quick Decision
1. Parse-only check script syntax: `--headless --path . --script <file> --check-only`.
2. If test logic depends on autoload singletons (for example `SceneManager`, `LocationManager`, `EventSystem`), do not use direct `--script` as primary validation; use scene/project context first.
3. For isolated script tests without autoload dependencies, run `--headless --path . --script <file>`.
4. Export build by using `--export-release` / `--export-debug` with an existing preset.
5. Diagnose CLI behavior by adding `--verbose` and always writing `--log-file`.
## Workflow
### 1. Resolve executable and project path
1. Prefer `godot` from PATH.
2. If not available, use explicit exe path (for this machine typically `D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64_console.exe` or `D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe`).
3. Run from repository root and always pass `--path .` unless intentionally targeting another project.
### 2. Preflight checks
1. Confirm engine version: `godot --version`.
2. Confirm options when needed: `godot --help`.
3. Confirm project exists: ensure `project.godot` is present under `--path`.
4. Read `project.godot` `[autoload]` and check whether the target test script references those singleton names.
5. Prepare a log output path (for example `.godot/test_xxx.log`) and pass `--log-file`.
### 3. Execute task type
1. Autoload-dependent validation (preferred when script references global singleton names):
`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log`
2. Scene-specific validation:
`godot --headless --path . --scene res://scenes/Maps/square.tscn --quit-after 90 --log-file .godot/smoke_square.log`
3. Script test (only for isolated logic or known SceneTree tests):
`godot --headless --path . --script tests/unit/test_xxx.gd --log-file .godot/test_xxx.log`
4. Script syntax only:
`godot --headless --path . --script tests/unit/test_xxx.gd --check-only --log-file .godot/check_xxx.log`
5. Export:
`godot --headless --path . --export-release "Web" web_assets/index.html --log-file .godot/export_web_release.log`
### 4. Capture and report results
1. Report exit code, key stdout/stderr lines, and failed command.
2. For failures, include one retry variant (for example add `--verbose`, switch explicit exe path, or switch from `--script` to `--scene` context).
3. Keep output concise and actionable.
4. If `--script` fails with missing singleton identifiers, mark it as context mismatch first, not business regression.
## Command Templates
### Windows (explicit exe)
```powershell
& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64_console.exe" --headless --path . --log-file .godot\test_websocket_close_code.log --script tests/unit/test_websocket_close_code.gd
```
### Generic (PATH)
```powershell
godot --headless --path . --log-file .godot/test_websocket_close_code.log --script tests/unit/test_websocket_close_code.gd
```
### With extra app args
```powershell
godot --headless --path . --log-file .godot/test_runner.log --script tests/unit/test_runner.gd -- --case websocket --timeout 30
```
## Minimal Runnable Examples
Run from repository root (`--path .`).
1. Run one scene-level smoke test (autoload-safe):
```powershell
godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log
```
2. Run one test script (isolated logic):
```powershell
godot --headless --path . --script tests/unit/test_websocket_close_code.gd --log-file .godot/test_websocket_close_code.log
```
3. Run one scene:
```powershell
godot --headless --path . --scene res://scenes/SomeScene.tscn --quit-after 90 --log-file .godot/smoke_scene.log
```
4. Parse script only (syntax check):
```powershell
godot --headless --path . --script tests/unit/test_websocket_close_code.gd --check-only --log-file .godot/check_websocket.log
```
If `godot` is not in PATH, replace `godot` with explicit exe call:
```powershell
& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe" <same arguments>
```
## Option Summary Reference
Use `references/godot-cli-commands.md` for categorized option summary and quick recipes based on `godot --help` output.
## Guardrails
1. Prefer non-interactive commands.
2. Prefer `--headless` for tests and scripts.
3. For this environment, include `--log-file` for reproducible logs and to avoid console build logging issues.
4. Avoid assuming GUT addon exists; check `addons/gut/gut_cmdline.gd` before using GUT command.
5. Use `--check-only` when user requests parse/syntax validation only.
6. For long-running runs, include `--quit-after` when appropriate.
7. Do not classify missing autoload singleton errors in `--script` mode as product regressions until scene/project-context validation is also run.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Godot CLI Test Runner"
short_description: "Run Godot tests and command-line workflows"
default_prompt: "Use this skill to run Godot headless tests with autoload-aware strategy, check CLI options, and execute Godot commands with reproducible --log-file diagnostics."

View File

@@ -0,0 +1,74 @@
# Godot CLI Commands Summary
This reference summarizes the provided `godot --help` output for fast command selection.
## 1. Core inspection
- Help: `godot --help`
- Version: `godot --version`
- Verbose logs: `godot --verbose`
- Quiet mode: `godot --quiet`
## 2. Project targeting and run mode
- Point to project directory: `godot --path .`
- Run specific scene: `godot --path . --scene res://scenes/MainScene.tscn`
- Headless mode: `godot --headless --path . ...`
- Quit quickly: `godot --path . --quit`
- Quit after N frames: `godot --path . --quit-after 120`
## 3. Script execution and tests
- Run script: `godot --headless --path . --script tests/unit/test_xxx.gd`
- Parse-only script check: `godot --headless --path . --script tests/unit/test_xxx.gd --check-only`
- Pass custom user args to script:
`godot --headless --path . --script tests/unit/test_runner.gd -- --case websocket`
- Autoload-safe smoke test (preferred when test uses singleton globals):
`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log`
## 4. Debug and diagnostics
- Local debugger: `godot --debug --path .`
- Remote debug: `godot --remote-debug tcp://127.0.0.1:6007 --path .`
- Print FPS: `godot --path . --print-fps`
- Log to file: `godot --path . --log-file logs/godot.log`
- Disable VSync for profiling: `godot --path . --disable-vsync`
## 5. Display and runtime controls
- Fullscreen: `godot --path . --fullscreen`
- Windowed: `godot --path . --windowed`
- Resolution: `godot --path . --resolution 1920x1080`
- Max FPS: `godot --path . --max-fps 60`
- Fixed FPS: `godot --path . --fixed-fps 60`
- Time scale: `godot --path . --time-scale 0.5`
## 6. Export operations (editor build only)
- Release export:
`godot --path . --export-release "Web" build/web/index.html`
- Debug export:
`godot --path . --export-debug "Web" build/web/index.html`
- Pack export:
`godot --path . --export-pack "Web" build/web/game.pck`
- Check preset syntax only:
`godot --path . --export-debug "Web" --check-only`
## 7. Common quick recipes
- Run a unit test script (isolated logic):
`godot --headless --path . --script tests/unit/test_websocket_close_code.gd --log-file .godot/test_websocket_close_code.log`
- Validate script syntax without running:
`godot --headless --path . --script tests/unit/test_websocket_close_code.gd --check-only --log-file .godot/check_websocket.log`
- Run game with verbose logs:
`godot --verbose --path . --log-file .godot/main_verbose.log`
- Run scene and auto-exit after startup checks:
`godot --headless --path . --scene res://scenes/MainScene.tscn --quit-after 120 --log-file .godot/smoke_main.log`
## 8. Windows explicit executable pattern
When `godot` is not in PATH, call the executable directly:
```powershell
& "D:\technology\biancheng\Godot\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_websocket_close_code.gd
```
## 9. Notes for AI execution
- Prefer `--headless` for tests and scripts in terminal/CI.
- Always include `--path .` for reproducibility.
- Use `--check-only` for parse checks when execution is not needed.
- If a script depends on autoload singleton names from `project.godot` (`SceneManager`, `LocationManager`, `EventSystem`, etc.), validate in scene/project context before concluding regression.
- Prefer `--log-file` for reliable diagnostics and environment-specific logging issues.
- Add `--verbose` when failure context is insufficient.

View File

@@ -1,36 +1,36 @@
{
"ui": {
"login": "登录",
"register": "注册",
"username": "用户名",
"password": "密码",
"email": "邮箱",
"confirm_password": "确认密码",
"verification_code": "验证码",
"send_code": "发送验证码",
"forgot_password": "忘记密码",
"enter_town": "进入小镇",
"logout": "退出登录"
"login": "登录",
"register": "注册",
"username": "用户名",
"password": "密码",
"email": "邮箱",
"confirm_password": "确认密码",
"verification_code": "验证码",
"send_code": "发送验证码",
"forgot_password": "忘记密码",
"enter_town": "进入小镇",
"logout": "退出登录"
},
"messages": {
"login_success": "登录成功!正在进入鲸鱼镇...",
"register_success": "注册成功!欢迎加入鲸鱼镇",
"network_error": "网络连接失败,请检查网络连接",
"invalid_username": "用户名只能包含字母、数字和下划线",
"invalid_email": "请输入有效的邮箱地址",
"password_too_short": "密码长度至少8位",
"password_mismatch": "两次输入的密码不一致",
"verification_code_sent": "验证码已发送到您的邮箱,请查收"
"login_success": "登录成功!正在进入鲸鱼镇...",
"register_success": "注册成功!欢迎加入鲸鱼镇",
"network_error": "网络连接失败,请检查网络连接",
"invalid_username": "用户名只能包含字母、数字和下划线",
"invalid_email": "请输入有效的邮箱地址",
"password_too_short": "密码长度至少8位",
"password_mismatch": "两次输入的密码不一致",
"verification_code_sent": "验证码已发送到您的邮箱,请查收"
},
"game": {
"level": "等级",
"coins": "金币",
"experience": "经验",
"energy": "体力",
"explore": "探索小镇",
"inventory": "背包",
"shop": "商店",
"friends": "好友",
"settings": "设置"
"level": "等级",
"coins": "金币",
"experience": "经验",
"energy": "体力",
"explore": "探索小镇",
"inventory": "背包",
"shop": "商店",
"friends": "好友",
"settings": "设置"
}
}
}

View File

@@ -155,9 +155,9 @@ WhaleTown/ # 🐋 项目根目录
│ ├── performance/ # ⚡ 性能测试(帧率、内存优化)
│ └── api/ # 🌐 API接口测试
└── 🌐 web_assets/ # 🌍 Web导出资源
├── html/ # 📄 HTML模板文件
├── css/ # 🎨 样式文件
└── js/ # 📜 JavaScript脚本
├── html/ # 📄 HTML模板文件
├── css/ # 🎨 样式文件
└── js/ # 📜 JavaScript脚本
```
### 🔧 核心架构说明

View File

@@ -102,6 +102,7 @@ var _game_token: String = "" # @deprecated 使用 _access_token 替代
# 初始化管理器
func _init() -> void:
_load_auth_data()
_connect_network_signals()
# 清理资源
func cleanup() -> void:
@@ -110,6 +111,8 @@ func cleanup() -> void:
NetworkManager.cancel_request(request_id)
active_request_ids.clear()
_disconnect_network_signals()
# ============ Token 管理 ============
# 保存 Token 到内存
@@ -675,7 +678,7 @@ func _on_send_login_code_response(success: bool, data: Dictionary, error_info: D
func _on_forgot_password_response(success: bool, data: Dictionary, error_info: Dictionary):
button_state_changed.emit("forgot_password_btn", false, "忘记密码")
var result = ResponseHandler.handle_send_login_code_response(success, data, error_info)
var result = ResponseHandler.handle_forgot_password_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
@@ -714,10 +717,10 @@ func _can_send_verification_code(email: String) -> bool:
if not email_data.sent:
return true
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
var current_timestamp: int = int(Time.get_unix_time_from_system())
var sent_timestamp: int = int(email_data.get("time", 0))
return (current_timestamp - email_data.time) >= code_cooldown
return float(current_timestamp - sent_timestamp) >= code_cooldown
# 获取剩余冷却时间
func get_remaining_cooldown_time(email: String) -> int:
@@ -725,15 +728,15 @@ func get_remaining_cooldown_time(email: String) -> int:
return 0
var email_data = verification_codes_sent[email]
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
var current_timestamp: int = int(Time.get_unix_time_from_system())
var sent_timestamp: int = int(email_data.get("time", 0))
var remaining: int = int(code_cooldown - float(current_timestamp - sent_timestamp))
return int(code_cooldown - (current_timestamp - email_data.time))
return maxi(0, remaining)
# 记录验证码发送状态
func _record_verification_code_sent(email: String):
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
var current_timestamp: int = int(Time.get_unix_time_from_system())
if not verification_codes_sent.has(email):
verification_codes_sent[email] = {}
@@ -758,6 +761,30 @@ func _has_sent_verification_code(email: String) -> bool:
func _is_valid_identifier(identifier: String) -> bool:
return StringUtils.is_valid_email(identifier) or _is_valid_phone(identifier)
# 连接/断开 NetworkManager 请求信号,用于回收 active_request_ids
func _connect_network_signals() -> void:
if not NetworkManager.request_completed.is_connected(_on_network_request_completed):
NetworkManager.request_completed.connect(_on_network_request_completed)
if not NetworkManager.request_failed.is_connected(_on_network_request_failed):
NetworkManager.request_failed.connect(_on_network_request_failed)
func _disconnect_network_signals() -> void:
if NetworkManager.request_completed.is_connected(_on_network_request_completed):
NetworkManager.request_completed.disconnect(_on_network_request_completed)
if NetworkManager.request_failed.is_connected(_on_network_request_failed):
NetworkManager.request_failed.disconnect(_on_network_request_failed)
func _on_network_request_completed(request_id: String, _success: bool, _data: Dictionary) -> void:
_remove_active_request_id(request_id)
func _on_network_request_failed(request_id: String, _error_type: String, _message: String) -> void:
_remove_active_request_id(request_id)
func _remove_active_request_id(request_id: String) -> void:
var index: int = active_request_ids.find(request_id)
if index != -1:
active_request_ids.remove_at(index)
# 验证手机号格式
func _is_valid_phone(phone: String) -> bool:
var regex = RegEx.new()

View File

@@ -44,12 +44,6 @@ var _next_spawn_name: String = "" # 下一个场景的出生
# 便于统一管理和修改场景路径
var scene_paths: Dictionary = {
"main": "res://scenes/MainScene.tscn", # 主场景 - 游戏入口
"auth": "res://scenes/ui/LoginWindow.tscn", # 认证场景 - 登录窗口
"game": "res://scenes/maps/game_scene.tscn", # 游戏场景 - 主要游戏内容
"battle": "res://scenes/maps/battle_scene.tscn", # 战斗场景 - 战斗系统
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
"settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面
"square": "res://scenes/Maps/square.tscn", # 广场地图
"room": "res://scenes/Maps/room.tscn", # 房间地图
"fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图

View File

@@ -106,6 +106,9 @@ var _reconnect_timer: Timer = Timer.new()
# 是否为正常关闭(非异常断开)
var _clean_close: bool = true
# 当前 CLOSED 状态是否已经处理过(防止每帧重复处理 close 事件)
var _closed_state_handled: bool = false
# 心跳定时器
var _heartbeat_timer: Timer = Timer.new()
@@ -163,14 +166,20 @@ func _exit_tree() -> void:
# ============================================================================
# 连接到游戏服务器
func connect_to_game_server() -> void:
func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
push_warning("已经在连接或已连接状态")
return
_set_connection_state(ConnectionState.CONNECTING)
if is_reconnect_attempt:
_set_connection_state(ConnectionState.RECONNECTING)
else:
_set_connection_state(ConnectionState.CONNECTING)
_clean_close = true
_reconnect_attempt = 0
_closed_state_handled = false
# 仅在首次/手动连接时重置重连计数,重连流程保持累计尝试次数
if not is_reconnect_attempt:
_reconnect_attempt = 0
var err: Error = _websocket_peer.connect_to_url(WEBSOCKET_URL)
if err != OK:
@@ -295,23 +304,40 @@ func _check_websocket_state() -> void:
match state:
WebSocketPeer.STATE_CONNECTING:
_closed_state_handled = false
# 正在连接
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
_set_connection_state(ConnectionState.CONNECTING)
WebSocketPeer.STATE_OPEN:
_closed_state_handled = false
# 连接成功
if _connection_state != ConnectionState.CONNECTED:
_on_websocket_connected()
WebSocketPeer.STATE_CLOSING:
_closed_state_handled = false
# 正在关闭
pass
WebSocketPeer.STATE_CLOSED:
# 连接关闭
var code: int = _websocket_peer.get_close_code()
_on_websocket_closed(code != 0) # code=0 表示正常关闭
if _closed_state_handled:
return
_closed_state_handled = true
# 仅在连接生命周期中发生的关闭才触发关闭处理
var should_handle_close: bool = (
_connection_state == ConnectionState.CONNECTED
or _connection_state == ConnectionState.CONNECTING
or _connection_state == ConnectionState.RECONNECTING
)
if should_handle_close:
var close_code: int = _websocket_peer.get_close_code()
_on_websocket_closed(_is_clean_close_code(close_code))
# WebSocket 连接成功处理
func _on_websocket_connected() -> void:
@@ -333,6 +359,11 @@ func _on_websocket_closed(clean_close: bool) -> void:
else:
_set_connection_state(ConnectionState.DISCONNECTED)
# 判断关闭码是否为干净关闭
# Godot 中 close_code == -1 表示非干净关闭(异常断开)
func _is_clean_close_code(close_code: int) -> bool:
return close_code != -1
# ============================================================================
# 内部方法 - 重连机制
# ============================================================================
@@ -375,7 +406,7 @@ func _calculate_reconnect_delay() -> float:
# 重连定时器超时处理
func _on_reconnect_timeout() -> void:
_clean_close = false
connect_to_game_server()
connect_to_game_server(true)
# ============================================================================
# 内部方法 - 心跳机制

View File

@@ -66,10 +66,10 @@
```gdscript
extends GutTest
func test_event_emission():
var sender = Node.new()
watch_signals(EventSystem)
EventSystem.emit_event(EventNames.PLAYER_MOVED, {})
assert_signal_emitted(EventSystem, "event_raised")
var sender = Node.new()
watch_signals(EventSystem)
EventSystem.emit_event(EventNames.PLAYER_MOVED, {})
assert_signal_emitted(EventSystem, "event_raised")
```
## 🔄 8. Standard Development Workflow (MANDATORY)
@@ -152,13 +152,13 @@ class_name Player
# 3. Lifecycle
func _physics_process(delta: float) -> void:
_move(delta)
_move(delta)
# 4. Private Methods
func _move(_delta: float) -> void:
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = dir * move_speed
move_and_slide()
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = dir * move_speed
move_and_slide()
```
## 10. 🔄 Plan Mode Protocol (MANDATORY)
- **Planning Phase**:
@@ -166,9 +166,9 @@ func _move(_delta: float) -> void:
- **Execution & Reporting**:
- Every time a TODO item is completed, the corresponding `.md` document MUST be updated.
- After updating the document, report to the user with the following:
1. **Completed Items**: What was just finished.
2. **User Acceptance Rules**: Instructions on how the user can test/verify the current progress.
3. **Next Step**: The next TODO item to be tackled.
1. **Completed Items**: What was just finished.
2. **User Acceptance Rules**: Instructions on how the user can test/verify the current progress.
3. **Next Step**: The next TODO item to be tackled.
- **Strict Confirmation**:
- After reporting progress, **Claude MUST stop and wait**.
- Do NOT proceed to the next TODO until the user has replied with confirmation/approval.

2495
docs/api-documentation.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
name="Web"
platform="Web"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
@@ -11,6 +10,11 @@ include_filter=""
exclude_filter=""
export_path="web_assets/index.html"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
@@ -40,6 +44,6 @@ progressive_web_app/orientation=0
progressive_web_app/icon_144x144="uid://bwy5r7soxi76a"
progressive_web_app/icon_180x180="uid://drpllpsjdiaex"
progressive_web_app/icon_512x512="uid://dt817lem3dwee"
progressive_web_app/background_color=Color(0.19215687, 0.42352942, 1, 1)
progressive_web_app/background_color=Color(0.07450981, 0.28627452, 1, 1)
threads/emscripten_pool_size=8
threads/godot_pool_size=4

View File

@@ -61,8 +61,12 @@ var _player_spawned: bool = false # Track if local player has been spawned
var _local_player: Node = null # Reference to the local player node
var _position_update_timer: float = 0.0 # Throttle timer for position updates
var _current_session_id: String = "" # Cache session ID to avoid race condition on exit
var _is_joining_session: bool = false
const POSITION_UPDATE_INTERVAL: float = 0.125 # Send at most 8 times per second
const PRIVATE_SCENES = ["room"] # List of scenes that should be private instances
var _join_token_poll_interval: float = 0.5
var _join_token_wait_timeout: float = 15.0
var _join_player_wait_timeout_frames: int = 300
func _on_session_joined(data: Dictionary):
# 对账远程玩家列表,使用 position.mapId 过滤
@@ -199,26 +203,51 @@ func _update_remote_player_position(user: Dictionary):
player.update_position(Vector2(pos.x, pos.y))
func _join_session_with_player(session_id: String):
# 检查是否有Token如果没有则等待
if LocationManager._auth_token == "":
# 轮询等待Token就绪 (简单重试机制)
await get_tree().create_timer(0.5).timeout
_join_session_with_player(session_id)
if _is_joining_session:
return
# 等待玩家生成完毕
if not _player_spawned or not _local_player:
_is_joining_session = true
# 检查是否有 Token没有则轮询等待带超时
var token_wait_elapsed: float = 0.0
while LocationManager._auth_token == "":
if not is_inside_tree():
_is_joining_session = false
return
if token_wait_elapsed >= _join_token_wait_timeout:
push_warning("BaseLevel: 等待认证 Token 超时,取消加入会话 %s" % session_id)
_is_joining_session = false
return
await get_tree().create_timer(_join_token_poll_interval).timeout
token_wait_elapsed += _join_token_poll_interval
# 等待玩家生成完毕(带帧数上限)
var wait_frames: int = 0
while not _player_spawned or not _local_player:
if not is_inside_tree():
_is_joining_session = false
return
if wait_frames >= _join_player_wait_timeout_frames:
push_warning("BaseLevel: 等待本地玩家就绪超时,取消加入会话 %s" % session_id)
_is_joining_session = false
return
await get_tree().process_frame
_join_session_with_player(session_id)
wait_frames += 1
if not is_instance_valid(_local_player):
push_warning("BaseLevel: 本地玩家无效,取消加入会话 %s" % session_id)
_is_joining_session = false
return
var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO
var pos = _local_player.global_position
LocationManager.join_session(session_id, pos)
# 进入会话后立即同步一次位置
await get_tree().create_timer(0.1).timeout
LocationManager.send_position_update(session_id, pos)
if is_inside_tree():
LocationManager.send_position_update(session_id, pos)
_is_joining_session = false
func _process(delta):
# 发送位置更新 (节流机制)

View File

@@ -91,6 +91,7 @@ var toast_manager: ToastManager
# 验证码冷却计时器
var cooldown_timer: Timer = null
var cooldown_email: String = ""
# 当前登录模式(从管理器同步)
var current_login_mode: AuthManager.LoginMode = AuthManager.LoginMode.PASSWORD
@@ -291,9 +292,6 @@ func _on_register_pressed():
func _on_send_code_pressed():
var email = register_email.text.strip_edges()
auth_manager.send_email_verification_code(email)
# 开始冷却计时器
_start_cooldown_timer(email)
# 获取登录验证码按钮
func _on_get_login_code_pressed():
@@ -374,8 +372,10 @@ func _on_controller_register_failed(_message: String):
# 验证码发送成功处理
func _on_controller_verification_code_sent(_message: String):
# 验证码发送成功,冷却计时器已经在按钮点击时启动
pass
var email = auth_manager.current_email
if email == "":
email = register_email.text.strip_edges()
_start_cooldown_timer(email)
# 验证码发送失败处理
func _on_controller_verification_code_failed(_message: String):
@@ -429,12 +429,20 @@ func _on_controller_show_toast_message(message: String, is_success: bool):
# ============ 验证码冷却管理 ============
# 开始冷却计时器
func _start_cooldown_timer(_email: String):
func _start_cooldown_timer(email: String):
if cooldown_timer != null:
cooldown_timer.queue_free()
cooldown_timer = null
cooldown_email = email
if cooldown_email == "":
cooldown_email = register_email.text.strip_edges()
send_code_btn.disabled = true
send_code_btn.text = "重新发送(60)"
var remaining_time = auth_manager.get_remaining_cooldown_time(cooldown_email)
if remaining_time <= 0:
remaining_time = 60
send_code_btn.text = "重新发送(%d)" % remaining_time
cooldown_timer = Timer.new()
add_child(cooldown_timer)
@@ -444,7 +452,8 @@ func _start_cooldown_timer(_email: String):
# 冷却计时器超时处理
func _on_cooldown_timer_timeout():
var remaining_time = auth_manager.get_remaining_cooldown_time(register_email.text.strip_edges())
var email = cooldown_email if cooldown_email != "" else register_email.text.strip_edges()
var remaining_time = auth_manager.get_remaining_cooldown_time(email)
if remaining_time > 0:
send_code_btn.text = "重新发送(%d)" % remaining_time
@@ -462,6 +471,7 @@ func _reset_verification_button():
cooldown_timer.queue_free()
cooldown_timer = null
cooldown_email = ""
send_code_btn.disabled = false
send_code_btn.text = "发送验证码"

View File

@@ -1,7 +1,7 @@
[gd_scene load_steps=4 format=3 uid="uid://bvn8y7x2qkqxe"]
[ext_resource type="Script" uid="uid://ddb8v5c6aeqe7" path="res://tests/auth/auth_ui_test.gd" id="1_test_script"]
[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/LoginWindow.tscn" id="2_auth_scene"]
[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_auth_scene"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_feedback_info"]
bg_color = Color(0.2, 0.5, 0.8, 0.9)

View File

@@ -0,0 +1,185 @@
extends SceneTree
# Auth/BaseLevel 回归测试(无需 GUT
#
# 运行方式:
# "D:\\technology\\biancheng\\Godot\\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_auth_baselevel_regressions.gd
var _failures: Array[String] = []
class TestBaseLevel:
extends BaseLevel
func _ready() -> void:
# 测试场景中跳过原始 _ready避免网络/场景副作用
pass
func _init() -> void:
call_deferred("_run")
func _run() -> void:
await _test_forgot_password_response_handler()
await _test_auth_request_tracking_cleanup()
await _test_verification_code_timestamp_and_remaining_time()
await _test_send_code_cooldown_starts_on_success_only()
await _test_join_session_wait_has_timeout_guards()
if _failures.is_empty():
print("PASS: test_auth_baselevel_regressions")
quit(0)
return
for failure in _failures:
push_error(failure)
print("FAIL: test_auth_baselevel_regressions (%d)" % _failures.size())
quit(1)
func _test_forgot_password_response_handler() -> void:
var manager := AuthManager.new()
var toast_message: String = ""
manager.show_toast_message.connect(func(message: String, _is_success: bool): toast_message = message)
manager._on_forgot_password_response(true, {}, {})
_assert(
"重置验证码" in toast_message,
"忘记密码成功响应应使用 forgot_password 处理器文案"
)
toast_message = ""
manager._on_forgot_password_response(false, {"error_code": "USER_NOT_FOUND", "message": "用户不存在"}, {})
_assert(
("用户不存在" in toast_message) and ("请检查邮箱或手机号" in toast_message),
"忘记密码失败响应应使用 forgot_password 错误分支"
)
manager.cleanup()
func _test_auth_request_tracking_cleanup() -> void:
var manager := AuthManager.new()
manager.active_request_ids = ["req_a", "req_b"]
manager._on_network_request_completed("req_a", true, {})
_assert(
manager.active_request_ids.size() == 1 and manager.active_request_ids[0] == "req_b",
"请求完成后应从 active_request_ids 中移除对应 request_id"
)
manager._on_network_request_failed("req_b", "NETWORK_ERROR", "x")
_assert(
manager.active_request_ids.is_empty(),
"请求失败后也应从 active_request_ids 中移除对应 request_id"
)
manager.cleanup()
func _test_verification_code_timestamp_and_remaining_time() -> void:
var manager := AuthManager.new()
var email := "cooldown_regression@example.com"
manager._record_verification_code_sent(email)
var sent_timestamp: int = int(manager.verification_codes_sent[email].get("time", 0))
_assert(
sent_timestamp > 86400,
"验证码发送时间应记录 Unix 时间戳,避免跨天计算异常"
)
manager.verification_codes_sent[email].time = int(Time.get_unix_time_from_system()) - 1000
_assert(
manager.get_remaining_cooldown_time(email) == 0,
"冷却已过期时,剩余时间应为 0"
)
manager.cleanup()
func _test_send_code_cooldown_starts_on_success_only() -> void:
var packed_scene: PackedScene = load("res://scenes/ui/AuthScene.tscn")
_assert(packed_scene != null, "应能加载 AuthScene.tscn")
if packed_scene == null:
return
var auth_scene: Control = packed_scene.instantiate()
root.add_child(auth_scene)
await process_frame
auth_scene.register_email.text = "invalid_email"
auth_scene._on_send_code_pressed()
await process_frame
_assert(
auth_scene.cooldown_timer == null,
"邮箱不合法时不应启动验证码冷却计时器"
)
_assert(
not auth_scene.send_code_btn.disabled,
"邮箱不合法时发送按钮不应被锁定"
)
# 模拟发送成功回调触发冷却(并补齐 manager 内部冷却记录)
auth_scene.auth_manager._record_verification_code_sent("regression@example.com")
auth_scene._on_controller_verification_code_sent("ok")
_assert(
auth_scene.cooldown_timer != null,
"验证码发送成功后应启动冷却计时器"
)
_assert(
auth_scene.send_code_btn.disabled,
"验证码发送成功后应禁用发送按钮"
)
auth_scene.queue_free()
await process_frame
func _test_join_session_wait_has_timeout_guards() -> void:
var level := TestBaseLevel.new()
root.add_child(level)
await process_frame
var original_token: String = LocationManager._auth_token
var original_poll_interval: float = level._join_token_poll_interval
var original_token_timeout: float = level._join_token_wait_timeout
var original_player_timeout_frames: int = level._join_player_wait_timeout_frames
level._join_token_poll_interval = 0.01
level._join_token_wait_timeout = 0.03
level._join_player_wait_timeout_frames = 2
# 场景 1: Token 一直未就绪,函数应在超时后退出,而不是无限递归等待
LocationManager._auth_token = ""
level._join_session_with_player("test_session")
await create_timer(0.15).timeout
_assert(
not level._is_joining_session,
"Token 长时间未就绪时应超时退出 join 流程"
)
# 场景 2: Token 已就绪但玩家一直未生成,也应在上限后退出
LocationManager._auth_token = "dummy_token"
level._player_spawned = false
level._local_player = null
level._join_session_with_player("test_session")
await process_frame
await process_frame
await process_frame
await process_frame
_assert(
not level._is_joining_session,
"玩家长时间未就绪时应在帧上限后退出 join 流程"
)
# 恢复现场
LocationManager._auth_token = original_token
level._join_token_poll_interval = original_poll_interval
level._join_token_wait_timeout = original_token_timeout
level._join_player_wait_timeout_frames = original_player_timeout_frames
level.queue_free()
await process_frame
func _assert(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View File

@@ -0,0 +1,142 @@
extends SceneTree
# WebSocket 关闭码与重连行为测试(无需 GUT
#
# 运行方式:
# "D:\\technology\\biancheng\\Godot\\Godot_v4.5.1-stable_win64.exe" --headless --path . --script tests/unit/test_websocket_close_code.gd
var _failures: Array[String] = []
func _init() -> void:
call_deferred("_run")
func _run() -> void:
var manager := ChatWebSocketManager.new()
root.add_child(manager)
await process_frame
_test_close_code_mapping(manager)
_test_idle_closed_state_does_not_trigger_reconnect(manager)
_test_reconnect_on_unclean_close(manager)
_test_no_reconnect_on_clean_close(manager)
_test_reconnect_attempt_not_reset_on_reconnect_connect(manager)
_test_reconnection_success_emits_and_resets_attempt(manager)
manager.queue_free()
await process_frame
if _failures.is_empty():
print("PASS: test_websocket_close_code")
quit(0)
return
for failure in _failures:
push_error(failure)
print("FAIL: test_websocket_close_code (%d)" % _failures.size())
quit(1)
func _test_close_code_mapping(manager: ChatWebSocketManager) -> void:
_assert(
not manager._is_clean_close_code(-1),
"close_code=-1 应判定为非干净关闭(异常断开)"
)
_assert(
manager._is_clean_close_code(1000),
"close_code=1000 应判定为干净关闭"
)
_assert(
manager._is_clean_close_code(0),
"close_code=0 不应判定为异常断开"
)
func _test_idle_closed_state_does_not_trigger_reconnect(manager: ChatWebSocketManager) -> void:
manager.enable_auto_reconnect(true, 5, 3.0)
manager._set_connection_state(ChatWebSocketManager.ConnectionState.DISCONNECTED)
manager._reconnect_attempt = 0
manager._closed_state_handled = false
manager._check_websocket_state()
_assert(
manager.get_connection_state() == ChatWebSocketManager.ConnectionState.DISCONNECTED,
"空闲 DISCONNECTED + STATE_CLOSED 不应触发自动重连"
)
_assert(
manager._reconnect_attempt == 0,
"空闲关闭态不应消耗重连次数"
)
func _test_reconnect_on_unclean_close(manager: ChatWebSocketManager) -> void:
var lost_count: int = 0
manager.connection_lost.connect(func(): lost_count += 1)
manager.enable_auto_reconnect(true, 5, 3.0)
manager._reconnect_attempt = 0
manager._set_connection_state(ChatWebSocketManager.ConnectionState.CONNECTED)
manager._on_websocket_closed(false)
_assert(
manager.get_connection_state() == ChatWebSocketManager.ConnectionState.RECONNECTING,
"异常断开时应进入 RECONNECTING 状态"
)
_assert(
manager._reconnect_attempt == 1,
"异常断开时应增加一次重连尝试计数"
)
_assert(
lost_count == 1,
"异常断开时应发射一次 connection_lost 信号"
)
# 清理重连定时器,避免影响后续用例
if is_instance_valid(manager._reconnect_timer):
manager._reconnect_timer.stop()
func _test_no_reconnect_on_clean_close(manager: ChatWebSocketManager) -> void:
var reconnect_attempt_before: int = manager._reconnect_attempt
manager._set_connection_state(ChatWebSocketManager.ConnectionState.CONNECTED)
manager._on_websocket_closed(true)
_assert(
manager.get_connection_state() == ChatWebSocketManager.ConnectionState.DISCONNECTED,
"干净关闭时应回到 DISCONNECTED 状态"
)
_assert(
manager._reconnect_attempt == reconnect_attempt_before,
"干净关闭时不应增加重连尝试计数"
)
func _test_reconnect_attempt_not_reset_on_reconnect_connect(manager: ChatWebSocketManager) -> void:
manager._reconnect_attempt = 3
manager._set_connection_state(ChatWebSocketManager.ConnectionState.RECONNECTING)
manager.connect_to_game_server(true)
_assert(
manager._reconnect_attempt == 3,
"重连触发 connect_to_game_server(true) 时不应重置重连尝试计数"
)
func _test_reconnection_success_emits_and_resets_attempt(manager: ChatWebSocketManager) -> void:
var success_count: int = 0
manager.reconnection_succeeded.connect(func(): success_count += 1)
manager._reconnect_attempt = 2
manager._set_connection_state(ChatWebSocketManager.ConnectionState.RECONNECTING)
manager._on_websocket_connected()
_assert(
success_count == 1,
"重连成功时应发射 reconnection_succeeded 信号"
)
_assert(
manager._reconnect_attempt == 0,
"重连成功时应重置重连尝试计数"
)
_assert(
manager.get_connection_state() == ChatWebSocketManager.ConnectionState.CONNECTED,
"重连成功后应进入 CONNECTED 状态"
)
func _assert(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View File

@@ -0,0 +1,40 @@
@echo off
echo ========================================
echo 鲸鱼镇 Web服务器 (web_assets)
echo ========================================
echo.
set "PORT=8000"
REM 检查Python
python --version >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo [错误] 未找到Python
echo 请安装Python: https://python.org/downloads
pause
exit /b 1
)
REM 检查文件
if not exist "web_assets\index.html" (
echo [错误] 未找到 web_assets\index.html
pause
exit /b 1
)
echo [启动] 启动服务器...
echo 端口: %PORT%
echo 目录: web_assets
echo.
echo 访问地址: http://localhost:%PORT%
echo.
echo 按 Ctrl+C 停止服务器
echo.
REM 启动服务器
cd web_assets
start http://localhost:%PORT%
python -m http.server %PORT%
cd ..
pause

View File

@@ -112,7 +112,7 @@ body {
<script src="index.js"></script>
<script>
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":35119676,"index.wasm":36145869},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"};
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":46883000,"index.wasm":37686550},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"};
const GODOT_THREADS_ENABLED = false;
const engine = new Engine(GODOT_CONFIG);

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"background_color":"#316cff","display":"standalone","icons":[{"sizes":"144x144","src":"index.144x144.png","type":"image/png"},{"sizes":"180x180","src":"index.180x180.png","type":"image/png"},{"sizes":"512x512","src":"index.512x512.png","type":"image/png"}],"name":"whaleTown","orientation":"any","start_url":"./index.html"}
{"background_color":"#1349ff","display":"standalone","icons":[{"sizes":"144x144","src":"index.144x144.png","type":"image/png"},{"sizes":"180x180","src":"index.180x180.png","type":"image/png"},{"sizes":"512x512","src":"index.512x512.png","type":"image/png"}],"name":"whaleTown","orientation":"any","start_url":"./index.html"}

Binary file not shown.

View File

@@ -4,7 +4,7 @@
// Incrementing CACHE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
/** @type {string} */
const CACHE_VERSION = '1766673973|3863914';
const CACHE_VERSION = '1773130384|168741112';
/** @type {string} */
const CACHE_PREFIX = 'whaleTown-sw-cache-';
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;

Binary file not shown.