Compare commits
19 Commits
6a3823ad16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326ab7ce5c | ||
|
|
603e7d9fc6 | ||
|
|
e989b4adf1 | ||
|
|
6e70aac0b9 | ||
| 625fe0ff6c | |||
|
|
0e5b9f947b | ||
| 7cca58cb07 | |||
|
|
749e2c257b | ||
|
|
e335a35f6c | ||
|
|
136e1344a0 | ||
|
|
25a21f92be | ||
|
|
9c2e3bf15a | ||
|
|
9e288dbb62 | ||
|
|
c8e73bec59 | ||
|
|
16f24ab26f | ||
|
|
414225e8c1 | ||
|
|
fb7cba4088 | ||
| 7b85147994 | |||
| 43e0c2b928 |
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Skill(whaletown-developer)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
335
.claude/skills/whaletown-developer/SKILL.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
---
|
||||||
|
name: whaletown-developer
|
||||||
|
description: Automate WhaleTown project's standard development workflow. Use this skill when implementing features, fixing bugs, creating scenes, or any code development tasks. Guides through 7-step process - architecture analysis, implementation, comment/naming validation, testing, and Git commit generation following project conventions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# WhaleTown Standard Development Workflow Skill
|
||||||
|
|
||||||
|
This skill automates the standard development workflow for the WhaleTown project, ensuring all developers follow unified specifications and quality standards.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Activate this skill when:
|
||||||
|
- Implementing new features ("实现XX功能", "添加XX系统")
|
||||||
|
- Fixing bugs ("修复XX Bug", "解决XX问题")
|
||||||
|
- Creating scenes ("创建XX场景", "设计XX界面")
|
||||||
|
- Developing modules ("开发XX模块", "构建XX组件")
|
||||||
|
- Any code development task requiring adherence to project standards
|
||||||
|
|
||||||
|
## Development Workflow Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Architecture Analysis (读取架构规范)
|
||||||
|
↓
|
||||||
|
Step 2: Implementation (按规范编码)
|
||||||
|
↓
|
||||||
|
Step 3: Comment Validation (注释规范检查)
|
||||||
|
↓
|
||||||
|
Step 4: Naming Validation (命名规范检查)
|
||||||
|
↓
|
||||||
|
Step 5: Test Writing (编写测试代码)
|
||||||
|
↓
|
||||||
|
Step 6: Test Execution (运行测试验证)
|
||||||
|
↓
|
||||||
|
Step 7: Git Commit (生成规范提交信息)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step-by-Step Workflow
|
||||||
|
|
||||||
|
### Step 1: Architecture Analysis
|
||||||
|
|
||||||
|
Read and apply the architecture specifications before implementation.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Read `docs/02-开发规范/架构与通信规范.md`
|
||||||
|
2. Determine file location based on feature type:
|
||||||
|
- Core systems → `_Core/managers/` or `_Core/systems/`
|
||||||
|
- Game scenes → `scenes/Maps/`, `scenes/Entities/`, `scenes/Components/`
|
||||||
|
- UI components → `scenes/ui/`
|
||||||
|
3. Identify communication method (MUST use EventSystem for cross-module communication)
|
||||||
|
4. List dependencies (required managers and systems)
|
||||||
|
5. Design event definitions (add to `_Core/EventNames.gd`)
|
||||||
|
|
||||||
|
**Layered Architecture:**
|
||||||
|
```
|
||||||
|
UI Layer (界面层) → scenes/ui/
|
||||||
|
↑
|
||||||
|
Scenes Layer (游戏层) → scenes/Maps/, scenes/Entities/
|
||||||
|
↑
|
||||||
|
_Core Layer (框架层) → _Core/managers/, _Core/systems/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Communication Principle:** "Signal Up, Call Down"
|
||||||
|
- Parents call child methods (downward calls)
|
||||||
|
- Children emit signals to notify parents (upward signals)
|
||||||
|
- Cross-module communication MUST use EventSystem
|
||||||
|
|
||||||
|
### Step 2: Implementation
|
||||||
|
|
||||||
|
Implement the feature following strict project conventions.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- **Type Safety**: All variables and functions MUST have type annotations
|
||||||
|
```gdscript
|
||||||
|
var speed: float = 200.0
|
||||||
|
func move(delta: float) -> void:
|
||||||
|
```
|
||||||
|
- **Godot 4.2+ Syntax**: NO `yield()`, use `await`
|
||||||
|
- **Node Caching**: Use `@onready` to cache node references, avoid `get_node()` in `_process()`
|
||||||
|
- **EventSystem Communication**: Use EventSystem for cross-module messaging
|
||||||
|
```gdscript
|
||||||
|
EventSystem.emit_event(EventNames.PLAYER_MOVED, {"position": global_position})
|
||||||
|
EventSystem.connect_event(EventNames.INTERACT_PRESSED, _on_interact_pressed)
|
||||||
|
```
|
||||||
|
- **Nearest Filter**: All Sprite2D/TileMap resources MUST use Nearest filter (no Linear filter)
|
||||||
|
- **AutoLoad Restrictions**: Only GameManager, SceneManager, EventSystem, NetworkManager, ResponseHandler allowed as autoloads
|
||||||
|
- **Low-level Entities**: Do NOT directly reference GameManager in Player/NPC entities, use events instead
|
||||||
|
|
||||||
|
### Step 3: Comment Validation
|
||||||
|
|
||||||
|
Ensure code comments meet project standards.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Read `docs/02-开发规范/代码注释规范.md`
|
||||||
|
2. Verify file header comment is complete:
|
||||||
|
```gdscript
|
||||||
|
# ============================================================================
|
||||||
|
# 文件名: FeatureName.gd
|
||||||
|
# 作用: 简短描述功能
|
||||||
|
#
|
||||||
|
# 主要功能:
|
||||||
|
# - 功能点1
|
||||||
|
# - 功能点2
|
||||||
|
#
|
||||||
|
# 依赖: 列出依赖的管理器/系统
|
||||||
|
# 作者: [开发者名称]
|
||||||
|
# 创建时间: YYYY-MM-DD
|
||||||
|
# ============================================================================
|
||||||
|
```
|
||||||
|
3. Verify all public functions have complete documentation:
|
||||||
|
```gdscript
|
||||||
|
# 函数功能描述
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# param_name: Type - 参数说明
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Type - 返回值说明
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# var result = function_name(param)
|
||||||
|
func function_name(param: Type) -> ReturnType:
|
||||||
|
```
|
||||||
|
4. Ensure complex logic has inline comments explaining WHY, not WHAT
|
||||||
|
|
||||||
|
### Step 4: Naming Validation
|
||||||
|
|
||||||
|
Verify all naming follows project conventions.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Read `docs/02-开发规范/命名规范.md`
|
||||||
|
2. Validate naming conventions:
|
||||||
|
- **Class names**: PascalCase (`class_name PlayerController`)
|
||||||
|
- **Variables/Functions**: camelCase (`var moveSpeed: float`, `func updateMovement()`)
|
||||||
|
- **Constants**: UPPER_CASE (`const MAX_HEALTH: int = 100`)
|
||||||
|
- **Private members**: Underscore prefix (`var _velocity: Vector2`)
|
||||||
|
- **Scene files**: snake_case with suffix (`player_scene.tscn`, `enemy_prefab.tscn`)
|
||||||
|
- **Script files**: PascalCase.gd (`PlayerController.gd`, `GameManager.gd`)
|
||||||
|
|
||||||
|
**Common Patterns:**
|
||||||
|
```gdscript
|
||||||
|
# ✅ Correct
|
||||||
|
const MAX_SPEED: float = 300.0
|
||||||
|
var currentHealth: int
|
||||||
|
var _isInitialized: bool = false
|
||||||
|
func getPlayerPosition() -> Vector2:
|
||||||
|
func _calculateDamage(baseDamage: int) -> int:
|
||||||
|
|
||||||
|
# ❌ Incorrect
|
||||||
|
const maxSpeed: float = 300.0 # Constants must be UPPER_CASE
|
||||||
|
var CurrentHealth: int # Variables must be camelCase
|
||||||
|
var is_initialized: bool = false # No snake_case for variables
|
||||||
|
func GetPlayerPosition() -> Vector2: # Functions must be camelCase
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Test Writing
|
||||||
|
|
||||||
|
Create unit tests for the implemented functionality.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Read `docs/03-技术实现/测试指南.md`
|
||||||
|
2. For _Core/ managers/systems, MUST create corresponding test file in `tests/unit/`
|
||||||
|
3. Test file naming: `test_[name].gd`
|
||||||
|
4. Test file structure:
|
||||||
|
```gdscript
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
## [FeatureName] 单元测试
|
||||||
|
|
||||||
|
var feature: FeatureName
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
feature = preload("res://_Core/managers/FeatureName.gd").new()
|
||||||
|
add_child(feature)
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
feature.queue_free()
|
||||||
|
|
||||||
|
func test_initialization():
|
||||||
|
var result = feature.initialize()
|
||||||
|
assert_true(result, "Feature should initialize successfully")
|
||||||
|
|
||||||
|
func test_core_functionality():
|
||||||
|
# Test core functionality
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Test Execution
|
||||||
|
|
||||||
|
Run tests to ensure code quality.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Run GUT tests using Bash tool:
|
||||||
|
```bash
|
||||||
|
godot --headless -s addons/gut/gut_cmdline.gd -gdir=res://tests/ -ginclude_subdirs
|
||||||
|
```
|
||||||
|
2. Verify all tests pass
|
||||||
|
3. If tests fail:
|
||||||
|
- Identify the root cause
|
||||||
|
- Fix the implementation or test
|
||||||
|
- Re-run tests until all pass
|
||||||
|
|
||||||
|
### Step 7: Git Commit
|
||||||
|
|
||||||
|
Generate standardized Git commit message.
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
1. Read `docs/02-开发规范/Git提交规范.md`
|
||||||
|
2. Determine commit type based on changes:
|
||||||
|
- `feat` - New features
|
||||||
|
- `fix` - Bug fixes
|
||||||
|
- `docs` - Documentation updates
|
||||||
|
- `refactor` - Code refactoring
|
||||||
|
- `perf` - Performance optimization
|
||||||
|
- `test` - Test additions/modifications
|
||||||
|
- `scene` - Scene file changes
|
||||||
|
- `ui` - UI related changes
|
||||||
|
3. Generate commit message using Chinese colon (:):
|
||||||
|
```
|
||||||
|
<类型>:<简短描述>
|
||||||
|
|
||||||
|
[可选的详细描述]
|
||||||
|
```
|
||||||
|
4. Follow principles:
|
||||||
|
- **One commit, one change** - Most important rule
|
||||||
|
- Use imperative verbs (添加, 修复, 更新)
|
||||||
|
- Keep description concise (< 50 characters)
|
||||||
|
- If multiple types of changes, split into separate commits
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# ✅ Good commits
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
git commit -m "fix:修复角色跳跃时的碰撞检测问题"
|
||||||
|
git commit -m "test:添加角色控制器单元测试"
|
||||||
|
|
||||||
|
# ❌ Bad commits
|
||||||
|
git commit -m "fix + feat:修复Bug并添加新功能" # Mixed types
|
||||||
|
git commit -m "update player" # Vague, English
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
Use TodoWrite tool to track workflow progress:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
TodoWrite.create_todos([
|
||||||
|
"Step 1: 架构分析 - 读取架构规范",
|
||||||
|
"Step 2: 功能实现 - 按规范编码",
|
||||||
|
"Step 3: 注释规范检查",
|
||||||
|
"Step 4: 命名规范检查",
|
||||||
|
"Step 5: 测试代码编写",
|
||||||
|
"Step 6: 测试验证 - 运行测试",
|
||||||
|
"Step 7: Git 提交 - 生成提交信息"
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark each step as `completed` immediately after finishing.
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
Before completing the workflow, verify:
|
||||||
|
|
||||||
|
- [ ] File location follows layered architecture (_Core, scenes, UI)
|
||||||
|
- [ ] Uses EventSystem for cross-module communication
|
||||||
|
- [ ] Event names added to EventNames.gd
|
||||||
|
- [ ] All variables and functions have type annotations
|
||||||
|
- [ ] Naming conventions correct (PascalCase/camelCase/UPPER_CASE)
|
||||||
|
- [ ] File header comment complete
|
||||||
|
- [ ] Public functions have complete documentation
|
||||||
|
- [ ] Unit tests created and passing
|
||||||
|
- [ ] Git commit message follows specification
|
||||||
|
- [ ] No Godot 3.x syntax (yield → await)
|
||||||
|
- [ ] Node references cached with @onready
|
||||||
|
- [ ] Sprite2D/TileMap use Nearest filter
|
||||||
|
|
||||||
|
## Reference Documents
|
||||||
|
|
||||||
|
The skill automatically reads these documents at appropriate steps:
|
||||||
|
|
||||||
|
- Architecture: `docs/02-开发规范/架构与通信规范.md`
|
||||||
|
- Comments: `docs/02-开发规范/代码注释规范.md`
|
||||||
|
- Naming: `docs/02-开发规范/命名规范.md`
|
||||||
|
- Testing: `docs/03-技术实现/测试指南.md`
|
||||||
|
- Git: `docs/02-开发规范/Git提交规范.md`
|
||||||
|
- Project Instructions: `claude.md` (root directory)
|
||||||
|
|
||||||
|
For detailed checklist reference, see `references/checklist.md` in this skill directory.
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
User request: "实现玩家二段跳功能"
|
||||||
|
|
||||||
|
1. **Architecture Analysis** ✅
|
||||||
|
- Read architecture spec
|
||||||
|
- Target: `scenes/Entities/Player/Player.gd`
|
||||||
|
- Communication: Emit `PLAYER_DOUBLE_JUMPED` event
|
||||||
|
- Dependencies: EventSystem, Input
|
||||||
|
- Event: Add `PLAYER_DOUBLE_JUMPED` to EventNames.gd
|
||||||
|
|
||||||
|
2. **Implementation** ✅
|
||||||
|
- Create double jump logic with type annotations
|
||||||
|
- Use EventSystem.emit_event() for notifications
|
||||||
|
- Cache references with @onready
|
||||||
|
- Use await instead of yield
|
||||||
|
|
||||||
|
3. **Comment Validation** ✅
|
||||||
|
- Add file header with feature description
|
||||||
|
- Document double jump function parameters
|
||||||
|
- Add inline comments for jump logic
|
||||||
|
|
||||||
|
4. **Naming Validation** ✅
|
||||||
|
- Verify: `var canDoubleJump: bool` (camelCase)
|
||||||
|
- Verify: `const MAX_DOUBLE_JUMPS: int` (UPPER_CASE)
|
||||||
|
- Verify: `func performDoubleJump()` (camelCase)
|
||||||
|
|
||||||
|
5. **Test Writing** ✅
|
||||||
|
- Create `tests/unit/test_player_double_jump.gd`
|
||||||
|
- Test initialization, jump execution, limits
|
||||||
|
|
||||||
|
6. **Test Execution** ✅
|
||||||
|
- Run: `godot --headless -s addons/gut/gut_cmdline.gd`
|
||||||
|
- All tests pass ✅
|
||||||
|
|
||||||
|
7. **Git Commit** ✅
|
||||||
|
```bash
|
||||||
|
git add scenes/Entities/Player/Player.gd _Core/EventNames.gd tests/unit/test_player_double_jump.gd
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This skill enforces quality standards through automated validation
|
||||||
|
- Each step builds upon the previous, ensuring comprehensive quality control
|
||||||
|
- Skipping steps will result in incomplete or non-compliant code
|
||||||
|
- The 7-step workflow is designed for team consistency and maintainability
|
||||||
285
.claude/skills/whaletown-developer/references/checklist.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# WhaleTown Development Quality Checklist
|
||||||
|
|
||||||
|
快速参考检查清单,用于验证代码是否符合项目规范。
|
||||||
|
|
||||||
|
## 架构检查清单
|
||||||
|
|
||||||
|
### 文件位置
|
||||||
|
- [ ] 核心系统文件位于 `_Core/managers/` 或 `_Core/systems/`
|
||||||
|
- [ ] 游戏场景文件位于 `scenes/Maps/`, `scenes/Entities/`, `scenes/Components/`
|
||||||
|
- [ ] UI 组件文件位于 `scenes/ui/`
|
||||||
|
- [ ] 测试文件位于 `tests/unit/` 或 `tests/integration/`
|
||||||
|
|
||||||
|
### 通信方式
|
||||||
|
- [ ] 跨模块通信使用 EventSystem
|
||||||
|
- [ ] 新增事件定义在 `_Core/EventNames.gd` 中
|
||||||
|
- [ ] 遵循 "Signal Up, Call Down" 原则
|
||||||
|
- [ ] 父节点调用子节点方法(向下调用)
|
||||||
|
- [ ] 子节点发出信号通知父节点(向上信号)
|
||||||
|
|
||||||
|
### 依赖管理
|
||||||
|
- [ ] 仅使用允许的自动加载:GameManager, SceneManager, EventSystem, NetworkManager, ResponseHandler
|
||||||
|
- [ ] 底层实体(Player, NPC)不直接访问 GameManager
|
||||||
|
- [ ] 底层实体通过事件系统与全局管理器通信
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码规范检查清单
|
||||||
|
|
||||||
|
### 类型安全
|
||||||
|
- [ ] 所有变量都有类型注解:`var speed: float = 200.0`
|
||||||
|
- [ ] 所有函数都有参数和返回值类型:`func move(delta: float) -> void:`
|
||||||
|
- [ ] 常量都有类型注解:`const MAX_HEALTH: int = 100`
|
||||||
|
|
||||||
|
### Godot 4.2+ 语法
|
||||||
|
- [ ] 使用 `await` 代替 `yield()`
|
||||||
|
- [ ] 使用 `@onready` 缓存节点引用
|
||||||
|
- [ ] 避免在 `_process()` 中使用 `get_node()`
|
||||||
|
- [ ] 信号连接使用 `.connect()` 语法
|
||||||
|
|
||||||
|
### 资源设置
|
||||||
|
- [ ] 所有 Sprite2D 使用 Nearest 滤镜(不使用 Linear)
|
||||||
|
- [ ] 所有 TileMap 使用 Nearest 滤镜
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 命名规范检查清单
|
||||||
|
|
||||||
|
### 类名
|
||||||
|
- [ ] 使用 PascalCase:`class_name PlayerController`
|
||||||
|
- [ ] 文件名与类名一致:`PlayerController.gd`
|
||||||
|
|
||||||
|
### 变量
|
||||||
|
- [ ] 公共变量使用 camelCase:`var moveSpeed: float`
|
||||||
|
- [ ] 私有变量使用下划线前缀:`var _velocity: Vector2`
|
||||||
|
- [ ] 布尔变量使用 is/has/can 前缀:`var isJumping: bool`
|
||||||
|
|
||||||
|
### 函数
|
||||||
|
- [ ] 使用 camelCase:`func updateMovement()`
|
||||||
|
- [ ] 获取函数使用 `get` 前缀:`func getPlayerPosition()`
|
||||||
|
- [ ] 设置函数使用 `set` 前缀:`func setHealth(value: int)`
|
||||||
|
- [ ] 判断函数使用 `is/has/can` 前缀:`func isAlive()`, `func canJump()`
|
||||||
|
- [ ] 私有函数使用下划线前缀:`func _calculateDamage()`
|
||||||
|
|
||||||
|
### 常量
|
||||||
|
- [ ] 使用 UPPER_CASE:`const MAX_HEALTH: int = 100`
|
||||||
|
- [ ] 使用下划线分隔:`const JUMP_FORCE: float = -400.0`
|
||||||
|
|
||||||
|
### 枚举
|
||||||
|
- [ ] 枚举类型使用 PascalCase:`enum PlayerState`
|
||||||
|
- [ ] 枚举值使用 UPPER_CASE:`IDLE, WALKING, RUNNING`
|
||||||
|
|
||||||
|
### 文件命名
|
||||||
|
- [ ] 脚本文件:PascalCase.gd (`PlayerController.gd`)
|
||||||
|
- [ ] 场景文件:snake_case_scene.tscn (`main_scene.tscn`)
|
||||||
|
- [ ] 预制体文件:snake_case_prefab.tscn (`player_prefab.tscn`)
|
||||||
|
- [ ] 资源文件:snake_case (`sprite_player_idle.png`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注释规范检查清单
|
||||||
|
|
||||||
|
### 文件头注释
|
||||||
|
- [ ] 包含文件名
|
||||||
|
- [ ] 包含作用描述
|
||||||
|
- [ ] 列出主要功能
|
||||||
|
- [ ] 列出依赖的管理器/系统
|
||||||
|
- [ ] 包含作者和创建时间
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```gdscript
|
||||||
|
# ============================================================================
|
||||||
|
# 文件名: PlayerController.gd
|
||||||
|
# 作用: 玩家角色控制器,处理玩家输入和移动逻辑
|
||||||
|
#
|
||||||
|
# 主要功能:
|
||||||
|
# - 处理键盘和手柄输入
|
||||||
|
# - 控制角色移动和跳跃
|
||||||
|
# - 管理角色状态切换
|
||||||
|
#
|
||||||
|
# 依赖: EventSystem, InputManager
|
||||||
|
# 作者: [开发者名称]
|
||||||
|
# 创建时间: 2025-01-03
|
||||||
|
# ============================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 函数注释
|
||||||
|
- [ ] 公共函数有完整注释
|
||||||
|
- [ ] 包含功能描述
|
||||||
|
- [ ] 列出参数说明(名称、类型、含义)
|
||||||
|
- [ ] 说明返回值(类型、含义)
|
||||||
|
- [ ] 提供使用示例(对于复杂函数)
|
||||||
|
- [ ] 标注注意事项(如果有)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```gdscript
|
||||||
|
# 处理玩家输入并更新移动状态
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# delta: float - 帧时间间隔
|
||||||
|
#
|
||||||
|
# 返回值: 无
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 需要在 _physics_process 中调用
|
||||||
|
# - 会自动处理重力和碰撞
|
||||||
|
func handleMovement(delta: float) -> void:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 行内注释
|
||||||
|
- [ ] 复杂逻辑有注释说明
|
||||||
|
- [ ] 注释解释 WHY(为什么),不解释 WHAT(是什么)
|
||||||
|
- [ ] 避免显而易见的注释
|
||||||
|
- [ ] 使用 TODO/FIXME/NOTE 等标记
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试规范检查清单
|
||||||
|
|
||||||
|
### 测试文件
|
||||||
|
- [ ] _Core/ 中的管理器/系统都有对应测试文件
|
||||||
|
- [ ] 测试文件位于 `tests/unit/` 或 `tests/integration/`
|
||||||
|
- [ ] 测试文件命名:`test_[name].gd`
|
||||||
|
- [ ] 测试文件继承自 GutTest:`extends GutTest`
|
||||||
|
|
||||||
|
### 测试结构
|
||||||
|
- [ ] 包含测试类注释
|
||||||
|
- [ ] 实现 `before_each()` 进行测试前置设置
|
||||||
|
- [ ] 实现 `after_each()` 进行测试清理
|
||||||
|
- [ ] 测试方法命名:`test_[功能名称]()`
|
||||||
|
|
||||||
|
### 测试覆盖
|
||||||
|
- [ ] 测试核心功能的正常流程
|
||||||
|
- [ ] 测试错误处理和边界条件
|
||||||
|
- [ ] 测试初始化和清理逻辑
|
||||||
|
- [ ] 所有测试都能通过
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```gdscript
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
## PlayerController 单元测试
|
||||||
|
|
||||||
|
var player: PlayerController
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
player = preload("res://scenes/Entities/Player/PlayerController.gd").new()
|
||||||
|
add_child(player)
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
player.queue_free()
|
||||||
|
|
||||||
|
func test_initialization():
|
||||||
|
var result = player.initialize()
|
||||||
|
assert_true(result, "Player should initialize successfully")
|
||||||
|
|
||||||
|
func test_movement():
|
||||||
|
# 测试移动功能
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 提交规范检查清单
|
||||||
|
|
||||||
|
### 提交类型
|
||||||
|
- [ ] 使用正确的提交类型:
|
||||||
|
- `feat` - 新功能
|
||||||
|
- `fix` - Bug 修复
|
||||||
|
- `docs` - 文档更新
|
||||||
|
- `refactor` - 代码重构
|
||||||
|
- `test` - 测试相关
|
||||||
|
- `scene` - 场景文件
|
||||||
|
- `ui` - UI 相关
|
||||||
|
|
||||||
|
### 提交格式
|
||||||
|
- [ ] 使用中文冒号(:)
|
||||||
|
- [ ] 描述简洁明了(< 50 字符)
|
||||||
|
- [ ] 使用动词开头(添加、修复、更新)
|
||||||
|
- [ ] 一次提交只包含一种类型的改动
|
||||||
|
|
||||||
|
### 提交原则
|
||||||
|
- [ ] 一次提交只做一件事
|
||||||
|
- [ ] 提交的代码能够正常运行
|
||||||
|
- [ ] 避免 fix + feat 混合提交
|
||||||
|
- [ ] 如需多种改动,拆分成多次提交
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```bash
|
||||||
|
# ✅ 正确
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
git commit -m "fix:修复角色跳跃时的碰撞检测问题"
|
||||||
|
git commit -m "test:添加角色控制器单元测试"
|
||||||
|
|
||||||
|
# ❌ 错误
|
||||||
|
git commit -m "fix + feat:修复Bug并添加新功能" # 混合类型
|
||||||
|
git commit -m "update player" # 描述不清晰,使用英文
|
||||||
|
git commit -m "fix: 修复Bug" # 使用英文冒号
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流检查清单
|
||||||
|
|
||||||
|
使用此清单验证开发任务是否完整执行 7 步工作流:
|
||||||
|
|
||||||
|
### Step 1: 架构分析
|
||||||
|
- [ ] 已读取 `docs/02-开发规范/架构与通信规范.md`
|
||||||
|
- [ ] 已确定文件位置(_Core, scenes, UI)
|
||||||
|
- [ ] 已确定通信方式(EventSystem)
|
||||||
|
- [ ] 已列出依赖的管理器/系统
|
||||||
|
- [ ] 已设计事件定义(如需要)
|
||||||
|
|
||||||
|
### Step 2: 功能实现
|
||||||
|
- [ ] 代码遵循分层架构
|
||||||
|
- [ ] 所有变量和函数有类型注解
|
||||||
|
- [ ] 使用 Godot 4.2+ 语法
|
||||||
|
- [ ] 使用 EventSystem 进行跨模块通信
|
||||||
|
- [ ] 使用 @onready 缓存节点引用
|
||||||
|
|
||||||
|
### Step 3: 注释规范检查
|
||||||
|
- [ ] 已读取 `docs/02-开发规范/代码注释规范.md`
|
||||||
|
- [ ] 文件头注释完整
|
||||||
|
- [ ] 公共函数有完整注释
|
||||||
|
- [ ] 复杂逻辑有行内注释
|
||||||
|
|
||||||
|
### Step 4: 命名规范检查
|
||||||
|
- [ ] 已读取 `docs/02-开发规范/命名规范.md`
|
||||||
|
- [ ] 类名使用 PascalCase
|
||||||
|
- [ ] 变量/函数使用 camelCase
|
||||||
|
- [ ] 常量使用 UPPER_CASE
|
||||||
|
- [ ] 私有成员使用下划线前缀
|
||||||
|
|
||||||
|
### Step 5: 测试代码编写
|
||||||
|
- [ ] 已读取 `docs/03-技术实现/测试指南.md`
|
||||||
|
- [ ] 创建了测试文件 `tests/unit/test_[name].gd`
|
||||||
|
- [ ] 测试文件继承自 GutTest
|
||||||
|
- [ ] 编写了核心功能测试
|
||||||
|
|
||||||
|
### Step 6: 测试验证
|
||||||
|
- [ ] 运行了 GUT 测试命令
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] 如有失败,已修复并重新测试
|
||||||
|
|
||||||
|
### Step 7: Git 提交
|
||||||
|
- [ ] 已读取 `docs/02-开发规范/Git提交规范.md`
|
||||||
|
- [ ] 生成了符合规范的提交信息
|
||||||
|
- [ ] 提交类型正确
|
||||||
|
- [ ] 使用中文冒号
|
||||||
|
- [ ] 遵循"一次提交只做一件事"原则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速自检问题
|
||||||
|
|
||||||
|
在提交代码前,问自己以下问题:
|
||||||
|
|
||||||
|
1. **架构**: 文件放在正确的位置了吗?
|
||||||
|
2. **通信**: 是否使用 EventSystem 进行跨模块通信?
|
||||||
|
3. **类型**: 所有变量和函数都有类型注解吗?
|
||||||
|
4. **命名**: 命名是否符合规范(PascalCase/camelCase/UPPER_CASE)?
|
||||||
|
5. **注释**: 文件头和公共函数有完整注释吗?
|
||||||
|
6. **测试**: 创建并运行测试了吗?所有测试都通过了吗?
|
||||||
|
7. **提交**: Git 提交信息符合规范吗?
|
||||||
|
|
||||||
|
如果以上问题都能回答"是",那么代码已经符合 WhaleTown 项目的质量标准!✅
|
||||||
312
.claude/skills/whaletown-developer/使用说明.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# WhaleTown Developer Skill 使用说明
|
||||||
|
|
||||||
|
## 📖 简介
|
||||||
|
|
||||||
|
`whaletown-developer` 是 WhaleTown 项目的标准开发工作流自动化技能,确保所有开发任务都遵循统一的项目规范和质量标准。
|
||||||
|
|
||||||
|
## 🎯 适用场景
|
||||||
|
|
||||||
|
在以下情况下使用此 skill:
|
||||||
|
|
||||||
|
- ✅ 实现新功能("实现玩家二段跳"、"添加存档系统")
|
||||||
|
- ✅ 修复 Bug("修复角色碰撞问题"、"解决UI显示错误")
|
||||||
|
- ✅ 创建场景("创建商店场景"、"设计背包界面")
|
||||||
|
- ✅ 开发模块("开发任务系统"、"构建战斗组件")
|
||||||
|
- ✅ 任何需要遵循项目规范的代码开发任务
|
||||||
|
|
||||||
|
## 🚀 调用方式
|
||||||
|
|
||||||
|
### 方式一:通过 Claude(推荐)
|
||||||
|
|
||||||
|
```
|
||||||
|
用户:帮我实现一个 NPC
|
||||||
|
Claude:/whaletown-developer 实现一个 NPC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:直接请求
|
||||||
|
|
||||||
|
```
|
||||||
|
用户:使用 whaletown-developer skill 创建玩家移动系统
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 7 步工作流程
|
||||||
|
|
||||||
|
skill 会自动执行以下标准化流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: 架构分析
|
||||||
|
↓ 读取架构规范,确定文件位置、通信方式、依赖关系
|
||||||
|
|
||||||
|
Step 2: 功能实现
|
||||||
|
↓ 按照类型安全、命名规范、EventSystem 通信等要求编码
|
||||||
|
|
||||||
|
Step 3: 注释规范检查
|
||||||
|
↓ 验证文件头、函数文档、行内注释是否完整
|
||||||
|
|
||||||
|
Step 4: 命名规范检查
|
||||||
|
↓ 验证 PascalCase/camelCase/UPPER_CASE 命名规范
|
||||||
|
|
||||||
|
Step 5: 测试代码编写
|
||||||
|
↓ 为核心功能创建 GUT 单元测试
|
||||||
|
|
||||||
|
Step 6: 测试验证
|
||||||
|
↓ 运行测试确保功能正常
|
||||||
|
|
||||||
|
Step 7: Git 提交
|
||||||
|
↓ 生成符合规范的提交信息
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 使用示例
|
||||||
|
|
||||||
|
### 示例 1:创建玩家控制器
|
||||||
|
|
||||||
|
**用户输入:**
|
||||||
|
```
|
||||||
|
帮我创建一个玩家角色控制器
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill 执行:**
|
||||||
|
1. 分析架构 → 确定放在 `scenes/Entities/Player/PlayerController.gd`
|
||||||
|
2. 实现功能 → 创建带类型注解的移动、跳跃逻辑
|
||||||
|
3. 检查注释 → 添加完整的文件头和函数文档
|
||||||
|
4. 检查命名 → 确保 `moveSpeed`、`MAX_HEALTH` 等命名正确
|
||||||
|
5. 编写测试 → 创建 `tests/unit/test_player_controller.gd`
|
||||||
|
6. 运行测试 → 验证功能正常
|
||||||
|
7. 生成提交 → `feat:实现玩家角色控制器`
|
||||||
|
|
||||||
|
### 示例 2:修复跳跃 Bug
|
||||||
|
|
||||||
|
**用户输入:**
|
||||||
|
```
|
||||||
|
修复玩家跳跃时的碰撞检测问题
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill 执行:**
|
||||||
|
1. 分析架构 → 定位到 `scenes/Entities/Player/Player.gd`
|
||||||
|
2. 实现修复 → 修改碰撞检测逻辑
|
||||||
|
3. 检查注释 → 添加修复说明注释
|
||||||
|
4. 检查命名 → 确保变量命名规范
|
||||||
|
5. 编写测试 → 添加碰撞测试用例
|
||||||
|
6. 运行测试 → 确认 Bug 已修复
|
||||||
|
7. 生成提交 → `fix:修复玩家跳跃时的碰撞检测问题`
|
||||||
|
|
||||||
|
### 示例 3:添加事件通信
|
||||||
|
|
||||||
|
**用户输入:**
|
||||||
|
```
|
||||||
|
实现 NPC 对话系统的事件通信
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill 执行:**
|
||||||
|
1. 分析架构 → 使用 EventSystem 跨模块通信
|
||||||
|
2. 实现功能 → 在 EventNames.gd 添加 `NPC_DIALOG_STARTED` 事件
|
||||||
|
3. 检查注释 → 文档化事件数据格式
|
||||||
|
4. 检查命名 → 确保事件名称符合规范
|
||||||
|
5. 编写测试 → 测试事件发送和接收
|
||||||
|
6. 运行测试 → 验证通信正常
|
||||||
|
7. 生成提交 → `feat:实现NPC对话系统的事件通信`
|
||||||
|
|
||||||
|
## ✅ 质量保证
|
||||||
|
|
||||||
|
每次使用 skill 后,代码都会符合以下标准:
|
||||||
|
|
||||||
|
### 架构层面
|
||||||
|
- ✅ 文件位置符合分层架构(_Core、scenes、UI)
|
||||||
|
- ✅ 使用 EventSystem 实现跨模块通信
|
||||||
|
- ✅ 事件名称已添加到 EventNames.gd
|
||||||
|
- ✅ 遵循"Signal Up, Call Down"原则
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
- ✅ 所有变量和函数都有类型注解
|
||||||
|
- ✅ 命名规范正确(PascalCase/camelCase/UPPER_CASE)
|
||||||
|
- ✅ 使用 Godot 4.2+ 语法(await 而非 yield)
|
||||||
|
- ✅ 节点引用使用 @onready 缓存
|
||||||
|
|
||||||
|
### 文档规范
|
||||||
|
- ✅ 文件头注释完整(文件名、作用、功能、依赖)
|
||||||
|
- ✅ 公共函数有完整文档(参数、返回值、示例)
|
||||||
|
- ✅ 复杂逻辑有行内注释说明
|
||||||
|
|
||||||
|
### 测试覆盖
|
||||||
|
- ✅ 核心功能有单元测试
|
||||||
|
- ✅ 测试文件命名规范(test_*.gd)
|
||||||
|
- ✅ 测试通过验证
|
||||||
|
|
||||||
|
### Git 规范
|
||||||
|
- ✅ 提交信息格式正确(类型:描述)
|
||||||
|
- ✅ 遵循"一次提交只做一件事"原则
|
||||||
|
- ✅ 使用中文冒号和动词开头
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
Skill 会自动读取以下规范文档:
|
||||||
|
|
||||||
|
- `docs/02-开发规范/架构与通信规范.md` - 分层架构和 EventSystem
|
||||||
|
- `docs/02-开发规范/代码注释规范.md` - 注释格式要求
|
||||||
|
- `docs/02-开发规范/命名规范.md` - 命名约定
|
||||||
|
- `docs/03-技术实现/测试指南.md` - 测试框架使用
|
||||||
|
- `docs/02-开发规范/Git提交规范.md` - 提交信息格式
|
||||||
|
- `CLAUDE.md` - 项目总体指导
|
||||||
|
|
||||||
|
## ⚙️ 配置文件
|
||||||
|
|
||||||
|
Skill 相关配置文件位置:
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/skills/whaletown-developer/
|
||||||
|
├── SKILL.md # Skill 定义文件
|
||||||
|
├── 使用说明.md # 本文档
|
||||||
|
└── references/
|
||||||
|
└── checklist.md # 质量检查清单
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 工作流程可视化
|
||||||
|
|
||||||
|
```
|
||||||
|
用户请求
|
||||||
|
↓
|
||||||
|
调用 whaletown-developer skill
|
||||||
|
↓
|
||||||
|
[Step 1] 架构分析
|
||||||
|
├─ 读取架构规范文档
|
||||||
|
├─ 确定文件位置
|
||||||
|
├─ 识别通信方式
|
||||||
|
└─ 设计事件定义
|
||||||
|
↓
|
||||||
|
[Step 2] 功能实现
|
||||||
|
├─ 遵循类型安全
|
||||||
|
├─ 使用 EventSystem
|
||||||
|
├─ 缓存节点引用
|
||||||
|
└─ 使用 Godot 4.2+ 语法
|
||||||
|
↓
|
||||||
|
[Step 3] 注释规范检查
|
||||||
|
├─ 验证文件头注释
|
||||||
|
├─ 验证函数文档
|
||||||
|
└─ 检查行内注释
|
||||||
|
↓
|
||||||
|
[Step 4] 命名规范检查
|
||||||
|
├─ 类名 PascalCase
|
||||||
|
├─ 变量/函数 camelCase
|
||||||
|
├─ 常量 UPPER_CASE
|
||||||
|
└─ 私有成员 _prefix
|
||||||
|
↓
|
||||||
|
[Step 5] 测试代码编写
|
||||||
|
├─ 创建测试文件
|
||||||
|
├─ 编写测试用例
|
||||||
|
└─ 覆盖核心功能
|
||||||
|
↓
|
||||||
|
[Step 6] 测试验证
|
||||||
|
├─ 运行 GUT 测试
|
||||||
|
├─ 验证测试通过
|
||||||
|
└─ 修复失败测试
|
||||||
|
↓
|
||||||
|
[Step 7] Git 提交
|
||||||
|
├─ 确定提交类型
|
||||||
|
├─ 生成提交信息
|
||||||
|
└─ 遵循提交规范
|
||||||
|
↓
|
||||||
|
完成 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎓 最佳实践
|
||||||
|
|
||||||
|
### 1. 明确任务描述
|
||||||
|
```bash
|
||||||
|
# ✅ 好的描述
|
||||||
|
"实现玩家二段跳功能"
|
||||||
|
"修复敌人AI路径寻找Bug"
|
||||||
|
"创建商店购买界面"
|
||||||
|
|
||||||
|
# ❌ 模糊描述
|
||||||
|
"改一下玩家"
|
||||||
|
"修复Bug"
|
||||||
|
"做个界面"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 一次处理一个功能
|
||||||
|
```bash
|
||||||
|
# ✅ 推荐
|
||||||
|
用户:实现玩家移动
|
||||||
|
用户:实现玩家跳跃
|
||||||
|
|
||||||
|
# ❌ 不推荐
|
||||||
|
用户:实现玩家移动、跳跃、攻击、技能系统
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 信任 Skill 流程
|
||||||
|
- Skill 会按照 7 步流程确保质量
|
||||||
|
- 不需要手动检查命名、注释等细节
|
||||||
|
- 专注于功能需求和业务逻辑
|
||||||
|
|
||||||
|
### 4. 查看生成的提交信息
|
||||||
|
- Skill 会在 Step 7 生成规范的提交信息
|
||||||
|
- 可以直接使用或根据需要微调
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **首次使用**
|
||||||
|
- 确保已阅读 `CLAUDE.md` 了解项目规范
|
||||||
|
- 确认所有规范文档都已存在
|
||||||
|
|
||||||
|
2. **测试环境**
|
||||||
|
- 确保 GUT 测试框架已安装(如需运行测试)
|
||||||
|
- Godot 可执行文件在 PATH 中(Step 6 测试执行)
|
||||||
|
|
||||||
|
3. **中断处理**
|
||||||
|
- 如果工作流被中断,可以继续执行剩余步骤
|
||||||
|
- Skill 使用 TodoWrite 追踪进度
|
||||||
|
|
||||||
|
4. **规范更新**
|
||||||
|
- 项目规范文档更新时,Skill 会自动读取最新版本
|
||||||
|
- 无需手动同步
|
||||||
|
|
||||||
|
## 🤝 反馈与改进
|
||||||
|
|
||||||
|
如果遇到问题或有改进建议:
|
||||||
|
|
||||||
|
1. 检查是否所有规范文档都已更新
|
||||||
|
2. 确认任务描述清晰明确
|
||||||
|
3. 查看 Skill 执行日志定位问题
|
||||||
|
4. 向团队报告问题或建议
|
||||||
|
|
||||||
|
## 📊 效果对比
|
||||||
|
|
||||||
|
### 不使用 Skill
|
||||||
|
```
|
||||||
|
开发者手动:
|
||||||
|
1. 不确定文件放哪里 ❌
|
||||||
|
2. 可能忘记类型注解 ❌
|
||||||
|
3. 注释不完整 ❌
|
||||||
|
4. 命名不一致 ❌
|
||||||
|
5. 没有测试 ❌
|
||||||
|
6. 提交信息格式错误 ❌
|
||||||
|
结果:代码质量参差不齐
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Skill
|
||||||
|
```
|
||||||
|
Skill 自动化:
|
||||||
|
1. 自动确定正确位置 ✅
|
||||||
|
2. 强制类型安全 ✅
|
||||||
|
3. 完整注释文档 ✅
|
||||||
|
4. 统一命名规范 ✅
|
||||||
|
5. 自动生成测试 ✅
|
||||||
|
6. 规范提交信息 ✅
|
||||||
|
结果:高质量、一致性代码
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 总结
|
||||||
|
|
||||||
|
whaletown-developer skill 是你的开发助手,它会:
|
||||||
|
|
||||||
|
- 🤖 **自动化** 7 步标准流程
|
||||||
|
- 📏 **标准化** 代码质量
|
||||||
|
- 🔒 **保证** 规范遵循
|
||||||
|
- ⚡ **加速** 开发效率
|
||||||
|
- 🧪 **确保** 测试覆盖
|
||||||
|
|
||||||
|
**记住:专注于功能实现,让 Skill 处理规范和质量!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开始使用:** 只需告诉 Claude "帮我实现 XXX 功能" 即可!
|
||||||
@@ -58,3 +58,14 @@ const GRID_SNAP_REQUESTED = "grid_snap_requested"
|
|||||||
# 测试事件
|
# 测试事件
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
const TEST_EVENT = "test_event"
|
const TEST_EVENT = "test_event"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 聊天事件
|
||||||
|
# ============================================================================
|
||||||
|
const CHAT_MESSAGE_SENT = "chat_message_sent"
|
||||||
|
const CHAT_MESSAGE_RECEIVED = "chat_message_received"
|
||||||
|
const CHAT_ERROR_OCCURRED = "chat_error_occurred"
|
||||||
|
const CHAT_CONNECTION_STATE_CHANGED = "chat_connection_state_changed"
|
||||||
|
const CHAT_POSITION_UPDATED = "chat_position_updated"
|
||||||
|
const CHAT_LOGIN_SUCCESS = "chat_login_success"
|
||||||
|
const CHAT_LOGIN_FAILED = "chat_login_failed"
|
||||||
@@ -68,6 +68,9 @@ enum LoginMode {
|
|||||||
|
|
||||||
# ============ 成员变量 ============
|
# ============ 成员变量 ============
|
||||||
|
|
||||||
|
# 当前用户ID(静态变量,用于BaseLevel等场景访问)
|
||||||
|
static var current_user_id: String = ""
|
||||||
|
|
||||||
# 登录状态
|
# 登录状态
|
||||||
var current_login_mode: LoginMode = LoginMode.PASSWORD
|
var current_login_mode: LoginMode = LoginMode.PASSWORD
|
||||||
var is_processing: bool = false
|
var is_processing: bool = false
|
||||||
@@ -80,19 +83,188 @@ var current_email: String = ""
|
|||||||
# 网络请求管理
|
# 网络请求管理
|
||||||
var active_request_ids: Array = []
|
var active_request_ids: Array = []
|
||||||
|
|
||||||
|
# ============ Token 管理 ============
|
||||||
|
|
||||||
|
# 本地存储路径常量
|
||||||
|
const AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
||||||
|
|
||||||
|
# Token 存储(内存中,用于快速访问)
|
||||||
|
var _access_token: String = "" # JWT访问令牌(短期,用于API和WebSocket)
|
||||||
|
var _refresh_token: String = "" # JWT刷新令牌(长期,用于获取新access_token)
|
||||||
|
var _user_info: Dictionary = {} # 用户信息
|
||||||
|
var _token_expiry: float = 0.0 # access_token过期时间(Unix时间戳)
|
||||||
|
|
||||||
|
# 游戏 token(废弃,保留供旧接口)
|
||||||
|
var _game_token: String = "" # @deprecated 使用 _access_token 替代
|
||||||
|
|
||||||
# ============ 生命周期方法 ============
|
# ============ 生命周期方法 ============
|
||||||
|
|
||||||
# 初始化管理器
|
# 初始化管理器
|
||||||
func _init():
|
func _init() -> void:
|
||||||
print("AuthManager 初始化完成")
|
_load_auth_data()
|
||||||
|
|
||||||
# 清理资源
|
# 清理资源
|
||||||
func cleanup():
|
func cleanup() -> void:
|
||||||
# 取消所有活动的网络请求
|
# 取消所有活动的网络请求
|
||||||
for request_id in active_request_ids:
|
for request_id in active_request_ids:
|
||||||
NetworkManager.cancel_request(request_id)
|
NetworkManager.cancel_request(request_id)
|
||||||
active_request_ids.clear()
|
active_request_ids.clear()
|
||||||
|
|
||||||
|
# ============ Token 管理 ============
|
||||||
|
|
||||||
|
# 保存 Token 到内存
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# data: Dictionary - 登录响应数据
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 从登录响应中提取 access_token 和 refresh_token
|
||||||
|
# - 保存到内存变量中
|
||||||
|
# - 保存用户信息
|
||||||
|
func _save_tokens_to_memory(data: Dictionary) -> void:
|
||||||
|
if not data.has("data"):
|
||||||
|
push_warning("AuthManager: 登录响应中没有 data 字段")
|
||||||
|
return
|
||||||
|
|
||||||
|
var token_data: Dictionary = data.data
|
||||||
|
_access_token = token_data.get("access_token", "")
|
||||||
|
_refresh_token = token_data.get("refresh_token", "")
|
||||||
|
_user_info = token_data.get("user", {})
|
||||||
|
_token_expiry = Time.get_unix_time_from_system() + float(token_data.get("expires_in", 0))
|
||||||
|
|
||||||
|
# 设置当前用户ID(用于BaseLevel等场景)
|
||||||
|
if _user_info.has("id"):
|
||||||
|
AuthManager.current_user_id = str(_user_info.id)
|
||||||
|
|
||||||
|
_game_token = _access_token
|
||||||
|
|
||||||
|
# 保存 Token 到本地(ConfigFile)
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# data: Dictionary - 登录响应数据
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 将 refresh_token 和用户信息保存到 ConfigFile
|
||||||
|
# - access_token 不保存到本地,仅保存在内存中
|
||||||
|
func _save_tokens_to_local(data: Dictionary) -> void:
|
||||||
|
if not data.has("data"):
|
||||||
|
return
|
||||||
|
|
||||||
|
var token_data: Dictionary = data.data
|
||||||
|
var auth_data: Dictionary = {
|
||||||
|
"refresh_token": token_data.get("refresh_token", ""),
|
||||||
|
"user_id": token_data.get("user", {}).get("id", ""),
|
||||||
|
"username": token_data.get("user", {}).get("username", ""),
|
||||||
|
"saved_at": Time.get_unix_time_from_system()
|
||||||
|
}
|
||||||
|
|
||||||
|
var config: ConfigFile = ConfigFile.new()
|
||||||
|
config.load(AUTH_CONFIG_PATH)
|
||||||
|
config.set_value("auth", "refresh_token", auth_data["refresh_token"])
|
||||||
|
config.set_value("auth", "user_id", auth_data["user_id"])
|
||||||
|
config.set_value("auth", "username", auth_data["username"])
|
||||||
|
config.set_value("auth", "saved_at", auth_data["saved_at"])
|
||||||
|
|
||||||
|
var error: Error = config.save(AUTH_CONFIG_PATH)
|
||||||
|
if error != OK:
|
||||||
|
push_error("AuthManager: 保存Token到本地失败,错误码: %d" % error)
|
||||||
|
|
||||||
|
# 从本地加载 Token(游戏启动时调用)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 从 ConfigFile 加载 refresh_token 和用户信息
|
||||||
|
# - access_token 需要通过 refresh_token 刷新获取
|
||||||
|
func _load_auth_data() -> void:
|
||||||
|
if not FileAccess.file_exists(AUTH_CONFIG_PATH):
|
||||||
|
return
|
||||||
|
|
||||||
|
var config: ConfigFile = ConfigFile.new()
|
||||||
|
var error: Error = config.load(AUTH_CONFIG_PATH)
|
||||||
|
|
||||||
|
if error != OK:
|
||||||
|
push_error("AuthManager: 加载本地认证数据失败,错误码: %d" % error)
|
||||||
|
return
|
||||||
|
|
||||||
|
_refresh_token = config.get_value("auth", "refresh_token", "")
|
||||||
|
var user_id: String = config.get_value("auth", "user_id", "")
|
||||||
|
var username: String = config.get_value("auth", "username", "")
|
||||||
|
|
||||||
|
if not _refresh_token.is_empty():
|
||||||
|
_user_info = {
|
||||||
|
"id": user_id,
|
||||||
|
"username": username
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
push_warning("AuthManager: 本地认证数据无效(没有 refresh_token)")
|
||||||
|
|
||||||
|
# 清除本地认证数据(登出时调用)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 清除内存中的 Token
|
||||||
|
# - 删除本地 ConfigFile
|
||||||
|
func _clear_auth_data() -> void:
|
||||||
|
_access_token = ""
|
||||||
|
_refresh_token = ""
|
||||||
|
_user_info = {}
|
||||||
|
_token_expiry = 0.0
|
||||||
|
_game_token = ""
|
||||||
|
|
||||||
|
if FileAccess.file_exists(AUTH_CONFIG_PATH):
|
||||||
|
DirAccess.remove_absolute(AUTH_CONFIG_PATH)
|
||||||
|
|
||||||
|
# ============ Token 访问方法 ============
|
||||||
|
|
||||||
|
# 设置游戏 token(建议优先使用 _save_tokens_to_memory)
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# token: String - 游戏认证 token
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - 登录成功后设置 token
|
||||||
|
# - 从服务器响应中获取 token
|
||||||
|
func set_game_token(token: String) -> void:
|
||||||
|
_game_token = token
|
||||||
|
_access_token = token # 同步更新 access_token
|
||||||
|
|
||||||
|
# 获取游戏 token
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - access_token(如果未设置则返回空字符串)
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - ChatManager 连接 WebSocket 时需要 token
|
||||||
|
# - 其他需要游戏认证的场景
|
||||||
|
func get_game_token() -> String:
|
||||||
|
return _access_token
|
||||||
|
|
||||||
|
# 获取 access token
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - JWT访问令牌
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - API请求认证
|
||||||
|
# - WebSocket聊天认证
|
||||||
|
func get_access_token() -> String:
|
||||||
|
return _access_token
|
||||||
|
|
||||||
|
# 获取 refresh token
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - JWT刷新令牌
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - 刷新过期的 access token
|
||||||
|
func get_refresh_token() -> String:
|
||||||
|
return _refresh_token
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Dictionary - 用户信息字典
|
||||||
|
func get_user_info() -> Dictionary:
|
||||||
|
return _user_info
|
||||||
|
|
||||||
# ============ 登录相关方法 ============
|
# ============ 登录相关方法 ============
|
||||||
|
|
||||||
# 执行密码登录
|
# 执行密码登录
|
||||||
@@ -421,7 +593,7 @@ func validate_verification_code(code: String) -> Dictionary:
|
|||||||
# ============ 网络响应处理 ============
|
# ============ 网络响应处理 ============
|
||||||
|
|
||||||
# 处理登录响应
|
# 处理登录响应
|
||||||
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary):
|
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
_reset_login_state()
|
_reset_login_state()
|
||||||
|
|
||||||
var result = ResponseHandler.handle_login_response(success, data, error_info)
|
var result = ResponseHandler.handle_login_response(success, data, error_info)
|
||||||
@@ -430,9 +602,11 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
|||||||
show_toast_message.emit(result.message, result.success)
|
show_toast_message.emit(result.message, result.success)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
var username = ""
|
# 保存 Token 到内存和本地
|
||||||
if data.has("data") and data.data.has("user") and data.data.user.has("username"):
|
_save_tokens_to_memory(data)
|
||||||
username = data.data.user.username
|
_save_tokens_to_local(data)
|
||||||
|
|
||||||
|
var username: String = _user_info.get("username", "")
|
||||||
|
|
||||||
# 延迟发送登录成功信号
|
# 延迟发送登录成功信号
|
||||||
await Engine.get_main_loop().create_timer(1.0).timeout
|
await Engine.get_main_loop().create_timer(1.0).timeout
|
||||||
@@ -441,7 +615,7 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
|||||||
login_failed.emit(result.message)
|
login_failed.emit(result.message)
|
||||||
|
|
||||||
# 处理验证码登录响应
|
# 处理验证码登录响应
|
||||||
func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary):
|
func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
_reset_login_state()
|
_reset_login_state()
|
||||||
|
|
||||||
var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info)
|
var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info)
|
||||||
@@ -450,9 +624,11 @@ func _on_verification_login_response(success: bool, data: Dictionary, error_info
|
|||||||
show_toast_message.emit(result.message, result.success)
|
show_toast_message.emit(result.message, result.success)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
var username = ""
|
# 保存 Token 到内存和本地
|
||||||
if data.has("data") and data.data.has("user") and data.data.user.has("username"):
|
_save_tokens_to_memory(data)
|
||||||
username = data.data.user.username
|
_save_tokens_to_local(data)
|
||||||
|
|
||||||
|
var username: String = _user_info.get("username", "")
|
||||||
|
|
||||||
await Engine.get_main_loop().create_timer(1.0).timeout
|
await Engine.get_main_loop().create_timer(1.0).timeout
|
||||||
login_success.emit(username)
|
login_success.emit(username)
|
||||||
|
|||||||
726
_Core/managers/ChatManager.gd
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ChatManager.gd - 聊天系统业务逻辑核心
|
||||||
|
# ============================================================================
|
||||||
|
# 管理聊天功能的核心业务逻辑
|
||||||
|
#
|
||||||
|
# 核心职责:
|
||||||
|
# - 聊天消息发送/接收协调
|
||||||
|
# - 客户端频率限制(10条/分钟)
|
||||||
|
# - 消息历史管理(最多100条)
|
||||||
|
# - Signal Up: 通过信号和 EventSystem 向上通知
|
||||||
|
# - 整合 AuthManager 获取 token
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# ChatManager.connect_to_chat_server()
|
||||||
|
# ChatManager.send_chat_message("Hello", "local")
|
||||||
|
# ChatManager.chat_message_received.connect(_on_message_received)
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 作为自动加载单例,全局可访问
|
||||||
|
# - 遵循 "Signal Up, Call Down" 架构
|
||||||
|
# - 所有聊天事件通过 EventSystem 广播
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 信号定义 (Signal Up)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 聊天消息已发送信号
|
||||||
|
# 参数:
|
||||||
|
# message_id: String - 消息 ID
|
||||||
|
# timestamp: float - 时间戳
|
||||||
|
signal chat_message_sent(message_id: String, timestamp: float)
|
||||||
|
|
||||||
|
# 聊天消息已接收信号
|
||||||
|
# 参数:
|
||||||
|
# from_user: String - 发送者用户名
|
||||||
|
# content: String - 消息内容
|
||||||
|
# show_bubble: bool - 是否显示气泡
|
||||||
|
# timestamp: float - 时间戳
|
||||||
|
signal chat_message_received(from_user: String, content: String, show_bubble: bool, timestamp: float)
|
||||||
|
|
||||||
|
# 聊天错误发生信号
|
||||||
|
# 参数:
|
||||||
|
# error_code: String - 错误代码
|
||||||
|
# message: String - 错误消息
|
||||||
|
signal chat_error_occurred(error_code: String, message: String)
|
||||||
|
|
||||||
|
# 聊天连接状态变化信号
|
||||||
|
# 参数:
|
||||||
|
# state: int - 连接状态(0=DISCONNECTED, 1=CONNECTING, 2=CONNECTED, 3=RECONNECTING, 4=ERROR)
|
||||||
|
signal chat_connection_state_changed(state: int)
|
||||||
|
|
||||||
|
# 位置更新成功信号
|
||||||
|
# 参数:
|
||||||
|
# stream: String - Stream 名称
|
||||||
|
# topic: String - Topic 名称
|
||||||
|
signal chat_position_updated(stream: String, topic: String)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 常量定义
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# WebSocket 服务器 URL(原生 WebSocket)
|
||||||
|
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||||
|
|
||||||
|
# 重连配置
|
||||||
|
const RECONNECT_MAX_ATTEMPTS: int = 5
|
||||||
|
const RECONNECT_BASE_DELAY: float = 3.0
|
||||||
|
|
||||||
|
# 频率限制配置
|
||||||
|
const RATE_LIMIT_MESSAGES: int = 10
|
||||||
|
const RATE_LIMIT_WINDOW: float = 60.0 # 秒
|
||||||
|
|
||||||
|
# 消息限制
|
||||||
|
const MAX_MESSAGE_LENGTH: int = 1000
|
||||||
|
|
||||||
|
# 当前会话消息限制(当前游戏会话,超过后删除最旧的)
|
||||||
|
const MAX_SESSION_MESSAGES: int = 100
|
||||||
|
|
||||||
|
# 历史消息分页大小(从 Zulip 后端每次加载的数量)
|
||||||
|
const HISTORY_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
|
# 错误消息映射
|
||||||
|
const CHAT_ERROR_MESSAGES: Dictionary = {
|
||||||
|
"AUTH_FAILED": "聊天认证失败,请重新登录",
|
||||||
|
"RATE_LIMIT": "消息发送过于频繁,请稍后再试",
|
||||||
|
"CONTENT_FILTERED": "消息内容包含违规内容",
|
||||||
|
"CONTENT_TOO_LONG": "消息内容过长(最大1000字符)",
|
||||||
|
"PERMISSION_DENIED": "您没有权限发送消息",
|
||||||
|
"SESSION_EXPIRED": "会话已过期,请重新连接",
|
||||||
|
"ZULIP_ERROR": "消息服务暂时不可用",
|
||||||
|
"INTERNAL_ERROR": "服务器内部错误"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 成员变量
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# WebSocket 管理器
|
||||||
|
var _websocket_manager: ChatWebSocketManager
|
||||||
|
|
||||||
|
# 是否已登录
|
||||||
|
var _is_logged_in: bool = false
|
||||||
|
|
||||||
|
# 消息历史记录(当前会话,最多100条,超过后删除最旧的)
|
||||||
|
var _message_history: Array[Dictionary] = []
|
||||||
|
|
||||||
|
# 历史消息加载状态
|
||||||
|
var _history_loading: bool = false
|
||||||
|
var _has_more_history: bool = true
|
||||||
|
var _oldest_message_timestamp: float = 0.0
|
||||||
|
|
||||||
|
# 消息发送时间戳(用于频率限制)
|
||||||
|
var _message_timestamps: Array[float] = []
|
||||||
|
|
||||||
|
# 当前用户信息
|
||||||
|
var _current_username: String = ""
|
||||||
|
var _current_map: String = ""
|
||||||
|
|
||||||
|
# 游戏 token
|
||||||
|
var _game_token: String = ""
|
||||||
|
|
||||||
|
# 发送后本地回显去重(避免服务端也回发导致重复显示)
|
||||||
|
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
|
||||||
|
var _pending_self_messages: Array[Dictionary] = []
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 生命周期方法
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 初始化
|
||||||
|
func _ready() -> void:
|
||||||
|
# 创建 WebSocket 管理器
|
||||||
|
_websocket_manager = ChatWebSocketManager.new()
|
||||||
|
add_child(_websocket_manager)
|
||||||
|
|
||||||
|
# 连接信号
|
||||||
|
_connect_signals()
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
if is_instance_valid(_websocket_manager):
|
||||||
|
_websocket_manager.queue_free()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - Token 管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 设置游戏 token
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# token: String - 游戏认证 token
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# ChatManager.set_game_token("your_game_token")
|
||||||
|
func set_game_token(token: String) -> void:
|
||||||
|
_game_token = token
|
||||||
|
|
||||||
|
# 获取游戏 token
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - 当前游戏 token
|
||||||
|
func get_game_token() -> String:
|
||||||
|
return _game_token
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 连接管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接到聊天服务器
|
||||||
|
func connect_to_chat_server() -> void:
|
||||||
|
if _websocket_manager.is_websocket_connected():
|
||||||
|
push_warning("聊天服务器已连接")
|
||||||
|
return
|
||||||
|
|
||||||
|
_websocket_manager.connect_to_game_server()
|
||||||
|
|
||||||
|
# 断开聊天服务器
|
||||||
|
func disconnect_from_chat_server() -> void:
|
||||||
|
# 发送登出消息
|
||||||
|
if _is_logged_in:
|
||||||
|
var logout_data := {"type": "logout"}
|
||||||
|
_websocket_manager.send_message(JSON.stringify(logout_data))
|
||||||
|
_is_logged_in = false
|
||||||
|
|
||||||
|
# 断开连接
|
||||||
|
_websocket_manager.disconnect_websocket()
|
||||||
|
|
||||||
|
# 检查是否已连接
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# bool - 是否已连接
|
||||||
|
func is_chat_connected() -> bool:
|
||||||
|
return _websocket_manager.is_websocket_connected()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 聊天操作
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 发送聊天消息
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# content: String - 消息内容
|
||||||
|
# scope: String - 消息范围("local" 或具体 topic 名称)
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# ChatManager.send_chat_message("Hello, world!", "local")
|
||||||
|
func send_chat_message(content: String, scope: String = "local") -> void:
|
||||||
|
# 检查连接状态
|
||||||
|
if not _websocket_manager.is_websocket_connected():
|
||||||
|
_handle_error("NOT_CONNECTED", "未连接到聊天服务器")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查登录状态
|
||||||
|
if not _is_logged_in:
|
||||||
|
_handle_error("NOT_LOGGED_IN", "尚未登录聊天服务器")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查消息长度
|
||||||
|
if content.length() > MAX_MESSAGE_LENGTH:
|
||||||
|
_handle_error("CONTENT_TOO_LONG", "消息内容过长")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查频率限制
|
||||||
|
if not can_send_message():
|
||||||
|
var wait_time := get_time_until_next_message()
|
||||||
|
_handle_error("RATE_LIMIT", "请等待 %.1f 秒后再试" % wait_time)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 构建消息数据
|
||||||
|
var message_data := {
|
||||||
|
"type": "chat",
|
||||||
|
"content": content,
|
||||||
|
"scope": scope
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送消息(JSON 字符串)
|
||||||
|
var json_string := JSON.stringify(message_data)
|
||||||
|
var send_err: Error = _websocket_manager.send_message(json_string)
|
||||||
|
if send_err != OK:
|
||||||
|
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 记录发送时间
|
||||||
|
_record_message_timestamp()
|
||||||
|
|
||||||
|
# 添加到历史
|
||||||
|
_add_message_to_history({
|
||||||
|
"from_user": _current_username,
|
||||||
|
"content": content,
|
||||||
|
"timestamp": Time.get_unix_time_from_system(),
|
||||||
|
"is_self": true
|
||||||
|
})
|
||||||
|
|
||||||
|
var now_timestamp: float = Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
# 记录待去重的“自己消息”(如果服务端也回发 chat_render,则避免重复显示)
|
||||||
|
_pending_self_messages.append({
|
||||||
|
"content": content,
|
||||||
|
"expires_at": now_timestamp + SELF_ECHO_DEDUPE_WINDOW
|
||||||
|
})
|
||||||
|
|
||||||
|
# 本地回显:UI 目前只订阅 CHAT_MESSAGE_RECEIVED,所以这里也发一次 received
|
||||||
|
chat_message_received.emit(_current_username, content, true, now_timestamp)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||||
|
"from_user": _current_username,
|
||||||
|
"content": content,
|
||||||
|
"show_bubble": true,
|
||||||
|
"timestamp": now_timestamp,
|
||||||
|
"is_self": true
|
||||||
|
})
|
||||||
|
|
||||||
|
# 消息发送完成回调
|
||||||
|
func _on_chat_message_sent(_request_id: String, success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||||
|
if success:
|
||||||
|
var message_id: String = str(data.get("data", {}).get("id", ""))
|
||||||
|
var timestamp: float = Time.get_unix_time_from_system()
|
||||||
|
chat_message_sent.emit(message_id, timestamp)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_MESSAGE_SENT, {
|
||||||
|
"message_id": message_id,
|
||||||
|
"timestamp": timestamp
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
_handle_error("SEND_FAILED", error_info.get("message", "发送失败"))
|
||||||
|
|
||||||
|
# 更新玩家位置
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# x: float - X 坐标
|
||||||
|
# y: float - Y 坐标
|
||||||
|
# map_id: String - 地图 ID
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# ChatManager.update_player_position(150.0, 200.0, "novice_village")
|
||||||
|
func update_player_position(x: float, y: float, map_id: String) -> void:
|
||||||
|
if not _websocket_manager.is_websocket_connected():
|
||||||
|
return
|
||||||
|
|
||||||
|
var position_data := {
|
||||||
|
"type": "position",
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"mapId": map_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送消息(JSON 字符串)
|
||||||
|
var json_string := JSON.stringify(position_data)
|
||||||
|
_websocket_manager.send_message(json_string)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 频率限制
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 检查是否可以发送消息
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# bool - 是否可以发送
|
||||||
|
func can_send_message() -> bool:
|
||||||
|
var current_time := Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
# 清理过期的时间戳
|
||||||
|
var filter_func := func(timestamp: float) -> bool:
|
||||||
|
return current_time - timestamp < RATE_LIMIT_WINDOW
|
||||||
|
_message_timestamps = _message_timestamps.filter(filter_func)
|
||||||
|
|
||||||
|
# 检查数量
|
||||||
|
return _message_timestamps.size() < RATE_LIMIT_MESSAGES
|
||||||
|
|
||||||
|
# 获取距离下次可发送消息的时间
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# float - 等待时间(秒)
|
||||||
|
func get_time_until_next_message() -> float:
|
||||||
|
if _message_timestamps.is_empty():
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
if _message_timestamps.size() < RATE_LIMIT_MESSAGES:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 找到最早的时间戳
|
||||||
|
var earliest_timestamp: float = _message_timestamps[0]
|
||||||
|
var current_time := Time.get_unix_time_from_system()
|
||||||
|
var elapsed := current_time - earliest_timestamp
|
||||||
|
|
||||||
|
if elapsed >= RATE_LIMIT_WINDOW:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return RATE_LIMIT_WINDOW - elapsed
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 消息历史
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 获取消息历史
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Array[Dictionary] - 消息历史数组
|
||||||
|
func get_message_history() -> Array[Dictionary]:
|
||||||
|
return _message_history.duplicate()
|
||||||
|
|
||||||
|
# 清空消息历史
|
||||||
|
func clear_message_history() -> void:
|
||||||
|
_message_history.clear()
|
||||||
|
|
||||||
|
# 重置当前会话(每次登录/重连时调用)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 清空当前会话消息缓存
|
||||||
|
# - 重置历史消息加载状态
|
||||||
|
# - 不影响 Zulip 后端的历史消息
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - 用户登录成功后
|
||||||
|
# - 重新连接到聊天服务器后
|
||||||
|
func reset_session() -> void:
|
||||||
|
_message_history.clear()
|
||||||
|
_history_loading = false
|
||||||
|
_has_more_history = true
|
||||||
|
_oldest_message_timestamp = 0.0
|
||||||
|
|
||||||
|
# 加载历史消息(按需从 Zulip 后端获取)
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# count: int - 要加载的消息数量(默认 HISTORY_PAGE_SIZE)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 从 Zulip 后端获取历史消息
|
||||||
|
# - 添加到当前会话历史开头
|
||||||
|
# - 触发 CHAT_MESSAGE_RECEIVED 事件显示消息
|
||||||
|
#
|
||||||
|
# 使用场景:
|
||||||
|
# - 用户滚动到聊天窗口顶部
|
||||||
|
# - 用户主动点击"加载历史"按钮
|
||||||
|
#
|
||||||
|
# 注意:
|
||||||
|
# - 这是异步操作,需要通过 Zulip API 实现
|
||||||
|
# - 当前实现为占位符,需要后端 API 支持
|
||||||
|
func load_history(_count: int = HISTORY_PAGE_SIZE) -> void:
|
||||||
|
if _history_loading:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _has_more_history:
|
||||||
|
return
|
||||||
|
|
||||||
|
_history_loading = true
|
||||||
|
|
||||||
|
# TODO: 实现从 Zulip 后端获取历史消息
|
||||||
|
# NetworkManager.get_chat_history(_oldest_message_timestamp, count, _on_history_loaded)
|
||||||
|
|
||||||
|
# 历史消息加载完成回调
|
||||||
|
func _on_history_loaded(messages: Array) -> void:
|
||||||
|
_history_loading = false
|
||||||
|
|
||||||
|
if messages.is_empty():
|
||||||
|
_has_more_history = false
|
||||||
|
return
|
||||||
|
|
||||||
|
# 将历史消息插入到当前会话历史开头
|
||||||
|
for i in range(messages.size() - 1, -1, -1):
|
||||||
|
var message: Dictionary = messages[i]
|
||||||
|
_message_history.push_front(message)
|
||||||
|
|
||||||
|
# 触发事件显示消息(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||||
|
"from_user": message.get("from_user", ""),
|
||||||
|
"content": message.get("content", ""),
|
||||||
|
"show_bubble": false,
|
||||||
|
"timestamp": message.get("timestamp", 0.0),
|
||||||
|
"is_self": (not _current_username.is_empty() and message.get("from_user", "") == _current_username),
|
||||||
|
"is_history": true # 标记为历史消息
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最旧消息时间戳
|
||||||
|
var oldest: Dictionary = messages.back()
|
||||||
|
if oldest.has("timestamp"):
|
||||||
|
_oldest_message_timestamp = oldest.timestamp
|
||||||
|
|
||||||
|
# 检查是否还有更多历史
|
||||||
|
if messages.size() < HISTORY_PAGE_SIZE:
|
||||||
|
_has_more_history = false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 信号连接
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接信号
|
||||||
|
func _connect_signals() -> void:
|
||||||
|
# WebSocket 管理器信号
|
||||||
|
_websocket_manager.connection_state_changed.connect(_on_connection_state_changed)
|
||||||
|
_websocket_manager.data_received.connect(_on_data_received)
|
||||||
|
|
||||||
|
# 发送登录消息
|
||||||
|
func _send_login_message() -> void:
|
||||||
|
var login_data := {
|
||||||
|
"type": "login",
|
||||||
|
"token": _game_token
|
||||||
|
}
|
||||||
|
|
||||||
|
var json_string := JSON.stringify(login_data)
|
||||||
|
_websocket_manager.send_message(json_string)
|
||||||
|
|
||||||
|
# 连接状态变化
|
||||||
|
func _on_connection_state_changed(state: int) -> void:
|
||||||
|
# 发射信号
|
||||||
|
chat_connection_state_changed.emit(state)
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, {
|
||||||
|
"state": state
|
||||||
|
})
|
||||||
|
|
||||||
|
# 如果连接成功,发送登录消息
|
||||||
|
if state == 2: # CONNECTED
|
||||||
|
_send_login_message()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 消息处理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# WebSocket 数据接收
|
||||||
|
func _on_data_received(message: String) -> void:
|
||||||
|
# 解析 JSON 消息
|
||||||
|
var json := JSON.new()
|
||||||
|
var parse_result := json.parse(message)
|
||||||
|
|
||||||
|
if parse_result != OK:
|
||||||
|
push_error("ChatManager: JSON 解析失败")
|
||||||
|
return
|
||||||
|
|
||||||
|
var data: Dictionary = json.data
|
||||||
|
|
||||||
|
# 检查消息类型字段
|
||||||
|
var message_type: String = data.get("t", "")
|
||||||
|
|
||||||
|
match message_type:
|
||||||
|
"login_success":
|
||||||
|
_handle_login_success(data)
|
||||||
|
"login_error":
|
||||||
|
_handle_login_error(data)
|
||||||
|
"chat":
|
||||||
|
_handle_chat_render(data)
|
||||||
|
"chat_sent":
|
||||||
|
_handle_chat_sent(data)
|
||||||
|
"chat_error":
|
||||||
|
_handle_chat_error(data)
|
||||||
|
"chat_render":
|
||||||
|
_handle_chat_render(data)
|
||||||
|
"position_updated":
|
||||||
|
_handle_position_updated(data)
|
||||||
|
_:
|
||||||
|
push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
|
||||||
|
|
||||||
|
# 处理登录成功
|
||||||
|
func _handle_login_success(data: Dictionary) -> void:
|
||||||
|
_is_logged_in = true
|
||||||
|
_current_username = data.get("username", "")
|
||||||
|
_current_map = data.get("currentMap", "")
|
||||||
|
|
||||||
|
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
|
||||||
|
reset_session()
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_LOGIN_SUCCESS, {
|
||||||
|
"username": _current_username,
|
||||||
|
"current_map": _current_map
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理登录失败
|
||||||
|
func _handle_login_error(data: Dictionary) -> void:
|
||||||
|
var error_message: String = data.get("message", "登录失败")
|
||||||
|
|
||||||
|
_is_logged_in = false
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播错误(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_LOGIN_FAILED, {
|
||||||
|
"error_code": "LOGIN_FAILED",
|
||||||
|
"message": error_message
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理聊天消息发送成功
|
||||||
|
func _handle_chat_sent(data: Dictionary) -> void:
|
||||||
|
var message_id: String = str(data.get("messageId", ""))
|
||||||
|
var timestamp: float = data.get("timestamp", 0.0)
|
||||||
|
|
||||||
|
# 发射信号
|
||||||
|
chat_message_sent.emit(message_id, timestamp)
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_MESSAGE_SENT, {
|
||||||
|
"message_id": message_id,
|
||||||
|
"timestamp": timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理聊天消息发送失败
|
||||||
|
func _handle_chat_error(data: Dictionary) -> void:
|
||||||
|
var error_message: String = data.get("message", "消息发送失败")
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播错误(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||||||
|
"error_code": "CHAT_SEND_FAILED",
|
||||||
|
"message": error_message
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理接收到的聊天消息
|
||||||
|
func _handle_chat_render(data: Dictionary) -> void:
|
||||||
|
# 兼容不同后端字段命名:
|
||||||
|
# - chat_render: {from, txt, bubble, timestamp}
|
||||||
|
# - chat: {content, scope, (可选 from/username/timestamp)}
|
||||||
|
var from_user: String = data.get("from", data.get("from_user", data.get("username", "")))
|
||||||
|
var content: String = data.get("txt", data.get("content", ""))
|
||||||
|
var show_bubble: bool = bool(data.get("bubble", data.get("show_bubble", false)))
|
||||||
|
|
||||||
|
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
|
||||||
|
|
||||||
|
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
|
||||||
|
if is_self and _consume_pending_self_message(content):
|
||||||
|
# 已经本地回显过,避免重复显示
|
||||||
|
return
|
||||||
|
|
||||||
|
# 如果服务端没带发送者信息,但内容匹配最近自己发送的消息,则认为是自己消息
|
||||||
|
if from_user.is_empty() and _consume_pending_self_message(content):
|
||||||
|
from_user = _current_username
|
||||||
|
is_self = true
|
||||||
|
|
||||||
|
# 添加到历史
|
||||||
|
_add_message_to_history({
|
||||||
|
"from_user": from_user,
|
||||||
|
"content": content,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"is_self": is_self
|
||||||
|
})
|
||||||
|
|
||||||
|
# 发射信号
|
||||||
|
chat_message_received.emit(from_user, content, show_bubble, timestamp)
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||||
|
"from_user": from_user,
|
||||||
|
"content": content,
|
||||||
|
"show_bubble": show_bubble,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"is_self": is_self
|
||||||
|
})
|
||||||
|
|
||||||
|
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
|
||||||
|
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
|
||||||
|
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
|
||||||
|
var ts := float(timestamp_raw)
|
||||||
|
return ts if ts > 0.0 else Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
var ts_str := str(timestamp_raw)
|
||||||
|
if ts_str.strip_edges().is_empty():
|
||||||
|
return Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
|
||||||
|
var numeric_regex := RegEx.new()
|
||||||
|
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
|
||||||
|
if numeric_regex.search(ts_str) != null:
|
||||||
|
var ts_num := float(ts_str)
|
||||||
|
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
# ISO 8601: 2026-01-19T15:15:43.930Z
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
|
||||||
|
var result := regex.search(ts_str)
|
||||||
|
if result == null:
|
||||||
|
return Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
var utc_dict := {
|
||||||
|
"year": int(result.get_string(1)),
|
||||||
|
"month": int(result.get_string(2)),
|
||||||
|
"day": int(result.get_string(3)),
|
||||||
|
"hour": int(result.get_string(4)),
|
||||||
|
"minute": int(result.get_string(5)),
|
||||||
|
"second": int(result.get_string(6))
|
||||||
|
}
|
||||||
|
return Time.get_unix_time_from_datetime_dict(utc_dict)
|
||||||
|
|
||||||
|
# 处理位置更新成功
|
||||||
|
func _handle_position_updated(data: Dictionary) -> void:
|
||||||
|
var stream: String = data.get("stream", "")
|
||||||
|
var topic: String = data.get("topic", "")
|
||||||
|
|
||||||
|
# 发射信号
|
||||||
|
chat_position_updated.emit(stream, topic)
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_POSITION_UPDATED, {
|
||||||
|
"stream": stream,
|
||||||
|
"topic": topic
|
||||||
|
})
|
||||||
|
|
||||||
|
# 处理错误响应(如果需要)
|
||||||
|
func _handle_error_response(data: Dictionary) -> void:
|
||||||
|
var error_code: String = data.get("code", "")
|
||||||
|
var error_message: String = data.get("message", "")
|
||||||
|
|
||||||
|
_handle_error(error_code, error_message)
|
||||||
|
|
||||||
|
# 处理 Socket 错误(如果需要)
|
||||||
|
func _on_socket_error(error: String) -> void:
|
||||||
|
_handle_error("SOCKET_ERROR", error)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 工具函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 处理错误
|
||||||
|
func _handle_error(error_code: String, error_message: String) -> void:
|
||||||
|
push_error("ChatManager: [%s] %s" % [error_code, error_message])
|
||||||
|
|
||||||
|
# 获取用户友好的错误消息
|
||||||
|
var user_message: String = CHAT_ERROR_MESSAGES.get(error_code, error_message) as String
|
||||||
|
|
||||||
|
# 发射信号
|
||||||
|
chat_error_occurred.emit(error_code, user_message)
|
||||||
|
|
||||||
|
# 通过 EventSystem 广播(Signal Up)
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_ERROR_OCCURRED, {
|
||||||
|
"error_code": error_code,
|
||||||
|
"message": user_message
|
||||||
|
})
|
||||||
|
|
||||||
|
# 特殊处理认证失败
|
||||||
|
if error_code == "AUTH_FAILED" or error_code == "SESSION_EXPIRED":
|
||||||
|
_is_logged_in = false
|
||||||
|
EventSystem.emit_event(EventNames.CHAT_LOGIN_FAILED, {
|
||||||
|
"error_code": error_code
|
||||||
|
})
|
||||||
|
|
||||||
|
# 记录消息发送时间戳
|
||||||
|
func _record_message_timestamp() -> void:
|
||||||
|
var current_time := Time.get_unix_time_from_system()
|
||||||
|
_message_timestamps.append(current_time)
|
||||||
|
|
||||||
|
# 消费一个待去重的“自己消息”(允许相同内容多次发送:每次消费一个)
|
||||||
|
func _consume_pending_self_message(content: String) -> bool:
|
||||||
|
var now := Time.get_unix_time_from_system()
|
||||||
|
|
||||||
|
# 先清理过期项
|
||||||
|
for i in range(_pending_self_messages.size() - 1, -1, -1):
|
||||||
|
var item: Dictionary = _pending_self_messages[i]
|
||||||
|
if float(item.get("expires_at", 0.0)) < now:
|
||||||
|
_pending_self_messages.remove_at(i)
|
||||||
|
|
||||||
|
# 再匹配内容
|
||||||
|
for i in range(_pending_self_messages.size() - 1, -1, -1):
|
||||||
|
if str(_pending_self_messages[i].get("content", "")) == content:
|
||||||
|
_pending_self_messages.remove_at(i)
|
||||||
|
return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
|
# 添加消息到当前会话历史
|
||||||
|
func _add_message_to_history(message: Dictionary) -> void:
|
||||||
|
_message_history.append(message)
|
||||||
|
|
||||||
|
# 更新最旧消息时间戳(用于历史消息加载)
|
||||||
|
if _oldest_message_timestamp == 0.0 or message.timestamp < _oldest_message_timestamp:
|
||||||
|
_oldest_message_timestamp = message.timestamp
|
||||||
|
|
||||||
|
# 限制当前会话消息数量(超过后删除最旧的)
|
||||||
|
if _message_history.size() > MAX_SESSION_MESSAGES:
|
||||||
|
_message_history.pop_front()
|
||||||
1
_Core/managers/ChatManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6lnbss2i3pss
|
||||||
@@ -57,7 +57,6 @@ var game_version: String = "1.0.0" # 游戏版本号
|
|||||||
# 初始化游戏管理器
|
# 初始化游戏管理器
|
||||||
# 在节点准备就绪时调用,设置初始状态
|
# 在节点准备就绪时调用,设置初始状态
|
||||||
func _ready():
|
func _ready():
|
||||||
print("GameManager 初始化完成")
|
|
||||||
change_state(GameState.AUTH) # 启动时进入认证状态
|
change_state(GameState.AUTH) # 启动时进入认证状态
|
||||||
|
|
||||||
# ============ 状态管理方法 ============
|
# ============ 状态管理方法 ============
|
||||||
@@ -81,9 +80,6 @@ func change_state(new_state: GameState):
|
|||||||
previous_state = current_state
|
previous_state = current_state
|
||||||
current_state = new_state
|
current_state = new_state
|
||||||
|
|
||||||
# 输出状态变更日志
|
|
||||||
print("游戏状态变更: ", GameState.keys()[previous_state], " -> ", GameState.keys()[current_state])
|
|
||||||
|
|
||||||
# 发送状态变更信号
|
# 发送状态变更信号
|
||||||
game_state_changed.emit(new_state)
|
game_state_changed.emit(new_state)
|
||||||
|
|
||||||
@@ -100,7 +96,7 @@ func get_current_state() -> GameState:
|
|||||||
# GameState - 上一个游戏状态
|
# GameState - 上一个游戏状态
|
||||||
#
|
#
|
||||||
# 使用场景:
|
# 使用场景:
|
||||||
# - 从暂停状态恢复时,返回到之前的状态
|
# - 从暂停状态恢复时,返回到暂停前状态
|
||||||
# - 错误处理时回退到安全状态
|
# - 错误处理时回退到安全状态
|
||||||
func get_previous_state() -> GameState:
|
func get_previous_state() -> GameState:
|
||||||
return previous_state
|
return previous_state
|
||||||
@@ -114,14 +110,12 @@ func get_previous_state() -> GameState:
|
|||||||
#
|
#
|
||||||
# 功能:
|
# 功能:
|
||||||
# - 存储当前登录用户信息
|
# - 存储当前登录用户信息
|
||||||
# - 输出用户设置日志
|
|
||||||
#
|
#
|
||||||
# 注意事项:
|
# 注意事项:
|
||||||
# - 用户登录成功后调用此方法
|
# - 用户登录成功后调用此方法
|
||||||
# - 用户登出时应传入空字符串
|
# - 用户登出时应传入空字符串
|
||||||
func set_current_user(username: String):
|
func set_current_user(username: String):
|
||||||
current_user = username
|
current_user = username
|
||||||
print("当前用户设置为: ", username)
|
|
||||||
|
|
||||||
# 获取当前登录用户
|
# 获取当前登录用户
|
||||||
#
|
#
|
||||||
|
|||||||
155
_Core/managers/LocationManager.gd
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LocationManager.gd - 位置同步管理器
|
||||||
|
# ============================================================================
|
||||||
|
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理
|
||||||
|
#
|
||||||
|
# 协议文档: new_docs/game_architecture_design.md
|
||||||
|
# 后端地址: wss://whaletownend.xinghangee.icu/location-broadcast
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
signal connected_to_server()
|
||||||
|
signal connection_closed()
|
||||||
|
signal connection_error()
|
||||||
|
signal session_joined(data: Dictionary)
|
||||||
|
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 PING_INTERVAL = 25.0 # 秒
|
||||||
|
|
||||||
|
var _socket: WebSocketPeer
|
||||||
|
var _connected: bool = false
|
||||||
|
var _ping_timer: float = 0.0
|
||||||
|
var _auth_token: String = ""
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_socket = WebSocketPeer.new()
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
||||||
|
|
||||||
|
func _process(delta):
|
||||||
|
_socket.poll()
|
||||||
|
var state = _socket.get_ready_state()
|
||||||
|
|
||||||
|
if state == WebSocketPeer.STATE_OPEN:
|
||||||
|
if not _connected:
|
||||||
|
_on_connected()
|
||||||
|
|
||||||
|
# 处理接收到的数据包
|
||||||
|
while _socket.get_available_packet_count() > 0:
|
||||||
|
var packet = _socket.get_packet()
|
||||||
|
_handle_packet(packet)
|
||||||
|
|
||||||
|
# 心跳处理
|
||||||
|
_ping_timer += delta
|
||||||
|
if _ping_timer >= PING_INTERVAL:
|
||||||
|
_send_heartbeat()
|
||||||
|
_ping_timer = 0.0
|
||||||
|
|
||||||
|
elif state == WebSocketPeer.STATE_CLOSED:
|
||||||
|
if _connected:
|
||||||
|
_on_disconnected()
|
||||||
|
|
||||||
|
func connect_to_server():
|
||||||
|
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||||
|
return
|
||||||
|
|
||||||
|
var err = _socket.connect_to_url(WS_URL)
|
||||||
|
if err != OK:
|
||||||
|
push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err)
|
||||||
|
connection_error.emit()
|
||||||
|
else:
|
||||||
|
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
||||||
|
pass
|
||||||
|
|
||||||
|
func close_connection():
|
||||||
|
_socket.close()
|
||||||
|
|
||||||
|
func set_auth_token(token: String):
|
||||||
|
_auth_token = token
|
||||||
|
|
||||||
|
# ============ 协议发送 ============
|
||||||
|
|
||||||
|
func send_packet(event: String, data: Dictionary):
|
||||||
|
if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||||
|
return
|
||||||
|
|
||||||
|
var message = {
|
||||||
|
"event": event,
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
var json_str = JSON.stringify(message)
|
||||||
|
_socket.put_packet(json_str.to_utf8_buffer())
|
||||||
|
|
||||||
|
func join_session(map_id: String, initial_pos: Vector2):
|
||||||
|
var data = {
|
||||||
|
"sessionId": map_id,
|
||||||
|
"initialPosition": {
|
||||||
|
"x": initial_pos.x,
|
||||||
|
"y": initial_pos.y,
|
||||||
|
"mapId": map_id
|
||||||
|
},
|
||||||
|
"token": _auth_token
|
||||||
|
}
|
||||||
|
send_packet("join_session", data)
|
||||||
|
|
||||||
|
func leave_session(map_id: String):
|
||||||
|
send_packet("leave_session", {"sessionId": map_id})
|
||||||
|
|
||||||
|
func send_position_update(map_id: String, pos: Vector2, anim_data: Dictionary = {}):
|
||||||
|
var data = {
|
||||||
|
"x": pos.x,
|
||||||
|
"y": pos.y,
|
||||||
|
"mapId": map_id,
|
||||||
|
"metadata": anim_data
|
||||||
|
}
|
||||||
|
if map_id == "":
|
||||||
|
push_warning("LocationManager: position_update 的 map_id 为空")
|
||||||
|
send_packet("position_update", data)
|
||||||
|
|
||||||
|
func _send_heartbeat():
|
||||||
|
send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
|
||||||
|
|
||||||
|
# ============ 事件处理 ============
|
||||||
|
|
||||||
|
func _on_connected():
|
||||||
|
_connected = true
|
||||||
|
connected_to_server.emit()
|
||||||
|
|
||||||
|
func _on_disconnected():
|
||||||
|
_connected = false
|
||||||
|
connection_closed.emit()
|
||||||
|
|
||||||
|
func _handle_packet(packet: PackedByteArray):
|
||||||
|
var json_str = packet.get_string_from_utf8()
|
||||||
|
var json = JSON.new()
|
||||||
|
var err = json.parse(json_str)
|
||||||
|
|
||||||
|
if err != OK:
|
||||||
|
push_error("LocationManager: JSON 解析失败 - %s" % json.get_error_message())
|
||||||
|
return
|
||||||
|
|
||||||
|
var message = json.data
|
||||||
|
if not message is Dictionary or not message.has("event"):
|
||||||
|
return
|
||||||
|
|
||||||
|
var event = message["event"]
|
||||||
|
var data = message.get("data", {})
|
||||||
|
|
||||||
|
match event:
|
||||||
|
"session_joined":
|
||||||
|
session_joined.emit(data)
|
||||||
|
"user_joined":
|
||||||
|
user_joined.emit(data)
|
||||||
|
"user_left":
|
||||||
|
user_left.emit(data)
|
||||||
|
"position_update":
|
||||||
|
position_updated.emit(data)
|
||||||
|
"heartbeat_response":
|
||||||
|
pass # 静默处理
|
||||||
|
"error":
|
||||||
|
push_error("LocationManager: WebSocket 错误事件 - %s" % JSON.stringify(data))
|
||||||
|
_:
|
||||||
|
push_warning("LocationManager: 未处理的 WebSocket 事件 %s" % event)
|
||||||
1
_Core/managers/LocationManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://biyq426f66nkb
|
||||||
@@ -109,13 +109,6 @@ class RequestInfo:
|
|||||||
var active_requests: Dictionary = {} # 存储所有活动请求 {request_id: RequestInfo}
|
var active_requests: Dictionary = {} # 存储所有活动请求 {request_id: RequestInfo}
|
||||||
var request_counter: int = 0 # 请求计数器,用于生成唯一ID
|
var request_counter: int = 0 # 请求计数器,用于生成唯一ID
|
||||||
|
|
||||||
# ============ 生命周期方法 ============
|
|
||||||
|
|
||||||
# 初始化网络管理器
|
|
||||||
# 在节点准备就绪时调用
|
|
||||||
func _ready():
|
|
||||||
print("NetworkManager 已初始化")
|
|
||||||
|
|
||||||
# ============ 公共API接口 ============
|
# ============ 公共API接口 ============
|
||||||
|
|
||||||
# 发送GET请求
|
# 发送GET请求
|
||||||
@@ -193,14 +186,6 @@ func delete_request(endpoint: String, callback: Callable = Callable(), timeout:
|
|||||||
#
|
#
|
||||||
# 回调函数签名:
|
# 回调函数签名:
|
||||||
# func callback(success: bool, data: Dictionary, error_info: Dictionary)
|
# func callback(success: bool, data: Dictionary, error_info: Dictionary)
|
||||||
#
|
|
||||||
# 使用示例:
|
|
||||||
# NetworkManager.login("user@example.com", "password123", func(success, data, error):
|
|
||||||
# if success:
|
|
||||||
# print("登录成功: ", data)
|
|
||||||
# else:
|
|
||||||
# print("登录失败: ", error.message)
|
|
||||||
# )
|
|
||||||
func login(identifier: String, password: String, callback: Callable = Callable()) -> String:
|
func login(identifier: String, password: String, callback: Callable = Callable()) -> String:
|
||||||
var data = {
|
var data = {
|
||||||
"identifier": identifier,
|
"identifier": identifier,
|
||||||
@@ -472,41 +457,24 @@ func send_request(endpoint: String, method: RequestType, headers: PackedStringAr
|
|||||||
var godot_method = _convert_to_godot_method(method)
|
var godot_method = _convert_to_godot_method(method)
|
||||||
var error = http_request.request(full_url, headers, godot_method, body)
|
var error = http_request.request(full_url, headers, godot_method, body)
|
||||||
|
|
||||||
print("=== 发送网络请求 ===")
|
|
||||||
print("请求ID: ", request_id)
|
|
||||||
print("URL: ", full_url)
|
|
||||||
print("方法: ", RequestType.keys()[method])
|
|
||||||
print("Headers: ", headers)
|
|
||||||
print("Body: ", body if body.length() < 200 else body.substr(0, 200) + "...")
|
|
||||||
print("发送结果: ", error)
|
|
||||||
|
|
||||||
if error != OK:
|
if error != OK:
|
||||||
print("请求发送失败,错误代码: ", error)
|
push_error("NetworkManager: 请求发送失败,错误码: %d" % error)
|
||||||
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络请求发送失败: " + str(error))
|
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络请求发送失败: " + str(error))
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
return request_id
|
return request_id
|
||||||
|
|
||||||
# 请求完成回调
|
# 请求完成回调
|
||||||
func _on_request_completed(request_id: String, result: int, response_code: int,
|
func _on_request_completed(request_id: String, _result: int, response_code: int,
|
||||||
headers: PackedStringArray, body: PackedByteArray):
|
_headers: PackedStringArray, body: PackedByteArray):
|
||||||
print("=== 网络请求完成 ===")
|
|
||||||
print("请求ID: ", request_id)
|
|
||||||
print("结果: ", result)
|
|
||||||
print("状态码: ", response_code)
|
|
||||||
print("响应头: ", headers)
|
|
||||||
|
|
||||||
# 获取请求信息
|
# 获取请求信息
|
||||||
if not active_requests.has(request_id):
|
if not active_requests.has(request_id):
|
||||||
print("警告: 未找到请求ID ", request_id)
|
push_warning("NetworkManager: 未找到请求ID %s" % request_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
var _request_info = active_requests[request_id]
|
var _request_info = active_requests[request_id]
|
||||||
var response_text = body.get_string_from_utf8()
|
var response_text = body.get_string_from_utf8()
|
||||||
|
|
||||||
print("响应体长度: ", body.size(), " 字节")
|
|
||||||
print("响应内容: ", response_text if response_text.length() < 500 else response_text.substr(0, 500) + "...")
|
|
||||||
|
|
||||||
# 处理网络连接失败
|
# 处理网络连接失败
|
||||||
if response_code == 0:
|
if response_code == 0:
|
||||||
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络连接失败,请检查网络连接")
|
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络连接失败,请检查网络连接")
|
||||||
@@ -542,13 +510,11 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
|
|||||||
# 特殊情况:206测试模式 - 根据API文档,这是成功的测试模式响应
|
# 特殊情况:206测试模式 - 根据API文档,这是成功的测试模式响应
|
||||||
elif response_code == 206 and error_code == "TEST_MODE_ONLY":
|
elif response_code == 206 and error_code == "TEST_MODE_ONLY":
|
||||||
is_success = true
|
is_success = true
|
||||||
print("🧪 测试模式响应: ", message)
|
|
||||||
# 201创建成功
|
# 201创建成功
|
||||||
elif response_code == 201:
|
elif response_code == 201:
|
||||||
is_success = true
|
is_success = true
|
||||||
|
|
||||||
if is_success:
|
if is_success:
|
||||||
print("✅ 请求成功: ", request_id)
|
|
||||||
# 发送成功信号
|
# 发送成功信号
|
||||||
request_completed.emit(request_id, true, data)
|
request_completed.emit(request_id, true, data)
|
||||||
|
|
||||||
@@ -556,8 +522,6 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
|
|||||||
if request_info.callback.is_valid():
|
if request_info.callback.is_valid():
|
||||||
request_info.callback.call(true, data, {})
|
request_info.callback.call(true, data, {})
|
||||||
else:
|
else:
|
||||||
print("❌ 请求失败: ", request_id, " - HTTP:", response_code, " 错误码:", error_code, " 消息:", message)
|
|
||||||
|
|
||||||
# 确定错误类型
|
# 确定错误类型
|
||||||
var error_type = _determine_error_type(response_code, error_code)
|
var error_type = _determine_error_type(response_code, error_code)
|
||||||
|
|
||||||
@@ -579,7 +543,7 @@ func _handle_response(request_id: String, response_code: int, data: Dictionary):
|
|||||||
|
|
||||||
# 处理请求错误
|
# 处理请求错误
|
||||||
func _handle_request_error(request_id: String, error_type: ErrorType, message: String):
|
func _handle_request_error(request_id: String, error_type: ErrorType, message: String):
|
||||||
print("❌ 请求错误: ", request_id, " - ", message)
|
push_error("NetworkManager: 请求错误 %s - %s" % [request_id, message])
|
||||||
|
|
||||||
# 发送错误信号
|
# 发送错误信号
|
||||||
request_failed.emit(request_id, ErrorType.keys()[error_type], message)
|
request_failed.emit(request_id, ErrorType.keys()[error_type], message)
|
||||||
@@ -639,8 +603,6 @@ func _cleanup_request(request_id: String):
|
|||||||
# 从活动请求中移除
|
# 从活动请求中移除
|
||||||
active_requests.erase(request_id)
|
active_requests.erase(request_id)
|
||||||
|
|
||||||
print("🧹 清理请求: ", request_id)
|
|
||||||
|
|
||||||
# 转换请求方法
|
# 转换请求方法
|
||||||
func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
|
func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
|
||||||
match method:
|
match method:
|
||||||
@@ -662,14 +624,12 @@ func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
|
|||||||
# 取消请求
|
# 取消请求
|
||||||
func cancel_request(request_id: String) -> bool:
|
func cancel_request(request_id: String) -> bool:
|
||||||
if active_requests.has(request_id):
|
if active_requests.has(request_id):
|
||||||
print("🚫 取消请求: ", request_id)
|
|
||||||
_cleanup_request(request_id)
|
_cleanup_request(request_id)
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# 取消所有请求
|
# 取消所有请求
|
||||||
func cancel_all_requests():
|
func cancel_all_requests():
|
||||||
print("🚫 取消所有请求")
|
|
||||||
var request_ids = active_requests.keys()
|
var request_ids = active_requests.keys()
|
||||||
for request_id in request_ids:
|
for request_id in request_ids:
|
||||||
cancel_request(request_id)
|
cancel_request(request_id)
|
||||||
|
|||||||
@@ -128,21 +128,15 @@ func handle_send_verification_code_response(success: bool, data: Dictionary, err
|
|||||||
var error_code = data.get("error_code", "")
|
var error_code = data.get("error_code", "")
|
||||||
if error_code == "TEST_MODE_ONLY":
|
if error_code == "TEST_MODE_ONLY":
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "🧪 测试模式:验证码已生成,请查看控制台"
|
result.message = "🧪 测试模式:验证码已生成"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
# 在控制台显示验证码
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
if data.has("data") and data.data.has("verification_code"):
|
||||||
print("🔑 测试模式验证码: ", data.data.verification_code)
|
|
||||||
result.message += "\n验证码: " + str(data.data.verification_code)
|
result.message += "\n验证码: " + str(data.data.verification_code)
|
||||||
else:
|
else:
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "📧 验证码已发送到您的邮箱,请查收"
|
result.message = "📧 验证码已发送到您的邮箱,请查收"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
# 开发环境下显示验证码
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
|
||||||
print("🔑 开发环境验证码: ", data.data.verification_code)
|
|
||||||
else:
|
else:
|
||||||
result = _handle_send_code_error(data, error_info)
|
result = _handle_send_code_error(data, error_info)
|
||||||
|
|
||||||
@@ -156,18 +150,15 @@ func handle_send_login_code_response(success: bool, data: Dictionary, error_info
|
|||||||
var error_code = data.get("error_code", "")
|
var error_code = data.get("error_code", "")
|
||||||
if error_code == "TEST_MODE_ONLY":
|
if error_code == "TEST_MODE_ONLY":
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "测试模式:登录验证码已生成,请查看控制台"
|
result.message = "测试模式:登录验证码已生成"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
if data.has("data") and data.data.has("verification_code"):
|
||||||
print("测试模式登录验证码: ", data.data.verification_code)
|
result.message += "\n验证码: " + str(data.data.verification_code)
|
||||||
else:
|
else:
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "登录验证码已发送,请查收"
|
result.message = "登录验证码已发送,请查收"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
|
||||||
print("开发环境登录验证码: ", data.data.verification_code)
|
|
||||||
else:
|
else:
|
||||||
result = _handle_send_login_code_error(data, error_info)
|
result = _handle_send_login_code_error(data, error_info)
|
||||||
|
|
||||||
@@ -214,18 +205,15 @@ func handle_resend_email_verification_response(success: bool, data: Dictionary,
|
|||||||
var error_code = data.get("error_code", "")
|
var error_code = data.get("error_code", "")
|
||||||
if error_code == "TEST_MODE_ONLY":
|
if error_code == "TEST_MODE_ONLY":
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "🧪 测试模式:验证码已重新生成,请查看控制台"
|
result.message = "🧪 测试模式:验证码已重新生成"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
if data.has("data") and data.data.has("verification_code"):
|
||||||
print("🔑 测试模式重新发送验证码: ", data.data.verification_code)
|
result.message += "\n验证码: " + str(data.data.verification_code)
|
||||||
else:
|
else:
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "📧 验证码已重新发送到您的邮箱,请查收"
|
result.message = "📧 验证码已重新发送到您的邮箱,请查收"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
|
||||||
print("🔑 开发环境重新发送验证码: ", data.data.verification_code)
|
|
||||||
else:
|
else:
|
||||||
result = _handle_resend_email_verification_error(data, error_info)
|
result = _handle_resend_email_verification_error(data, error_info)
|
||||||
|
|
||||||
@@ -239,18 +227,15 @@ func handle_forgot_password_response(success: bool, data: Dictionary, error_info
|
|||||||
var error_code = data.get("error_code", "")
|
var error_code = data.get("error_code", "")
|
||||||
if error_code == "TEST_MODE_ONLY":
|
if error_code == "TEST_MODE_ONLY":
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "🧪 测试模式:重置验证码已生成,请查看控制台"
|
result.message = "🧪 测试模式:重置验证码已生成"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
if data.has("data") and data.data.has("verification_code"):
|
||||||
print("🔑 测试模式重置验证码: ", data.data.verification_code)
|
result.message += "\n验证码: " + str(data.data.verification_code)
|
||||||
else:
|
else:
|
||||||
result.success = true
|
result.success = true
|
||||||
result.message = "📧 重置验证码已发送,请查收"
|
result.message = "📧 重置验证码已发送,请查收"
|
||||||
result.toast_type = "success"
|
result.toast_type = "success"
|
||||||
|
|
||||||
if data.has("data") and data.data.has("verification_code"):
|
|
||||||
print("🔑 开发环境重置验证码: ", data.data.verification_code)
|
|
||||||
else:
|
else:
|
||||||
result = _handle_forgot_password_error(data, error_info)
|
result = _handle_forgot_password_error(data, error_info)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ signal scene_change_started(scene_name: String)
|
|||||||
var current_scene_name: String = "" # 当前场景名称
|
var current_scene_name: String = "" # 当前场景名称
|
||||||
var is_changing_scene: bool = false # 是否正在切换场景
|
var is_changing_scene: bool = false # 是否正在切换场景
|
||||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||||
|
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||||
|
|
||||||
# 场景路径映射表
|
# 场景路径映射表
|
||||||
# 将场景名称映射到实际的文件路径
|
# 将场景名称映射到实际的文件路径
|
||||||
@@ -49,17 +50,13 @@ var scene_paths: Dictionary = {
|
|||||||
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
|
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
|
||||||
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
|
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
|
||||||
"settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面
|
"settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面
|
||||||
"room": "res://scenes/Maps/room.tscn", # 房间场景
|
"square": "res://scenes/Maps/square.tscn", # 广场地图
|
||||||
"square": "res://scenes/Maps/square.tscn" # 广场场景
|
"room": "res://scenes/Maps/room.tscn", # 房间地图
|
||||||
|
"fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图
|
||||||
|
"datawhale_home": "res://scenes/Maps/datawhale_home.tscn", # 数据鲸鱼之家
|
||||||
|
"community": "res://scenes/Maps/community.tscn" # 社区地图
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============ 生命周期方法 ============
|
|
||||||
|
|
||||||
# 初始化场景管理器
|
|
||||||
# 在节点准备就绪时调用
|
|
||||||
func _ready():
|
|
||||||
print("SceneManager 初始化完成")
|
|
||||||
|
|
||||||
# ============ 场景切换方法 ============
|
# ============ 场景切换方法 ============
|
||||||
|
|
||||||
# 切换到指定场景
|
# 切换到指定场景
|
||||||
@@ -80,8 +77,6 @@ func _ready():
|
|||||||
#
|
#
|
||||||
# 使用示例:
|
# 使用示例:
|
||||||
# var success = SceneManager.change_scene("main", true)
|
# var success = SceneManager.change_scene("main", true)
|
||||||
# if success:
|
|
||||||
# print("场景切换成功")
|
|
||||||
#
|
#
|
||||||
# 注意事项:
|
# 注意事项:
|
||||||
# - 场景切换是异步操作
|
# - 场景切换是异步操作
|
||||||
@@ -90,16 +85,15 @@ func _ready():
|
|||||||
func change_scene(scene_name: String, use_transition: bool = true):
|
func change_scene(scene_name: String, use_transition: bool = true):
|
||||||
# 防止重复切换
|
# 防止重复切换
|
||||||
if is_changing_scene:
|
if is_changing_scene:
|
||||||
print("场景切换中,忽略新的切换请求")
|
push_warning("SceneManager: 场景切换中,忽略新的切换请求")
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# 检查场景是否存在
|
# 检查场景是否存在
|
||||||
if not scene_paths.has(scene_name):
|
if not scene_paths.has(scene_name):
|
||||||
print("错误: 未找到场景 ", scene_name)
|
push_error("SceneManager: 未找到场景 %s" % scene_name)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
var scene_path = scene_paths[scene_name]
|
var scene_path = scene_paths[scene_name]
|
||||||
print("开始切换场景: ", current_scene_name, " -> ", scene_name)
|
|
||||||
|
|
||||||
# 设置切换状态
|
# 设置切换状态
|
||||||
is_changing_scene = true
|
is_changing_scene = true
|
||||||
@@ -112,7 +106,7 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
# 执行场景切换
|
# 执行场景切换
|
||||||
var error = get_tree().change_scene_to_file(scene_path)
|
var error = get_tree().change_scene_to_file(scene_path)
|
||||||
if error != OK:
|
if error != OK:
|
||||||
print("场景切换失败: ", error)
|
push_error("SceneManager: 场景切换失败 %s -> %s, 错误码: %d" % [current_scene_name, scene_name, error])
|
||||||
is_changing_scene = false
|
is_changing_scene = false
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -125,7 +119,6 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
if use_transition:
|
if use_transition:
|
||||||
await hide_transition()
|
await hide_transition()
|
||||||
|
|
||||||
print("场景切换完成: ", scene_name)
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# ============ 查询方法 ============
|
# ============ 查询方法 ============
|
||||||
@@ -137,16 +130,71 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
|||||||
func get_current_scene_name() -> String:
|
func get_current_scene_name() -> String:
|
||||||
return current_scene_name
|
return current_scene_name
|
||||||
|
|
||||||
|
# ============ 场景位置和出生点管理 ============
|
||||||
|
|
||||||
# 设置下一个场景的初始位置
|
# 设置下一个场景的初始位置
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# pos: Vector2 - 玩家在下一个场景的初始位置
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 用于场景切换时传递玩家位置信息
|
||||||
|
# - 配合 DoorTeleport 等传送机制使用
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# SceneManager.set_next_scene_position(Vector2(100, 200))
|
||||||
|
# SceneManager.change_scene("room")
|
||||||
func set_next_scene_position(pos: Vector2) -> void:
|
func set_next_scene_position(pos: Vector2) -> void:
|
||||||
_next_scene_position = pos
|
_next_scene_position = pos
|
||||||
|
|
||||||
# 获取并清除下一个场景的初始位置
|
# 获取并清除下一个场景的初始位置
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Variant - Vector2 位置或 null(如果未设置)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 获取预设的场景初始位置
|
||||||
|
# - 获取后自动清除,避免影响后续场景切换
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 此方法会清除存储的位置,只能获取一次
|
||||||
|
# - 如果未设置位置,返回 null
|
||||||
func get_next_scene_position() -> Variant:
|
func get_next_scene_position() -> Variant:
|
||||||
var pos = _next_scene_position
|
var pos = _next_scene_position
|
||||||
_next_scene_position = null
|
_next_scene_position = null
|
||||||
return pos
|
return pos
|
||||||
|
|
||||||
|
# 设置下一个场景的出生点名称
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# spawn_name: String - 出生点节点的名称
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 指定玩家在下一个场景应该出现在哪个出生点
|
||||||
|
# - 配合场景中的 Marker2D 出生点使用
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# SceneManager.set_next_spawn_name("DoorExit")
|
||||||
|
# SceneManager.change_scene("square")
|
||||||
|
func set_next_spawn_name(spawn_name: String) -> void:
|
||||||
|
_next_spawn_name = spawn_name
|
||||||
|
|
||||||
|
# 获取并清除下一个场景的出生点名称
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - 出生点名称(如果未设置则返回空字符串)
|
||||||
|
#
|
||||||
|
# 功能:
|
||||||
|
# - 获取预设的出生点名称
|
||||||
|
# - 获取后自动清除,避免影响后续场景切换
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 此方法会清除存储的名称,只能获取一次
|
||||||
|
# - 如果未设置名称,返回空字符串
|
||||||
|
func get_next_spawn_name() -> String:
|
||||||
|
var name = _next_spawn_name
|
||||||
|
_next_spawn_name = ""
|
||||||
|
return name
|
||||||
|
|
||||||
# ============ 场景注册方法 ============
|
# ============ 场景注册方法 ============
|
||||||
|
|
||||||
@@ -164,7 +212,6 @@ func get_next_scene_position() -> Variant:
|
|||||||
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
|
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
|
||||||
func register_scene(scene_name: String, scene_path: String):
|
func register_scene(scene_name: String, scene_path: String):
|
||||||
scene_paths[scene_name] = scene_path
|
scene_paths[scene_name] = scene_path
|
||||||
print("注册场景: ", scene_name, " -> ", scene_path)
|
|
||||||
|
|
||||||
# ============ 过渡效果方法 ============
|
# ============ 过渡效果方法 ============
|
||||||
|
|
||||||
@@ -180,8 +227,7 @@ func register_scene(scene_name: String, scene_path: String):
|
|||||||
#
|
#
|
||||||
# TODO: 实现淡入淡出、滑动等过渡效果
|
# TODO: 实现淡入淡出、滑动等过渡效果
|
||||||
func show_transition():
|
func show_transition():
|
||||||
# TODO: 实现场景切换过渡效果
|
# TODO: 实现场景切换过渡效果(当前仅占位延时)
|
||||||
print("显示场景切换过渡效果")
|
|
||||||
await get_tree().create_timer(0.2).timeout
|
await get_tree().create_timer(0.2).timeout
|
||||||
|
|
||||||
# 隐藏场景切换过渡效果
|
# 隐藏场景切换过渡效果
|
||||||
@@ -196,6 +242,5 @@ func show_transition():
|
|||||||
#
|
#
|
||||||
# TODO: 实现与show_transition()对应的隐藏效果
|
# TODO: 实现与show_transition()对应的隐藏效果
|
||||||
func hide_transition():
|
func hide_transition():
|
||||||
# TODO: 隐藏场景切换过渡效果
|
# TODO: 隐藏场景切换过渡效果(当前仅占位延时)
|
||||||
print("隐藏场景切换过渡效果")
|
|
||||||
await get_tree().create_timer(0.2).timeout
|
await get_tree().create_timer(0.2).timeout
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ var toast_counter: int = 0 # Toast计数器,用于生成
|
|||||||
# container: Control - Toast消息的容器节点
|
# container: Control - Toast消息的容器节点
|
||||||
func setup(container: Control):
|
func setup(container: Control):
|
||||||
toast_container = container
|
toast_container = container
|
||||||
print("ToastManager 初始化完成")
|
|
||||||
|
|
||||||
# ============ 公共方法 ============
|
# ============ 公共方法 ============
|
||||||
|
|
||||||
@@ -51,10 +50,9 @@ func setup(container: Control):
|
|||||||
# is_success: bool - 是否为成功消息(影响颜色)
|
# is_success: bool - 是否为成功消息(影响颜色)
|
||||||
func show_toast(message: String, is_success: bool = true):
|
func show_toast(message: String, is_success: bool = true):
|
||||||
if toast_container == null:
|
if toast_container == null:
|
||||||
print("错误: toast_container 节点不存在")
|
push_error("ToastManager: toast_container 节点不存在")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("显示Toast消息: ", message, " 成功: ", is_success)
|
|
||||||
_create_toast_instance(message, is_success)
|
_create_toast_instance(message, is_success)
|
||||||
|
|
||||||
# 清理所有Toast
|
# 清理所有Toast
|
||||||
@@ -123,21 +121,17 @@ func _create_toast_instance(message: String, is_success: bool):
|
|||||||
|
|
||||||
# 平台特定的字体处理
|
# 平台特定的字体处理
|
||||||
if is_web:
|
if is_web:
|
||||||
print("Web平台Toast字体处理")
|
|
||||||
# Web平台使用主题文件
|
# Web平台使用主题文件
|
||||||
var chinese_theme = load("res://assets/ui/chinese_theme.tres")
|
var chinese_theme = load("res://assets/ui/chinese_theme.tres")
|
||||||
if chinese_theme:
|
if chinese_theme:
|
||||||
text_label.theme = chinese_theme
|
text_label.theme = chinese_theme
|
||||||
print("Web平台应用中文主题")
|
|
||||||
else:
|
else:
|
||||||
print("Web平台中文主题加载失败")
|
push_warning("ToastManager: Web平台中文主题加载失败,使用默认字体")
|
||||||
else:
|
else:
|
||||||
print("桌面平台Toast字体处理")
|
|
||||||
# 桌面平台直接加载中文字体
|
# 桌面平台直接加载中文字体
|
||||||
var desktop_chinese_font = load("res://assets/fonts/msyh.ttc")
|
var desktop_chinese_font = load("res://assets/fonts/msyh.ttc")
|
||||||
if desktop_chinese_font:
|
if desktop_chinese_font:
|
||||||
text_label.add_theme_font_override("font", desktop_chinese_font)
|
text_label.add_theme_font_override("font", desktop_chinese_font)
|
||||||
print("桌面平台使用中文字体")
|
|
||||||
|
|
||||||
text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
text_label.custom_minimum_size = Vector2(280, 0)
|
text_label.custom_minimum_size = Vector2(280, 0)
|
||||||
|
|||||||
429
_Core/managers/WebSocketManager.gd
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# WebSocketManager.gd - WebSocket 连接生命周期管理(原生 WebSocket 版本)
|
||||||
|
# ============================================================================
|
||||||
|
# 管理 WebSocket 连接状态、自动重连和错误恢复
|
||||||
|
#
|
||||||
|
# 核心职责:
|
||||||
|
# - 连接状态管理(断开、连接中、已连接、重连中)
|
||||||
|
# - 自动重连机制(指数退避)
|
||||||
|
# - 连接错误恢复
|
||||||
|
# - WebSocket 消息发送/接收
|
||||||
|
# ============================================================================
|
||||||
|
# 使用方式:
|
||||||
|
# WebSocketManager.connect_to_game_server()
|
||||||
|
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 作为自动加载单例,全局可访问
|
||||||
|
# - 自动处理连接断开和重连
|
||||||
|
# - 通过信号通知连接状态变化
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class_name ChatWebSocketManager
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 信号定义
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接状态变化信号
|
||||||
|
# 参数:
|
||||||
|
# new_state: ConnectionState - 新的连接状态
|
||||||
|
signal connection_state_changed(new_state: ConnectionState)
|
||||||
|
|
||||||
|
# 连接丢失信号
|
||||||
|
signal connection_lost()
|
||||||
|
|
||||||
|
# 重连成功信号
|
||||||
|
signal reconnection_succeeded()
|
||||||
|
|
||||||
|
# 重连失败信号
|
||||||
|
# 参数:
|
||||||
|
# attempt: int - 当前重连尝试次数
|
||||||
|
# max_attempts: int - 最大重连次数
|
||||||
|
signal reconnection_failed(attempt: int, max_attempts: int)
|
||||||
|
|
||||||
|
# WebSocket 消息接收信号
|
||||||
|
# 参数:
|
||||||
|
# message: String - 接收到的消息内容(JSON 字符串)
|
||||||
|
signal data_received(message: String)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 枚举定义
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接状态枚举
|
||||||
|
enum ConnectionState {
|
||||||
|
DISCONNECTED, # 未连接
|
||||||
|
CONNECTING, # 连接中
|
||||||
|
CONNECTED, # 已连接
|
||||||
|
RECONNECTING, # 重连中
|
||||||
|
ERROR # 错误状态
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 常量定义
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# WebSocket 服务器 URL(原生 WebSocket)
|
||||||
|
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||||
|
|
||||||
|
# 默认最大重连次数
|
||||||
|
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
|
||||||
|
|
||||||
|
# 默认重连基础延迟(秒)
|
||||||
|
const DEFAULT_RECONNECT_BASE_DELAY: float = 3.0
|
||||||
|
|
||||||
|
# 最大重连延迟(秒)
|
||||||
|
const MAX_RECONNECT_DELAY: float = 30.0
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 成员变量
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# WebSocket peer
|
||||||
|
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
|
||||||
|
|
||||||
|
# 当前连接状态
|
||||||
|
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
|
||||||
|
|
||||||
|
# 自动重连启用标志
|
||||||
|
var _auto_reconnect_enabled: bool = true
|
||||||
|
|
||||||
|
# 最大重连次数
|
||||||
|
var _max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
|
||||||
|
|
||||||
|
# 重连基础延迟
|
||||||
|
var _reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY
|
||||||
|
|
||||||
|
# 当前重连尝试次数
|
||||||
|
var _reconnect_attempt: int = 0
|
||||||
|
|
||||||
|
# 重连定时器
|
||||||
|
var _reconnect_timer: Timer = Timer.new()
|
||||||
|
|
||||||
|
# 是否为正常关闭(非异常断开)
|
||||||
|
var _clean_close: bool = true
|
||||||
|
|
||||||
|
# 心跳定时器
|
||||||
|
var _heartbeat_timer: Timer = Timer.new()
|
||||||
|
|
||||||
|
# 心跳间隔(秒)
|
||||||
|
const HEARTBEAT_INTERVAL: float = 30.0
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 生命周期方法
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 初始化
|
||||||
|
func _ready() -> void:
|
||||||
|
# 设置重连定时器
|
||||||
|
_setup_reconnect_timer()
|
||||||
|
|
||||||
|
# 设置心跳定时器
|
||||||
|
_setup_heartbeat_timer()
|
||||||
|
|
||||||
|
# 启动处理循环
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
|
# 处理每帧
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
# 检查 WebSocket 状态变化
|
||||||
|
_check_websocket_state()
|
||||||
|
|
||||||
|
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
|
||||||
|
|
||||||
|
if state == WebSocketPeer.STATE_OPEN:
|
||||||
|
# 接收数据
|
||||||
|
_websocket_peer.poll()
|
||||||
|
|
||||||
|
# 处理收到的数据
|
||||||
|
while _websocket_peer.get_available_packet_count() > 0:
|
||||||
|
var packet: PackedByteArray = _websocket_peer.get_packet()
|
||||||
|
var message: String = packet.get_string_from_utf8()
|
||||||
|
|
||||||
|
# 发射消息接收信号
|
||||||
|
data_received.emit(message)
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_disconnect()
|
||||||
|
|
||||||
|
if is_instance_valid(_reconnect_timer):
|
||||||
|
_reconnect_timer.stop()
|
||||||
|
_reconnect_timer.queue_free()
|
||||||
|
|
||||||
|
if is_instance_valid(_heartbeat_timer):
|
||||||
|
_heartbeat_timer.stop()
|
||||||
|
_heartbeat_timer.queue_free()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 连接管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接到游戏服务器
|
||||||
|
func connect_to_game_server() -> void:
|
||||||
|
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
|
||||||
|
push_warning("已经在连接或已连接状态")
|
||||||
|
return
|
||||||
|
|
||||||
|
_set_connection_state(ConnectionState.CONNECTING)
|
||||||
|
_clean_close = true
|
||||||
|
_reconnect_attempt = 0
|
||||||
|
|
||||||
|
var err: Error = _websocket_peer.connect_to_url(WEBSOCKET_URL)
|
||||||
|
if err != OK:
|
||||||
|
push_error("WebSocketManager: 连接失败 - %s" % error_string(err))
|
||||||
|
_set_connection_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 启动心跳
|
||||||
|
_start_heartbeat()
|
||||||
|
|
||||||
|
# 断开 WebSocket 连接
|
||||||
|
func disconnect_websocket() -> void:
|
||||||
|
_disconnect()
|
||||||
|
|
||||||
|
# 断开连接(内部方法)
|
||||||
|
func _disconnect() -> void:
|
||||||
|
_clean_close = true
|
||||||
|
|
||||||
|
# 停止重连定时器
|
||||||
|
_reconnect_timer.stop()
|
||||||
|
|
||||||
|
# 停止心跳
|
||||||
|
_heartbeat_timer.stop()
|
||||||
|
|
||||||
|
# 关闭 WebSocket
|
||||||
|
if _websocket_peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||||
|
_websocket_peer.close()
|
||||||
|
|
||||||
|
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
# 检查 WebSocket 是否已连接
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# bool - WebSocket 是否已连接
|
||||||
|
func is_websocket_connected() -> bool:
|
||||||
|
return _connection_state == ConnectionState.CONNECTED
|
||||||
|
|
||||||
|
# 获取当前连接状态
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# ConnectionState - 当前连接状态
|
||||||
|
func get_connection_state() -> ConnectionState:
|
||||||
|
return _connection_state
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 消息发送
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 发送 WebSocket 消息
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# message: String - 要发送的消息内容(JSON 字符串)
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Error - 错误码,OK 表示成功
|
||||||
|
func send_message(message: String) -> Error:
|
||||||
|
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||||
|
push_warning("WebSocketManager: 未连接,无法发送消息")
|
||||||
|
return ERR_UNCONFIGURED
|
||||||
|
|
||||||
|
var err: Error = _websocket_peer.send_text(message)
|
||||||
|
if err != OK:
|
||||||
|
push_error("WebSocketManager: 发送消息失败 - %s" % error_string(err))
|
||||||
|
return err
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 自动重连
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 启用/禁用自动重连
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# enabled: bool - 是否启用自动重连
|
||||||
|
# max_attempts: int - 最大重连次数(默认 5)
|
||||||
|
# base_delay: float - 基础重连延迟,秒(默认 3.0)
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# WebSocketManager.enable_auto_reconnect(true, 5, 3.0)
|
||||||
|
func enable_auto_reconnect(enabled: bool, max_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS, base_delay: float = DEFAULT_RECONNECT_BASE_DELAY) -> void:
|
||||||
|
_auto_reconnect_enabled = enabled
|
||||||
|
_max_reconnect_attempts = max_attempts
|
||||||
|
_reconnect_base_delay = base_delay
|
||||||
|
|
||||||
|
# 获取重连信息
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# Dictionary - 重连信息 {enabled, attempt, max_attempts, delay}
|
||||||
|
func get_reconnect_info() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"enabled": _auto_reconnect_enabled,
|
||||||
|
"attempt": _reconnect_attempt,
|
||||||
|
"max_attempts": _max_reconnect_attempts,
|
||||||
|
"next_delay": _calculate_reconnect_delay() if _connection_state == ConnectionState.RECONNECTING else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 连接状态管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 设置连接状态
|
||||||
|
func _set_connection_state(new_state: ConnectionState) -> void:
|
||||||
|
if _connection_state == new_state:
|
||||||
|
return
|
||||||
|
|
||||||
|
_connection_state = new_state
|
||||||
|
|
||||||
|
# 发射信号
|
||||||
|
connection_state_changed.emit(new_state)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - WebSocket 状态监控
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 检查 WebSocket 状态变化
|
||||||
|
func _check_websocket_state() -> void:
|
||||||
|
# 必须先 poll 才能获取最新状态
|
||||||
|
_websocket_peer.poll()
|
||||||
|
|
||||||
|
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
|
||||||
|
|
||||||
|
match state:
|
||||||
|
WebSocketPeer.STATE_CONNECTING:
|
||||||
|
# 正在连接
|
||||||
|
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
|
||||||
|
_set_connection_state(ConnectionState.CONNECTING)
|
||||||
|
|
||||||
|
WebSocketPeer.STATE_OPEN:
|
||||||
|
# 连接成功
|
||||||
|
if _connection_state != ConnectionState.CONNECTED:
|
||||||
|
_on_websocket_connected()
|
||||||
|
|
||||||
|
WebSocketPeer.STATE_CLOSING:
|
||||||
|
# 正在关闭
|
||||||
|
pass
|
||||||
|
|
||||||
|
WebSocketPeer.STATE_CLOSED:
|
||||||
|
# 连接关闭
|
||||||
|
var code: int = _websocket_peer.get_close_code()
|
||||||
|
_on_websocket_closed(code != 0) # code=0 表示正常关闭
|
||||||
|
|
||||||
|
# WebSocket 连接成功处理
|
||||||
|
func _on_websocket_connected() -> void:
|
||||||
|
# 如果是重连,发射重连成功信号
|
||||||
|
if _connection_state == ConnectionState.RECONNECTING:
|
||||||
|
_reconnect_attempt = 0
|
||||||
|
reconnection_succeeded.emit()
|
||||||
|
|
||||||
|
_set_connection_state(ConnectionState.CONNECTED)
|
||||||
|
|
||||||
|
# WebSocket 连接关闭处理
|
||||||
|
func _on_websocket_closed(clean_close: bool) -> void:
|
||||||
|
_clean_close = clean_close
|
||||||
|
|
||||||
|
# 如果是异常断开且启用了自动重连
|
||||||
|
if not clean_close and _auto_reconnect_enabled:
|
||||||
|
connection_lost.emit()
|
||||||
|
_attempt_reconnect()
|
||||||
|
else:
|
||||||
|
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 重连机制
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 设置重连定时器
|
||||||
|
func _setup_reconnect_timer() -> void:
|
||||||
|
_reconnect_timer = Timer.new()
|
||||||
|
_reconnect_timer.one_shot = true
|
||||||
|
_reconnect_timer.autostart = false
|
||||||
|
add_child(_reconnect_timer)
|
||||||
|
|
||||||
|
_reconnect_timer.timeout.connect(_on_reconnect_timeout)
|
||||||
|
|
||||||
|
# 尝试重连
|
||||||
|
func _attempt_reconnect() -> void:
|
||||||
|
# 检查是否超过最大重连次数
|
||||||
|
if _reconnect_attempt >= _max_reconnect_attempts:
|
||||||
|
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
|
||||||
|
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
|
||||||
|
_set_connection_state(ConnectionState.ERROR)
|
||||||
|
return
|
||||||
|
|
||||||
|
_reconnect_attempt += 1
|
||||||
|
_set_connection_state(ConnectionState.RECONNECTING)
|
||||||
|
|
||||||
|
# 计算重连延迟(指数退避)
|
||||||
|
var delay: float = _calculate_reconnect_delay()
|
||||||
|
|
||||||
|
# 启动重连定时器
|
||||||
|
_reconnect_timer.start(delay)
|
||||||
|
|
||||||
|
# 计算重连延迟(指数退避)
|
||||||
|
func _calculate_reconnect_delay() -> float:
|
||||||
|
# 指数退避: base_delay * 2^(attempt-1)
|
||||||
|
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
|
||||||
|
|
||||||
|
# 限制最大延迟
|
||||||
|
return min(delay, MAX_RECONNECT_DELAY)
|
||||||
|
|
||||||
|
# 重连定时器超时处理
|
||||||
|
func _on_reconnect_timeout() -> void:
|
||||||
|
_clean_close = false
|
||||||
|
connect_to_game_server()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 心跳机制
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 设置心跳定时器
|
||||||
|
func _setup_heartbeat_timer() -> void:
|
||||||
|
_heartbeat_timer = Timer.new()
|
||||||
|
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
|
||||||
|
_heartbeat_timer.one_shot = false
|
||||||
|
_heartbeat_timer.autostart = false
|
||||||
|
add_child(_heartbeat_timer)
|
||||||
|
|
||||||
|
_heartbeat_timer.timeout.connect(_on_heartbeat)
|
||||||
|
|
||||||
|
# 启动心跳
|
||||||
|
func _start_heartbeat() -> void:
|
||||||
|
_heartbeat_timer.start()
|
||||||
|
|
||||||
|
# 停止心跳
|
||||||
|
func _stop_heartbeat() -> void:
|
||||||
|
_heartbeat_timer.stop()
|
||||||
|
|
||||||
|
# 心跳超时处理
|
||||||
|
func _on_heartbeat() -> void:
|
||||||
|
# 不发送心跳,避免服务器返回 "消息格式错误"
|
||||||
|
# 如果需要心跳,服务器应该支持特定格式
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 工具方法
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 获取连接状态描述
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - 连接状态描述
|
||||||
|
func get_state_description() -> String:
|
||||||
|
match _connection_state:
|
||||||
|
ConnectionState.DISCONNECTED:
|
||||||
|
return "未连接"
|
||||||
|
ConnectionState.CONNECTING:
|
||||||
|
return "连接中"
|
||||||
|
ConnectionState.CONNECTED:
|
||||||
|
return "已连接"
|
||||||
|
ConnectionState.RECONNECTING:
|
||||||
|
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
|
||||||
|
ConnectionState.ERROR:
|
||||||
|
return "错误"
|
||||||
|
_:
|
||||||
|
return "未知状态"
|
||||||
1
_Core/managers/WebSocketManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dmbgtbf6gyk6t
|
||||||
@@ -27,13 +27,6 @@ extends Node
|
|||||||
# 结构: {event_name: [{"callback": Callable, "target": Node}, ...]}
|
# 结构: {event_name: [{"callback": Callable, "target": Node}, ...]}
|
||||||
var event_listeners: Dictionary = {}
|
var event_listeners: Dictionary = {}
|
||||||
|
|
||||||
# ============ 生命周期方法 ============
|
|
||||||
|
|
||||||
# 初始化事件系统
|
|
||||||
# 在节点准备就绪时调用
|
|
||||||
func _ready():
|
|
||||||
print("EventSystem 初始化完成")
|
|
||||||
|
|
||||||
# ============ 事件监听器管理 ============
|
# ============ 事件监听器管理 ============
|
||||||
|
|
||||||
# 注册事件监听器
|
# 注册事件监听器
|
||||||
@@ -59,6 +52,11 @@ func connect_event(event_name: String, callback: Callable, target: Node = null):
|
|||||||
if not event_listeners.has(event_name):
|
if not event_listeners.has(event_name):
|
||||||
event_listeners[event_name] = []
|
event_listeners[event_name] = []
|
||||||
|
|
||||||
|
# 避免重复注册同一个监听器
|
||||||
|
for listener in event_listeners[event_name]:
|
||||||
|
if listener.callback == callback and listener.target == target:
|
||||||
|
return
|
||||||
|
|
||||||
# 创建监听器信息
|
# 创建监听器信息
|
||||||
var listener_info = {
|
var listener_info = {
|
||||||
"callback": callback,
|
"callback": callback,
|
||||||
@@ -67,7 +65,6 @@ func connect_event(event_name: String, callback: Callable, target: Node = null):
|
|||||||
|
|
||||||
# 添加到监听器列表
|
# 添加到监听器列表
|
||||||
event_listeners[event_name].append(listener_info)
|
event_listeners[event_name].append(listener_info)
|
||||||
print("注册事件监听器: ", event_name, " -> ", callback)
|
|
||||||
|
|
||||||
# 移除事件监听器
|
# 移除事件监听器
|
||||||
#
|
#
|
||||||
@@ -93,7 +90,6 @@ func disconnect_event(event_name: String, callback: Callable, target: Node = nul
|
|||||||
# 匹配callback和target
|
# 匹配callback和target
|
||||||
if listener.callback == callback and listener.target == target:
|
if listener.callback == callback and listener.target == target:
|
||||||
listeners.remove_at(i)
|
listeners.remove_at(i)
|
||||||
print("移除事件监听器: ", event_name, " -> ", callback)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# ============ 事件发送 ============
|
# ============ 事件发送 ============
|
||||||
@@ -117,13 +113,11 @@ func disconnect_event(event_name: String, callback: Callable, target: Node = nul
|
|||||||
# - 事件发送是同步的,所有监听器会立即执行
|
# - 事件发送是同步的,所有监听器会立即执行
|
||||||
# - 如果监听器执行出错,不会影响其他监听器
|
# - 如果监听器执行出错,不会影响其他监听器
|
||||||
func emit_event(event_name: String, data: Variant = null):
|
func emit_event(event_name: String, data: Variant = null):
|
||||||
print("发送事件: ", event_name, " 数据: ", data)
|
|
||||||
|
|
||||||
# 检查是否有监听器
|
# 检查是否有监听器
|
||||||
if not event_listeners.has(event_name):
|
if not event_listeners.has(event_name):
|
||||||
return
|
return
|
||||||
|
|
||||||
var listeners = event_listeners[event_name]
|
var listeners = event_listeners[event_name].duplicate()
|
||||||
for listener_info in listeners:
|
for listener_info in listeners:
|
||||||
var target = listener_info.target
|
var target = listener_info.target
|
||||||
var callback = listener_info.callback
|
var callback = listener_info.callback
|
||||||
@@ -161,7 +155,6 @@ func cleanup_invalid_listeners():
|
|||||||
# 如果目标节点无效,移除监听器
|
# 如果目标节点无效,移除监听器
|
||||||
if target != null and not is_instance_valid(target):
|
if target != null and not is_instance_valid(target):
|
||||||
listeners.remove_at(i)
|
listeners.remove_at(i)
|
||||||
print("清理无效监听器: ", event_name)
|
|
||||||
|
|
||||||
# ============ 查询方法 ============
|
# ============ 查询方法 ============
|
||||||
|
|
||||||
@@ -192,4 +185,3 @@ func get_listener_count(event_name: String) -> int:
|
|||||||
# - 使用前请确保所有模块都能正确处理监听器丢失
|
# - 使用前请确保所有模块都能正确处理监听器丢失
|
||||||
func clear_all_listeners():
|
func clear_all_listeners():
|
||||||
event_listeners.clear()
|
event_listeners.clear()
|
||||||
print("清空所有事件监听器")
|
|
||||||
|
|||||||
@@ -131,13 +131,14 @@ static func get_grid_rect(grid_pos: Vector2i) -> Rect2:
|
|||||||
var world_pos = grid_to_world(grid_pos)
|
var world_pos = grid_to_world(grid_pos)
|
||||||
return Rect2(world_pos, Vector2(GRID_SIZE, GRID_SIZE))
|
return Rect2(world_pos, Vector2(GRID_SIZE, GRID_SIZE))
|
||||||
|
|
||||||
# 打印网格信息(调试用)
|
# 获取网格信息(调试辅助)
|
||||||
static func print_grid_info(world_pos: Vector2) -> void:
|
static func print_grid_info(world_pos: Vector2) -> Dictionary:
|
||||||
var grid_pos = world_to_grid(world_pos)
|
var grid_pos = world_to_grid(world_pos)
|
||||||
var snapped_pos = snap_to_grid(world_pos)
|
var snapped_pos = snap_to_grid(world_pos)
|
||||||
var center_pos = grid_to_world_center(grid_pos)
|
var center_pos = grid_to_world_center(grid_pos)
|
||||||
|
return {
|
||||||
print("世界坐标: ", world_pos)
|
"world_position": world_pos,
|
||||||
print("网格坐标: ", grid_pos)
|
"grid_position": grid_pos,
|
||||||
print("吸附位置: ", snapped_pos)
|
"snapped_position": snapped_pos,
|
||||||
print("网格中心: ", center_pos)
|
"center_position": center_pos
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class_name StringUtils
|
|||||||
#
|
#
|
||||||
# 使用示例:
|
# 使用示例:
|
||||||
# if StringUtils.is_valid_email("user@example.com"):
|
# if StringUtils.is_valid_email("user@example.com"):
|
||||||
# print("邮箱格式正确")
|
# # 邮箱格式正确
|
||||||
static func is_valid_email(email: String) -> bool:
|
static func is_valid_email(email: String) -> bool:
|
||||||
var regex = RegEx.new()
|
var regex = RegEx.new()
|
||||||
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
|
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
|
||||||
@@ -60,7 +60,7 @@ static func is_valid_email(email: String) -> bool:
|
|||||||
#
|
#
|
||||||
# 使用示例:
|
# 使用示例:
|
||||||
# if StringUtils.is_valid_username("user_123"):
|
# if StringUtils.is_valid_username("user_123"):
|
||||||
# print("用户名格式正确")
|
# # 用户名格式正确
|
||||||
static func is_valid_username(username: String) -> bool:
|
static func is_valid_username(username: String) -> bool:
|
||||||
# 检查长度
|
# 检查长度
|
||||||
if username.is_empty() or username.length() > 50:
|
if username.is_empty() or username.length() > 50:
|
||||||
@@ -92,7 +92,7 @@ static func is_valid_username(username: String) -> bool:
|
|||||||
# 使用示例:
|
# 使用示例:
|
||||||
# var result = StringUtils.validate_password_strength("MyPass123!")
|
# var result = StringUtils.validate_password_strength("MyPass123!")
|
||||||
# if result.valid:
|
# if result.valid:
|
||||||
# print("密码强度: ", result.message)
|
# # 可按需展示 result.message
|
||||||
static func validate_password_strength(password: String) -> Dictionary:
|
static func validate_password_strength(password: String) -> Dictionary:
|
||||||
var result = {"valid": false, "message": "", "strength": 0}
|
var result = {"valid": false, "message": "", "strength": 0}
|
||||||
|
|
||||||
|
|||||||
BIN
assets/audio/music/Spawn Fixed.mp3
Normal file
19
assets/audio/music/Spawn Fixed.mp3.import
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="mp3"
|
||||||
|
type="AudioStreamMP3"
|
||||||
|
uid="uid://c5ujr1fj14n58"
|
||||||
|
path="res://.godot/imported/Spawn Fixed.mp3-cdebcc11ab16736c9b4a0906adccb431.mp3str"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/audio/music/Spawn Fixed.mp3"
|
||||||
|
dest_files=["res://.godot/imported/Spawn Fixed.mp3-cdebcc11ab16736c9b4a0906adccb431.mp3str"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
loop=false
|
||||||
|
loop_offset=0
|
||||||
|
bpm=0
|
||||||
|
beat_count=0
|
||||||
|
bar_beats=4
|
||||||
BIN
assets/characters/npc_286_241.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
40
assets/characters/npc_286_241.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://brko2ik6t6ib5"
|
||||||
|
path="res://.godot/imported/npc_286_241.png-dfe6daef11d0f27f7902e69d6057828f.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/characters/npc_286_241.png"
|
||||||
|
dest_files=["res://.godot/imported/npc_286_241.png-dfe6daef11d0f27f7902e69d6057828f.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/materials/NoticeBoard.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
40
assets/materials/NoticeBoard.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dw7lapxn4m8ry"
|
||||||
|
path="res://.godot/imported/NoticeBoard.png-038eefee12f116fb9502ed755594cede.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/materials/NoticeBoard.png"
|
||||||
|
dest_files=["res://.godot/imported/NoticeBoard.png-038eefee12f116fb9502ed755594cede.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=0
|
||||||
BIN
assets/materials/WelcomeBoard.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
40
assets/materials/WelcomeBoard.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c5y8qxm3jn2wv"
|
||||||
|
path="res://.godot/imported/WelcomeBoard.png-bcff7f9bf968cb5d7630e2ad47f2fb42.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/materials/WelcomeBoard.png"
|
||||||
|
dest_files=["res://.godot/imported/WelcomeBoard.png-bcff7f9bf968cb5d7630e2ad47f2fb42.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=0
|
||||||
BIN
assets/ui/auth/Image (1).png
Normal file
|
After Width: | Height: | Size: 1000 KiB |
40
assets/ui/auth/Image (1).png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c8on1etsscvcg"
|
||||||
|
path="res://.godot/imported/Image (1).png-aa55b4ad122083e1e56d7de11ec05750.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/Image (1).png"
|
||||||
|
dest_files=["res://.godot/imported/Image (1).png-aa55b4ad122083e1e56d7de11ec05750.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/Image (2).png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
40
assets/ui/auth/Image (2).png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dh07ed5tsiyhp"
|
||||||
|
path="res://.godot/imported/Image (2).png-d93edf5c7f9059af2b89d1fb72733335.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/Image (2).png"
|
||||||
|
dest_files=["res://.godot/imported/Image (2).png-d93edf5c7f9059af2b89d1fb72733335.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/Image (3).png
Normal file
|
After Width: | Height: | Size: 902 KiB |
40
assets/ui/auth/Image (3).png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://kvgofnnxp31q"
|
||||||
|
path="res://.godot/imported/Image (3).png-0fd2a2989e780fcbaf4376bb6e288f40.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/Image (3).png"
|
||||||
|
dest_files=["res://.godot/imported/Image (3).png-0fd2a2989e780fcbaf4376bb6e288f40.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/微信图片_20251218232924_983_1425.png
Normal file
|
After Width: | Height: | Size: 992 KiB |
40
assets/ui/auth/微信图片_20251218232924_983_1425.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://0pt270gaevbk"
|
||||||
|
path="res://.godot/imported/微信图片_20251218232924_983_1425.png-0163d5f3fcf9765f1d8d0ec22851e7b0.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/微信图片_20251218232924_983_1425.png"
|
||||||
|
dest_files=["res://.godot/imported/微信图片_20251218232924_983_1425.png-0163d5f3fcf9765f1d8d0ec22851e7b0.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/清空内部组件的登录框架 (1).png
Normal file
|
After Width: | Height: | Size: 690 KiB |
40
assets/ui/auth/清空内部组件的登录框架 (1).png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://b6wlro1vixo8a"
|
||||||
|
path="res://.godot/imported/清空内部组件的登录框架 (1).png-4be35396d0272fde2f195d02324c3439.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/清空内部组件的登录框架 (1).png"
|
||||||
|
dest_files=["res://.godot/imported/清空内部组件的登录框架 (1).png-4be35396d0272fde2f195d02324c3439.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/登录背景.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
40
assets/ui/auth/登录背景.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dyma4hpodhdxi"
|
||||||
|
path="res://.godot/imported/登录背景.png-89ec90e6b5b2b96eba5cf8e1b1d50ed8.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/登录背景.png"
|
||||||
|
dest_files=["res://.godot/imported/登录背景.png-89ec90e6b5b2b96eba5cf8e1b1d50ed8.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/auth/输入框.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
40
assets/ui/auth/输入框.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cnrffaqbtw8f5"
|
||||||
|
path="res://.godot/imported/输入框.png-74076fbd98c6a5dee8485c2e25f4d583.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/auth/输入框.png"
|
||||||
|
dest_files=["res://.godot/imported/输入框.png-74076fbd98c6a5dee8485c2e25f4d583.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/chat/缩略框背景.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
40
assets/ui/chat/缩略框背景.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cchjgp6qh7u61"
|
||||||
|
path="res://.godot/imported/缩略框背景.png-1ffcbaafc0bc1c1b17eae2ad5370d0bc.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/chat/缩略框背景.png"
|
||||||
|
dest_files=["res://.godot/imported/缩略框背景.png-1ffcbaafc0bc1c1b17eae2ad5370d0bc.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/chat/装饰.png
Normal file
|
After Width: | Height: | Size: 276 B |
40
assets/ui/chat/装饰.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://ct0cl4h2i6ydn"
|
||||||
|
path="res://.godot/imported/装饰.png-ee12c19262540c6b6c0fc47732c88ff9.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/chat/装饰.png"
|
||||||
|
dest_files=["res://.godot/imported/装饰.png-ee12c19262540c6b6c0fc47732c88ff9.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/chat/装饰2.png
Normal file
|
After Width: | Height: | Size: 281 B |
40
assets/ui/chat/装饰2.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://q0ijn5y0tbw3"
|
||||||
|
path="res://.godot/imported/装饰2.png-d3c5ac2817d592f843ba8179eb83dd5b.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/chat/装饰2.png"
|
||||||
|
dest_files=["res://.godot/imported/装饰2.png-d3c5ac2817d592f843ba8179eb83dd5b.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/ui/chat/输入框背景.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
40
assets/ui/chat/输入框背景.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://clmgyxpeh5742"
|
||||||
|
path="res://.godot/imported/输入框背景.png-0abeb0259c1b08c235f188bb6bf412c1.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/ui/chat/输入框背景.png"
|
||||||
|
dest_files=["res://.godot/imported/输入框背景.png-0abeb0259c1b08c235f188bb6bf412c1.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
82
claude.md
@@ -29,8 +29,8 @@
|
|||||||
- **Type Safety**: ALWAYS use strict static typing: `var speed: float = 100.0`, `func _ready() -> void`.
|
- **Type Safety**: ALWAYS use strict static typing: `var speed: float = 100.0`, `func _ready() -> void`.
|
||||||
- **Naming Conventions**:
|
- **Naming Conventions**:
|
||||||
- `class_name PascalCase` at the top of every script.
|
- `class_name PascalCase` at the top of every script.
|
||||||
- Variables/Functions: `snake_case`. Constants: `SCREAMING_SNAKE_CASE`.
|
- Variables/Functions: `camelCase` (e.g., `var moveSpeed`, `func updateMovement()`). Constants: `UPPER_CASE`.
|
||||||
- Private members: Prefix with underscore `_` (e.g., `var _health: int`).
|
- Private members: Prefix with underscore `_` (e.g., `var _velocity: Vector2`).
|
||||||
- **Node Access**: Use `%UniqueName` for UI and internal scene components.
|
- **Node Access**: Use `%UniqueName` for UI and internal scene components.
|
||||||
- **Signals**: Use "Signal Up, Call Down". Parent calls child methods; Child emits signals.
|
- **Signals**: Use "Signal Up, Call Down". Parent calls child methods; Child emits signals.
|
||||||
- **Forbidden Patterns**:
|
- **Forbidden Patterns**:
|
||||||
@@ -72,13 +72,74 @@
|
|||||||
assert_signal_emitted(EventSystem, "event_raised")
|
assert_signal_emitted(EventSystem, "event_raised")
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 🧘 The Zen of Development
|
## 🔄 8. Standard Development Workflow (MANDATORY)
|
||||||
|
|
||||||
|
**CRITICAL**: When performing ANY development task (implementing features, fixing bugs, creating scenes), you MUST follow this 7-step standardized workflow:
|
||||||
|
|
||||||
|
### Quick Start: Use the Skill (Recommended) ⭐
|
||||||
|
```bash
|
||||||
|
/whaletown-developer [任务描述]
|
||||||
|
```
|
||||||
|
Example: `/whaletown-developer 实现玩家二段跳功能`
|
||||||
|
|
||||||
|
The skill automates the entire 7-step process and enforces all quality standards.
|
||||||
|
|
||||||
|
### The 7-Step Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Architecture Analysis → Read docs/02-开发规范/架构与通信规范.md
|
||||||
|
Step 2: Implementation → Follow layered architecture, type safety, EventSystem
|
||||||
|
Step 3: Comment Validation → Read docs/02-开发规范/代码注释规范.md
|
||||||
|
Step 4: Naming Validation → Read docs/02-开发规范/命名规范.md
|
||||||
|
Step 5: Test Writing → Read docs/03-技术实现/测试指南.md
|
||||||
|
Step 6: Test Execution → Run: godot --headless -s addons/gut/gut_cmdline.gd
|
||||||
|
Step 7: Git Commit → Read docs/02-开发规范/Git提交规范.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow Enforcement Rules
|
||||||
|
|
||||||
|
1. **Never Skip Steps**: All 7 steps are mandatory for every development task
|
||||||
|
2. **Read Specs First**: Each step requires reading the corresponding specification document
|
||||||
|
3. **Use TodoWrite**: Track progress through all 7 steps using TodoWrite tool
|
||||||
|
4. **Mark Completed**: Mark each step as completed immediately after finishing
|
||||||
|
5. **Quality Gates**: Cannot proceed to next step until current step passes validation
|
||||||
|
|
||||||
|
### Naming Convention Clarification
|
||||||
|
|
||||||
|
**IMPORTANT**: The project uses **camelCase** for variables/functions, NOT snake_case:
|
||||||
|
- ✅ Correct: `var moveSpeed: float`, `func updateMovement()`
|
||||||
|
- ❌ Incorrect: `var move_speed: float`, `func update_movement()`
|
||||||
|
|
||||||
|
See `docs/02-开发规范/命名规范.md` for complete details.
|
||||||
|
|
||||||
|
### Quality Checklist (Every Development Task)
|
||||||
|
|
||||||
|
- [ ] File location follows layered architecture (_Core, scenes, UI)
|
||||||
|
- [ ] Uses EventSystem for cross-module communication
|
||||||
|
- [ ] Event names added to EventNames.gd
|
||||||
|
- [ ] All variables/functions have type annotations
|
||||||
|
- [ ] Naming: PascalCase (classes), camelCase (vars/funcs), UPPER_CASE (constants)
|
||||||
|
- [ ] File header comment complete
|
||||||
|
- [ ] Public functions have complete documentation
|
||||||
|
- [ ] Unit tests created and passing
|
||||||
|
- [ ] Git commit message follows specification
|
||||||
|
- [ ] No Godot 3.x syntax (await not yield, @onready cached)
|
||||||
|
|
||||||
|
### Reference Documents
|
||||||
|
|
||||||
|
- **Full Workflow**: `docs/AI_docs/workflows/standard_development_workflow.md`
|
||||||
|
- **Quick Checklist**: `.claude/skills/whaletown-developer/references/checklist.md`
|
||||||
|
- **Skill Definition**: `.claude/skills/whaletown-developer/SKILL.md`
|
||||||
|
|
||||||
|
**Remember**: Consistency through automation. Use `/whaletown-developer` to ensure no steps are missed.
|
||||||
|
|
||||||
|
## 9. 🧘 The Zen of Development
|
||||||
- **Juice or Death**: Every interaction (UI popup, NPC talk) MUST have a Tween placeholder.
|
- **Juice or Death**: Every interaction (UI popup, NPC talk) MUST have a Tween placeholder.
|
||||||
- **Zero Magic Numbers**: All speeds/timers MUST be `@export` or defined in `Config/`.
|
- **Zero Magic Numbers**: All speeds/timers MUST be `@export` or defined in `Config/`.
|
||||||
- **Simplicity**: If a function does two things, split it.
|
- **Simplicity**: If a function does two things, split it.
|
||||||
- **Back of the Fence**: Hidden logic (like ResponseHandler.gd) must be as clean as the HUD.
|
- **Back of the Fence**: Hidden logic (like ResponseHandler.gd) must be as clean as the HUD.
|
||||||
|
|
||||||
## 9. 📝 Code Template (Entity Pattern)
|
## 10. 📝 Code Template (Entity Pattern)
|
||||||
```gdscript
|
```gdscript
|
||||||
extends CharacterBody2D
|
extends CharacterBody2D
|
||||||
class_name Player
|
class_name Player
|
||||||
@@ -98,3 +159,16 @@ func _move(_delta: float) -> void:
|
|||||||
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
||||||
velocity = dir * move_speed
|
velocity = dir * move_speed
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
```
|
||||||
|
## 10. 🔄 Plan Mode Protocol (MANDATORY)
|
||||||
|
- **Planning Phase**:
|
||||||
|
- Whenever using **Plan Mode** to outline a task, a TODO list MUST be outputted to `docs/ai_docs/plan/[feature_name].md`.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
663
docs/AI_docs/workflows/standard_development_workflow.md
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
# WhaleTown 标准开发工作流
|
||||||
|
|
||||||
|
> **AI 编程助手专用**:本文档定义了 WhaleTown 项目的标准化开发流程,确保所有开发者遵循统一的规范和质量标准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 工作流概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WhaleTown 7步标准开发流程 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Step 1: 架构分析 → 读取架构规范,确定文件位置和通信方式
|
||||||
|
↓
|
||||||
|
Step 2: 功能实现 → 按规范编码,遵循类型安全和事件驱动
|
||||||
|
↓
|
||||||
|
Step 3: 注释规范检查 → 验证文件头、函数注释的完整性
|
||||||
|
↓
|
||||||
|
Step 4: 命名规范检查 → 验证PascalCase/camelCase/UPPER_CASE
|
||||||
|
↓
|
||||||
|
Step 5: 测试代码编写 → 创建GUT单元测试,覆盖核心功能
|
||||||
|
↓
|
||||||
|
Step 6: 测试验证 → 运行测试,确保所有测试通过
|
||||||
|
↓
|
||||||
|
Step 7: Git 提交 → 生成符合规范的提交信息并提交
|
||||||
|
|
||||||
|
总耗时:约 20-40 分钟(根据功能复杂度)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 使用方式
|
||||||
|
|
||||||
|
### 方式一:使用 Skill(推荐)⭐
|
||||||
|
|
||||||
|
最简单、最高效的方式是使用 `whaletown-developer` skill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/whaletown-developer 实现玩家二段跳功能
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill 会自动执行全部 7 步流程,确保不遗漏任何步骤。
|
||||||
|
|
||||||
|
### 方式二:手动执行流程
|
||||||
|
|
||||||
|
如果需要手动控制流程,请按照以下步骤逐步执行,并参考本文档的详细说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 详细步骤说明
|
||||||
|
|
||||||
|
### Step 1: 架构分析(5分钟)
|
||||||
|
|
||||||
|
**目标**: 理解功能在项目中的位置和通信方式
|
||||||
|
|
||||||
|
**规范文档**: `docs/02-开发规范/架构与通信规范.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 读取架构规范文档
|
||||||
|
- [ ] 确定文件位置(_Core, scenes, UI)
|
||||||
|
- [ ] 确定通信方式(EventSystem)
|
||||||
|
- [ ] 列出依赖的管理器/系统
|
||||||
|
- [ ] 设计事件定义(如需要)
|
||||||
|
|
||||||
|
#### 分层架构决策树
|
||||||
|
|
||||||
|
```
|
||||||
|
功能是核心系统(管理器/全局系统)?
|
||||||
|
├─ 是 → 放在 _Core/managers/ 或 _Core/systems/
|
||||||
|
└─ 否 → 功能是游戏场景相关?
|
||||||
|
├─ 是 → 放在 scenes/Maps/, scenes/Entities/, scenes/Components/
|
||||||
|
└─ 否 → 功能是UI界面?
|
||||||
|
├─ 是 → 放在 scenes/ui/
|
||||||
|
└─ 否 → 重新分析功能定位
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 通信方式决策
|
||||||
|
|
||||||
|
- **同模块内通信**: 父调用子方法(向下),子发出信号(向上)
|
||||||
|
- **跨模块通信**: MUST 使用 EventSystem
|
||||||
|
- **事件定义位置**: 所有事件名称定义在 `_Core/EventNames.gd`
|
||||||
|
|
||||||
|
#### 示例:玩家二段跳功能
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 架构分析结果
|
||||||
|
文件位置: scenes/Entities/Player/Player.gd # 游戏场景层
|
||||||
|
通信方式: EventSystem.emit_event() # 跨模块通信
|
||||||
|
依赖: EventSystem, Input # 系统依赖
|
||||||
|
事件: PLAYER_DOUBLE_JUMPED # 需要在 EventNames.gd 中定义
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: 功能实现(10-20分钟)
|
||||||
|
|
||||||
|
**目标**: 按照规范实现功能代码
|
||||||
|
|
||||||
|
**规范文档**: `docs/02-开发规范/架构与通信规范.md`, `claude.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 创建或修改文件在正确位置
|
||||||
|
- [ ] 所有变量和函数有类型注解
|
||||||
|
- [ ] 使用 Godot 4.2+ 语法(await, @onready)
|
||||||
|
- [ ] 通过 EventSystem 进行跨模块通信
|
||||||
|
- [ ] 如有新事件,添加到 EventNames.gd
|
||||||
|
- [ ] 使用 Nearest 滤镜(Sprite2D/TileMap)
|
||||||
|
|
||||||
|
#### 核心规范要点
|
||||||
|
|
||||||
|
**1. 严格类型安全**
|
||||||
|
```gdscript
|
||||||
|
# ✅ 正确
|
||||||
|
var speed: float = 200.0
|
||||||
|
var currentHealth: int = 100
|
||||||
|
func move(delta: float) -> void:
|
||||||
|
func getHealth() -> int:
|
||||||
|
|
||||||
|
# ❌ 错误
|
||||||
|
var speed = 200.0 # 缺少类型注解
|
||||||
|
func move(delta): # 缺少参数和返回值类型
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Godot 4.2+ 语法**
|
||||||
|
```gdscript
|
||||||
|
# ✅ 正确
|
||||||
|
await get_tree().create_timer(1.0).timeout
|
||||||
|
@onready var sprite: Sprite2D = $Sprite2D
|
||||||
|
|
||||||
|
# ❌ 错误
|
||||||
|
yield(get_tree().create_timer(1.0), "timeout") # Godot 3.x
|
||||||
|
var sprite = get_node("Sprite2D") # 应在 _ready 外缓存
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. EventSystem 通信**
|
||||||
|
```gdscript
|
||||||
|
# 发送事件
|
||||||
|
EventSystem.emit_event(EventNames.PLAYER_DOUBLE_JUMPED, {
|
||||||
|
"position": global_position,
|
||||||
|
"direction": velocity.normalized()
|
||||||
|
})
|
||||||
|
|
||||||
|
# 监听事件
|
||||||
|
func _ready() -> void:
|
||||||
|
EventSystem.connect_event(EventNames.INTERACT_PRESSED, _on_interact_pressed)
|
||||||
|
|
||||||
|
func _on_interact_pressed(data: Dictionary = {}) -> void:
|
||||||
|
# 处理交互逻辑
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 自动加载限制**
|
||||||
|
```gdscript
|
||||||
|
# ✅ 正确:在高层组件中访问
|
||||||
|
func _ready() -> void:
|
||||||
|
var current_state = GameManager.get_game_state()
|
||||||
|
|
||||||
|
# ❌ 错误:在底层实体(Player, NPC)中直接访问
|
||||||
|
func _ready() -> void:
|
||||||
|
GameManager.register_player(self) # 不应该这样做
|
||||||
|
|
||||||
|
# ✅ 正确:底层实体使用事件
|
||||||
|
func _ready() -> void:
|
||||||
|
EventSystem.emit_event(EventNames.PLAYER_SPAWNED, {"player": self})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: 注释规范检查(3-5分钟)
|
||||||
|
|
||||||
|
**目标**: 确保代码注释完整且符合规范
|
||||||
|
|
||||||
|
**规范文档**: `docs/02-开发规范/代码注释规范.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 文件头注释完整
|
||||||
|
- [ ] 所有公共函数有完整注释
|
||||||
|
- [ ] 复杂逻辑有行内注释
|
||||||
|
- [ ] 使用 TODO/FIXME/NOTE 标记(如需要)
|
||||||
|
|
||||||
|
#### 文件头注释模板
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# ============================================================================
|
||||||
|
# 文件名: PlayerController.gd
|
||||||
|
# 作用: 玩家角色控制器,处理玩家输入和移动逻辑
|
||||||
|
#
|
||||||
|
# 主要功能:
|
||||||
|
# - 处理键盘和手柄输入
|
||||||
|
# - 控制角色移动和跳跃
|
||||||
|
# - 管理角色状态切换
|
||||||
|
# - 实现二段跳功能
|
||||||
|
#
|
||||||
|
# 依赖: EventSystem, InputManager
|
||||||
|
# 作者: [开发者名称]
|
||||||
|
# 创建时间: 2025-01-03
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
extends CharacterBody2D
|
||||||
|
class_name PlayerController
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 函数注释模板
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 执行二段跳
|
||||||
|
#
|
||||||
|
# 在玩家空中时允许执行一次额外的跳跃
|
||||||
|
# 二段跳的力度为普通跳跃的80%
|
||||||
|
#
|
||||||
|
# 参数: 无
|
||||||
|
#
|
||||||
|
# 返回值: 无
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# if Input.is_action_just_pressed("jump") and canDoubleJump:
|
||||||
|
# performDoubleJump()
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 只能在空中且 canDoubleJump 为 true 时调用
|
||||||
|
# - 执行后会将 canDoubleJump 设置为 false
|
||||||
|
# - 落地时会重置 canDoubleJump 为 true
|
||||||
|
func performDoubleJump() -> void:
|
||||||
|
velocity.y = JUMP_FORCE * 0.8
|
||||||
|
canDoubleJump = false
|
||||||
|
EventSystem.emit_event(EventNames.PLAYER_DOUBLE_JUMPED, {
|
||||||
|
"position": global_position
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: 命名规范检查(2-3分钟)
|
||||||
|
|
||||||
|
**目标**: 验证所有命名符合项目规范
|
||||||
|
|
||||||
|
**规范文档**: `docs/02-开发规范/命名规范.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 类名使用 PascalCase
|
||||||
|
- [ ] 变量/函数使用 camelCase
|
||||||
|
- [ ] 常量使用 UPPER_CASE
|
||||||
|
- [ ] 私有成员使用下划线前缀
|
||||||
|
- [ ] 文件命名符合规范
|
||||||
|
|
||||||
|
#### 命名规范速查表
|
||||||
|
|
||||||
|
| 元素类型 | 命名规范 | 示例 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| **类名** | PascalCase | `class_name PlayerController` |
|
||||||
|
| **变量** | camelCase | `var moveSpeed: float` |
|
||||||
|
| **私有变量** | _camelCase | `var _velocity: Vector2` |
|
||||||
|
| **函数** | camelCase | `func updateMovement()` |
|
||||||
|
| **私有函数** | _camelCase | `func _calculateDamage()` |
|
||||||
|
| **常量** | UPPER_CASE | `const MAX_HEALTH: int = 100` |
|
||||||
|
| **枚举类型** | PascalCase | `enum PlayerState` |
|
||||||
|
| **枚举值** | UPPER_CASE | `IDLE, WALKING, RUNNING` |
|
||||||
|
| **脚本文件** | PascalCase.gd | `PlayerController.gd` |
|
||||||
|
| **场景文件** | snake_case_scene.tscn | `main_scene.tscn` |
|
||||||
|
| **预制体** | snake_case_prefab.tscn | `player_prefab.tscn` |
|
||||||
|
|
||||||
|
#### 常见错误检查
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# ✅ 正确
|
||||||
|
class_name PlayerController
|
||||||
|
const MAX_JUMPS: int = 2
|
||||||
|
var moveSpeed: float = 200.0
|
||||||
|
var canDoubleJump: bool = true
|
||||||
|
var _velocity: Vector2 = Vector2.ZERO
|
||||||
|
func performDoubleJump() -> void:
|
||||||
|
func _calculateJumpForce() -> float:
|
||||||
|
|
||||||
|
# ❌ 错误
|
||||||
|
class_name player_controller # 应使用 PascalCase
|
||||||
|
const maxJumps: int = 2 # 常量应使用 UPPER_CASE
|
||||||
|
var MoveSpeed: float = 200.0 # 变量应使用 camelCase
|
||||||
|
var can_double_jump: bool = true # 不要使用 snake_case
|
||||||
|
func PerformDoubleJump(): # 函数应使用 camelCase
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: 测试代码编写(5-10分钟)
|
||||||
|
|
||||||
|
**目标**: 为实现的功能创建单元测试
|
||||||
|
|
||||||
|
**规范文档**: `docs/03-技术实现/测试指南.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 创建测试文件 `tests/unit/test_[name].gd`
|
||||||
|
- [ ] 测试文件继承自 GutTest
|
||||||
|
- [ ] 实现 before_each 和 after_each
|
||||||
|
- [ ] 编写核心功能测试
|
||||||
|
- [ ] 编写边界条件测试
|
||||||
|
|
||||||
|
#### 测试文件模板
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# tests/unit/test_player_double_jump.gd
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
## PlayerController 二段跳功能单元测试
|
||||||
|
|
||||||
|
var player: PlayerController
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
# 每个测试前创建新的 Player 实例
|
||||||
|
player = preload("res://scenes/Entities/Player/PlayerController.gd").new()
|
||||||
|
add_child(player)
|
||||||
|
player.initialize()
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
# 每个测试后清理
|
||||||
|
player.queue_free()
|
||||||
|
|
||||||
|
func test_can_double_jump_after_first_jump():
|
||||||
|
# 测试:第一次跳跃后可以二段跳
|
||||||
|
player.performJump()
|
||||||
|
assert_true(player.canDoubleJump, "Should be able to double jump after first jump")
|
||||||
|
|
||||||
|
func test_cannot_triple_jump():
|
||||||
|
# 测试:不能三段跳
|
||||||
|
player.performJump()
|
||||||
|
player.performDoubleJump()
|
||||||
|
assert_false(player.canDoubleJump, "Should not be able to triple jump")
|
||||||
|
|
||||||
|
func test_reset_double_jump_on_ground():
|
||||||
|
# 测试:落地后重置二段跳
|
||||||
|
player.performJump()
|
||||||
|
player.performDoubleJump()
|
||||||
|
player._on_landed() # 模拟落地
|
||||||
|
assert_true(player.canDoubleJump, "Double jump should reset when landing")
|
||||||
|
|
||||||
|
func test_double_jump_emits_event():
|
||||||
|
# 测试:二段跳发出事件
|
||||||
|
watch_signals(EventSystem)
|
||||||
|
player.performDoubleJump()
|
||||||
|
assert_signal_emitted(EventSystem, "event_raised")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 测试覆盖建议
|
||||||
|
|
||||||
|
1. **正常流程**: 功能的标准使用场景
|
||||||
|
2. **边界条件**: 极限值、特殊输入
|
||||||
|
3. **错误处理**: 异常情况、错误输入
|
||||||
|
4. **事件通信**: 验证事件正确发送和接收
|
||||||
|
5. **状态管理**: 状态转换的正确性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: 测试验证(2-3分钟)
|
||||||
|
|
||||||
|
**目标**: 运行测试确保代码质量
|
||||||
|
|
||||||
|
**规范文档**: `docs/03-技术实现/测试指南.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 运行 GUT 测试命令
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] 如有失败,修复并重新测试
|
||||||
|
- [ ] 确认测试覆盖核心功能
|
||||||
|
|
||||||
|
#### 运行测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行所有测试
|
||||||
|
godot --headless -s addons/gut/gut_cmdline.gd -gdir=res://tests/ -ginclude_subdirs
|
||||||
|
|
||||||
|
# 运行特定测试文件
|
||||||
|
godot --headless -s addons/gut/gut_cmdline.gd -gtest=res://tests/unit/test_player_double_jump.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 测试结果分析
|
||||||
|
|
||||||
|
**所有测试通过**:
|
||||||
|
```
|
||||||
|
========================
|
||||||
|
= PASSED: 4 of 4 tests =
|
||||||
|
========================
|
||||||
|
```
|
||||||
|
✅ 进入下一步
|
||||||
|
|
||||||
|
**部分测试失败**:
|
||||||
|
```
|
||||||
|
==========================
|
||||||
|
= FAILED: 1 of 4 tests =
|
||||||
|
==========================
|
||||||
|
FAILED: test_cannot_triple_jump
|
||||||
|
Expected: false
|
||||||
|
Got: true
|
||||||
|
```
|
||||||
|
❌ 修复问题后重新测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Git 提交(3-5分钟)
|
||||||
|
|
||||||
|
**目标**: 生成符合规范的 Git 提交信息
|
||||||
|
|
||||||
|
**规范文档**: `docs/02-开发规范/Git提交规范.md`
|
||||||
|
|
||||||
|
#### 执行清单
|
||||||
|
|
||||||
|
- [ ] 确定提交类型(feat/fix/docs/refactor等)
|
||||||
|
- [ ] 生成规范的提交信息
|
||||||
|
- [ ] 使用中文冒号(:)
|
||||||
|
- [ ] 描述简洁明了
|
||||||
|
- [ ] 遵循"一次提交只做一件事"
|
||||||
|
|
||||||
|
#### 提交类型选择
|
||||||
|
|
||||||
|
| 改动类型 | 提交类型 | 示例 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 新功能 | `feat` | `feat:实现玩家二段跳功能` |
|
||||||
|
| Bug修复 | `fix` | `fix:修复跳跃碰撞检测问题` |
|
||||||
|
| 文档更新 | `docs` | `docs:更新架构规范文档` |
|
||||||
|
| 代码重构 | `refactor` | `refactor:重构移动系统逻辑` |
|
||||||
|
| 性能优化 | `perf` | `perf:优化物理计算性能` |
|
||||||
|
| 测试相关 | `test` | `test:添加二段跳单元测试` |
|
||||||
|
| 场景文件 | `scene` | `scene:创建战斗场景` |
|
||||||
|
| UI界面 | `ui` | `ui:设计暂停菜单界面` |
|
||||||
|
|
||||||
|
#### 提交示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例1:新功能(完整流程)
|
||||||
|
git add scenes/Entities/Player/PlayerController.gd
|
||||||
|
git add _Core/EventNames.gd
|
||||||
|
git add tests/unit/test_player_double_jump.gd
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
|
||||||
|
# 示例2:Bug修复
|
||||||
|
git add scenes/Entities/Player/PlayerController.gd
|
||||||
|
git commit -m "fix:修复二段跳状态未重置的问题"
|
||||||
|
|
||||||
|
# 示例3:测试添加
|
||||||
|
git add tests/unit/test_player_movement.gd
|
||||||
|
git commit -m "test:添加玩家移动系统单元测试"
|
||||||
|
|
||||||
|
# 示例4:带详细描述的提交
|
||||||
|
git commit -m "feat:实现玩家二段跳功能
|
||||||
|
|
||||||
|
- 添加二段跳核心逻辑
|
||||||
|
- 在空中允许执行一次额外跳跃
|
||||||
|
- 二段跳力度为普通跳跃的80%
|
||||||
|
- 发送 PLAYER_DOUBLE_JUMPED 事件
|
||||||
|
- 落地时重置二段跳能力"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 多类型改动处理
|
||||||
|
|
||||||
|
**⚠️ 如果同时有多种类型改动,必须拆分提交:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ❌ 错误:混合提交
|
||||||
|
git commit -m "fix + feat:修复Bug并添加新功能"
|
||||||
|
|
||||||
|
# ✅ 正确:拆分提交
|
||||||
|
git add PlayerController.gd # 只暂存 Bug 修复部分
|
||||||
|
git commit -m "fix:修复跳跃碰撞检测问题"
|
||||||
|
|
||||||
|
git add PlayerController.gd # 暂存新功能部分
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 完整工作流检查清单
|
||||||
|
|
||||||
|
在完成开发任务后,使用此清单验证是否执行了全部流程:
|
||||||
|
|
||||||
|
### 总览检查
|
||||||
|
- [ ] ✅ Step 1: 架构分析完成
|
||||||
|
- [ ] ✅ Step 2: 功能实现完成
|
||||||
|
- [ ] ✅ Step 3: 注释规范检查通过
|
||||||
|
- [ ] ✅ Step 4: 命名规范检查通过
|
||||||
|
- [ ] ✅ Step 5: 测试代码编写完成
|
||||||
|
- [ ] ✅ Step 6: 测试验证通过
|
||||||
|
- [ ] ✅ Step 7: Git 提交完成
|
||||||
|
|
||||||
|
### 详细检查
|
||||||
|
- [ ] 文件位置符合分层架构(_Core, scenes, UI)
|
||||||
|
- [ ] 使用 EventSystem 进行跨模块通信
|
||||||
|
- [ ] 新事件已添加到 EventNames.gd
|
||||||
|
- [ ] 所有变量和函数有类型注解
|
||||||
|
- [ ] 使用 Godot 4.2+ 语法(await, @onready)
|
||||||
|
- [ ] 命名规范正确(PascalCase/camelCase/UPPER_CASE)
|
||||||
|
- [ ] 文件头注释完整
|
||||||
|
- [ ] 公共函数有完整文档注释
|
||||||
|
- [ ] 创建了单元测试文件
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] Git 提交信息符合规范
|
||||||
|
- [ ] Sprite2D/TileMap 使用 Nearest 滤镜
|
||||||
|
- [ ] 未违反自动加载限制
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 最佳实践
|
||||||
|
|
||||||
|
### 使用 TodoWrite 追踪进度
|
||||||
|
|
||||||
|
在执行工作流时,使用 TodoWrite 工具追踪每个步骤:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
TodoWrite.create_todos([
|
||||||
|
"Step 1: 架构分析 - 读取架构规范",
|
||||||
|
"Step 2: 功能实现 - 按规范编码",
|
||||||
|
"Step 3: 注释规范检查",
|
||||||
|
"Step 4: 命名规范检查",
|
||||||
|
"Step 5: 测试代码编写",
|
||||||
|
"Step 6: 测试验证 - 运行测试",
|
||||||
|
"Step 7: Git 提交 - 生成提交信息"
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
每完成一步,立即标记为 `completed`。
|
||||||
|
|
||||||
|
### 常见错误避免
|
||||||
|
|
||||||
|
1. **跳过测试**: 测试不是可选项,必须为核心功能编写测试
|
||||||
|
2. **混合提交**: 不要在一次提交中混合 fix 和 feat
|
||||||
|
3. **命名不一致**: 严格遵循 PascalCase/camelCase/UPPER_CASE
|
||||||
|
4. **缺少注释**: 公共函数必须有完整注释
|
||||||
|
5. **直接访问单例**: 底层实体使用事件,不直接访问 GameManager
|
||||||
|
|
||||||
|
### 提升效率技巧
|
||||||
|
|
||||||
|
1. **使用 Skill**: 调用 `/whaletown-developer` 自动执行全流程
|
||||||
|
2. **模板复用**: 参考现有代码的结构和注释模板
|
||||||
|
3. **增量提交**: 不要等所有功能完成才提交,完成一个逻辑单元就提交
|
||||||
|
4. **快速参考**: 使用 `.claude/skills/whaletown-developer/references/checklist.md` 快速自检
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文档索引
|
||||||
|
|
||||||
|
### 核心规范文档
|
||||||
|
- **架构与通信**: `docs/02-开发规范/架构与通信规范.md`
|
||||||
|
- **代码注释**: `docs/02-开发规范/代码注释规范.md`
|
||||||
|
- **命名规范**: `docs/02-开发规范/命名规范.md`
|
||||||
|
- **Git 提交**: `docs/02-开发规范/Git提交规范.md`
|
||||||
|
- **测试指南**: `docs/03-技术实现/测试指南.md`
|
||||||
|
- **项目指令**: `claude.md` (根目录)
|
||||||
|
|
||||||
|
### 辅助文档
|
||||||
|
- **Skill 指令**: `.claude/skills/whaletown-developer/SKILL.md`
|
||||||
|
- **快速检查清单**: `.claude/skills/whaletown-developer/references/checklist.md`
|
||||||
|
- **功能开发流程**: `docs/AI_docs/workflows/feature_development.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 示例:完整开发流程
|
||||||
|
|
||||||
|
### 任务:实现玩家二段跳功能
|
||||||
|
|
||||||
|
#### Step 1: 架构分析 (3分钟)
|
||||||
|
```
|
||||||
|
读取: docs/02-开发规范/架构与通信规范.md
|
||||||
|
分析结果:
|
||||||
|
- 文件位置: scenes/Entities/Player/PlayerController.gd
|
||||||
|
- 通信方式: EventSystem
|
||||||
|
- 依赖: EventSystem, Input
|
||||||
|
- 事件: PLAYER_DOUBLE_JUMPED (需添加到 EventNames.gd)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 功能实现 (15分钟)
|
||||||
|
```gdscript
|
||||||
|
# scenes/Entities/Player/PlayerController.gd
|
||||||
|
extends CharacterBody2D
|
||||||
|
class_name PlayerController
|
||||||
|
|
||||||
|
const JUMP_FORCE: float = -400.0
|
||||||
|
const MAX_DOUBLE_JUMPS: int = 1
|
||||||
|
|
||||||
|
var canDoubleJump: bool = true
|
||||||
|
var doubleJumpCount: int = 0
|
||||||
|
|
||||||
|
func performDoubleJump() -> void:
|
||||||
|
if not canDoubleJump or doubleJumpCount >= MAX_DOUBLE_JUMPS:
|
||||||
|
return
|
||||||
|
|
||||||
|
velocity.y = JUMP_FORCE * 0.8
|
||||||
|
doubleJumpCount += 1
|
||||||
|
canDoubleJump = false
|
||||||
|
|
||||||
|
EventSystem.emit_event(EventNames.PLAYER_DOUBLE_JUMPED, {
|
||||||
|
"position": global_position
|
||||||
|
})
|
||||||
|
|
||||||
|
func _on_landed() -> void:
|
||||||
|
doubleJumpCount = 0
|
||||||
|
canDoubleJump = true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3-4: 注释和命名检查 (5分钟)
|
||||||
|
```
|
||||||
|
✅ 文件头注释完整
|
||||||
|
✅ 函数注释完整
|
||||||
|
✅ 类名 PascalCase: PlayerController
|
||||||
|
✅ 变量 camelCase: canDoubleJump
|
||||||
|
✅ 常量 UPPER_CASE: MAX_DOUBLE_JUMPS
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: 编写测试 (8分钟)
|
||||||
|
```gdscript
|
||||||
|
# tests/unit/test_player_double_jump.gd
|
||||||
|
extends GutTest
|
||||||
|
|
||||||
|
var player: PlayerController
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
player = PlayerController.new()
|
||||||
|
add_child(player)
|
||||||
|
|
||||||
|
func test_can_double_jump():
|
||||||
|
assert_true(player.canDoubleJump)
|
||||||
|
|
||||||
|
func test_double_jump_resets_on_landing():
|
||||||
|
player.performDoubleJump()
|
||||||
|
player._on_landed()
|
||||||
|
assert_true(player.canDoubleJump)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 6: 测试验证 (2分钟)
|
||||||
|
```bash
|
||||||
|
$ godot --headless -s addons/gut/gut_cmdline.gd
|
||||||
|
========================
|
||||||
|
= PASSED: 2 of 2 tests =
|
||||||
|
========================
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 7: Git 提交 (3分钟)
|
||||||
|
```bash
|
||||||
|
git add scenes/Entities/Player/PlayerController.gd
|
||||||
|
git add _Core/EventNames.gd
|
||||||
|
git add tests/unit/test_player_double_jump.gd
|
||||||
|
git commit -m "feat:实现玩家二段跳功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
**总耗时**: 约 36 分钟
|
||||||
|
**结果**: ✅ 功能实现完整,符合所有规范
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 总结
|
||||||
|
|
||||||
|
遵循此 7 步标准开发工作流,可以确保:
|
||||||
|
|
||||||
|
1. **代码质量**: 符合项目的所有规范和标准
|
||||||
|
2. **团队一致**: 所有开发者使用相同的流程和规范
|
||||||
|
3. **可维护性**: 清晰的注释、规范的命名、完整的测试
|
||||||
|
4. **高效协作**: 规范的 Git 提交历史,便于追溯和回滚
|
||||||
|
5. **质量保证**: 测试驱动开发,确保功能正确性
|
||||||
|
|
||||||
|
**记住**: 使用 `/whaletown-developer` skill 可以自动化执行此流程!🚀
|
||||||
@@ -22,6 +22,8 @@ SceneManager="*res://_Core/managers/SceneManager.gd"
|
|||||||
EventSystem="*res://_Core/systems/EventSystem.gd"
|
EventSystem="*res://_Core/systems/EventSystem.gd"
|
||||||
NetworkManager="*res://_Core/managers/NetworkManager.gd"
|
NetworkManager="*res://_Core/managers/NetworkManager.gd"
|
||||||
ResponseHandler="*res://_Core/managers/ResponseHandler.gd"
|
ResponseHandler="*res://_Core/managers/ResponseHandler.gd"
|
||||||
|
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||||
|
LocationManager="*res://_Core/managers/LocationManager.gd"
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ extends Control
|
|||||||
# 场景节点引用
|
# 场景节点引用
|
||||||
@onready var auth_scene: Control = $AuthScene
|
@onready var auth_scene: Control = $AuthScene
|
||||||
@onready var main_game_ui: Control = $MainGameUI
|
@onready var main_game_ui: Control = $MainGameUI
|
||||||
|
@onready var chat_ui: Control = %ChatUI
|
||||||
@onready var user_label: Label = $MainGameUI/TopBar/HBoxContainer/UserLabel
|
@onready var user_label: Label = $MainGameUI/TopBar/HBoxContainer/UserLabel
|
||||||
@onready var logout_button: Button = $MainGameUI/TopBar/HBoxContainer/LogoutButton
|
@onready var logout_button: Button = $MainGameUI/TopBar/HBoxContainer/LogoutButton
|
||||||
|
|
||||||
@@ -48,10 +49,7 @@ var player_max_energy: int = 100
|
|||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# 初始化游戏状态
|
# 初始化游戏状态
|
||||||
# setup_game()
|
setup_game()
|
||||||
|
|
||||||
# [TEST] 临时绕过登录
|
|
||||||
call_deferred("_on_login_success", "LocalTester")
|
|
||||||
|
|
||||||
# 连接登录成功信号
|
# 连接登录成功信号
|
||||||
auth_scene.login_success.connect(_on_login_success)
|
auth_scene.login_success.connect(_on_login_success)
|
||||||
@@ -77,36 +75,7 @@ func show_main_game():
|
|||||||
auth_scene.visible = false
|
auth_scene.visible = false
|
||||||
main_game_ui.visible = true
|
main_game_ui.visible = true
|
||||||
user_label.text = "当前用户: " + current_user
|
user_label.text = "当前用户: " + current_user
|
||||||
# update_player_status()
|
update_player_status()
|
||||||
# print("进入主游戏界面")
|
|
||||||
|
|
||||||
# [TEST] 进入测试环境
|
|
||||||
_setup_test_environment()
|
|
||||||
|
|
||||||
func _setup_test_environment():
|
|
||||||
print("正在初始化测试环境: 广场 + 玩家")
|
|
||||||
|
|
||||||
# 1. 隐藏UI
|
|
||||||
current_state = GameState.MAIN_GAME
|
|
||||||
auth_scene.visible = false
|
|
||||||
main_game_ui.visible = false
|
|
||||||
|
|
||||||
# 2. 加载地图
|
|
||||||
var map_res = load("res://Scenes/Maps/square.tscn")
|
|
||||||
if map_res:
|
|
||||||
var map_instance = map_res.instantiate()
|
|
||||||
add_child(map_instance)
|
|
||||||
|
|
||||||
# 3. 加载玩家
|
|
||||||
var player_res = load("res://Scenes/characters/player.tscn")
|
|
||||||
if player_res:
|
|
||||||
var player_instance = player_res.instantiate()
|
|
||||||
player_instance.position = Vector2(800, 600) # 设置初始位置
|
|
||||||
map_instance.add_child(player_instance)
|
|
||||||
else:
|
|
||||||
print("错误: 无法加载玩家场景")
|
|
||||||
else:
|
|
||||||
print("错误: 无法加载广场地图")
|
|
||||||
|
|
||||||
func update_player_status():
|
func update_player_status():
|
||||||
level_label.text = "等级: " + str(player_level)
|
level_label.text = "等级: " + str(player_level)
|
||||||
@@ -117,34 +86,66 @@ func update_player_status():
|
|||||||
func _on_login_success(username: String):
|
func _on_login_success(username: String):
|
||||||
# 登录成功后的处理
|
# 登录成功后的处理
|
||||||
current_user = username
|
current_user = username
|
||||||
print("用户 ", username, " 登录成功!")
|
|
||||||
show_main_game()
|
# 连接到聊天服务器(在进入游戏界面之前)
|
||||||
|
# 注意:token 已在 AuthScene._on_controller_login_success 中设置
|
||||||
|
ChatManager.connect_to_chat_server()
|
||||||
|
|
||||||
|
# 连接到位置同步服务器
|
||||||
|
LocationManager.connect_to_server()
|
||||||
|
|
||||||
|
# 直接进入游戏地图(不显示MainGameUI)
|
||||||
|
_setup_game_environment()
|
||||||
|
|
||||||
func _on_logout_pressed():
|
func _on_logout_pressed():
|
||||||
# 登出处理
|
# 登出处理
|
||||||
current_user = ""
|
current_user = ""
|
||||||
|
|
||||||
|
# 断开聊天服务器连接
|
||||||
|
ChatManager.disconnect_from_chat_server()
|
||||||
|
|
||||||
show_auth_scene()
|
show_auth_scene()
|
||||||
|
|
||||||
# 游戏功能按钮处理
|
# 游戏功能按钮处理
|
||||||
func _on_explore_pressed():
|
func _on_explore_pressed():
|
||||||
print("探索小镇功能")
|
|
||||||
show_game_message("🗺️ 探索功能开发中...")
|
show_game_message("🗺️ 探索功能开发中...")
|
||||||
|
|
||||||
func _on_inventory_pressed():
|
func _on_inventory_pressed():
|
||||||
print("背包功能")
|
|
||||||
show_game_message("🎒 背包功能开发中...")
|
show_game_message("🎒 背包功能开发中...")
|
||||||
|
|
||||||
func _on_shop_pressed():
|
func _on_shop_pressed():
|
||||||
print("商店功能")
|
|
||||||
show_game_message("🏪 商店功能开发中...")
|
show_game_message("🏪 商店功能开发中...")
|
||||||
|
|
||||||
func _on_friends_pressed():
|
func _on_friends_pressed():
|
||||||
print("好友功能")
|
|
||||||
show_game_message("👥 好友功能开发中...")
|
show_game_message("👥 好友功能开发中...")
|
||||||
|
|
||||||
func show_game_message(message: String):
|
func show_game_message(_message: String):
|
||||||
print("游戏消息: ", message)
|
# TODO: 用 Toast 或游戏内提示框替代占位实现
|
||||||
# 这里可以添加UI提示框显示消息
|
pass
|
||||||
|
|
||||||
|
# 设置游戏环境(登录后直接加载地图)
|
||||||
|
func _setup_game_environment():
|
||||||
|
# 防止登录切场景时输入状态残留导致角色“卡移动键”
|
||||||
|
_release_movement_actions()
|
||||||
|
|
||||||
|
# 1. 隐藏UI
|
||||||
|
current_state = GameState.MAIN_GAME
|
||||||
|
auth_scene.visible = false
|
||||||
|
main_game_ui.visible = false
|
||||||
|
|
||||||
|
# 2. 隐藏聊天框(需要按Enter才显示)
|
||||||
|
if is_instance_valid(chat_ui) and chat_ui.has_method("hide_chat"):
|
||||||
|
chat_ui.hide_chat()
|
||||||
|
|
||||||
|
# 3. 使用 SceneManager 切换到广场地图
|
||||||
|
SceneManager.change_scene("square", false) # false = 不使用过渡效果
|
||||||
|
|
||||||
|
func _release_movement_actions() -> void:
|
||||||
|
Input.action_release("move_left")
|
||||||
|
Input.action_release("move_right")
|
||||||
|
Input.action_release("move_up")
|
||||||
|
Input.action_release("move_down")
|
||||||
|
Input.flush_buffered_events()
|
||||||
|
|
||||||
# 处理全局输入
|
# 处理全局输入
|
||||||
func _input(event):
|
func _input(event):
|
||||||
@@ -155,5 +156,4 @@ func _input(event):
|
|||||||
get_tree().quit()
|
get_tree().quit()
|
||||||
GameState.MAIN_GAME:
|
GameState.MAIN_GAME:
|
||||||
# 在游戏中按ESC可能显示菜单或返回登录
|
# 在游戏中按ESC可能显示菜单或返回登录
|
||||||
# show_auth_scene()
|
show_auth_scene()
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
[gd_scene load_steps=3 format=3 uid="uid://cjabtnqbdd2ey"]
|
[gd_scene load_steps=4 format=3 uid="uid://cjabtnqbdd2ey"]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scenes/MainScene.gd" id="1_script"]
|
[ext_resource type="Script" uid="uid://ghehm4srs0ho" path="res://scenes/MainScene.gd" id="1_script"]
|
||||||
[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_main"]
|
[ext_resource type="PackedScene" uid="uid://by7m8snb4xllf" path="res://scenes/ui/AuthScene.tscn" id="2_main"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://bv7k2nan4xj8q" path="res://scenes/ui/ChatUI.tscn" id="3_chat_ui"]
|
||||||
|
|
||||||
[node name="Main" type="Control"]
|
[node name="Main" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
script = ExtResource("1_script")
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
[node name="AuthScene" parent="." instance=ExtResource("2_main")]
|
[node name="AuthScene" parent="." instance=ExtResource("2_main")]
|
||||||
@@ -19,12 +22,15 @@ layout_mode = 1
|
|||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="TopBar" type="Panel" parent="MainGameUI"]
|
[node name="TopBar" type="Panel" parent="MainGameUI"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 10
|
anchors_preset = 10
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
offset_bottom = 60.0
|
offset_bottom = 60.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
|
||||||
[node name="HBoxContainer" type="HBoxContainer" parent="MainGameUI/TopBar"]
|
[node name="HBoxContainer" type="HBoxContainer" parent="MainGameUI/TopBar"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -35,6 +41,8 @@ offset_left = 20.0
|
|||||||
offset_top = 10.0
|
offset_top = 10.0
|
||||||
offset_right = -20.0
|
offset_right = -20.0
|
||||||
offset_bottom = -10.0
|
offset_bottom = -10.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="UserLabel" type="Label" parent="MainGameUI/TopBar/HBoxContainer"]
|
[node name="UserLabel" type="Label" parent="MainGameUI/TopBar/HBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -52,19 +60,23 @@ anchors_preset = 15
|
|||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
offset_top = 60.0
|
offset_top = 60.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="CenterContainer" type="CenterContainer" parent="MainGameUI/MainContent"]
|
[node name="CenterContainer" type="CenterContainer" parent="MainGameUI/MainContent"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="MainGameUI/MainContent/CenterContainer"]
|
[node name="VBoxContainer" type="VBoxContainer" parent="MainGameUI/MainContent/CenterContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
|
||||||
[node name="StatusPanel" type="Panel" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer"]
|
[node name="StatusPanel" type="Panel" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer"]
|
||||||
layout_mode = 2
|
|
||||||
custom_minimum_size = Vector2(400, 150)
|
custom_minimum_size = Vector2(400, 150)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
[node name="StatusContainer" type="MarginContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel"]
|
[node name="StatusContainer" type="MarginContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -75,6 +87,8 @@ offset_left = 20.0
|
|||||||
offset_top = 20.0
|
offset_top = 20.0
|
||||||
offset_right = -20.0
|
offset_right = -20.0
|
||||||
offset_bottom = -20.0
|
offset_bottom = -20.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="StatusGrid" type="GridContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel/StatusContainer"]
|
[node name="StatusGrid" type="GridContainer" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/StatusPanel/StatusContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
@@ -101,21 +115,24 @@ layout_mode = 2
|
|||||||
columns = 2
|
columns = 2
|
||||||
|
|
||||||
[node name="ExploreButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
[node name="ExploreButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
||||||
layout_mode = 2
|
|
||||||
custom_minimum_size = Vector2(150, 50)
|
custom_minimum_size = Vector2(150, 50)
|
||||||
|
layout_mode = 2
|
||||||
text = "🗺️ 探索小镇"
|
text = "🗺️ 探索小镇"
|
||||||
|
|
||||||
[node name="InventoryButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
[node name="InventoryButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
||||||
layout_mode = 2
|
|
||||||
custom_minimum_size = Vector2(150, 50)
|
custom_minimum_size = Vector2(150, 50)
|
||||||
|
layout_mode = 2
|
||||||
text = "🎒 背包"
|
text = "🎒 背包"
|
||||||
|
|
||||||
[node name="ShopButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
[node name="ShopButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
||||||
layout_mode = 2
|
|
||||||
custom_minimum_size = Vector2(150, 50)
|
custom_minimum_size = Vector2(150, 50)
|
||||||
|
layout_mode = 2
|
||||||
text = "🏪 商店"
|
text = "🏪 商店"
|
||||||
|
|
||||||
[node name="FriendsButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
[node name="FriendsButton" type="Button" parent="MainGameUI/MainContent/CenterContainer/VBoxContainer/GameMenuGrid"]
|
||||||
layout_mode = 2
|
|
||||||
custom_minimum_size = Vector2(150, 50)
|
custom_minimum_size = Vector2(150, 50)
|
||||||
|
layout_mode = 2
|
||||||
text = "👥 好友"
|
text = "👥 好友"
|
||||||
|
|
||||||
|
[node name="ChatUI" parent="MainGameUI" instance=ExtResource("3_chat_ui")]
|
||||||
|
layout_mode = 1
|
||||||
|
|||||||
287
scenes/Maps/BaseLevel.gd
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
class_name BaseLevel
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
# 基础关卡脚本
|
||||||
|
# 负责处理通用的关卡逻辑,如玩家生成
|
||||||
|
const CHAT_UI_SCENE: PackedScene = preload("res://scenes/ui/ChatUI.tscn")
|
||||||
|
const CHAT_UI_NODE_NAME: String = "ChatUI"
|
||||||
|
const UI_LAYER_NODE_NAME: String = "UILayer"
|
||||||
|
const UI_LAYER_ORDER: int = 10
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
_ensure_chat_ui()
|
||||||
|
|
||||||
|
# 延时一帧确保所有子节点就绪
|
||||||
|
call_deferred("_spawn_player")
|
||||||
|
|
||||||
|
# 连接到多人会话
|
||||||
|
# 获取当前场景名字作为 Session ID
|
||||||
|
_current_session_id = SceneManager.get_current_scene_name()
|
||||||
|
if _current_session_id == "":
|
||||||
|
_current_session_id = "square"
|
||||||
|
|
||||||
|
# 如果是私人场景,生成唯一的 Session ID (e.g., room_123)
|
||||||
|
if _current_session_id in PRIVATE_SCENES:
|
||||||
|
_current_session_id = _current_session_id + "_" + str(AuthManager.current_user_id)
|
||||||
|
|
||||||
|
# 如果 WebSocket 已连接,直接加入
|
||||||
|
if LocationManager._socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||||
|
|
||||||
|
_join_session_with_player(_current_session_id)
|
||||||
|
else:
|
||||||
|
# 否则等待连接成功信号
|
||||||
|
LocationManager.connected_to_server.connect(func(): _join_session_with_player(_current_session_id))
|
||||||
|
|
||||||
|
# 连接远程玩家相关信号
|
||||||
|
LocationManager.session_joined.connect(_on_session_joined)
|
||||||
|
LocationManager.user_joined.connect(_on_user_joined)
|
||||||
|
LocationManager.user_left.connect(_on_user_left)
|
||||||
|
LocationManager.position_updated.connect(_on_position_updated)
|
||||||
|
|
||||||
|
func _ensure_chat_ui() -> void:
|
||||||
|
var ui_layer := get_node_or_null(UI_LAYER_NODE_NAME) as CanvasLayer
|
||||||
|
if ui_layer == null:
|
||||||
|
ui_layer = CanvasLayer.new()
|
||||||
|
ui_layer.name = UI_LAYER_NODE_NAME
|
||||||
|
ui_layer.layer = UI_LAYER_ORDER
|
||||||
|
add_child(ui_layer)
|
||||||
|
|
||||||
|
var chat_ui := ui_layer.get_node_or_null(CHAT_UI_NODE_NAME)
|
||||||
|
if chat_ui == null:
|
||||||
|
chat_ui = CHAT_UI_SCENE.instantiate()
|
||||||
|
chat_ui.name = CHAT_UI_NODE_NAME
|
||||||
|
ui_layer.add_child(chat_ui)
|
||||||
|
|
||||||
|
if chat_ui.has_method("hide_chat"):
|
||||||
|
chat_ui.call_deferred("hide_chat")
|
||||||
|
|
||||||
|
var remote_players: Dictionary = {} # userId -> RemotePlayer instance
|
||||||
|
var remote_player_scene = preload("res://scenes/characters/remote_player.tscn")
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
func _on_session_joined(data: Dictionary):
|
||||||
|
# 对账远程玩家列表,使用 position.mapId 过滤
|
||||||
|
if not data.has("users"):
|
||||||
|
return
|
||||||
|
|
||||||
|
var current_map = _current_session_id
|
||||||
|
if current_map == "":
|
||||||
|
current_map = SceneManager.get_current_scene_name()
|
||||||
|
if current_map == "":
|
||||||
|
current_map = "square"
|
||||||
|
|
||||||
|
# 1. 收集服务器返回的、且 mapId 匹配当前场景的用户ID
|
||||||
|
var valid_user_ids: Array = []
|
||||||
|
var valid_users: Dictionary = {} # userId -> user data
|
||||||
|
|
||||||
|
for user in data.users:
|
||||||
|
if not user.has("userId") or str(user.userId) == str(AuthManager.current_user_id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查 position.mapId 是否匹配当前场景
|
||||||
|
var user_map_id = ""
|
||||||
|
if user.has("position") and user.position != null:
|
||||||
|
if typeof(user.position) == TYPE_DICTIONARY and user.position.has("mapId"):
|
||||||
|
user_map_id = str(user.position.mapId)
|
||||||
|
|
||||||
|
if user_map_id == current_map:
|
||||||
|
var uid = str(user.userId)
|
||||||
|
valid_user_ids.append(uid)
|
||||||
|
valid_users[uid] = user
|
||||||
|
|
||||||
|
# 2. 清理幽灵:移除本地有但不在有效列表中的玩家
|
||||||
|
var local_user_ids = remote_players.keys()
|
||||||
|
for user_id in local_user_ids:
|
||||||
|
if str(user_id) not in valid_user_ids:
|
||||||
|
_remove_remote_player(user_id)
|
||||||
|
|
||||||
|
# 3. 添加或更新有效的玩家
|
||||||
|
for uid in valid_user_ids:
|
||||||
|
var user = valid_users[uid]
|
||||||
|
if remote_players.has(uid):
|
||||||
|
# 已存在,更新位置
|
||||||
|
_update_remote_player_position(user)
|
||||||
|
else:
|
||||||
|
_add_remote_player(user)
|
||||||
|
|
||||||
|
func _on_user_joined(data: Dictionary):
|
||||||
|
var user = data.get("user", {})
|
||||||
|
if user.has("userId"):
|
||||||
|
if user.userId == AuthManager.current_user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 将 position 数据合并到 user 字典中,以便 _add_remote_player 统一处理
|
||||||
|
if data.has("position"):
|
||||||
|
user["position"] = data.position
|
||||||
|
|
||||||
|
_add_remote_player(user)
|
||||||
|
|
||||||
|
func _on_user_left(data: Dictionary):
|
||||||
|
var user_id = data.get("userId")
|
||||||
|
if user_id:
|
||||||
|
_remove_remote_player(user_id)
|
||||||
|
|
||||||
|
func _on_position_updated(data: Dictionary):
|
||||||
|
var user_id = data.get("userId")
|
||||||
|
|
||||||
|
if user_id and remote_players.has(user_id):
|
||||||
|
var player = remote_players[user_id]
|
||||||
|
# 数据可能直接是位置(扁平)或者包含在 position 字段中
|
||||||
|
# 根据后端协议: { userId:..., position: {x,y...}, ... }
|
||||||
|
var pos_data = data.get("position", {})
|
||||||
|
if pos_data.is_empty():
|
||||||
|
pos_data = data
|
||||||
|
|
||||||
|
# 检查 mapId 是否匹配当前场景
|
||||||
|
if pos_data.has("mapId") and str(pos_data.mapId) != "":
|
||||||
|
var current_map = _current_session_id
|
||||||
|
if current_map == "":
|
||||||
|
current_map = "square"
|
||||||
|
|
||||||
|
if str(pos_data.mapId) != current_map:
|
||||||
|
_remove_remote_player(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if player.has_method("update_position") and pos_data.has("x") and pos_data.has("y"):
|
||||||
|
player.update_position(Vector2(pos_data.x, pos_data.y))
|
||||||
|
|
||||||
|
func _add_remote_player(user_data: Dictionary):
|
||||||
|
var user_id = str(user_data.get("userId", ""))
|
||||||
|
if user_id == "":
|
||||||
|
return
|
||||||
|
|
||||||
|
# 防止重复创建
|
||||||
|
if remote_players.has(user_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
var remote_player = remote_player_scene.instantiate()
|
||||||
|
|
||||||
|
# 使用统一的 setup 方法
|
||||||
|
if remote_player.has_method("setup"):
|
||||||
|
remote_player.setup(user_data)
|
||||||
|
else:
|
||||||
|
# 回退到手动设置位置
|
||||||
|
remote_player.position = Vector2.ZERO
|
||||||
|
if user_data.has("position"):
|
||||||
|
var p = user_data.position
|
||||||
|
if p.has("x") and p.has("y"):
|
||||||
|
remote_player.position = Vector2(p.x, p.y)
|
||||||
|
|
||||||
|
# 添加到场景玩家容器
|
||||||
|
if has_node("Objects/Players"):
|
||||||
|
$Objects/Players.add_child(remote_player)
|
||||||
|
else:
|
||||||
|
add_child(remote_player)
|
||||||
|
|
||||||
|
remote_players[user_id] = remote_player
|
||||||
|
|
||||||
|
func _remove_remote_player(user_id):
|
||||||
|
var uid = str(user_id)
|
||||||
|
if remote_players.has(uid):
|
||||||
|
var player = remote_players[uid]
|
||||||
|
if is_instance_valid(player):
|
||||||
|
player.queue_free()
|
||||||
|
remote_players.erase(uid)
|
||||||
|
|
||||||
|
func _update_remote_player_position(user: Dictionary):
|
||||||
|
var user_id = str(user.get("userId", ""))
|
||||||
|
var player = remote_players.get(user_id)
|
||||||
|
if not player or not is_instance_valid(player):
|
||||||
|
return
|
||||||
|
if user.has("position"):
|
||||||
|
var pos = user.position
|
||||||
|
if pos.has("x") and pos.has("y") and player.has_method("update_position"):
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 等待玩家生成完毕
|
||||||
|
if not _player_spawned or not _local_player:
|
||||||
|
await get_tree().process_frame
|
||||||
|
_join_session_with_player(session_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
var pos = _local_player.global_position if is_instance_valid(_local_player) else Vector2.ZERO
|
||||||
|
|
||||||
|
LocationManager.join_session(session_id, pos)
|
||||||
|
|
||||||
|
# 进入会话后立即同步一次位置
|
||||||
|
await get_tree().create_timer(0.1).timeout
|
||||||
|
LocationManager.send_position_update(session_id, pos)
|
||||||
|
|
||||||
|
func _process(delta):
|
||||||
|
# 发送位置更新 (节流机制)
|
||||||
|
if not _player_spawned or not _local_player:
|
||||||
|
return # Wait for player to be spawned
|
||||||
|
|
||||||
|
if LocationManager._socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||||
|
return # WebSocket not connected
|
||||||
|
|
||||||
|
if not is_instance_valid(_local_player):
|
||||||
|
return # Player was freed
|
||||||
|
|
||||||
|
# 检查 velocity 属性
|
||||||
|
if not "velocity" in _local_player:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 只有在移动时才更新计时器和发送
|
||||||
|
if _local_player.velocity.length() > 0:
|
||||||
|
_position_update_timer += delta
|
||||||
|
if _position_update_timer >= POSITION_UPDATE_INTERVAL:
|
||||||
|
_position_update_timer = 0.0
|
||||||
|
var map_id = _current_session_id if _current_session_id != "" else "square"
|
||||||
|
LocationManager.send_position_update(map_id, _local_player.global_position)
|
||||||
|
|
||||||
|
func _spawn_player():
|
||||||
|
# 1. 确定出生位置
|
||||||
|
var spawn_pos = Vector2.ZERO
|
||||||
|
var spawn_name = SceneManager.get_next_spawn_name()
|
||||||
|
|
||||||
|
# 查找逻辑:优先查找名为 spawn_name 的节点,其次找 DefaultSpawn
|
||||||
|
var target_node_name = spawn_name if spawn_name != "" else "DefaultSpawn"
|
||||||
|
var marker_node = null
|
||||||
|
|
||||||
|
# 策略 A: 在 SpawnPoints 容器中查找
|
||||||
|
if has_node("SpawnPoints"):
|
||||||
|
marker_node = $SpawnPoints.get_node_or_null(target_node_name)
|
||||||
|
|
||||||
|
# 策略 B: 如果没找到,在当前节点(根节点)下查找
|
||||||
|
if marker_node == null:
|
||||||
|
marker_node = get_node_or_null(target_node_name)
|
||||||
|
|
||||||
|
# 如果找到了标记点,使用其位置
|
||||||
|
if marker_node:
|
||||||
|
spawn_pos = marker_node.global_position
|
||||||
|
else:
|
||||||
|
# 策略 C: 检查 SceneManager 是否有备用坐标
|
||||||
|
var pos_param = SceneManager.get_next_scene_position()
|
||||||
|
if pos_param != null:
|
||||||
|
spawn_pos = pos_param
|
||||||
|
else:
|
||||||
|
push_warning("BaseLevel: 出生点 '%s' 不存在,使用默认坐标 (0, 0)" % target_node_name)
|
||||||
|
|
||||||
|
# 2. 实例化玩家
|
||||||
|
var player_scene = preload("res://scenes/characters/player.tscn")
|
||||||
|
var player = player_scene.instantiate()
|
||||||
|
player.global_position = spawn_pos
|
||||||
|
|
||||||
|
# 添加到场景
|
||||||
|
# 如果有Objects/Players容器则添加进去,否则直接添加到当前节点
|
||||||
|
if has_node("Objects/Players"):
|
||||||
|
$Objects/Players.add_child(player)
|
||||||
|
else:
|
||||||
|
add_child(player)
|
||||||
|
|
||||||
|
_local_player = player
|
||||||
|
_player_spawned = true
|
||||||
1
scenes/Maps/BaseLevel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b43tvo8cykfrq
|
||||||
29
scenes/Maps/DoorTeleport.gd
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
extends Area2D
|
||||||
|
|
||||||
|
# 场景名称
|
||||||
|
@export var target_scene_name: String = ""
|
||||||
|
@export var target_position: Vector2 = Vector2.ZERO # 目标场景的生成位置 (Vector2.ZERO 表示不设置)
|
||||||
|
@export var target_spawn_name: String = "" # 目标场景的 Marker2D 名称 (例如 "FromSquare")
|
||||||
|
|
||||||
|
# Called when the node enters the scene tree for the first time.
|
||||||
|
func _ready() -> void:
|
||||||
|
# 连接 body_entered 信号
|
||||||
|
body_entered.connect(_on_body_entered)
|
||||||
|
|
||||||
|
func _on_body_entered(body: Node2D) -> void:
|
||||||
|
# 检查进入对象是否为玩家
|
||||||
|
if body.has_method("_handle_movement"):
|
||||||
|
_teleport_player()
|
||||||
|
|
||||||
|
func _teleport_player() -> void:
|
||||||
|
if target_scene_name == "":
|
||||||
|
push_error("DoorTeleport: target_scene_name 为空")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 设置参数
|
||||||
|
if target_spawn_name != "":
|
||||||
|
SceneManager.set_next_spawn_name(target_spawn_name)
|
||||||
|
elif target_position != Vector2.ZERO:
|
||||||
|
SceneManager.set_next_scene_position(target_position)
|
||||||
|
|
||||||
|
SceneManager.change_scene(target_scene_name)
|
||||||
1
scenes/Maps/DoorTeleport.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://rlkavptfhr4y
|
||||||
14
scenes/Maps/community.tscn
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://dscbaqkb1klwl"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cle66our01dq1" path="res://assets/sprites/environment/community_512_512.png" id="1_jrtph"]
|
||||||
|
|
||||||
|
[node name="Community" type="StaticBody2D"]
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
position = Vector2(4.7683716e-07, -336)
|
||||||
|
scale = Vector2(1.28125, 1.28125)
|
||||||
|
texture = ExtResource("1_jrtph")
|
||||||
|
|
||||||
|
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."]
|
||||||
|
position = Vector2(152, 16)
|
||||||
|
polygon = PackedVector2Array(-456, -48, -192, -48, -192, -112, -120, -112, -120, -56, 168, -48, 168, -696, -472, -688, -472, -48)
|
||||||
13
scenes/Maps/datawhale_home.tscn
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://bvfyllcy5fi8o"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://bxmbnywn7pd35" path="res://assets/sprites/environment/house_384_256.png" id="1_xrxds"]
|
||||||
|
|
||||||
|
[node name="DataWhaleHome" type="StaticBody2D"]
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
position = Vector2(16, -160)
|
||||||
|
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)
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
extends Area2D
|
|
||||||
|
|
||||||
# 场景名称
|
|
||||||
@export var target_scene_name: String = "room"
|
|
||||||
@export var target_position: Vector2 = Vector2.ZERO # 目标场景的生成位置 (Vector2.ZERO 表示使用默认位置/不设置)
|
|
||||||
|
|
||||||
# Called when the node enters the scene tree for the first time.
|
|
||||||
func _ready() -> void:
|
|
||||||
# 连接 body_entered 信号
|
|
||||||
body_entered.connect(_on_body_entered)
|
|
||||||
|
|
||||||
func _on_body_entered(body: Node2D) -> void:
|
|
||||||
# 检查进入的物体是否为玩家
|
|
||||||
# 我们可以通过多种方式检查:
|
|
||||||
# 1. 检查是否在 "player" 组中 (推荐,但需要设置)
|
|
||||||
# 2. 检查是否有特定方法 (如 _handle_movement)
|
|
||||||
# 3. 检查类名 (如果 scripts/characters/player.gd 有 class_name)
|
|
||||||
|
|
||||||
# 这里使用方法检查作为一种鲁棒的方式,或者检查是否为 CharacterBody2D 且父节点层级符合预期
|
|
||||||
# 最简单直接的方式是检查是否有 _handle_movement 方法,这是 player.gd 特有的
|
|
||||||
if body.has_method("_handle_movement"):
|
|
||||||
print("玩家进入传送门,正在切换到场景: ", target_scene_name)
|
|
||||||
_teleport_player()
|
|
||||||
|
|
||||||
func _teleport_player() -> void:
|
|
||||||
# 如果设置了目标位置,则传递给 SceneManager
|
|
||||||
if target_position != Vector2.ZERO:
|
|
||||||
SceneManager.set_next_scene_position(target_position)
|
|
||||||
|
|
||||||
# 使用 SceneManager 切换场景
|
|
||||||
# 确保 SceneManager 已经注册了相关场景路径
|
|
||||||
SceneManager.change_scene(target_scene_name)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://3wghcufucve5
|
|
||||||
14
scenes/Maps/fountain.tscn
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://vq5qgk3k6t7e"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://dujutnr03apoj" path="res://assets/sprites/environment/fountain_256_192.png" id="1_utxq6"]
|
||||||
|
|
||||||
|
[node name="Fountain" type="StaticBody2D"]
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
position = Vector2(0, -128)
|
||||||
|
scale = Vector2(1.3125001, 1.3061225)
|
||||||
|
texture = ExtResource("1_utxq6")
|
||||||
|
|
||||||
|
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."]
|
||||||
|
position = Vector2(-8, 16)
|
||||||
|
polygon = PackedVector2Array(-64, -80, -104, -112, -112, -152, -96, -192, -88, -216, -64, -216, -24, -224, -16, -256, 32, -248, 24, -216, 32, -216, 40, -240, 56, -224, 72, -232, 72, -208, 96, -216, 104, -208, 120, -160, 128, -128, 96, -96, 80, -112, 0, -88, -48, -104, -64, -88)
|
||||||
29
scenes/characters/NPCController.gd
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
extends CharacterBody2D
|
||||||
|
|
||||||
|
signal interaction_happened(text)
|
||||||
|
|
||||||
|
@export var npc_name: String = "NPC"
|
||||||
|
@export 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)
|
||||||
|
collision_mask = 3
|
||||||
|
|
||||||
|
func interact():
|
||||||
|
show_bubble(dialogue)
|
||||||
|
interaction_happened.emit(dialogue)
|
||||||
|
return null
|
||||||
|
|
||||||
|
func show_bubble(text):
|
||||||
|
var bubble = preload("res://scenes/ui/ChatBubble.tscn").instantiate()
|
||||||
|
add_child(bubble)
|
||||||
|
bubble.set_text(text)
|
||||||
1
scenes/characters/NPCController.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dy3uf1rlu4h1u
|
||||||
@@ -9,10 +9,13 @@ const MOVE_SPEED = 200.0
|
|||||||
# 节点引用
|
# 节点引用
|
||||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||||
@onready var sprite: Sprite2D = $Sprite2D
|
@onready var sprite: Sprite2D = $Sprite2D
|
||||||
|
@onready var ray_cast: RayCast2D = $RayCast2D
|
||||||
|
|
||||||
var last_direction := "down"
|
var last_direction := "down"
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_reset_movement_input_state()
|
||||||
|
|
||||||
# 检查是否有初始位置设置
|
# 检查是否有初始位置设置
|
||||||
call_deferred("_check_spawn_position")
|
call_deferred("_check_spawn_position")
|
||||||
|
|
||||||
@@ -20,6 +23,22 @@ func _ready() -> void:
|
|||||||
if animation_player.has_animation("idle"):
|
if animation_player.has_animation("idle"):
|
||||||
animation_player.play("idle")
|
animation_player.play("idle")
|
||||||
|
|
||||||
|
# Initialize RayCast
|
||||||
|
ray_cast.add_exception(self) # Ignore local player
|
||||||
|
ray_cast.enabled = true
|
||||||
|
ray_cast.target_position = Vector2(0, 60)
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
if what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
||||||
|
_reset_movement_input_state()
|
||||||
|
|
||||||
|
func _reset_movement_input_state() -> void:
|
||||||
|
Input.action_release("move_left")
|
||||||
|
Input.action_release("move_right")
|
||||||
|
Input.action_release("move_up")
|
||||||
|
Input.action_release("move_down")
|
||||||
|
Input.flush_buffered_events()
|
||||||
|
|
||||||
func _check_spawn_position() -> void:
|
func _check_spawn_position() -> void:
|
||||||
var spawn_pos = SceneManager.get_next_scene_position()
|
var spawn_pos = SceneManager.get_next_scene_position()
|
||||||
if spawn_pos != null:
|
if spawn_pos != null:
|
||||||
@@ -27,8 +46,23 @@ func _check_spawn_position() -> void:
|
|||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_handle_movement(delta)
|
_handle_movement(delta)
|
||||||
|
_handle_interaction()
|
||||||
|
|
||||||
|
func _handle_interaction() -> void:
|
||||||
|
if Input.is_action_just_pressed("interact"):
|
||||||
|
if ray_cast.is_colliding():
|
||||||
|
var collider = ray_cast.get_collider()
|
||||||
|
if collider and collider.has_method("interact"):
|
||||||
|
collider.interact()
|
||||||
|
|
||||||
func _handle_movement(_delta: float) -> void:
|
func _handle_movement(_delta: float) -> void:
|
||||||
|
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
|
||||||
|
if _is_text_input_focused():
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
_play_idle_animation()
|
||||||
|
move_and_slide()
|
||||||
|
return
|
||||||
|
|
||||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||||
var direction := Input.get_vector(
|
var direction := Input.get_vector(
|
||||||
"move_left", "move_right",
|
"move_left", "move_right",
|
||||||
@@ -59,16 +93,24 @@ func _update_animation_state(direction: Vector2) -> void:
|
|||||||
if abs(direction.x) > abs(direction.y):
|
if abs(direction.x) > abs(direction.y):
|
||||||
if direction.x > 0:
|
if direction.x > 0:
|
||||||
last_direction = "right"
|
last_direction = "right"
|
||||||
|
ray_cast.target_position = Vector2(60, 0)
|
||||||
else:
|
else:
|
||||||
last_direction = "left"
|
last_direction = "left"
|
||||||
|
ray_cast.target_position = Vector2(-60, 0)
|
||||||
else:
|
else:
|
||||||
if direction.y > 0:
|
if direction.y > 0:
|
||||||
last_direction = "down"
|
last_direction = "down"
|
||||||
|
ray_cast.target_position = Vector2(0, 60)
|
||||||
else:
|
else:
|
||||||
last_direction = "up"
|
last_direction = "up"
|
||||||
|
ray_cast.target_position = Vector2(0, -60)
|
||||||
|
|
||||||
animation_player.play("walk_" + last_direction)
|
animation_player.play("walk_" + last_direction)
|
||||||
|
|
||||||
func _play_idle_animation() -> void:
|
func _play_idle_animation() -> void:
|
||||||
if animation_player:
|
if animation_player:
|
||||||
animation_player.play("idle_" + last_direction)
|
animation_player.play("idle_" + last_direction)
|
||||||
|
|
||||||
|
func _is_text_input_focused() -> bool:
|
||||||
|
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||||
|
return focus_owner is LineEdit or focus_owner is TextEdit
|
||||||
1
scenes/characters/PlayerController.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://fdswi18nel8n
|
||||||
82
scenes/characters/RemotePlayer.gd
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
extends CharacterBody2D
|
||||||
|
|
||||||
|
# 远程玩家脚本
|
||||||
|
# 负责处理位置同步和动画播放
|
||||||
|
# 严格遵循 Visual Only 原则:无输入处理,无物理碰撞
|
||||||
|
|
||||||
|
# 公共属性 (snake_case)
|
||||||
|
var user_id: String = ""
|
||||||
|
var target_position: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
# 内部状态
|
||||||
|
var last_direction: String = "down"
|
||||||
|
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||||
|
@onready var sprite: Sprite2D = $Sprite2D
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# 初始化时确保无物理处理
|
||||||
|
set_physics_process(false)
|
||||||
|
# 初始位置设为当前位置
|
||||||
|
target_position = global_position
|
||||||
|
|
||||||
|
# 确保禁用物理碰撞 (双重保险)
|
||||||
|
if has_node("CollisionShape2D"):
|
||||||
|
$CollisionShape2D.disabled = true
|
||||||
|
|
||||||
|
func _process(delta: float):
|
||||||
|
# 1. 平滑移动插值
|
||||||
|
var current_pos = global_position
|
||||||
|
var dist = current_pos.distance_to(target_position)
|
||||||
|
|
||||||
|
if dist > 1.0:
|
||||||
|
# 简单的线性插值,速度系数 10.0 可根据需要调整
|
||||||
|
var new_pos = current_pos.lerp(target_position, 10.0 * delta)
|
||||||
|
|
||||||
|
# 计算移动向量用于动画朝向
|
||||||
|
var move_vec = new_pos - current_pos
|
||||||
|
_update_animation(move_vec)
|
||||||
|
|
||||||
|
global_position = new_pos
|
||||||
|
else:
|
||||||
|
# 距离很近时直接吸附并播放待机动画
|
||||||
|
global_position = target_position
|
||||||
|
_play_idle_animation()
|
||||||
|
|
||||||
|
# 统一初始化方法
|
||||||
|
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
|
||||||
|
func setup(data: Dictionary):
|
||||||
|
if data.has("userId"):
|
||||||
|
user_id = data.userId
|
||||||
|
|
||||||
|
if data.has("position"):
|
||||||
|
var pos_data = data.position
|
||||||
|
if pos_data.has("x") and pos_data.has("y"):
|
||||||
|
var new_pos = Vector2(pos_data.x, pos_data.y)
|
||||||
|
global_position = new_pos
|
||||||
|
target_position = new_pos
|
||||||
|
|
||||||
|
# 更新目标位置
|
||||||
|
func update_position(new_pos: Vector2):
|
||||||
|
target_position = new_pos
|
||||||
|
|
||||||
|
func _update_animation(move_vec: Vector2):
|
||||||
|
if not animation_player:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 确定主方向
|
||||||
|
if abs(move_vec.x) > abs(move_vec.y):
|
||||||
|
if move_vec.x > 0:
|
||||||
|
last_direction = "right"
|
||||||
|
else:
|
||||||
|
last_direction = "left"
|
||||||
|
else:
|
||||||
|
if move_vec.y > 0:
|
||||||
|
last_direction = "down"
|
||||||
|
else:
|
||||||
|
last_direction = "up"
|
||||||
|
|
||||||
|
animation_player.play("walk_" + last_direction)
|
||||||
|
|
||||||
|
func _play_idle_animation():
|
||||||
|
if animation_player:
|
||||||
|
animation_player.play("idle_" + last_direction)
|
||||||
1
scenes/characters/RemotePlayer.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dtbajfsljdht5
|
||||||
64
scenes/characters/npc.tscn
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
[gd_scene load_steps=7 format=3 uid="uid://npc2282a2new"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://brko2ik6t6ib5" path="res://assets/characters/npc_286_241.png" id="1_2r34a"]
|
||||||
|
[ext_resource type="Script" uid="uid://dy3uf1rlu4h1u" path="res://scenes/characters/NPCController.gd" id="1_script"]
|
||||||
|
|
||||||
|
[sub_resource type="RectangleShape2D" id="RectangleShape2D_npc"]
|
||||||
|
size = Vector2(48, 24)
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_2r34a"]
|
||||||
|
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="Animation_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="AnimationLibrary_npc"]
|
||||||
|
_data = {
|
||||||
|
&"RESET": SubResource("Animation_2r34a"),
|
||||||
|
&"idle": SubResource("Animation_idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
[node name="NPC" type="CharacterBody2D"]
|
||||||
|
position = Vector2(-8, 0)
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
texture = ExtResource("1_2r34a")
|
||||||
|
hframes = 4
|
||||||
|
vframes = 4
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
light_mask = 5
|
||||||
|
visibility_layer = 5
|
||||||
|
shape = SubResource("RectangleShape2D_npc")
|
||||||
|
|
||||||
|
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||||
|
libraries = {
|
||||||
|
&"": SubResource("AnimationLibrary_npc")
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[gd_scene load_steps=13 format=3 uid="uid://b2f8e24plwqgj"]
|
[gd_scene load_steps=13 format=3 uid="uid://b2f8e24plwqgj"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://btka26hrcvgen" path="res://Scenes/characters/player.gd" id="1_script"]
|
[ext_resource type="Script" uid="uid://fdswi18nel8n" path="res://scenes/characters/PlayerController.gd" id="1_script"]
|
||||||
[ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_texture"]
|
[ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_texture"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
|
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
|
||||||
@@ -175,3 +175,5 @@ libraries = {
|
|||||||
|
|
||||||
[node name="Camera2D" type="Camera2D" parent="."]
|
[node name="Camera2D" type="Camera2D" parent="."]
|
||||||
zoom = Vector2(2, 2)
|
zoom = Vector2(2, 2)
|
||||||
|
|
||||||
|
[node name="RayCast2D" type="RayCast2D" parent="."]
|
||||||
|
|||||||
175
scenes/characters/remote_player.tscn
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
[gd_scene load_steps=13 format=3 uid="uid://chb8mcqhfnkkr"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://dtbajfsljdht5" path="res://scenes/characters/RemotePlayer.gd" id="1_mu86i"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cghab1hkx5lg5" path="res://assets/characters/player_spritesheet.png" id="2_7oc7u"]
|
||||||
|
|
||||||
|
[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_1"]
|
||||||
|
radius = 21.0
|
||||||
|
height = 48.0
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_idle_down"]
|
||||||
|
resource_name = "idle_down"
|
||||||
|
length = 0.1
|
||||||
|
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),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [0]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_idle_left"]
|
||||||
|
resource_name = "idle_left"
|
||||||
|
length = 0.1
|
||||||
|
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),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [12]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_idle_right"]
|
||||||
|
resource_name = "idle_right"
|
||||||
|
length = 0.1
|
||||||
|
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),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [8]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_idle_up"]
|
||||||
|
resource_name = "idle_up"
|
||||||
|
length = 0.1
|
||||||
|
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),
|
||||||
|
"transitions": PackedFloat32Array(1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [4]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_walk_down"]
|
||||||
|
resource_name = "walk_down"
|
||||||
|
length = 0.8
|
||||||
|
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, 0.2, 0.4, 0.6),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [0, 1, 2, 3]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_walk_left"]
|
||||||
|
resource_name = "walk_left"
|
||||||
|
length = 0.8
|
||||||
|
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, 0.2, 0.4, 0.6),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [12, 13, 14, 15]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_walk_right"]
|
||||||
|
resource_name = "walk_right"
|
||||||
|
length = 0.8
|
||||||
|
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, 0.2, 0.4, 0.6),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [8, 9, 10, 11]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Animation" id="Animation_walk_up"]
|
||||||
|
resource_name = "walk_up"
|
||||||
|
length = 0.8
|
||||||
|
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, 0.2, 0.4, 0.6),
|
||||||
|
"transitions": PackedFloat32Array(1, 1, 1, 1),
|
||||||
|
"update": 1,
|
||||||
|
"values": [4, 5, 6, 7]
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="AnimationLibrary" id="AnimationLibrary_1"]
|
||||||
|
_data = {
|
||||||
|
&"idle_down": SubResource("Animation_idle_down"),
|
||||||
|
&"idle_left": SubResource("Animation_idle_left"),
|
||||||
|
&"idle_right": SubResource("Animation_idle_right"),
|
||||||
|
&"idle_up": SubResource("Animation_idle_up"),
|
||||||
|
&"walk_down": SubResource("Animation_walk_down"),
|
||||||
|
&"walk_left": SubResource("Animation_walk_left"),
|
||||||
|
&"walk_right": SubResource("Animation_walk_right"),
|
||||||
|
&"walk_up": SubResource("Animation_walk_up")
|
||||||
|
}
|
||||||
|
|
||||||
|
[node name="RemotePlayer" type="CharacterBody2D"]
|
||||||
|
script = ExtResource("1_mu86i")
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
position = Vector2(1.5000005, -24.5)
|
||||||
|
texture = ExtResource("2_7oc7u")
|
||||||
|
hframes = 4
|
||||||
|
vframes = 4
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
position = Vector2(2, -24)
|
||||||
|
shape = SubResource("CapsuleShape2D_1")
|
||||||
|
disabled = true
|
||||||
|
|
||||||
|
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
|
||||||
|
libraries = {
|
||||||
|
&"": SubResource("AnimationLibrary_1")
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ func _validate_texture():
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 调试方法
|
# 信息方法
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# 获取瓦片信息
|
# 获取瓦片信息
|
||||||
@@ -113,10 +113,3 @@ func get_tile_info() -> Dictionary:
|
|||||||
"texture_size": texture.get_size() if texture else Vector2.ZERO,
|
"texture_size": texture.get_size() if texture else Vector2.ZERO,
|
||||||
"auto_snap": auto_snap
|
"auto_snap": auto_snap
|
||||||
}
|
}
|
||||||
|
|
||||||
# 打印瓦片信息
|
|
||||||
func print_info():
|
|
||||||
var info = get_tile_info()
|
|
||||||
print("=== 草地瓦片信息 ===")
|
|
||||||
for key in info:
|
|
||||||
print(key, ": ", info[key])
|
|
||||||
11
scenes/prefabs/items/NoticeBoard.gd
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
extends StaticBody2D
|
||||||
|
|
||||||
|
func interact():
|
||||||
|
# Check if dialog already exists
|
||||||
|
if get_tree().root.has_node("NoticeDialog"):
|
||||||
|
return
|
||||||
|
|
||||||
|
var dialog = preload("res://scenes/ui/notice_dialog.tscn").instantiate()
|
||||||
|
dialog.name = "NoticeDialog"
|
||||||
|
get_tree().root.add_child(dialog)
|
||||||
|
return null # No bubble text needed
|
||||||
1
scenes/prefabs/items/NoticeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://pnlgf420wktn
|
||||||
13
scenes/prefabs/items/WelcomeBoard.gd
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
extends StaticBody2D
|
||||||
|
|
||||||
|
func interact():
|
||||||
|
# Prevent multiple dialogs
|
||||||
|
if get_tree().root.has_node("WelcomeDialog"):
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Spawn the Welcome Dialog
|
||||||
|
var dialog = preload("res://scenes/ui/welcome_dialog.tscn").instantiate()
|
||||||
|
dialog.name = "WelcomeDialog"
|
||||||
|
# Add to the Scene Root (World) or CanvasLayer if it has one
|
||||||
|
get_tree().root.add_child(dialog)
|
||||||
|
return null # Return null prevents Player from showing a bubble
|
||||||
1
scenes/prefabs/items/WelcomeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d2od22agputjt
|
||||||
20
scenes/prefabs/items/notice_board.tscn
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[gd_scene load_steps=4 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, 53.333332)
|
||||||
|
|
||||||
|
[node name="NoticeBoard" type="StaticBody2D"]
|
||||||
|
scale = Vector2(0.6, 0.6)
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
position = Vector2(0, -16)
|
||||||
|
scale = Vector2(0.5, 0.5)
|
||||||
|
texture = ExtResource("2_sprite")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
position = Vector2(0, 13.333335)
|
||||||
|
shape = SubResource("RectangleShape2D_nb")
|
||||||
19
scenes/prefabs/items/welcome_board.tscn
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[gd_scene load_steps=4 format=3 uid="uid://c7k8yay002w4"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://d2od22agputjt" path="res://scenes/prefabs/items/WelcomeBoard.gd" id="1_script"]
|
||||||
|
[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, 26.5)
|
||||||
|
|
||||||
|
[node name="WelcomeBoard" type="StaticBody2D"]
|
||||||
|
collision_layer = 3
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||||
|
scale = Vector2(0.25, 0.25)
|
||||||
|
texture = ExtResource("2_sprite")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
position = Vector2(0, 18.75)
|
||||||
|
shape = SubResource("RectangleShape2D_board")
|
||||||
211
scenes/prefabs/ui/ChatMessage.gd
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
extends PanelContainer
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ChatMessage.gd - 聊天消息气泡组件
|
||||||
|
# ============================================================================
|
||||||
|
# 显示单条聊天消息的 UI 组件
|
||||||
|
#
|
||||||
|
# 核心职责:
|
||||||
|
# - 显示消息发送者、内容、时间戳
|
||||||
|
# - 区分自己和他人的消息样式
|
||||||
|
# - 自动格式化时间戳
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# var message := chat_message_scene.instantiate()
|
||||||
|
# message.set_message("PlayerName", "Hello!", timestamp, false)
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 使用 @onready 缓存节点引用
|
||||||
|
# - 最大宽度限制为 400 像素
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class_name ChatMessage
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 导出参数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 最大宽度(像素)
|
||||||
|
@export var max_width: int = 400
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 节点引用
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 用户名标签
|
||||||
|
var username_label: Label
|
||||||
|
|
||||||
|
# 时间戳标签
|
||||||
|
var timestamp_label: Label
|
||||||
|
|
||||||
|
# 内容标签
|
||||||
|
var content_label: RichTextLabel
|
||||||
|
|
||||||
|
# 用户信息容器
|
||||||
|
var user_info_container: HBoxContainer
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 生命周期方法
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_cache_node_refs()
|
||||||
|
|
||||||
|
func _cache_node_refs() -> void:
|
||||||
|
if not username_label:
|
||||||
|
username_label = get_node_or_null("VBoxContainer/UserInfoContainer/UsernameLabel")
|
||||||
|
if not timestamp_label:
|
||||||
|
timestamp_label = get_node_or_null("VBoxContainer/UserInfoContainer/TimestampLabel")
|
||||||
|
if not content_label:
|
||||||
|
content_label = get_node_or_null("VBoxContainer/ContentLabel")
|
||||||
|
if not user_info_container:
|
||||||
|
user_info_container = get_node_or_null("VBoxContainer/UserInfoContainer")
|
||||||
|
|
||||||
|
# 内容换行与自适应高度
|
||||||
|
if content_label:
|
||||||
|
content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
content_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
content_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
content_label.fit_content = true
|
||||||
|
content_label.scroll_active = false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 成员变量
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 是否为自己发送的消息
|
||||||
|
var _is_self: bool = false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 设置消息内容
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# from_user: String - 发送者用户名
|
||||||
|
# content: String - 消息内容
|
||||||
|
# timestamp: float - Unix 时间戳
|
||||||
|
# is_self: bool - 是否为自己发送的消息(默认 false)
|
||||||
|
#
|
||||||
|
# 使用示例:
|
||||||
|
# message.set_message("Alice", "Hello!", 1703500800.0, false)
|
||||||
|
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
|
||||||
|
_is_self = is_self
|
||||||
|
_cache_node_refs()
|
||||||
|
|
||||||
|
var safe_from_user := from_user
|
||||||
|
if safe_from_user.strip_edges().is_empty():
|
||||||
|
safe_from_user = "我" if is_self else "玩家"
|
||||||
|
|
||||||
|
# 设置用户名(带空值检查)
|
||||||
|
if username_label:
|
||||||
|
username_label.text = safe_from_user
|
||||||
|
else:
|
||||||
|
push_error("ChatMessage: username_label is null!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 设置内容
|
||||||
|
if content_label:
|
||||||
|
content_label.clear() # 清除默认文本和所有内容
|
||||||
|
content_label.append_text(content) # 作为纯文本追加,避免 BBCode 解析导致内容不显示
|
||||||
|
else:
|
||||||
|
push_error("ChatMessage: content_label is null!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 设置时间戳
|
||||||
|
if timestamp_label:
|
||||||
|
timestamp_label.text = _format_timestamp(timestamp)
|
||||||
|
else:
|
||||||
|
push_error("ChatMessage: timestamp_label is null!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 应用样式
|
||||||
|
_apply_style()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 样式处理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 应用样式(自己和他人的消息不同)
|
||||||
|
func _apply_style() -> void:
|
||||||
|
if not username_label or not timestamp_label or not user_info_container:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 重要:设置垂直 size flags 让 Panel 适应内容高度
|
||||||
|
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
|
|
||||||
|
if _is_self:
|
||||||
|
# 自己的消息:右侧对齐,蓝色背景
|
||||||
|
size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||||
|
user_info_container.alignment = BoxContainer.ALIGNMENT_END
|
||||||
|
|
||||||
|
# 设置面板样式
|
||||||
|
add_theme_stylebox_override("panel", _get_self_style())
|
||||||
|
|
||||||
|
# 设置文字颜色 - ID使用金色 #FFD700
|
||||||
|
username_label.add_theme_color_override("font_color", Color(1.0, 0.8431373, 0.0))
|
||||||
|
timestamp_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||||
|
if content_label:
|
||||||
|
content_label.add_theme_color_override("default_color", Color(0.95, 0.97, 1.0))
|
||||||
|
else:
|
||||||
|
# 他人的消息:左侧对齐,灰色背景
|
||||||
|
size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||||
|
|
||||||
|
# 设置面板样式
|
||||||
|
add_theme_stylebox_override("panel", _get_other_style())
|
||||||
|
|
||||||
|
# 设置文字颜色 - ID使用蓝色 #69c0ff
|
||||||
|
username_label.add_theme_color_override("font_color", Color(0.4117647, 0.7529412, 1.0))
|
||||||
|
timestamp_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
|
||||||
|
if content_label:
|
||||||
|
content_label.add_theme_color_override("default_color", Color(0.15, 0.15, 0.15))
|
||||||
|
|
||||||
|
# 获取自己消息的样式
|
||||||
|
func _get_self_style() -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.2, 0.6, 1.0, 0.8)
|
||||||
|
style.corner_radius_top_left = 10
|
||||||
|
style.corner_radius_top_right = 10
|
||||||
|
style.corner_radius_bottom_left = 10
|
||||||
|
style.corner_radius_bottom_right = 2
|
||||||
|
style.content_margin_left = 10
|
||||||
|
style.content_margin_right = 10
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
return style
|
||||||
|
|
||||||
|
# 获取他人消息的样式
|
||||||
|
func _get_other_style() -> StyleBoxFlat:
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.9, 0.9, 0.9, 0.8)
|
||||||
|
style.corner_radius_top_left = 10
|
||||||
|
style.corner_radius_top_right = 10
|
||||||
|
style.corner_radius_bottom_left = 2
|
||||||
|
style.corner_radius_bottom_right = 10
|
||||||
|
style.content_margin_left = 10
|
||||||
|
style.content_margin_right = 10
|
||||||
|
style.content_margin_top = 8
|
||||||
|
style.content_margin_bottom = 8
|
||||||
|
return style
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - 工具函数
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 格式化时间戳
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# timestamp: float - Unix 时间戳
|
||||||
|
#
|
||||||
|
# 返回值:
|
||||||
|
# String - 格式化的时间字符串
|
||||||
|
func _format_timestamp(timestamp: float) -> String:
|
||||||
|
if timestamp == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
var datetime := Time.get_datetime_dict_from_unix_time(timestamp)
|
||||||
|
|
||||||
|
# 格式化为 HH:MM
|
||||||
|
return "%02d:%02d" % [datetime.hour, datetime.minute]
|
||||||
1
scenes/prefabs/ui/ChatMessage.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://djqrgj3h0lif7
|
||||||
36
scenes/prefabs/ui/ChatMessage.tscn
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://dqx8k3n8yqjvu"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/prefabs/ui/ChatMessage.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="ChatMessage" type="PanelContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
offset_right = 400.0
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 6
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="UserInfoContainer" type="HBoxContainer" parent="VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="UsernameLabel" type="Label" parent="VBoxContainer/UserInfoContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.2, 0.4, 0.8, 1)
|
||||||
|
theme_override_font_sizes/font_size = 14
|
||||||
|
text = "Username"
|
||||||
|
|
||||||
|
[node name="TimestampLabel" type="Label" parent="VBoxContainer/UserInfoContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1)
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
text = "12:34"
|
||||||
|
|
||||||
|
[node name="ContentLabel" type="RichTextLabel" parent="VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
bbcode_enabled = true
|
||||||
|
fit_content = true
|
||||||
@@ -36,10 +36,10 @@ signal login_success(username: String)
|
|||||||
@onready var register_panel: Panel = $CenterContainer/RegisterPanel # 注册面板
|
@onready var register_panel: Panel = $CenterContainer/RegisterPanel # 注册面板
|
||||||
@onready var title_label: Label = $CenterContainer/LoginPanel/VBoxContainer/TitleLabel # 标题标签
|
@onready var title_label: Label = $CenterContainer/LoginPanel/VBoxContainer/TitleLabel # 标题标签
|
||||||
@onready var subtitle_label: Label = $CenterContainer/LoginPanel/VBoxContainer/SubtitleLabel # 副标题标签
|
@onready var subtitle_label: Label = $CenterContainer/LoginPanel/VBoxContainer/SubtitleLabel # 副标题标签
|
||||||
@onready var whale_frame: TextureRect = $WhaleFrame # 鲸鱼装饰框
|
@onready var whale_frame: TextureRect = $CenterContainer/WhaleFrame # 鲸鱼装饰框
|
||||||
|
|
||||||
# 登录表单输入控件
|
# 登录表单输入控件
|
||||||
@onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameContainer/UsernameInput
|
@onready var login_username: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameInput
|
||||||
@onready var login_password: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/PasswordContainer/PasswordInput
|
@onready var login_password: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/PasswordContainer/PasswordInput
|
||||||
@onready var login_verification: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/VerificationContainer/VerificationInputContainer/VerificationInput
|
@onready var login_verification: LineEdit = $CenterContainer/LoginPanel/VBoxContainer/LoginForm/VerificationContainer/VerificationInputContainer/VerificationInput
|
||||||
|
|
||||||
@@ -103,8 +103,6 @@ func _ready():
|
|||||||
_connect_signals() # 连接信号
|
_connect_signals() # 连接信号
|
||||||
_setup_ui() # 设置UI初始状态
|
_setup_ui() # 设置UI初始状态
|
||||||
|
|
||||||
print("认证场景视图已加载")
|
|
||||||
|
|
||||||
# 测试网络连接
|
# 测试网络连接
|
||||||
auth_manager.test_network_connection()
|
auth_manager.test_network_connection()
|
||||||
|
|
||||||
@@ -328,7 +326,9 @@ func _on_login_enter(_text: String):
|
|||||||
# ============ 控制器信号处理 ============
|
# ============ 控制器信号处理 ============
|
||||||
|
|
||||||
# 登录成功处理
|
# 登录成功处理
|
||||||
func _on_controller_login_success(username: String):
|
func _on_controller_login_success(username: String) -> void:
|
||||||
|
_release_focus_owner()
|
||||||
|
|
||||||
# 清空表单
|
# 清空表单
|
||||||
login_username.text = ""
|
login_username.text = ""
|
||||||
login_password.text = ""
|
login_password.text = ""
|
||||||
@@ -337,9 +337,22 @@ func _on_controller_login_success(username: String):
|
|||||||
_hide_field_error(login_password_error)
|
_hide_field_error(login_password_error)
|
||||||
_hide_field_error(login_verification_error)
|
_hide_field_error(login_verification_error)
|
||||||
|
|
||||||
|
# 设置 token 给 ChatManager(用于 WebSocket 聊天认证)
|
||||||
|
var token: String = auth_manager.get_access_token()
|
||||||
|
if not token.is_empty():
|
||||||
|
ChatManager.set_game_token(token)
|
||||||
|
|
||||||
|
# 同时设置 LocationManager token(用于位置同步)
|
||||||
|
LocationManager.set_auth_token(token)
|
||||||
|
|
||||||
# 发送登录成功信号给上层
|
# 发送登录成功信号给上层
|
||||||
login_success.emit(username)
|
login_success.emit(username)
|
||||||
|
|
||||||
|
func _release_focus_owner() -> void:
|
||||||
|
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||||
|
if is_instance_valid(focus_owner):
|
||||||
|
focus_owner.release_focus()
|
||||||
|
|
||||||
# 登录失败处理
|
# 登录失败处理
|
||||||
func _on_controller_login_failed(_message: String):
|
func _on_controller_login_failed(_message: String):
|
||||||
# 登录失败时不需要额外处理,Toast已经显示了错误信息
|
# 登录失败时不需要额外处理,Toast已经显示了错误信息
|
||||||
@@ -393,9 +406,9 @@ func _on_controller_form_validation_failed(field: String, message: String):
|
|||||||
register_confirm.grab_focus()
|
register_confirm.grab_focus()
|
||||||
|
|
||||||
# 网络状态变化处理
|
# 网络状态变化处理
|
||||||
func _on_controller_network_status_changed(network_connected: bool, message: String):
|
func _on_controller_network_status_changed(_network_connected: bool, _message: String):
|
||||||
# 可以在这里添加网络状态指示器
|
# 可以在这里添加网络状态指示器
|
||||||
print("网络状态: ", "连接" if network_connected else "断开", " - ", message)
|
pass
|
||||||
|
|
||||||
# 按钮状态变化处理
|
# 按钮状态变化处理
|
||||||
func _on_controller_button_state_changed(button_name: String, is_loading: bool, text: String):
|
func _on_controller_button_state_changed(button_name: String, is_loading: bool, text: String):
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
[gd_scene load_steps=10 format=3 uid="uid://by7m8snb4xllf"]
|
[gd_scene load_steps=13 format=3 uid="uid://by7m8snb4xllf"]
|
||||||
|
|
||||||
[ext_resource type="Texture2D" uid="uid://bx17oy8lvaca4" path="res://assets/ui/auth/bg_auth_scene.png" id="1_background"]
|
[ext_resource type="Texture2D" uid="uid://bx17oy8lvaca4" path="res://assets/ui/auth/bg_auth_scene.png" id="1_background"]
|
||||||
[ext_resource type="Texture2D" uid="uid://de4q4s1gxivtf" path="res://assets/ui/auth/login_frame_smart_transparent.png" id="2_frame"]
|
[ext_resource type="Script" uid="uid://b514h2wuido0h" path="res://scenes/ui/AuthScene.gd" id="3_script"]
|
||||||
[ext_resource type="Script" path="res://scenes/ui/AuthScene.gd" id="3_script"]
|
[ext_resource type="Texture2D" uid="uid://dyma4hpodhdxi" path="res://assets/ui/auth/登录背景.png" id="3_wh4n4"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cnrffaqbtw8f5" path="res://assets/ui/auth/输入框.png" id="4_lnw07"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1"]
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_26vyf"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_cjyup"]
|
||||||
|
texture = ExtResource("4_lnw07")
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hover"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hover"]
|
||||||
bg_color = Color(0.3, 0.6, 0.9, 1)
|
bg_color = Color(0.3, 0.6, 0.9, 1)
|
||||||
@@ -59,6 +63,8 @@ Button/styles/hover = SubResource("StyleBoxFlat_hover")
|
|||||||
Button/styles/normal = SubResource("StyleBoxFlat_normal")
|
Button/styles/normal = SubResource("StyleBoxFlat_normal")
|
||||||
Button/styles/pressed = SubResource("StyleBoxFlat_pressed")
|
Button/styles/pressed = SubResource("StyleBoxFlat_pressed")
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1"]
|
||||||
|
|
||||||
[node name="AuthScene" type="Control"]
|
[node name="AuthScene" type="Control"]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
@@ -81,22 +87,14 @@ texture = ExtResource("1_background")
|
|||||||
expand_mode = 1
|
expand_mode = 1
|
||||||
stretch_mode = 6
|
stretch_mode = 6
|
||||||
|
|
||||||
[node name="WhaleFrame" type="TextureRect" parent="."]
|
[node name="ColorRect" type="ColorRect" parent="."]
|
||||||
|
modulate = Color(0.84313726, 0.92941177, 0.98039216, 0.47058824)
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 8
|
anchors_preset = 15
|
||||||
anchor_left = 0.5
|
anchor_right = 1.0
|
||||||
anchor_top = 0.5
|
anchor_bottom = 1.0
|
||||||
anchor_right = 0.5
|
|
||||||
anchor_bottom = 0.5
|
|
||||||
offset_left = -300.0
|
|
||||||
offset_top = -300.0
|
|
||||||
offset_right = 300.0
|
|
||||||
offset_bottom = 300.0
|
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
texture = ExtResource("2_frame")
|
|
||||||
expand_mode = 1
|
|
||||||
stretch_mode = 5
|
|
||||||
|
|
||||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -112,35 +110,45 @@ offset_bottom = 236.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="LoginPanel" type="Panel" parent="CenterContainer"]
|
[node name="WhaleFrame" type="TextureRect" parent="CenterContainer"]
|
||||||
custom_minimum_size = Vector2(350, 400)
|
custom_minimum_size = Vector2(500, 0)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_styles/panel = SubResource("StyleBoxEmpty_1")
|
texture = ExtResource("3_wh4n4")
|
||||||
|
expand_mode = 4
|
||||||
|
stretch_mode = 5
|
||||||
|
|
||||||
|
[node name="LoginPanel" type="Panel" parent="CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(300, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxEmpty_26vyf")
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel"]
|
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 8
|
||||||
anchor_right = 1.0
|
anchor_left = 0.5
|
||||||
anchor_bottom = 1.0
|
anchor_top = 0.5
|
||||||
offset_left = 30.0
|
anchor_right = 0.5
|
||||||
offset_top = 30.0
|
anchor_bottom = 0.5
|
||||||
offset_right = -30.0
|
offset_left = -140.0
|
||||||
offset_bottom = -30.0
|
offset_top = -185.5
|
||||||
|
offset_right = 140.0
|
||||||
|
offset_bottom = 185.5
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
|
[node name="TitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
||||||
theme_override_font_sizes/font_size = 24
|
theme_override_colors/font_shadow_color = Color(0.011764706, 0.12156863, 0.101960786, 0)
|
||||||
|
theme_override_font_sizes/font_size = 32
|
||||||
text = "Whaletown"
|
text = "Whaletown"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="SubtitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
|
[node name="SubtitleLabel" type="Label" parent="CenterContainer/LoginPanel/VBoxContainer"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
theme_override_colors/font_color = Color(0.53333336, 0.53333336, 0.53333336, 1)
|
||||||
theme_override_font_sizes/font_size = 14
|
theme_override_font_sizes/font_size = 16
|
||||||
text = "开始你的小镇之旅!"
|
text = "开始你的小镇之旅!"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
vertical_alignment = 1
|
vertical_alignment = 1
|
||||||
@@ -180,10 +188,13 @@ theme_override_font_sizes/font_size = 12
|
|||||||
text = "用户名不能为空"
|
text = "用户名不能为空"
|
||||||
horizontal_alignment = 2
|
horizontal_alignment = 2
|
||||||
|
|
||||||
[node name="UsernameInput" type="LineEdit" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm/UsernameContainer"]
|
[node name="UsernameInput" type="LineEdit" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"]
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
||||||
theme_override_colors/font_placeholder_color = Color(0.5, 0.5, 0.5, 1)
|
theme_override_colors/font_placeholder_color = Color(0.5, 0.5, 0.5, 1)
|
||||||
|
theme_override_colors/selection_color = Color(0.5, 0.5, 0.5, 1)
|
||||||
|
theme_override_styles/normal = SubResource("StyleBoxTexture_cjyup")
|
||||||
placeholder_text = "用户名/手机/邮箱"
|
placeholder_text = "用户名/手机/邮箱"
|
||||||
|
|
||||||
[node name="PasswordContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"]
|
[node name="PasswordContainer" type="VBoxContainer" parent="CenterContainer/LoginPanel/VBoxContainer/LoginForm"]
|
||||||
@@ -552,6 +563,8 @@ theme = SubResource("Theme_button")
|
|||||||
text = "返回登录"
|
text = "返回登录"
|
||||||
|
|
||||||
[node name="ToastContainer" type="Control" parent="."]
|
[node name="ToastContainer" type="Control" parent="."]
|
||||||
|
modulate = Color(0.84313726, 0.92941177, 0.98039216, 1)
|
||||||
|
self_modulate = Color(0.84313726, 0.92941177, 0.9843137, 1)
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
|
|||||||
9
scenes/ui/ChatBubble.gd
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
@onready var label = $PanelContainer/Label
|
||||||
|
|
||||||
|
func set_text(text):
|
||||||
|
label.text = text
|
||||||
|
# Destroy after 5 seconds
|
||||||
|
await get_tree().create_timer(5.0).timeout
|
||||||
|
queue_free()
|
||||||
1
scenes/ui/ChatBubble.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b4aorojcbwkmb
|
||||||
42
scenes/ui/ChatBubble.tscn
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://b4aorojcbwkmb" path="res://scenes/ui/ChatBubble.gd" id="1_script"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"]
|
||||||
|
bg_color = Color(1, 1, 1, 0.9)
|
||||||
|
corner_radius_top_left = 5
|
||||||
|
corner_radius_top_right = 5
|
||||||
|
corner_radius_bottom_right = 5
|
||||||
|
corner_radius_bottom_left = 5
|
||||||
|
shadow_color = Color(0, 0, 0, 0.2)
|
||||||
|
shadow_size = 2
|
||||||
|
|
||||||
|
[node name="ChatBubble" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="PanelContainer" type="PanelContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 7
|
||||||
|
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
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 0
|
||||||
|
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
|
||||||
|
text = "..."
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 1
|
||||||
|
autowrap_mode = 3
|
||||||
|
custom_minimum_size = Vector2(150, 0)
|
||||||
351
scenes/ui/ChatUI.gd
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ChatUI.gd - 聊天界面控制器(Enter 显示/隐藏版本)
|
||||||
|
# ============================================================================
|
||||||
|
# 聊天系统的用户界面控制器
|
||||||
|
#
|
||||||
|
# 核心职责:
|
||||||
|
# - 显示聊天消息历史
|
||||||
|
# - 处理用户输入
|
||||||
|
# - 显示连接状态
|
||||||
|
# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏
|
||||||
|
# - 只有按 Enter 才会取消倒计时
|
||||||
|
# - Call Down: 通过 EventSystem 订阅聊天事件
|
||||||
|
#
|
||||||
|
# 使用方式:
|
||||||
|
# var chat_ui := preload("res://scenes/ui/ChatUI.tscn").instantiate()
|
||||||
|
# add_child(chat_ui)
|
||||||
|
#
|
||||||
|
# 注意事项:
|
||||||
|
# - 遵循 "Signal Up, Call Down" 架构
|
||||||
|
# - 使用 @onready 缓存节点引用
|
||||||
|
# - 所有 UI 操作通过 ChatManager
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 节点引用
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 聊天面板
|
||||||
|
@onready var chat_panel: Control = %ChatPanel
|
||||||
|
|
||||||
|
# 聊天历史容器
|
||||||
|
@onready var chat_history: ScrollContainer = %ChatHistory
|
||||||
|
|
||||||
|
# 消息列表
|
||||||
|
@onready var message_list: VBoxContainer = %MessageList
|
||||||
|
|
||||||
|
# 聊天输入框
|
||||||
|
@onready var chat_input: LineEdit = %ChatInput
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 预加载资源
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 聊天消息场景
|
||||||
|
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 成员变量
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 是否显示聊天框
|
||||||
|
var _is_chat_visible: bool = false
|
||||||
|
|
||||||
|
# 隐藏计时器
|
||||||
|
var _hide_timer: Timer = null
|
||||||
|
|
||||||
|
# 是否在输入中(输入时不隐藏)
|
||||||
|
var _is_typing: bool = false
|
||||||
|
|
||||||
|
# 当前用户名
|
||||||
|
var _current_username: String = ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 生命周期方法
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 准备就绪
|
||||||
|
func _ready() -> void:
|
||||||
|
# 初始隐藏聊天框
|
||||||
|
hide_chat()
|
||||||
|
|
||||||
|
# 创建隐藏计时器
|
||||||
|
_create_hide_timer()
|
||||||
|
|
||||||
|
# 订阅事件(Call Down via EventSystem)
|
||||||
|
_subscribe_to_events()
|
||||||
|
|
||||||
|
# 连接 UI 信号
|
||||||
|
_connect_ui_signals()
|
||||||
|
|
||||||
|
# 清理
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
# 取消事件订阅
|
||||||
|
if EventSystem:
|
||||||
|
EventSystem.disconnect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||||
|
EventSystem.disconnect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||||
|
EventSystem.disconnect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
|
||||||
|
EventSystem.disconnect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
|
||||||
|
|
||||||
|
# 清理计时器
|
||||||
|
if _hide_timer:
|
||||||
|
_hide_timer.queue_free()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 输入处理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 处理全局输入
|
||||||
|
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):
|
||||||
|
_handle_enter_pressed()
|
||||||
|
|
||||||
|
# 处理 Enter 键按下
|
||||||
|
func _handle_enter_pressed() -> void:
|
||||||
|
# 如果聊天框未显示,显示它
|
||||||
|
if not _is_chat_visible:
|
||||||
|
show_chat()
|
||||||
|
# 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号
|
||||||
|
call_deferred("_grab_input_focus")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 如果聊天框已显示且输入框有焦点,检查输入框内容
|
||||||
|
if chat_input.has_focus():
|
||||||
|
# 如果输入框有内容,发送消息
|
||||||
|
if not chat_input.text.is_empty():
|
||||||
|
_on_send_button_pressed()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
|
||||||
|
chat_input.grab_focus()
|
||||||
|
|
||||||
|
# 延迟获取输入框焦点(避免事件冲突)
|
||||||
|
func _grab_input_focus() -> void:
|
||||||
|
if chat_input:
|
||||||
|
chat_input.grab_focus()
|
||||||
|
|
||||||
|
# 处理 GUI 输入(鼠标点击)
|
||||||
|
func _gui_input(event: InputEvent) -> void:
|
||||||
|
# 检查鼠标点击
|
||||||
|
if event is InputEventMouseButton and event.pressed:
|
||||||
|
if event.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
_handle_click_outside()
|
||||||
|
|
||||||
|
# 处理点击聊天框外部区域
|
||||||
|
func _handle_click_outside() -> void:
|
||||||
|
# 检查点击是否在聊天面板外部
|
||||||
|
if not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
|
||||||
|
# 延迟释放输入框焦点,避免事件冲突
|
||||||
|
if chat_input.has_focus():
|
||||||
|
call_deferred("_release_input_focus")
|
||||||
|
|
||||||
|
# 延迟释放输入框焦点(由 call_deferred 调用)
|
||||||
|
func _release_input_focus() -> void:
|
||||||
|
if chat_input and chat_input.has_focus():
|
||||||
|
chat_input.release_focus()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 显示/隐藏逻辑
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 显示聊天框
|
||||||
|
func show_chat() -> void:
|
||||||
|
_is_chat_visible = true
|
||||||
|
if is_instance_valid(chat_panel):
|
||||||
|
chat_panel.show()
|
||||||
|
|
||||||
|
# 停止隐藏计时器
|
||||||
|
_stop_hide_timer()
|
||||||
|
|
||||||
|
# 隐藏聊天框
|
||||||
|
func hide_chat() -> 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()
|
||||||
|
|
||||||
|
# 停止隐藏计时器
|
||||||
|
_stop_hide_timer()
|
||||||
|
|
||||||
|
# 创建隐藏计时器
|
||||||
|
func _create_hide_timer() -> void:
|
||||||
|
_hide_timer = Timer.new()
|
||||||
|
_hide_timer.wait_time = 5.0 # 5 秒
|
||||||
|
_hide_timer.one_shot = true
|
||||||
|
_hide_timer.timeout.connect(_on_hide_timeout)
|
||||||
|
add_child(_hide_timer)
|
||||||
|
|
||||||
|
# 开始隐藏倒计时
|
||||||
|
func _start_hide_timer() -> void:
|
||||||
|
if _is_typing:
|
||||||
|
return # 输入时不隐藏
|
||||||
|
if not is_instance_valid(_hide_timer):
|
||||||
|
return
|
||||||
|
if not _hide_timer.is_inside_tree():
|
||||||
|
return
|
||||||
|
|
||||||
|
_stop_hide_timer() # 先停止之前的计时器
|
||||||
|
_hide_timer.start()
|
||||||
|
|
||||||
|
# 停止隐藏倒计时
|
||||||
|
func _stop_hide_timer() -> void:
|
||||||
|
if is_instance_valid(_hide_timer) and _hide_timer.is_inside_tree():
|
||||||
|
_hide_timer.stop()
|
||||||
|
|
||||||
|
# 隐藏计时器超时
|
||||||
|
func _on_hide_timeout() -> void:
|
||||||
|
hide_chat()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# UI 事件处理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 连接 UI 信号
|
||||||
|
func _connect_ui_signals() -> void:
|
||||||
|
# 输入框回车
|
||||||
|
chat_input.text_submitted.connect(_on_chat_input_submitted)
|
||||||
|
|
||||||
|
# 输入框焦点变化
|
||||||
|
chat_input.focus_entered.connect(_on_input_focus_entered)
|
||||||
|
chat_input.focus_exited.connect(_on_input_focus_exited)
|
||||||
|
|
||||||
|
# 输入框获得焦点
|
||||||
|
func _on_input_focus_entered() -> void:
|
||||||
|
_is_typing = true
|
||||||
|
_stop_hide_timer() # 停止隐藏计时器
|
||||||
|
|
||||||
|
# 输入框失去焦点
|
||||||
|
func _on_input_focus_exited() -> void:
|
||||||
|
_is_typing = false
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
|
# 开始 5 秒倒计时
|
||||||
|
if not _is_chat_visible:
|
||||||
|
return
|
||||||
|
_start_hide_timer()
|
||||||
|
|
||||||
|
# 发送按钮点击处理
|
||||||
|
func _on_send_button_pressed() -> void:
|
||||||
|
var content: String = chat_input.text.strip_edges()
|
||||||
|
|
||||||
|
if content.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 发送消息
|
||||||
|
ChatManager.send_chat_message(content, "local")
|
||||||
|
|
||||||
|
# 清空输入框
|
||||||
|
chat_input.clear()
|
||||||
|
|
||||||
|
# 重新聚焦输入框
|
||||||
|
chat_input.grab_focus()
|
||||||
|
|
||||||
|
# 聊天输入提交(回车键)处理
|
||||||
|
func _on_chat_input_submitted(text: String) -> void:
|
||||||
|
_on_send_button_pressed()
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 事件订阅(Call Down)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 订阅事件
|
||||||
|
func _subscribe_to_events() -> void:
|
||||||
|
# 订阅聊天消息接收事件
|
||||||
|
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||||
|
|
||||||
|
# 订阅聊天错误事件
|
||||||
|
EventSystem.connect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||||
|
|
||||||
|
# 订阅连接状态变化事件
|
||||||
|
EventSystem.connect_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, _on_connection_state_changed, self)
|
||||||
|
|
||||||
|
# 订阅登录成功事件
|
||||||
|
EventSystem.connect_event(EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 事件处理器
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 处理接收到的聊天消息
|
||||||
|
func _on_chat_message_received(data: Dictionary) -> void:
|
||||||
|
var from_user: String = data.get("from_user", "")
|
||||||
|
var content: String = data.get("content", "")
|
||||||
|
var timestamp: float = data.get("timestamp", 0.0)
|
||||||
|
|
||||||
|
var is_self: bool = bool(data.get("is_self", false))
|
||||||
|
if not data.has("is_self") and not _current_username.is_empty() and from_user == _current_username:
|
||||||
|
is_self = true
|
||||||
|
|
||||||
|
# 添加到消息历史
|
||||||
|
add_message_to_history(from_user, content, timestamp, is_self)
|
||||||
|
|
||||||
|
# 处理聊天错误
|
||||||
|
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])
|
||||||
|
|
||||||
|
# 处理连接状态变化
|
||||||
|
func _on_connection_state_changed(data: Dictionary) -> void:
|
||||||
|
# 连接状态变化处理(当前不更新UI)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 处理登录成功
|
||||||
|
func _on_login_success(data: Dictionary) -> void:
|
||||||
|
_current_username = data.get("username", "")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 公共 API - 消息管理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 添加消息到历史
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# from_user: String - 发送者用户名
|
||||||
|
# content: String - 消息内容
|
||||||
|
# timestamp: float - 时间戳
|
||||||
|
# is_self: bool - 是否为自己发送的消息(默认 false)
|
||||||
|
func add_message_to_history(from_user: String, content: String, timestamp: float, is_self: bool) -> void:
|
||||||
|
# 如果聊天框隐藏,自动显示
|
||||||
|
if not _is_chat_visible:
|
||||||
|
show_chat()
|
||||||
|
|
||||||
|
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
||||||
|
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
|
||||||
|
|
||||||
|
# 创建消息节点
|
||||||
|
var message_node: ChatMessage = chat_message_scene.instantiate()
|
||||||
|
|
||||||
|
# 先加入场景树,再设置内容(避免 ChatMessage._ready 尚未执行导致节点引用为空)
|
||||||
|
message_list.add_child(row)
|
||||||
|
row.add_child(message_node)
|
||||||
|
|
||||||
|
# 设置消息内容
|
||||||
|
message_node.set_message(from_user, content, timestamp, is_self)
|
||||||
|
|
||||||
|
# 自动滚动到底部
|
||||||
|
call_deferred("_scroll_to_bottom")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 内部方法 - UI 更新
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 滚动到底部
|
||||||
|
func _scroll_to_bottom() -> void:
|
||||||
|
# 等待一帧,确保 UI 更新完成
|
||||||
|
await get_tree().process_frame
|
||||||
|
|
||||||
|
# 滚动到底部
|
||||||
|
if is_instance_valid(chat_history):
|
||||||
|
chat_history.scroll_vertical = chat_history.get_v_scroll_bar().max_value
|
||||||
1
scenes/ui/ChatUI.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://pibdlvhb12q8
|
||||||
120
scenes/ui/ChatUI.tscn
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
[gd_scene load_steps=8 format=3 uid="uid://bv7k2nan4xj8q"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://pibdlvhb12q8" path="res://scenes/ui/ChatUI.gd" id="1"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cchjgp6qh7u61" path="res://assets/ui/chat/缩略框背景.png" id="2_7dhmv"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://clmgyxpeh5742" path="res://assets/ui/chat/输入框背景.png" id="3_fbft8"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://q0ijn5y0tbw3" path="res://assets/ui/chat/装饰2.png" id="4_xo31h"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://ct0cl4h2i6ydn" path="res://assets/ui/chat/装饰.png" id="5_xlxdo"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xo31h"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_1ahvy"]
|
||||||
|
|
||||||
|
[node name="ChatUI" type="Control"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
custom_minimum_size = Vector2(10, 20)
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="ChatPanel" type="Control" parent="."]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 1
|
||||||
|
anchor_left = 0.007
|
||||||
|
anchor_top = 0.98700005
|
||||||
|
anchor_right = 0.007
|
||||||
|
anchor_bottom = 0.98700005
|
||||||
|
offset_left = 0.36800003
|
||||||
|
offset_top = -240.01605
|
||||||
|
offset_right = 340.368
|
||||||
|
offset_bottom = -0.016052246
|
||||||
|
grow_vertical = 0
|
||||||
|
|
||||||
|
[node name="TextureRect" type="TextureRect" parent="ChatPanel"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = -1
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_top = -240.0
|
||||||
|
offset_right = 340.0
|
||||||
|
texture = ExtResource("2_7dhmv")
|
||||||
|
expand_mode = 1
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="ChatPanel"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 15.0
|
||||||
|
offset_top = 15.0
|
||||||
|
offset_right = -15.0
|
||||||
|
offset_bottom = -55.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="ChatHistory" type="ScrollContainer" parent="ChatPanel/VBoxContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
horizontal_scroll_mode = 0
|
||||||
|
|
||||||
|
[node name="MessageList" type="VBoxContainer" parent="ChatPanel/VBoxContainer/ChatHistory"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 1
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="TextureRect2" type="TextureRect" parent="ChatPanel"]
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 10.0
|
||||||
|
offset_top = 10.0
|
||||||
|
offset_right = 20.0
|
||||||
|
offset_bottom = 20.0
|
||||||
|
texture = ExtResource("4_xo31h")
|
||||||
|
expand_mode = 1
|
||||||
|
|
||||||
|
[node name="TextureRect3" type="TextureRect" parent="ChatPanel"]
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 320.0
|
||||||
|
offset_top = 180.0
|
||||||
|
offset_right = 330.0
|
||||||
|
offset_bottom = 190.0
|
||||||
|
texture = ExtResource("5_xlxdo")
|
||||||
|
expand_mode = 1
|
||||||
|
|
||||||
|
[node name="TextureRect4" type="TextureRect" parent="ChatPanel"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = -1
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 15.0
|
||||||
|
offset_top = -40.0
|
||||||
|
offset_right = 325.0
|
||||||
|
offset_bottom = -10.0
|
||||||
|
texture = ExtResource("3_fbft8")
|
||||||
|
expand_mode = 1
|
||||||
|
|
||||||
|
[node name="InputContainer" type="HBoxContainer" parent="ChatPanel"]
|
||||||
|
self_modulate = Color(1, 1, 1, 0.03529412)
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 25.0
|
||||||
|
offset_top = 202.5
|
||||||
|
offset_right = 315.0
|
||||||
|
offset_bottom = 227.5
|
||||||
|
theme_override_constants/separation = 4
|
||||||
|
|
||||||
|
[node name="ChatInput" type="LineEdit" parent="ChatPanel/InputContainer"]
|
||||||
|
unique_name_in_owner = true
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
localize_numeral_system = false
|
||||||
|
theme_override_font_sizes/font_size = 12
|
||||||
|
theme_override_styles/normal = SubResource("StyleBoxEmpty_xo31h")
|
||||||
|
theme_override_styles/read_only = SubResource("StyleBoxEmpty_xo31h")
|
||||||
|
theme_override_styles/focus = SubResource("StyleBoxEmpty_1ahvy")
|
||||||
156
scenes/ui/NoticeDialog.gd
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
@onready var content_label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel/ContentLabel
|
||||||
|
@onready var prev_btn = $CenterContainer/PanelContainer/VBoxContainer/Footer/PrevButton
|
||||||
|
@onready var next_btn = $CenterContainer/PanelContainer/VBoxContainer/Footer/NextButton
|
||||||
|
@onready var dots_container = $CenterContainer/PanelContainer/VBoxContainer/Footer/DotsContainer
|
||||||
|
@onready var content_container = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer
|
||||||
|
|
||||||
|
# Mock Data
|
||||||
|
var pages = [
|
||||||
|
{
|
||||||
|
"text": "欢迎来到 [color=#3399ff]Datawhale Town[/color]!\n\n这里是开源学习者的家园。在这里,我们一同探索知识,分享成长。\n\n[center]🐋[/center]",
|
||||||
|
# 使用社区图片作为封面
|
||||||
|
"image_path": "res://assets/sprites/environment/community_512_512.png",
|
||||||
|
"image_color": Color(0.9, 0.9, 0.9) # 保留作为后备选项
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "最新活动:\n\n- 镇长刚刚搬进来了,就在喷泉左边。\n- 欢迎板已经设立,查看最新动态。\n- 玩家名字现在显示在头顶了!",
|
||||||
|
# 使用喷泉图片对应"喷泉左边"的描述
|
||||||
|
"image_path": "res://assets/sprites/environment/fountain_256_192.png",
|
||||||
|
"image_color": Color(0.8, 0.9, 0.8)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "操作提示:\n\n- 按 [color=#ffaa00]F[/color] 键可以与物体互动。\n- 在下方输入框输入文字并在气泡中显示。\n- 点击右下角按钮发送聊天。",
|
||||||
|
# 使用公告板图片对应"操作提示"
|
||||||
|
"image_path": "res://assets/sprites/environment/board.png",
|
||||||
|
"image_color": Color(0.9, 0.8, 0.8)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
var current_page = 0
|
||||||
|
var tween: Tween
|
||||||
|
var mock_pages = []
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# Pause the game
|
||||||
|
get_tree().paused = true
|
||||||
|
|
||||||
|
$CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton.pressed.connect(_on_close_pressed)
|
||||||
|
prev_btn.pressed.connect(_on_prev_pressed)
|
||||||
|
next_btn.pressed.connect(_on_next_pressed)
|
||||||
|
|
||||||
|
# Network Integration - Use direct callback for better error handling
|
||||||
|
# Short timeout (2.0s) so mock data appears quickly if server is down
|
||||||
|
NetworkManager.get_request("/notices", _on_notices_response, 2.0)
|
||||||
|
|
||||||
|
# Initial Setup (with generic "Loading" state)
|
||||||
|
mock_pages = pages.duplicate(true)
|
||||||
|
pages = [{"text": "[center]Loading notices...[/center]", "image_color": Color(0.9, 0.9, 0.9)}]
|
||||||
|
_setup_dots()
|
||||||
|
_update_ui(false)
|
||||||
|
|
||||||
|
func _on_notices_response(success: bool, data: Dictionary, _error_info: Dictionary):
|
||||||
|
var new_pages = []
|
||||||
|
if success and data.has("data") and data["data"] is Array:
|
||||||
|
new_pages = data["data"]
|
||||||
|
|
||||||
|
if new_pages.is_empty():
|
||||||
|
pages = mock_pages
|
||||||
|
else:
|
||||||
|
pages = new_pages
|
||||||
|
# Handle color strings from JSON if necessary
|
||||||
|
for p in pages:
|
||||||
|
if p.has("image_color") and p["image_color"] is String:
|
||||||
|
p["image_color"] = Color(p["image_color"])
|
||||||
|
|
||||||
|
current_page = 0
|
||||||
|
_setup_dots()
|
||||||
|
_update_ui(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_dots():
|
||||||
|
for child in dots_container.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
for i in range(pages.size()):
|
||||||
|
var dot = ColorRect.new()
|
||||||
|
dot.custom_minimum_size = Vector2(10, 10) # Base size
|
||||||
|
dots_container.add_child(dot)
|
||||||
|
|
||||||
|
func _update_ui(animate: bool = true):
|
||||||
|
if pages.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update Buttons
|
||||||
|
prev_btn.disabled = (current_page == 0)
|
||||||
|
next_btn.disabled = (current_page == pages.size() - 1)
|
||||||
|
|
||||||
|
# Update Dots Logic
|
||||||
|
var dots = dots_container.get_children()
|
||||||
|
for i in range(dots.size()):
|
||||||
|
if i == current_page:
|
||||||
|
dots[i].color = Color(0.2, 0.2, 0.2, 1) # Dark Active
|
||||||
|
dots[i].custom_minimum_size = Vector2(12, 12) # Active Slightly Larger
|
||||||
|
else:
|
||||||
|
dots[i].color = Color(0.8, 0.8, 0.8, 1) # Light Inactive
|
||||||
|
dots[i].custom_minimum_size = Vector2(10, 10)
|
||||||
|
|
||||||
|
# Update Content
|
||||||
|
if animate:
|
||||||
|
_animate_content_change()
|
||||||
|
else:
|
||||||
|
_set_content_immediate()
|
||||||
|
|
||||||
|
@onready var image_rect = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageRect
|
||||||
|
@onready var image_label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageLabel
|
||||||
|
|
||||||
|
func _set_content_immediate():
|
||||||
|
var page = pages[current_page]
|
||||||
|
content_label.text = page.get("text", "")
|
||||||
|
|
||||||
|
if page.has("image_path") and page["image_path"] != "":
|
||||||
|
var path = page["image_path"]
|
||||||
|
if ResourceLoader.exists(path):
|
||||||
|
image_rect.texture = load(path)
|
||||||
|
image_label.visible = false
|
||||||
|
else:
|
||||||
|
image_rect.texture = null
|
||||||
|
image_label.visible = true
|
||||||
|
image_label.text = "Image Not Found"
|
||||||
|
else:
|
||||||
|
image_rect.texture = null
|
||||||
|
image_label.visible = true
|
||||||
|
image_label.text = "No Image"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func _animate_content_change():
|
||||||
|
if tween and tween.is_valid():
|
||||||
|
tween.kill()
|
||||||
|
|
||||||
|
tween = create_tween()
|
||||||
|
|
||||||
|
# Fade Out
|
||||||
|
tween.tween_property(content_container, "modulate:a", 0.0, 0.15)
|
||||||
|
|
||||||
|
# Callback to change text
|
||||||
|
tween.tween_callback(self._set_content_immediate)
|
||||||
|
|
||||||
|
# Fade In
|
||||||
|
tween.tween_property(content_container, "modulate:a", 1.0, 0.15)
|
||||||
|
|
||||||
|
func _on_prev_pressed():
|
||||||
|
if current_page > 0:
|
||||||
|
current_page -= 1
|
||||||
|
_update_ui()
|
||||||
|
|
||||||
|
func _on_next_pressed():
|
||||||
|
if current_page < pages.size() - 1:
|
||||||
|
current_page += 1
|
||||||
|
_update_ui()
|
||||||
|
|
||||||
|
func _on_close_pressed():
|
||||||
|
# Unpause the game
|
||||||
|
get_tree().paused = false
|
||||||
|
queue_free()
|
||||||
1
scenes/ui/NoticeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cxi5rchnmk07p
|
||||||
20
scenes/ui/WelcomeDialog.gd
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
# Connect close button (X)
|
||||||
|
var header_close = find_child("CloseButton", true, false)
|
||||||
|
if header_close:
|
||||||
|
header_close.pressed.connect(_on_close_pressed)
|
||||||
|
|
||||||
|
# Connect Start button
|
||||||
|
var start_btn = find_child("StartButton", true, false)
|
||||||
|
if start_btn:
|
||||||
|
start_btn.pressed.connect(_on_close_pressed)
|
||||||
|
|
||||||
|
func _on_close_pressed():
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
func _input(event):
|
||||||
|
# Allow ESC to close
|
||||||
|
if event.is_action_pressed("ui_cancel"):
|
||||||
|
queue_free()
|
||||||
1
scenes/ui/WelcomeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cu6x4dxhsylw2
|
||||||
132
scenes/ui/notice_dialog.tscn
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
[gd_scene load_steps=3 format=3 uid="uid://rdmro1jxs6ga"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://cxi5rchnmk07p" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
|
||||||
|
bg_color = Color(0.95, 0.95, 0.95, 1)
|
||||||
|
corner_radius_top_left = 16
|
||||||
|
corner_radius_top_right = 16
|
||||||
|
corner_radius_bottom_right = 16
|
||||||
|
corner_radius_bottom_left = 16
|
||||||
|
shadow_color = Color(0, 0, 0, 0.2)
|
||||||
|
shadow_size = 8
|
||||||
|
|
||||||
|
[node name="NoticeDialog" type="CanvasLayer"]
|
||||||
|
process_mode = 3
|
||||||
|
script = ExtResource("1_script")
|
||||||
|
|
||||||
|
[node name="Dimmer" type="ColorRect" parent="."]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
color = Color(0, 0, 0, 0.5)
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(480, 420)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxFlat_rounded")
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="HeaderSpacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 4)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="LeftSpacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.2, 0.2, 0.2, 1)
|
||||||
|
theme_override_font_sizes/font_size = 22
|
||||||
|
text = "公告板"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="RightContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
alignment = 2
|
||||||
|
|
||||||
|
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer"]
|
||||||
|
custom_minimum_size = Vector2(32, 32)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "X"
|
||||||
|
flat = true
|
||||||
|
|
||||||
|
[node name="RightMargin" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer"]
|
||||||
|
custom_minimum_size = Vector2(8, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="ContentContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="ImagePanel" type="PanelContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 200)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="ImageRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
expand_mode = 1
|
||||||
|
stretch_mode = 5
|
||||||
|
|
||||||
|
[node name="ImageLabel" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0.6, 0.6, 0.6, 1)
|
||||||
|
text = "Image Placeholder"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
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
|
||||||
|
|
||||||
|
[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
|
||||||
|
bbcode_enabled = true
|
||||||
|
text = "Announcement Content..."
|
||||||
|
|
||||||
|
[node name="Footer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 48)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 20
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="PrevButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||||
|
custom_minimum_size = Vector2(40, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "<"
|
||||||
|
|
||||||
|
[node name="DotsContainer" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="NextButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Footer"]
|
||||||
|
custom_minimum_size = Vector2(40, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
text = ">"
|
||||||
|
|
||||||
|
[node name="BottomSpacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
custom_minimum_size = Vector2(0, 4)
|
||||||
|
layout_mode = 2
|
||||||
117
scenes/ui/welcome_dialog.tscn
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
[gd_scene load_steps=5 format=3 uid="uid://d8mam0n1a3b5"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://cu6x4dxhsylw2" 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"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
|
||||||
|
bg_color = Color(1, 1, 1, 1)
|
||||||
|
corner_radius_top_left = 10
|
||||||
|
corner_radius_top_right = 10
|
||||||
|
corner_radius_bottom_right = 10
|
||||||
|
corner_radius_bottom_left = 10
|
||||||
|
shadow_color = Color(0, 0, 0, 0.2)
|
||||||
|
shadow_size = 10
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_btn_rounded"]
|
||||||
|
bg_color = Color(0.95, 0.95, 0.95, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
corner_radius_top_left = 20
|
||||||
|
corner_radius_top_right = 20
|
||||||
|
corner_radius_bottom_right = 20
|
||||||
|
corner_radius_bottom_left = 20
|
||||||
|
|
||||||
|
[node name="WelcomeDialog" type="CanvasLayer"]
|
||||||
|
script = ExtResource("1_vs5b1")
|
||||||
|
|
||||||
|
[node name="ColorRect" type="ColorRect" parent="."]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
color = Color(0, 0, 0, 0.4)
|
||||||
|
|
||||||
|
[node name="CenterContainer" type="CenterContainer" parent="."]
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
|
||||||
|
custom_minimum_size = Vector2(400, 350)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxFlat_card")
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Header" type="HBoxContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="Spacer" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
||||||
|
theme_override_font_sizes/font_size = 18
|
||||||
|
text = "欢迎来到 Datawhale Town!"
|
||||||
|
|
||||||
|
[node name="Spacer2" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
|
||||||
|
[node name="CloseButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/Header"]
|
||||||
|
custom_minimum_size = Vector2(30, 30)
|
||||||
|
layout_mode = 2
|
||||||
|
text = "X"
|
||||||
|
flat = true
|
||||||
|
|
||||||
|
[node name="HSeparator" type="HSeparator" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="LogoContainer" type="CenterContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="TextureRect" type="TextureRect" parent="CenterContainer/PanelContainer/VBoxContainer/LogoContainer"]
|
||||||
|
custom_minimum_size = Vector2(300, 100)
|
||||||
|
layout_mode = 2
|
||||||
|
texture = ExtResource("2_dy5hw")
|
||||||
|
expand_mode = 1
|
||||||
|
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
|
||||||
|
text = "连接·共生·见证
|
||||||
|
Datawhale Town —— 学习者的赛博家园与精神坐标。
|
||||||
|
✨ 实时广场:看大家都在学什么。
|
||||||
|
🏠 个人空间:展示你的学习笔记与作品。
|
||||||
|
🤝 开源营地:更有氛围的组队学习体验。"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
autowrap_mode = 3
|
||||||
|
|
||||||
|
[node name="Spacer3" type="Control" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[node name="ActionContainer" type="CenterContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="StartButton" type="Button" parent="CenterContainer/PanelContainer/VBoxContainer/ActionContainer"]
|
||||||
|
custom_minimum_size = Vector2(150, 40)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_colors/font_color = Color(0, 0, 0, 1)
|
||||||
|
theme_override_styles/normal = SubResource("StyleBoxFlat_btn_rounded")
|
||||||
|
text = "开始探索"
|
||||||
|
|
||||||
|
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/PanelContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
357
tests/unit/test_chat_manager.gd.disabled
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
extends GutTest
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# test_chat_manager.gd - ChatManager 单元测试
|
||||||
|
# ============================================================================
|
||||||
|
# 测试聊天系统业务逻辑的功能
|
||||||
|
#
|
||||||
|
# 测试覆盖:
|
||||||
|
# - 初始化测试
|
||||||
|
# - Token 管理
|
||||||
|
# - 频率限制
|
||||||
|
# - 消息历史管理
|
||||||
|
# - 错误处理
|
||||||
|
# - 信号发射
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
var chat_manager: ChatManager
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 测试设置和清理
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func before_each():
|
||||||
|
# 每个测试前创建新实例
|
||||||
|
chat_manager = ChatManager.new()
|
||||||
|
add_child(chat_manager)
|
||||||
|
|
||||||
|
func after_each():
|
||||||
|
# 每个测试后清理
|
||||||
|
if is_instance_valid(chat_manager):
|
||||||
|
chat_manager.queue_free()
|
||||||
|
chat_manager = null
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 初始化测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_chat_manager_initialization():
|
||||||
|
# 测试管理器初始化
|
||||||
|
assert_not_null(chat_manager, "ChatManager 应该成功初始化")
|
||||||
|
assert_not_null(chat_manager._websocket_manager, "WebSocket 管理器应该被创建")
|
||||||
|
assert_not_null(chat_manager._socket_client, "Socket.IO 客户端应该被创建")
|
||||||
|
assert_false(chat_manager.is_chat_connected(), "初始状态应该是未连接")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Token 管理测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_set_game_token():
|
||||||
|
# 测试设置游戏 token
|
||||||
|
chat_manager.set_game_token("test_token_123")
|
||||||
|
|
||||||
|
assert_eq(chat_manager.get_game_token(), "test_token_123",
|
||||||
|
"Token 应该被正确设置")
|
||||||
|
|
||||||
|
func test_get_game_token_initially_empty():
|
||||||
|
# 测试初始 token 为空
|
||||||
|
assert_eq(chat_manager.get_game_token(), "",
|
||||||
|
"初始 token 应该为空字符串")
|
||||||
|
|
||||||
|
func test_set_empty_token():
|
||||||
|
# 测试设置空 token
|
||||||
|
chat_manager.set_game_token("")
|
||||||
|
|
||||||
|
assert_eq(chat_manager.get_game_token(), "",
|
||||||
|
"空 token 应该被接受")
|
||||||
|
|
||||||
|
func test_update_token():
|
||||||
|
# 测试更新 token
|
||||||
|
chat_manager.set_game_token("token1")
|
||||||
|
assert_eq(chat_manager.get_game_token(), "token1", "第一个 token 应该被设置")
|
||||||
|
|
||||||
|
chat_manager.set_game_token("token2")
|
||||||
|
assert_eq(chat_manager.get_game_token(), "token2", "token 应该被更新")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 频率限制测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_can_send_message_initially():
|
||||||
|
# 测试初始状态可以发送消息
|
||||||
|
assert_true(chat_manager.can_send_message(),
|
||||||
|
"初始状态应该可以发送消息")
|
||||||
|
|
||||||
|
func test_can_send_message_after_one_send():
|
||||||
|
# 测试发送一条消息后仍可发送
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
assert_true(chat_manager.can_send_message(),
|
||||||
|
"发送一条消息后应该仍可以发送")
|
||||||
|
|
||||||
|
func test_rate_limit_exceeded():
|
||||||
|
# 测试达到频率限制
|
||||||
|
# 记录 10 条消息(达到限制)
|
||||||
|
for i in range(10):
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
assert_false(chat_manager.can_send_message(),
|
||||||
|
"达到频率限制后不应该可以发送消息")
|
||||||
|
|
||||||
|
func test_rate_limit_window_expires():
|
||||||
|
# 测试频率限制窗口过期
|
||||||
|
# 记录 10 条消息
|
||||||
|
for i in range(10):
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
# 模拟时间流逝(将时间戳设置为很久以前)
|
||||||
|
var old_time := Time.get_unix_time_from_system() - 100.0
|
||||||
|
chat_manager._message_timestamps = []
|
||||||
|
for i in range(10):
|
||||||
|
chat_manager._message_timestamps.append(old_time)
|
||||||
|
|
||||||
|
assert_true(chat_manager.can_send_message(),
|
||||||
|
"旧消息过期后应该可以发送新消息")
|
||||||
|
|
||||||
|
func test_get_time_until_next_message():
|
||||||
|
# 测试获取下次可发送消息的等待时间
|
||||||
|
chat_manager._message_timestamps = []
|
||||||
|
|
||||||
|
var wait_time := chat_manager.get_time_until_next_message()
|
||||||
|
assert_eq(wait_time, 0.0, "没有消息时等待时间应该为 0")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 消息历史管理测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_add_message_to_history():
|
||||||
|
# 测试添加消息到历史
|
||||||
|
var message := {
|
||||||
|
"from_user": "Alice",
|
||||||
|
"content": "Hello!",
|
||||||
|
"timestamp": 1000.0,
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history.size(), 1, "历史应该有 1 条消息")
|
||||||
|
assert_eq(chat_manager._message_history[0].from_user, "Alice",
|
||||||
|
"消息发送者应该是 Alice")
|
||||||
|
|
||||||
|
func test_message_history_limit():
|
||||||
|
# 测试消息历史限制(最多 100 条)
|
||||||
|
# 添加 101 条消息
|
||||||
|
for i in range(101):
|
||||||
|
var message := {
|
||||||
|
"from_user": "User" + str(i),
|
||||||
|
"content": "Message " + str(i),
|
||||||
|
"timestamp": float(i),
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history.size(), 100,
|
||||||
|
"消息历史应该被限制在 100 条")
|
||||||
|
|
||||||
|
func test_get_message_history():
|
||||||
|
# 测试获取消息历史
|
||||||
|
var message1 := {"from_user": "Alice", "content": "Hi", "timestamp": 100.0}
|
||||||
|
var message2 := {"from_user": "Bob", "content": "Hello", "timestamp": 200.0}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message1)
|
||||||
|
chat_manager._add_message_to_history(message2)
|
||||||
|
|
||||||
|
var history := chat_manager.get_message_history()
|
||||||
|
|
||||||
|
assert_eq(history.size(), 2, "应该返回 2 条消息")
|
||||||
|
assert_eq(history[0].content, "Hi", "第一条消息内容应该匹配")
|
||||||
|
|
||||||
|
func test_clear_message_history():
|
||||||
|
# 测试清空消息历史
|
||||||
|
var message := {"from_user": "Alice", "content": "Hi", "timestamp": 100.0}
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history.size(), 1, "应该有 1 条消息")
|
||||||
|
|
||||||
|
chat_manager.clear_message_history()
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history.size(), 0, "历史应该被清空")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 错误处理测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_error_message_mapping():
|
||||||
|
# 测试错误消息映射
|
||||||
|
var error_codes := ["AUTH_FAILED", "RATE_LIMIT", "CONTENT_FILTERED",
|
||||||
|
"CONTENT_TOO_LONG", "PERMISSION_DENIED", "SESSION_EXPIRED",
|
||||||
|
"ZULIP_ERROR", "INTERNAL_ERROR"]
|
||||||
|
|
||||||
|
for code in error_codes:
|
||||||
|
assert_true(ChatManager.CHAT_ERROR_MESSAGES.has(code),
|
||||||
|
"错误码 " + code + " 应该有对应的错误消息")
|
||||||
|
|
||||||
|
func test_handle_error():
|
||||||
|
# 测试错误处理
|
||||||
|
watch_signals(chat_manager)
|
||||||
|
|
||||||
|
chat_manager._handle_error("AUTH_FAILED", "测试错误")
|
||||||
|
|
||||||
|
assert_signal_emitted(chat_manager, "chat_error_occurred",
|
||||||
|
"应该发射 chat_error_occurred 信号")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 常量测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_websocket_url_constant():
|
||||||
|
# 测试 WebSocket URL 常量
|
||||||
|
assert_eq(ChatManager.WEBSOCKET_URL, "wss://whaletownend.xinghangee.icu/game",
|
||||||
|
"WebSocket URL 应该匹配")
|
||||||
|
|
||||||
|
func test_rate_limit_constants():
|
||||||
|
# 测试频率限制常量
|
||||||
|
assert_eq(ChatManager.RATE_LIMIT_MESSAGES, 10,
|
||||||
|
"频率限制消息数应该是 10")
|
||||||
|
assert_eq(ChatManager.RATE_LIMIT_WINDOW, 60.0,
|
||||||
|
"频率限制时间窗口应该是 60 秒")
|
||||||
|
|
||||||
|
func test_message_limit_constants():
|
||||||
|
# 测试消息限制常量
|
||||||
|
assert_eq(ChatManager.MAX_MESSAGE_LENGTH, 1000,
|
||||||
|
"最大消息长度应该是 1000")
|
||||||
|
assert_eq(ChatManager.MAX_MESSAGE_HISTORY, 100,
|
||||||
|
"最大消息历史应该是 100")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 信号测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_chat_message_sent_signal():
|
||||||
|
# 测试消息发送信号
|
||||||
|
watch_signals(chat_manager)
|
||||||
|
|
||||||
|
chat_manager.emit_signal("chat_message_sent", "msg123", 1000.0)
|
||||||
|
|
||||||
|
assert_signal_emitted(chat_manager, "chat_message_sent",
|
||||||
|
"应该发射 chat_message_sent 信号")
|
||||||
|
|
||||||
|
func test_chat_message_received_signal():
|
||||||
|
# 测试消息接收信号
|
||||||
|
watch_signals(chat_manager)
|
||||||
|
|
||||||
|
chat_manager.emit_signal("chat_message_received", "Alice", "Hello!", true, 1000.0)
|
||||||
|
|
||||||
|
assert_signal_emitted(chat_manager, "chat_message_received",
|
||||||
|
"应该发射 chat_message_received 信号")
|
||||||
|
|
||||||
|
func test_chat_error_occurred_signal():
|
||||||
|
# 测试错误发生信号
|
||||||
|
watch_signals(chat_manager)
|
||||||
|
|
||||||
|
chat_manager.emit_signal("chat_error_occurred", "AUTH_FAILED", "认证失败")
|
||||||
|
|
||||||
|
assert_signal_emitted(chat_manager, "chat_error_occurred",
|
||||||
|
"应该发射 chat_error_occurred 信号")
|
||||||
|
|
||||||
|
func test_chat_connection_state_changed_signal():
|
||||||
|
# 测试连接状态变化信号
|
||||||
|
watch_signals(chat_manager)
|
||||||
|
|
||||||
|
chat_manager.emit_signal("chat_connection_state_changed",
|
||||||
|
WebSocketManager.ConnectionState.CONNECTED)
|
||||||
|
|
||||||
|
assert_signal_emitted(chat_manager, "chat_connection_state_changed",
|
||||||
|
"应该发射 chat_connection_state_changed 信号")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 边界条件测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_empty_message_content():
|
||||||
|
# 测试空消息内容
|
||||||
|
var message := {
|
||||||
|
"from_user": "Alice",
|
||||||
|
"content": "",
|
||||||
|
"timestamp": 1000.0,
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history.size(), 1,
|
||||||
|
"空消息应该被添加到历史")
|
||||||
|
|
||||||
|
func test_very_long_message_content():
|
||||||
|
# 测试超长消息内容(边界测试)
|
||||||
|
var long_content := "x".repeat(1000)
|
||||||
|
|
||||||
|
var message := {
|
||||||
|
"from_user": "Alice",
|
||||||
|
"content": long_content,
|
||||||
|
"timestamp": 1000.0,
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history[0].content.length(), 1000,
|
||||||
|
"超长消息应该被保留")
|
||||||
|
|
||||||
|
func test_unicode_in_message():
|
||||||
|
# 测试消息中的 Unicode 字符
|
||||||
|
var message := {
|
||||||
|
"from_user": "玩家",
|
||||||
|
"content": "你好,世界!🎮🎉",
|
||||||
|
"timestamp": 1000.0,
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history[0].content, "你好,世界!🎮🎉",
|
||||||
|
"Unicode 内容应该被正确保留")
|
||||||
|
|
||||||
|
func test_zero_timestamp():
|
||||||
|
# 测试零时间戳
|
||||||
|
var message := {
|
||||||
|
"from_user": "Alice",
|
||||||
|
"content": "Hello",
|
||||||
|
"timestamp": 0.0,
|
||||||
|
"is_self": false
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_manager._add_message_to_history(message)
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_history[0].timestamp, 0.0,
|
||||||
|
"零时间戳应该被保留")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 消息时间戳记录测试
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
func test_record_message_timestamp():
|
||||||
|
# 测试记录消息时间戳
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_timestamps.size(), 1,
|
||||||
|
"应该记录 1 个时间戳")
|
||||||
|
|
||||||
|
func test_multiple_message_timestamps():
|
||||||
|
# 测试记录多个消息时间戳
|
||||||
|
for i in range(5):
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
assert_eq(chat_manager._message_timestamps.size(), 5,
|
||||||
|
"应该记录 5 个时间戳")
|
||||||
|
|
||||||
|
func test_timestamps_in_order():
|
||||||
|
# 测试时间戳应该按顺序记录
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
await get_tree().process_frame
|
||||||
|
chat_manager._record_message_timestamp()
|
||||||
|
|
||||||
|
assert_gt(chat_manager._message_timestamps[1],
|
||||||
|
chat_manager._message_timestamps[0],
|
||||||
|
"后来的时间戳应该更大")
|
||||||