Compare commits
17 Commits
88a8eaad02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b03d623a5 | |||
| f981ef18b1 | |||
| 558a0ff9bc | |||
| dc403179f7 | |||
| dde92737b6 | |||
| 7e65137922 | |||
| 473c9f4692 | |||
| 9d5536fd32 | |||
| 3eef8c0814 | |||
| fa38b75d8f | |||
| 64b9931eac | |||
| 22f4ec5d76 | |||
| 23d63a9dda | |||
| 0b0bdbacfa | |||
| 8123bc6b22 | |||
| 64850c2cae | |||
| d96abbb8b9 |
105
.claude/skills/godot-cli-test-runner/SKILL.md
Normal file
105
.claude/skills/godot-cli-test-runner/SKILL.md
Normal 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.
|
||||
4
.claude/skills/godot-cli-test-runner/agents/openai.yaml
Normal file
4
.claude/skills/godot-cli-test-runner/agents/openai.yaml
Normal 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."
|
||||
@@ -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.
|
||||
@@ -6,6 +6,7 @@
|
||||
},
|
||||
"network": {
|
||||
"api_base_url": "https://whaletownend.xinghangee.icu",
|
||||
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
|
||||
"timeout": 30,
|
||||
"retry_count": 3
|
||||
},
|
||||
|
||||
@@ -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": "设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +155,9 @@ WhaleTown/ # 🐋 项目根目录
|
||||
│ ├── performance/ # ⚡ 性能测试(帧率、内存优化)
|
||||
│ └── api/ # 🌐 API接口测试
|
||||
└── 🌐 web_assets/ # 🌍 Web导出资源
|
||||
├── html/ # 📄 HTML模板文件
|
||||
├── css/ # 🎨 样式文件
|
||||
└── js/ # 📜 JavaScript脚本
|
||||
├── html/ # 📄 HTML模板文件
|
||||
├── css/ # 🎨 样式文件
|
||||
└── js/ # 📜 JavaScript脚本
|
||||
```
|
||||
|
||||
### 🔧 核心架构说明
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -126,6 +126,10 @@ var _game_token: String = ""
|
||||
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
||||
var _pending_self_messages: Array[Dictionary] = []
|
||||
|
||||
# 空消息类型告警限频(避免日志刷屏)
|
||||
const EMPTY_MESSAGE_TYPE_WARNING_INTERVAL: float = 10.0
|
||||
var _last_empty_message_type_warning_at: float = -1000.0
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
@@ -489,12 +493,22 @@ func _on_data_received(message: String) -> void:
|
||||
push_error("ChatManager: JSON 解析失败")
|
||||
return
|
||||
|
||||
var data: Dictionary = json.data
|
||||
var data_variant: Variant = json.data
|
||||
if not (data_variant is Dictionary):
|
||||
push_warning("ChatManager: 收到非对象消息,已忽略")
|
||||
return
|
||||
|
||||
# 检查消息类型字段
|
||||
var message_type: String = data.get("t", "")
|
||||
var data: Dictionary = data_variant
|
||||
|
||||
# 兼容不同后端字段命名:t / type
|
||||
var message_type: String = str(data.get("t", data.get("type", ""))).strip_edges()
|
||||
if message_type.is_empty():
|
||||
_warn_empty_message_type_limited(data)
|
||||
return
|
||||
|
||||
match message_type:
|
||||
"connected":
|
||||
pass
|
||||
"login_success":
|
||||
_handle_login_success(data)
|
||||
"login_error":
|
||||
@@ -509,9 +523,22 @@ func _on_data_received(message: String) -> void:
|
||||
_handle_chat_render(data)
|
||||
"position_updated":
|
||||
_handle_position_updated(data)
|
||||
"error":
|
||||
_handle_error_response(data)
|
||||
_:
|
||||
push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
|
||||
|
||||
func _warn_empty_message_type_limited(data: Dictionary) -> void:
|
||||
var now: float = Time.get_unix_time_from_system()
|
||||
if now - _last_empty_message_type_warning_at < EMPTY_MESSAGE_TYPE_WARNING_INTERVAL:
|
||||
return
|
||||
|
||||
_last_empty_message_type_warning_at = now
|
||||
var payload_preview: String = JSON.stringify(data)
|
||||
if payload_preview.length() > 180:
|
||||
payload_preview = payload_preview.substr(0, 180) + "..."
|
||||
push_warning("ChatManager: 收到未带消息类型的消息,已忽略 payload=%s" % payload_preview)
|
||||
|
||||
# 处理登录成功
|
||||
func _handle_login_success(data: Dictionary) -> void:
|
||||
_is_logged_in = true
|
||||
|
||||
@@ -6,7 +6,11 @@ extends Node
|
||||
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
|
||||
#
|
||||
# 协议文档: new_docs/game_architecture_design.md
|
||||
# 后端地址: wss://whaletownend.xinghangee.icu/location-broadcast
|
||||
# 后端地址默认值: wss://whaletownend.xinghangee.icu/game
|
||||
# 可通过以下方式覆盖:
|
||||
# 1) 环境变量 WHALETOWN_LOCATION_WS_URL
|
||||
# 2) Config/game_config.json 或 config/game_config.json 中的
|
||||
# network.location_ws_url / network.game_ws_url
|
||||
# ============================================================================
|
||||
|
||||
signal connected_to_server()
|
||||
@@ -17,23 +21,32 @@ signal user_joined(data: Dictionary)
|
||||
signal user_left(data: Dictionary)
|
||||
signal position_updated(data: Dictionary)
|
||||
|
||||
const WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast"
|
||||
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||
const WS_URL_ENV_KEY: String = "WHALETOWN_LOCATION_WS_URL"
|
||||
const PING_INTERVAL = 25.0 # 秒
|
||||
|
||||
var _socket: WebSocketPeer
|
||||
var _connected: bool = false
|
||||
var _ping_timer: float = 0.0
|
||||
var _auth_token: String = ""
|
||||
var _connection_error_reported: bool = false
|
||||
var _is_connecting: bool = false
|
||||
var _ws_url: String = DEFAULT_WS_URL
|
||||
|
||||
func _ready():
|
||||
_socket = WebSocketPeer.new()
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
||||
_ws_url = _resolve_ws_url()
|
||||
|
||||
func _process(delta):
|
||||
_socket.poll()
|
||||
|
||||
var state = _socket.get_ready_state()
|
||||
|
||||
if state == WebSocketPeer.STATE_OPEN:
|
||||
_connection_error_reported = false
|
||||
_is_connecting = false
|
||||
|
||||
if not _connected:
|
||||
_on_connected()
|
||||
|
||||
@@ -51,19 +64,71 @@ func _process(delta):
|
||||
elif state == WebSocketPeer.STATE_CLOSED:
|
||||
if _connected:
|
||||
_on_disconnected()
|
||||
elif _is_connecting and not _connection_error_reported:
|
||||
var close_code := _socket.get_close_code()
|
||||
var close_reason := _socket.get_close_reason()
|
||||
push_warning(
|
||||
"LocationManager: WebSocket 握手失败,close_code=%d, reason=%s" % [close_code, close_reason]
|
||||
)
|
||||
connection_error.emit()
|
||||
_connection_error_reported = true
|
||||
_is_connecting = false
|
||||
|
||||
func connect_to_server():
|
||||
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
var state: WebSocketPeer.State = _socket.get_ready_state()
|
||||
if state == WebSocketPeer.STATE_OPEN or state == WebSocketPeer.STATE_CONNECTING:
|
||||
return
|
||||
|
||||
var err = _socket.connect_to_url(WS_URL)
|
||||
|
||||
_connection_error_reported = false
|
||||
_is_connecting = true
|
||||
var err = _socket.connect_to_url(_ws_url)
|
||||
if err != OK:
|
||||
push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err)
|
||||
push_error("LocationManager: WebSocket 连接请求失败,url=%s, 错误码: %d" % [_ws_url, err])
|
||||
connection_error.emit()
|
||||
_connection_error_reported = true
|
||||
_is_connecting = false
|
||||
else:
|
||||
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
||||
pass
|
||||
|
||||
func _resolve_ws_url() -> String:
|
||||
var env_url: String = OS.get_environment(WS_URL_ENV_KEY).strip_edges()
|
||||
if not env_url.is_empty():
|
||||
return env_url
|
||||
|
||||
for config_path in ["res://Config/game_config.json", "res://config/game_config.json"]:
|
||||
var config_url: String = _load_ws_url_from_config(config_path)
|
||||
if not config_url.is_empty():
|
||||
return config_url
|
||||
|
||||
return DEFAULT_WS_URL
|
||||
|
||||
func _load_ws_url_from_config(config_path: String) -> String:
|
||||
if not FileAccess.file_exists(config_path):
|
||||
return ""
|
||||
|
||||
var content: String = FileAccess.get_file_as_string(config_path)
|
||||
if content.is_empty():
|
||||
return ""
|
||||
|
||||
var json := JSON.new()
|
||||
if json.parse(content) != OK:
|
||||
push_warning("LocationManager: 读取配置失败 %s - %s" % [config_path, json.get_error_message()])
|
||||
return ""
|
||||
|
||||
var data_variant: Variant = json.data
|
||||
if not (data_variant is Dictionary):
|
||||
return ""
|
||||
|
||||
var root: Dictionary = data_variant
|
||||
var network_variant: Variant = root.get("network", {})
|
||||
if not (network_variant is Dictionary):
|
||||
return ""
|
||||
|
||||
var network_config: Dictionary = network_variant
|
||||
var ws_url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||
return ws_url
|
||||
|
||||
func close_connection():
|
||||
_socket.close()
|
||||
|
||||
|
||||
@@ -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", # 喷泉地图
|
||||
@@ -192,9 +186,9 @@ func set_next_spawn_name(spawn_name: String) -> void:
|
||||
# - 此方法会清除存储的名称,只能获取一次
|
||||
# - 如果未设置名称,返回空字符串
|
||||
func get_next_spawn_name() -> String:
|
||||
var name = _next_spawn_name
|
||||
var spawn_name: String = _next_spawn_name
|
||||
_next_spawn_name = ""
|
||||
return name
|
||||
return spawn_name
|
||||
|
||||
# ============ 场景注册方法 ============
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 心跳机制
|
||||
|
||||
BIN
assets/characters/crayfish_npc_256_256.png
Normal file
BIN
assets/characters/crayfish_npc_256_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
7
assets/ui/world_text_theme.tres
Normal file
7
assets/ui/world_text_theme.tres
Normal file
@@ -0,0 +1,7 @@
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
|
||||
|
||||
[resource]
|
||||
resource_local_to_scene = true
|
||||
default_font = ExtResource("1_font")
|
||||
22
claude.md
22
claude.md
@@ -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
2495
docs/api-documentation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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):
|
||||
# 发送位置更新 (节流机制)
|
||||
|
||||
@@ -10,4 +10,4 @@ scale = Vector2(1.2916666, 1.2812501)
|
||||
texture = ExtResource("1_xrxds")
|
||||
|
||||
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."]
|
||||
polygon = PackedVector2Array(-216, -96, -216, -96, 200, -96, 200, -32, 192, -32, 192, 0, 72, 0, 72, -16, 48, -16, 48, -32, 48, -40, -40, -40, -48, -24, -64, -16, -72, -16, -80, 0, -200, 0, -200, -32, -216, -32)
|
||||
polygon = PackedVector2Array(-216, -96, -216, -96, 200, -96, 200, -32, 192, -32, 192, -8, 77, -8, 72, -16, 48, -16, 48, -32, 48, -40, -40, -40, -48, -24, -64, -16, -72, -16, -80, -5, -201, -6, -200, -32, -216, -32)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,29 +1,55 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
signal interaction_happened(text)
|
||||
# ============================================================================
|
||||
# 文件名: NPCController.gd
|
||||
# 作用: 通用 NPC 控制器,负责角色待机表现与交互对话
|
||||
#
|
||||
# 主要功能:
|
||||
# - 播放 NPC 待机动画
|
||||
# - 响应玩家射线交互
|
||||
# - 触发聊天气泡与 NPC 对话事件
|
||||
#
|
||||
# 依赖: EventSystem, EventNames, ChatBubble
|
||||
# 作者: Codex
|
||||
# 创建时间: 2026-03-10
|
||||
# ============================================================================
|
||||
|
||||
signal interaction_happened(text: String)
|
||||
|
||||
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
|
||||
|
||||
@export var npc_name: String = "NPC"
|
||||
@export var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||
|
||||
func _ready():
|
||||
$Sprite2D.texture = preload("res://assets/characters/npc_286_241.png")
|
||||
$Sprite2D.hframes = 4
|
||||
$Sprite2D.vframes = 4
|
||||
|
||||
# Start Idle Animation
|
||||
if has_node("AnimationPlayer"):
|
||||
$AnimationPlayer.play("idle")
|
||||
|
||||
# Ensure interaction layer
|
||||
collision_layer = 3 # Layer 1 & 2 (Blocking)
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
|
||||
func _ready() -> void:
|
||||
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
|
||||
if animation_player.has_animation("idle"):
|
||||
animation_player.play("idle")
|
||||
|
||||
# 保持 NPC 可被玩家射线与角色碰撞识别。
|
||||
collision_layer = 3
|
||||
collision_mask = 3
|
||||
|
||||
func interact():
|
||||
# 处理玩家交互,展示气泡并向全局事件系统广播。
|
||||
func interact() -> void:
|
||||
show_bubble(dialogue)
|
||||
EventSystem.emit_event(EventNames.NPC_TALKED, {
|
||||
"npc": self,
|
||||
"npc_name": npc_name,
|
||||
"dialogue": dialogue
|
||||
})
|
||||
interaction_happened.emit(dialogue)
|
||||
return null
|
||||
|
||||
func show_bubble(text):
|
||||
var bubble = preload("res://scenes/ui/ChatBubble.tscn").instantiate()
|
||||
# 在 NPC 头顶生成一次性聊天气泡。
|
||||
#
|
||||
# 参数:
|
||||
# text: String - 要展示的对话内容
|
||||
func show_bubble(text: String) -> void:
|
||||
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
|
||||
if bubble == null:
|
||||
return
|
||||
add_child(bubble)
|
||||
bubble.set_text(text)
|
||||
if bubble.has_method("set_text"):
|
||||
bubble.call("set_text", text)
|
||||
|
||||
@@ -81,6 +81,7 @@ func _handle_movement(_delta: float) -> void:
|
||||
|
||||
# 发送移动事件 (如果位置发生明显变化)
|
||||
if velocity.length() > 0:
|
||||
player_moved.emit(global_position)
|
||||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||||
"position": global_position
|
||||
})
|
||||
|
||||
65
scenes/characters/crayfish_npc.tscn
Normal file
65
scenes/characters/crayfish_npc.tscn
Normal file
@@ -0,0 +1,65 @@
|
||||
[gd_scene load_steps=7 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/crayfish_npc_256_256.png" id="1_texture"]
|
||||
[ext_resource type="Script" path="res://scenes/characters/NPCController.gd" id="2_script"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="1_shape"]
|
||||
size = Vector2(44, 22)
|
||||
|
||||
[sub_resource type="Animation" id="2_reset"]
|
||||
length = 0.001
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="3_idle"]
|
||||
resource_name = "idle"
|
||||
length = 1.2
|
||||
loop_mode = 1
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("Sprite2D:frame")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"times": PackedFloat32Array(0.0333333, 0.26666665, 0.4666667, 0.8, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [2, 1, 0, 4, 5]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="4_library"]
|
||||
_data = {
|
||||
&"RESET": SubResource("2_reset"),
|
||||
&"idle": SubResource("3_idle")
|
||||
}
|
||||
|
||||
[node name="CrayfishNpc" type="CharacterBody2D"]
|
||||
script = ExtResource("2_script")
|
||||
npc_name = "虾小满"
|
||||
dialogue = "欢迎来到 WhaleTown!我是虾小满,负责看着喷泉边的水路和码头消息。想找热闹的地方,顺着水边走就对啦。"
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture = ExtResource("1_texture")
|
||||
hframes = 4
|
||||
vframes = 4
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
light_mask = 5
|
||||
visibility_layer = 5
|
||||
shape = SubResource("1_shape")
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||
libraries = {
|
||||
&"": SubResource("4_library")
|
||||
}
|
||||
@@ -1,20 +1,35 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://rdmrm7j4iokr"]
|
||||
[gd_scene load_steps=6 format=3 uid="uid://rdmrm7j4iokr"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://pnlgf420wktn" path="res://scenes/prefabs/items/NoticeBoard.gd" id="1_script"]
|
||||
[ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_nb"]
|
||||
size = Vector2(160, 40)
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
|
||||
size = Vector2(14, 16)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_post"]
|
||||
size = Vector2(14, 16)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_center_bar"]
|
||||
size = Vector2(125, 4.666666)
|
||||
|
||||
[node name="NoticeBoard" type="StaticBody2D"]
|
||||
z_index = 1
|
||||
scale = Vector2(0.6, 0.6)
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
position = Vector2(0, -16)
|
||||
position = Vector2(0, -45.3)
|
||||
scale = Vector2(0.5, 0.5)
|
||||
texture = ExtResource("2_sprite")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, 6.6666665)
|
||||
shape = SubResource("RectangleShape2D_nb")
|
||||
[node name="LeftPostCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(-75, 10)
|
||||
shape = SubResource("RectangleShape2D_left_post")
|
||||
|
||||
[node name="RightPostCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(73.33333, 6.666667)
|
||||
shape = SubResource("RectangleShape2D_right_post")
|
||||
|
||||
[node name="CenterBottomCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(-2.5, -2.6666667)
|
||||
shape = SubResource("RectangleShape2D_center_bar")
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://c7k8yay002w4"]
|
||||
[gd_scene load_steps=5 format=3 uid="uid://c7k8yay002w4"]
|
||||
|
||||
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_sprite"]
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_board"]
|
||||
size = Vector2(112, 18.5)
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
|
||||
size = Vector2(16, 20)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_post"]
|
||||
size = Vector2(16, 20)
|
||||
|
||||
[sub_resource type="RectangleShape2D" id="RectangleShape2D_center_bar"]
|
||||
size = Vector2(76, 4)
|
||||
|
||||
[node name="WelcomeBoard" type="StaticBody2D"]
|
||||
z_index = 1
|
||||
collision_layer = 3
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
position = Vector2(0, -44.125)
|
||||
scale = Vector2(0.25, 0.25)
|
||||
texture = ExtResource("2_sprite")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(0, 14.75)
|
||||
shape = SubResource("RectangleShape2D_board")
|
||||
[node name="LeftPostCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(-49, -10)
|
||||
shape = SubResource("RectangleShape2D_left_post")
|
||||
|
||||
[node name="RightPostCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(47, -10)
|
||||
shape = SubResource("RectangleShape2D_right_post")
|
||||
|
||||
[node name="CenterBottomCollision" type="CollisionShape2D" parent="."]
|
||||
position = Vector2(-1, -22)
|
||||
shape = SubResource("RectangleShape2D_center_bar")
|
||||
|
||||
@@ -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 = "发送验证码"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" uid="uid://b4aorojcbwkmb" path="res://scenes/ui/ChatBubble.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"]
|
||||
bg_color = Color(1, 1, 1, 0.9)
|
||||
@@ -8,6 +9,10 @@ corner_radius_top_left = 5
|
||||
corner_radius_top_right = 5
|
||||
corner_radius_bottom_right = 5
|
||||
corner_radius_bottom_left = 5
|
||||
content_margin_left = 10.0
|
||||
content_margin_top = 6.0
|
||||
content_margin_right = 10.0
|
||||
content_margin_bottom = 6.0
|
||||
shadow_color = Color(0, 0, 0, 0.2)
|
||||
shadow_size = 2
|
||||
|
||||
@@ -15,6 +20,7 @@ shadow_size = 2
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
script = ExtResource("1_script")
|
||||
theme = ExtResource("2_theme")
|
||||
|
||||
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||
layout_mode = 1
|
||||
@@ -23,10 +29,10 @@ anchor_left = 0.5
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -75.0
|
||||
offset_top = -60.0
|
||||
offset_right = 75.0
|
||||
offset_bottom = -20.0
|
||||
offset_left = -120.0
|
||||
offset_top = -86.0
|
||||
offset_right = 120.0
|
||||
offset_bottom = -26.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
|
||||
@@ -34,9 +40,9 @@ theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
|
||||
[node name="Label" type="Label" parent="PanelContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.1, 0.1, 0.1, 1)
|
||||
theme_override_font_sizes/font_size = 8
|
||||
theme_override_font_sizes/font_size = 12
|
||||
text = "..."
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 3
|
||||
custom_minimum_size = Vector2(150, 0)
|
||||
custom_minimum_size = Vector2(220, 0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Control
|
||||
|
||||
# ============================================================================
|
||||
# ChatUI.gd - 聊天界面控制器(Enter 显示/隐藏版本)
|
||||
# ChatUI.gd - 聊天界面控制器(T 唤起 / Enter 发送)
|
||||
# ============================================================================
|
||||
# 聊天系统的用户界面控制器
|
||||
#
|
||||
@@ -9,8 +9,10 @@ extends Control
|
||||
# - 显示聊天消息历史
|
||||
# - 处理用户输入
|
||||
# - 显示连接状态
|
||||
# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏
|
||||
# - 只有按 Enter 才会取消倒计时
|
||||
# - T 唤起聊天框,Enter 发送消息
|
||||
# - 点击聊天框外部隐藏聊天框
|
||||
# - 失焦后 5 秒自动隐藏
|
||||
# - 显示/隐藏带 0.5s 过渡动画
|
||||
# - Call Down: 通过 EventSystem 订阅聊天事件
|
||||
#
|
||||
# 使用方式:
|
||||
@@ -46,6 +48,8 @@ extends Control
|
||||
# 聊天消息场景
|
||||
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
|
||||
|
||||
const CHAT_TRANSITION_DURATION: float = 0.5
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
# ============================================================================
|
||||
@@ -59,6 +63,9 @@ var _hide_timer: Timer = null
|
||||
# 是否在输入中(输入时不隐藏)
|
||||
var _is_typing: bool = false
|
||||
|
||||
# 显示/隐藏过渡动画
|
||||
var _transition_tween: Tween = null
|
||||
|
||||
# 当前用户名
|
||||
var _current_username: String = ""
|
||||
|
||||
@@ -69,7 +76,7 @@ var _current_username: String = ""
|
||||
# 准备就绪
|
||||
func _ready() -> void:
|
||||
# 初始隐藏聊天框
|
||||
hide_chat()
|
||||
hide_chat(true)
|
||||
|
||||
# 创建隐藏计时器
|
||||
_create_hide_timer()
|
||||
@@ -80,6 +87,9 @@ func _ready() -> void:
|
||||
# 连接 UI 信号
|
||||
_connect_ui_signals()
|
||||
|
||||
# 尽可能保持回车提交后输入框继续编辑状态(Godot 4.6+ 支持该属性)
|
||||
_enable_keep_editing_on_submit()
|
||||
|
||||
# 清理
|
||||
func _exit_tree() -> void:
|
||||
# 取消事件订阅
|
||||
@@ -93,30 +103,48 @@ func _exit_tree() -> void:
|
||||
if _hide_timer:
|
||||
_hide_timer.queue_free()
|
||||
|
||||
if is_instance_valid(_transition_tween):
|
||||
_transition_tween.kill()
|
||||
_transition_tween = null
|
||||
|
||||
# ============================================================================
|
||||
# 输入处理
|
||||
# ============================================================================
|
||||
|
||||
# 处理全局输入
|
||||
func _input(event: InputEvent) -> void:
|
||||
# 检查是否按下 Enter 键
|
||||
if event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER):
|
||||
if not (event is InputEventKey):
|
||||
return
|
||||
|
||||
var key_event := event as InputEventKey
|
||||
if not key_event.pressed or key_event.echo:
|
||||
return
|
||||
|
||||
# T 键用于唤起聊天(输入框聚焦时不拦截)
|
||||
if key_event.keycode == KEY_T and not chat_input.has_focus():
|
||||
_handle_t_pressed()
|
||||
return
|
||||
|
||||
# Enter 键用于发送聊天
|
||||
if key_event.keycode == KEY_ENTER or key_event.keycode == KEY_KP_ENTER:
|
||||
_handle_enter_pressed()
|
||||
|
||||
# 处理 T 键按下
|
||||
func _handle_t_pressed() -> void:
|
||||
if not _is_chat_visible:
|
||||
show_chat()
|
||||
|
||||
# 延迟聚焦,避免同一输入事件周期冲突
|
||||
call_deferred("_grab_input_focus")
|
||||
|
||||
# 处理 Enter 键按下
|
||||
func _handle_enter_pressed() -> void:
|
||||
# 如果聊天框未显示,显示它
|
||||
# 聊天框未显示时不处理 Enter
|
||||
if not _is_chat_visible:
|
||||
show_chat()
|
||||
# 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号
|
||||
call_deferred("_grab_input_focus")
|
||||
return
|
||||
|
||||
# 如果聊天框已显示且输入框有焦点,检查输入框内容
|
||||
# 输入框有焦点时,发送逻辑交给 LineEdit 的 text_submitted 统一处理
|
||||
if chat_input.has_focus():
|
||||
# 如果输入框有内容,发送消息
|
||||
if not chat_input.text.is_empty():
|
||||
_on_send_button_pressed()
|
||||
return
|
||||
|
||||
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
|
||||
@@ -136,11 +164,12 @@ func _gui_input(event: InputEvent) -> void:
|
||||
|
||||
# 处理点击聊天框外部区域
|
||||
func _handle_click_outside() -> void:
|
||||
if not _is_chat_visible:
|
||||
return
|
||||
|
||||
# 检查点击是否在聊天面板外部
|
||||
if not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
|
||||
# 延迟释放输入框焦点,避免事件冲突
|
||||
if chat_input.has_focus():
|
||||
call_deferred("_release_input_focus")
|
||||
hide_chat()
|
||||
|
||||
# 延迟释放输入框焦点(由 call_deferred 调用)
|
||||
func _release_input_focus() -> void:
|
||||
@@ -152,20 +181,32 @@ func _release_input_focus() -> void:
|
||||
# ============================================================================
|
||||
|
||||
# 显示聊天框
|
||||
func show_chat() -> void:
|
||||
func show_chat(immediate: bool = false) -> void:
|
||||
_is_chat_visible = true
|
||||
if is_instance_valid(chat_panel):
|
||||
chat_panel.show()
|
||||
|
||||
# 停止隐藏计时器
|
||||
_stop_hide_timer()
|
||||
|
||||
if not is_instance_valid(chat_panel):
|
||||
return
|
||||
|
||||
_stop_transition_tween()
|
||||
chat_panel.show()
|
||||
|
||||
if immediate:
|
||||
chat_panel.modulate.a = 1.0
|
||||
return
|
||||
|
||||
chat_panel.modulate.a = 0.0
|
||||
_transition_tween = create_tween()
|
||||
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
|
||||
_transition_tween.tween_property(chat_panel, "modulate:a", 1.0, CHAT_TRANSITION_DURATION)
|
||||
_transition_tween.finished.connect(_on_show_transition_finished)
|
||||
|
||||
# 隐藏聊天框
|
||||
func hide_chat() -> void:
|
||||
func hide_chat(immediate: bool = false) -> void:
|
||||
_is_chat_visible = false
|
||||
_is_typing = false
|
||||
if is_instance_valid(chat_panel):
|
||||
chat_panel.hide()
|
||||
|
||||
if is_instance_valid(chat_input) and chat_input.has_focus():
|
||||
chat_input.release_focus()
|
||||
@@ -173,6 +214,25 @@ func hide_chat() -> void:
|
||||
# 停止隐藏计时器
|
||||
_stop_hide_timer()
|
||||
|
||||
if not is_instance_valid(chat_panel):
|
||||
return
|
||||
|
||||
_stop_transition_tween()
|
||||
|
||||
if immediate:
|
||||
chat_panel.hide()
|
||||
chat_panel.modulate.a = 1.0
|
||||
return
|
||||
|
||||
if not chat_panel.visible:
|
||||
chat_panel.modulate.a = 1.0
|
||||
return
|
||||
|
||||
_transition_tween = create_tween()
|
||||
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
||||
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
|
||||
_transition_tween.finished.connect(_on_hide_transition_finished)
|
||||
|
||||
# 创建隐藏计时器
|
||||
func _create_hide_timer() -> void:
|
||||
_hide_timer = Timer.new()
|
||||
@@ -202,6 +262,20 @@ func _stop_hide_timer() -> void:
|
||||
func _on_hide_timeout() -> void:
|
||||
hide_chat()
|
||||
|
||||
func _on_show_transition_finished() -> void:
|
||||
_transition_tween = null
|
||||
|
||||
func _on_hide_transition_finished() -> void:
|
||||
_transition_tween = null
|
||||
if not _is_chat_visible and is_instance_valid(chat_panel):
|
||||
chat_panel.hide()
|
||||
chat_panel.modulate.a = 1.0
|
||||
|
||||
func _stop_transition_tween() -> void:
|
||||
if is_instance_valid(_transition_tween):
|
||||
_transition_tween.kill()
|
||||
_transition_tween = null
|
||||
|
||||
# ============================================================================
|
||||
# UI 事件处理
|
||||
# ============================================================================
|
||||
@@ -243,12 +317,30 @@ func _on_send_button_pressed() -> void:
|
||||
# 清空输入框
|
||||
chat_input.clear()
|
||||
|
||||
# 重新聚焦输入框
|
||||
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func _focus_input_after_send() -> void:
|
||||
if not _is_chat_visible:
|
||||
return
|
||||
if not is_instance_valid(chat_input):
|
||||
return
|
||||
chat_input.grab_focus()
|
||||
|
||||
# 聊天输入提交(回车键)处理
|
||||
func _on_chat_input_submitted(text: String) -> void:
|
||||
func _on_chat_input_submitted(_text: String) -> void:
|
||||
_on_send_button_pressed()
|
||||
# 即便提交的是空串,也保持输入焦点,便于连续输入
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func _enable_keep_editing_on_submit() -> void:
|
||||
if not is_instance_valid(chat_input):
|
||||
return
|
||||
|
||||
for property in chat_input.get_property_list():
|
||||
if property.get("name", "") == "keep_editing_on_text_submit":
|
||||
chat_input.set("keep_editing_on_text_submit", true)
|
||||
return
|
||||
|
||||
# ============================================================================
|
||||
# 事件订阅(Call Down)
|
||||
@@ -290,10 +382,11 @@ func _on_chat_error(data: Dictionary) -> void:
|
||||
var error_code: String = data.get("error_code", "")
|
||||
var message: String = data.get("message", "")
|
||||
|
||||
push_error("ChatUI: [%s] %s" % [error_code, message])
|
||||
# 聊天发送失败通常是业务校验(频率/内容)导致,不应按引擎级错误处理。
|
||||
push_warning("ChatUI: [%s] %s" % [error_code, message])
|
||||
|
||||
# 处理连接状态变化
|
||||
func _on_connection_state_changed(data: Dictionary) -> void:
|
||||
func _on_connection_state_changed(_data: Dictionary) -> void:
|
||||
# 连接状态变化处理(当前不更新UI)
|
||||
pass
|
||||
|
||||
@@ -319,7 +412,6 @@ func add_message_to_history(from_user: String, content: String, timestamp: float
|
||||
|
||||
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
||||
var row := HBoxContainer.new()
|
||||
row.layout_mode = 2 # 让 VBoxContainer 接管布局,否则会重叠在同一位置
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN
|
||||
|
||||
@@ -32,7 +32,7 @@ var current_page = 0
|
||||
var tween: Tween
|
||||
var mock_pages = []
|
||||
var _chat_ui: Control
|
||||
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP
|
||||
var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
||||
var _chat_ui_mouse_disabled: bool = false
|
||||
|
||||
func _ready():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends CanvasLayer
|
||||
|
||||
var _chat_ui: Control
|
||||
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP
|
||||
var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
|
||||
var _chat_ui_mouse_disabled: bool = false
|
||||
|
||||
func _ready():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://rdmro1jxs6ga"]
|
||||
[gd_scene load_steps=4 format=3 uid="uid://rdmro1jxs6ga"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://c227m0okmjt2t" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
|
||||
bg_color = Color(0.95, 0.95, 0.95, 1)
|
||||
@@ -33,6 +34,7 @@ grow_vertical = 2
|
||||
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(480, 420)
|
||||
layout_mode = 2
|
||||
theme = ExtResource("2_theme")
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_rounded")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||
@@ -96,13 +98,13 @@ vertical_alignment = 1
|
||||
[node name="TextPanel" type="MarginContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/margin_left = 16
|
||||
theme_override_constants/margin_right = 16
|
||||
theme_override_constants/margin_left = 20
|
||||
theme_override_constants/margin_right = 20
|
||||
|
||||
[node name="ContentLabel" type="RichTextLabel" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/default_color = Color(0.3, 0.3, 0.3, 1)
|
||||
theme_override_font_sizes/normal_font_size = 16
|
||||
theme_override_font_sizes/normal_font_size = 17
|
||||
bbcode_enabled = true
|
||||
text = "Announcement Content..."
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://d8mam0n1a3b5"]
|
||||
[gd_scene load_steps=6 format=3 uid="uid://d8mam0n1a3b5"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/prefabs/ui/WelcomeDialog.gd" id="1_vs5b1"]
|
||||
[ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/ui/WelcomeDialog.gd" id="1_vs5b1"]
|
||||
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"]
|
||||
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
|
||||
bg_color = Color(1, 1, 1, 1)
|
||||
@@ -44,6 +45,7 @@ grow_vertical = 2
|
||||
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||
custom_minimum_size = Vector2(400, 350)
|
||||
layout_mode = 2
|
||||
theme = ExtResource("3_theme")
|
||||
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||
@@ -90,7 +92,7 @@ stretch_mode = 5
|
||||
[node name="BodyText" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.3, 0.3, 0.3, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "连接·共生·见证
|
||||
Datawhale Town —— 学习者的赛博家园与精神坐标。
|
||||
✨ 实时广场:看大家都在学什么。
|
||||
|
||||
@@ -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)
|
||||
|
||||
185
tests/unit/test_auth_baselevel_regressions.gd
Normal file
185
tests/unit/test_auth_baselevel_regressions.gd
Normal 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)
|
||||
85
tests/unit/test_chat_ui_input_regressions.gd
Normal file
85
tests/unit/test_chat_ui_input_regressions.gd
Normal file
@@ -0,0 +1,85 @@
|
||||
extends SceneTree
|
||||
|
||||
# ChatUI 输入交互回归测试(无需 GUT)
|
||||
#
|
||||
# 运行方式(Godot 4.6):
|
||||
# "D:\technology\biancheng\Godot_v4.6-stable_win64.exe\Godot_v4.6-stable_win64_console.exe" --headless --path . --script tests/unit/test_chat_ui_input_regressions.gd
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
await _test_t_opens_enter_sends_outside_click_hides()
|
||||
|
||||
if _failures.is_empty():
|
||||
print("PASS: test_chat_ui_input_regressions")
|
||||
quit(0)
|
||||
return
|
||||
|
||||
for failure in _failures:
|
||||
push_error(failure)
|
||||
print("FAIL: test_chat_ui_input_regressions (%d)" % _failures.size())
|
||||
quit(1)
|
||||
|
||||
func _test_t_opens_enter_sends_outside_click_hides() -> void:
|
||||
var packed_scene: PackedScene = load("res://scenes/ui/ChatUI.tscn")
|
||||
_assert(packed_scene != null, "应能加载 ChatUI.tscn")
|
||||
if packed_scene == null:
|
||||
return
|
||||
|
||||
var chat_ui = packed_scene.instantiate()
|
||||
root.add_child(chat_ui)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var chat_panel = chat_ui.chat_panel
|
||||
var chat_input = chat_ui.chat_input
|
||||
|
||||
_assert(not chat_panel.visible, "初始化时聊天框应隐藏")
|
||||
_assert(not chat_ui._is_chat_visible, "初始化时 _is_chat_visible 应为 false")
|
||||
|
||||
var enter_event := InputEventKey.new()
|
||||
enter_event.pressed = true
|
||||
enter_event.keycode = KEY_ENTER
|
||||
chat_ui._input(enter_event)
|
||||
await process_frame
|
||||
_assert(not chat_panel.visible, "聊天框隐藏时按 Enter 不应唤起聊天框")
|
||||
|
||||
var t_event := InputEventKey.new()
|
||||
t_event.pressed = true
|
||||
t_event.keycode = KEY_T
|
||||
chat_ui._input(t_event)
|
||||
await create_timer(0.7).timeout
|
||||
|
||||
_assert(chat_panel.visible, "按 T 后聊天框应显示")
|
||||
_assert(chat_ui._is_chat_visible, "按 T 后 _is_chat_visible 应为 true")
|
||||
_assert(chat_input.has_focus(), "按 T 后输入框应获得焦点")
|
||||
|
||||
chat_input.text = "hello world"
|
||||
chat_ui._on_chat_input_submitted(chat_input.text)
|
||||
await process_frame
|
||||
_assert(chat_input.text.is_empty(), "输入框有内容时按 Enter 应触发发送并清空输入框")
|
||||
_assert(chat_input.has_focus(), "按 Enter 发送后输入框应自动保持焦点,便于连续输入")
|
||||
|
||||
# 将鼠标移动到左上角,通常在聊天框外
|
||||
var viewport := root.get_viewport()
|
||||
if viewport:
|
||||
viewport.warp_mouse(Vector2.ZERO)
|
||||
|
||||
var mouse_event := InputEventMouseButton.new()
|
||||
mouse_event.button_index = MOUSE_BUTTON_LEFT
|
||||
mouse_event.pressed = true
|
||||
chat_ui._gui_input(mouse_event)
|
||||
await create_timer(0.7).timeout
|
||||
|
||||
_assert(not chat_ui._is_chat_visible, "点击聊天框外部后 _is_chat_visible 应为 false")
|
||||
_assert(not chat_panel.visible, "点击聊天框外部后聊天框应隐藏")
|
||||
|
||||
chat_ui.queue_free()
|
||||
await process_frame
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
142
tests/unit/test_websocket_close_code.gd
Normal file
142
tests/unit/test_websocket_close_code.gd
Normal 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)
|
||||
40
tools/serve_web_assets.bat
Normal file
40
tools/serve_web_assets.bat
Normal 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
|
||||
@@ -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":47001952,"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
@@ -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.
@@ -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 = '1773223762|43101372';
|
||||
/** @type {string} */
|
||||
const CACHE_PREFIX = 'whaleTown-sw-cache-';
|
||||
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user