Compare commits
15 Commits
414225e8c1
...
feature/wh
| Author | SHA1 | Date | |
|---|---|---|---|
| 14bd5e1b29 | |||
| fecebbc4c1 | |||
| 75eb227b18 | |||
| 449cd1e8f3 | |||
| 8a5a4a0005 | |||
| ed7d89e39d | |||
| ce47bd6eeb | |||
| 7cca58cb07 | |||
| 13e6553748 | |||
| 6a3823ad16 | |||
| 4bfe49d5b1 | |||
| 1d3f580560 | |||
| 9259865e72 | |||
| 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 功能" 即可!
|
||||
@@ -57,15 +57,4 @@ const GRID_SNAP_REQUESTED = "grid_snap_requested"
|
||||
# ============================================================================
|
||||
# 测试事件
|
||||
# ============================================================================
|
||||
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"
|
||||
const TEST_EVENT = "test_event"
|
||||
@@ -29,7 +29,7 @@ extends RefCounted
|
||||
# ============ 信号定义 ============
|
||||
|
||||
# 登录成功信号
|
||||
signal login_success(username: String)
|
||||
signal login_success(username: String, token: String)
|
||||
|
||||
# 登录失败信号
|
||||
signal login_failed(message: String)
|
||||
@@ -80,8 +80,8 @@ var current_email: String = ""
|
||||
# 网络请求管理
|
||||
var active_request_ids: Array = []
|
||||
|
||||
# 游戏 token 管理(用于 WebSocket 聊天认证)
|
||||
var _game_token: String = ""
|
||||
# 当前登录用户ID (静态变量,全局访问)
|
||||
static var current_user_id: String = ""
|
||||
|
||||
# ============ 生命周期方法 ============
|
||||
|
||||
@@ -96,31 +96,6 @@ func cleanup():
|
||||
NetworkManager.cancel_request(request_id)
|
||||
active_request_ids.clear()
|
||||
|
||||
# ============ 游戏 Token 管理 ============
|
||||
|
||||
# 设置游戏 token
|
||||
#
|
||||
# 参数:
|
||||
# token: String - 游戏认证 token
|
||||
#
|
||||
# 使用场景:
|
||||
# - 登录成功后设置 token
|
||||
# - 从服务器响应中获取 token
|
||||
func set_game_token(token: String) -> void:
|
||||
_game_token = token
|
||||
print("AuthManager: 游戏 token 已设置")
|
||||
|
||||
# 获取游戏 token
|
||||
#
|
||||
# 返回值:
|
||||
# String - 游戏 token,如果未设置则返回空字符串
|
||||
#
|
||||
# 使用场景:
|
||||
# - ChatManager 连接 WebSocket 时需要 token
|
||||
# - 其他需要游戏认证的场景
|
||||
func get_game_token() -> String:
|
||||
return _game_token
|
||||
|
||||
# ============ 登录相关方法 ============
|
||||
|
||||
# 执行密码登录
|
||||
@@ -459,12 +434,20 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
||||
|
||||
if result.success:
|
||||
var username = ""
|
||||
if data.has("data") and data.data.has("user") and data.data.user.has("username"):
|
||||
username = data.data.user.username
|
||||
if data.has("data") and data.data.has("user"):
|
||||
var user_data = data.data.user
|
||||
if user_data.has("username"):
|
||||
username = user_data.username
|
||||
if user_data.has("id"):
|
||||
current_user_id = user_data.id
|
||||
print("AuthManager: Current User ID set to ", current_user_id)
|
||||
|
||||
# 延迟发送登录成功信号
|
||||
await Engine.get_main_loop().create_timer(1.0).timeout
|
||||
login_success.emit(username)
|
||||
var token = ""
|
||||
if data.has("data") and data.data.has("access_token"):
|
||||
token = data.data.access_token
|
||||
login_success.emit(username, token)
|
||||
else:
|
||||
login_failed.emit(result.message)
|
||||
|
||||
@@ -479,11 +462,16 @@ func _on_verification_login_response(success: bool, data: Dictionary, error_info
|
||||
|
||||
if result.success:
|
||||
var username = ""
|
||||
if data.has("data") and data.data.has("user") and data.data.user.has("username"):
|
||||
username = data.data.user.username
|
||||
if data.has("data") and data.data.has("user"):
|
||||
var user_data = data.data.user
|
||||
if user_data.has("username"):
|
||||
username = user_data.username
|
||||
if user_data.has("id"):
|
||||
current_user_id = user_data.id
|
||||
print("AuthManager: Current User ID set to ", current_user_id)
|
||||
|
||||
await Engine.get_main_loop().create_timer(1.0).timeout
|
||||
login_success.emit(username)
|
||||
login_success.emit(username, data.get("access_token", ""))
|
||||
else:
|
||||
login_failed.emit(result.message)
|
||||
|
||||
|
||||
@@ -1,643 +0,0 @@
|
||||
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 广播
|
||||
# ============================================================================
|
||||
|
||||
class_name ChatManager
|
||||
|
||||
# ============================================================================
|
||||
# 信号定义 (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: WebSocketManager.ConnectionState - 连接状态
|
||||
signal chat_connection_state_changed(state: WebSocketManager.ConnectionState)
|
||||
|
||||
# 位置更新成功信号
|
||||
# 参数:
|
||||
# stream: String - Stream 名称
|
||||
# topic: String - Topic 名称
|
||||
signal chat_position_updated(stream: String, topic: String)
|
||||
|
||||
# ============================================================================
|
||||
# 常量定义
|
||||
# ============================================================================
|
||||
|
||||
# WebSocket 服务器 URL
|
||||
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: WebSocketManager
|
||||
|
||||
# Socket.IO 客户端
|
||||
var _socket_client: SocketIOClient
|
||||
|
||||
# 是否已登录
|
||||
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 = ""
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
|
||||
# 初始化
|
||||
func _ready() -> void:
|
||||
print("ChatManager 初始化完成")
|
||||
|
||||
# 创建 WebSocket 管理器
|
||||
_websocket_manager = WebSocketManager.new()
|
||||
add_child(_websocket_manager)
|
||||
|
||||
# 获取 Socket.IO 客户端引用
|
||||
_socket_client = _websocket_manager.get_socket_client()
|
||||
|
||||
# 连接信号
|
||||
_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
|
||||
print("ChatManager: 游戏 token 已设置")
|
||||
|
||||
# 获取游戏 token
|
||||
#
|
||||
# 返回值:
|
||||
# String - 当前游戏 token
|
||||
func get_game_token() -> String:
|
||||
return _game_token
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 连接管理
|
||||
# ============================================================================
|
||||
|
||||
# 连接到聊天服务器
|
||||
func connect_to_chat_server() -> void:
|
||||
if _websocket_manager.is_connected():
|
||||
push_warning("聊天服务器已连接")
|
||||
return
|
||||
|
||||
print("=== ChatManager 开始连接 ===")
|
||||
_websocket_manager.connect_to_game_server()
|
||||
|
||||
# 断开聊天服务器
|
||||
func disconnect_from_chat_server() -> void:
|
||||
print("=== ChatManager 断开连接 ===")
|
||||
|
||||
# 发送登出消息
|
||||
if _is_logged_in:
|
||||
var logout_data := {"type": "logout"}
|
||||
_socket_client.emit("logout", logout_data)
|
||||
_is_logged_in = False
|
||||
|
||||
# 断开连接
|
||||
_websocket_manager.disconnect()
|
||||
|
||||
# 检查是否已连接
|
||||
#
|
||||
# 返回值:
|
||||
# bool - 是否已连接
|
||||
func is_connected() -> bool:
|
||||
return _websocket_manager.is_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_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 := {
|
||||
"t": "chat",
|
||||
"content": content,
|
||||
"scope": scope
|
||||
}
|
||||
|
||||
# 发送消息
|
||||
_socket_client.emit("chat", message_data)
|
||||
|
||||
# 记录发送时间
|
||||
_record_message_timestamp()
|
||||
|
||||
# 添加到历史
|
||||
_add_message_to_history({
|
||||
"from_user": _current_username,
|
||||
"content": content,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": true
|
||||
})
|
||||
|
||||
print("📤 发送聊天消息: ", content)
|
||||
|
||||
# 更新玩家位置
|
||||
#
|
||||
# 参数:
|
||||
# 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_connected():
|
||||
return
|
||||
|
||||
var position_data := {
|
||||
"t": "position",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"mapId": map_id
|
||||
}
|
||||
|
||||
_socket_client.emit("position", position_data)
|
||||
print("📍 更新位置: (%.2f, %.2f) in %s" % [x, y, map_id])
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 频率限制
|
||||
# ============================================================================
|
||||
|
||||
# 检查是否可以发送消息
|
||||
#
|
||||
# 返回值:
|
||||
# bool - 是否可以发送
|
||||
func can_send_message() -> bool:
|
||||
var current_time := Time.get_unix_time_from_system()
|
||||
|
||||
# 清理过期的时间戳
|
||||
_message_timestamps = _message_timestamps.filter(
|
||||
func(timestamp: float) -> bool:
|
||||
return current_time - timestamp < RATE_LIMIT_WINDOW
|
||||
)
|
||||
|
||||
# 检查数量
|
||||
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()
|
||||
print("🧹 清空消息历史")
|
||||
|
||||
# 重置当前会话(每次登录/重连时调用)
|
||||
#
|
||||
# 功能:
|
||||
# - 清空当前会话消息缓存
|
||||
# - 重置历史消息加载状态
|
||||
# - 不影响 Zulip 后端的历史消息
|
||||
#
|
||||
# 使用场景:
|
||||
# - 用户登录成功后
|
||||
# - 重新连接到聊天服务器后
|
||||
func reset_session() -> void:
|
||||
_message_history.clear()
|
||||
_history_loading = false
|
||||
_has_more_history = true
|
||||
_oldest_message_timestamp = 0.0
|
||||
print("🔄 重置聊天会话")
|
||||
|
||||
# 加载历史消息(按需从 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:
|
||||
print("⏳ 历史消息正在加载中...")
|
||||
return
|
||||
|
||||
if not _has_more_history:
|
||||
print("📚 没有更多历史消息")
|
||||
return
|
||||
|
||||
_history_loading = true
|
||||
print("📜 开始加载历史消息,数量: ", count)
|
||||
|
||||
# TODO: 实现从 Zulip 后端获取历史消息
|
||||
# NetworkManager.get_chat_history(_oldest_message_timestamp, count, _on_history_loaded)
|
||||
|
||||
# 临时实现:模拟历史消息加载(测试用)
|
||||
# await get_tree().create_timer(1.0).timeout
|
||||
# _on_history_loaded([])
|
||||
|
||||
# 历史消息加载完成回调
|
||||
func _on_history_loaded(messages: Array) -> void:
|
||||
_history_loading = false
|
||||
|
||||
if messages.is_empty():
|
||||
_has_more_history = false
|
||||
print("📚 没有更多历史消息")
|
||||
return
|
||||
|
||||
print("📜 历史消息加载完成,数量: ", messages.size())
|
||||
|
||||
# 将历史消息插入到当前会话历史开头
|
||||
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_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)
|
||||
|
||||
# Socket.IO 客户端信号
|
||||
_socket_client.connected.connect(_on_socket_connected)
|
||||
_socket_client.disconnected.connect(_on_socket_disconnected)
|
||||
_socket_client.event_received.connect(_on_socket_event_received)
|
||||
_socket_client.error_occurred.connect(_on_socket_error)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 连接状态处理
|
||||
# ============================================================================
|
||||
|
||||
# Socket 连接成功
|
||||
func _on_socket_connected() -> void:
|
||||
print("✅ ChatManager: Socket 连接成功")
|
||||
|
||||
# 发送登录消息
|
||||
_send_login_message()
|
||||
|
||||
# Socket 连接断开
|
||||
func _on_socket_disconnected(_clean_close: bool) -> void:
|
||||
print("🔌 ChatManager: Socket 连接断开")
|
||||
_is_logged_in = False
|
||||
|
||||
# 连接状态变化
|
||||
func _on_connection_state_changed(state: WebSocketManager.ConnectionState) -> void:
|
||||
print("📡 ChatManager: 连接状态变化 - ", WebSocketManager.ConnectionState.keys()[state])
|
||||
|
||||
# 发射信号
|
||||
chat_connection_state_changed.emit(state)
|
||||
|
||||
# 通过 EventSystem 广播(Signal Up)
|
||||
EventSystem.emit_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, {
|
||||
"state": state
|
||||
})
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 消息处理
|
||||
# ============================================================================
|
||||
|
||||
# Socket 事件接收
|
||||
func _on_socket_event_received(event_name: String, data: Dictionary) -> void:
|
||||
match event_name:
|
||||
"login_success":
|
||||
_handle_login_success(data)
|
||||
"chat_sent":
|
||||
_handle_chat_sent(data)
|
||||
"chat_render":
|
||||
_handle_chat_render(data)
|
||||
"position_updated":
|
||||
_handle_position_updated(data)
|
||||
"error":
|
||||
_handle_error_response(data)
|
||||
_:
|
||||
print("⚠️ ChatManager: 未处理的事件 - ", event_name)
|
||||
|
||||
# 处理登录成功
|
||||
func _handle_login_success(data: Dictionary) -> void:
|
||||
print("✅ ChatManager: 登录成功")
|
||||
|
||||
_is_logged_in = True
|
||||
_current_username = data.get("username", "")
|
||||
_current_map = data.get("currentMap", "")
|
||||
|
||||
# 重置当前会话缓存(每次登录/重连都清空,重新开始接收消息)
|
||||
reset_session()
|
||||
|
||||
print(" 用户名: ", _current_username)
|
||||
print(" 地图: ", _current_map)
|
||||
|
||||
# 通过 EventSystem 广播(Signal Up)
|
||||
EventSystem.emit_event(EventNames.CHAT_LOGIN_SUCCESS, {
|
||||
"username": _current_username,
|
||||
"current_map": _current_map
|
||||
})
|
||||
|
||||
# 处理聊天消息发送成功
|
||||
func _handle_chat_sent(data: Dictionary) -> void:
|
||||
var message_id: String = data.get("messageId", "")
|
||||
var timestamp: float = data.get("timestamp", 0.0)
|
||||
|
||||
print("✅ 消息发送成功: ", message_id)
|
||||
|
||||
# 发射信号
|
||||
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_render(data: Dictionary) -> void:
|
||||
var from_user: String = data.get("from", "")
|
||||
var content: String = data.get("txt", "")
|
||||
var show_bubble: bool = data.get("bubble", false)
|
||||
var timestamp: float = data.get("timestamp", 0.0)
|
||||
|
||||
print("📨 收到聊天消息: ", from_user, " -> ", content)
|
||||
|
||||
# 添加到历史
|
||||
_add_message_to_history({
|
||||
"from_user": from_user,
|
||||
"content": content,
|
||||
"timestamp": timestamp,
|
||||
"is_self": false
|
||||
})
|
||||
|
||||
# 发射信号
|
||||
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
|
||||
})
|
||||
|
||||
# 处理位置更新成功
|
||||
func _handle_position_updated(data: Dictionary) -> void:
|
||||
var stream: String = data.get("stream", "")
|
||||
var topic: String = data.get("topic", "")
|
||||
|
||||
print("✅ 位置更新成功: ", stream, " / ", 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 _send_login_message() -> void:
|
||||
if _game_token.is_empty():
|
||||
push_error("无法获取游戏 token,请先调用 set_game_token() 设置 token")
|
||||
_handle_error("AUTH_FAILED", "无法获取游戏 token,请先设置 token")
|
||||
return
|
||||
|
||||
var login_data := {
|
||||
"type": "login",
|
||||
"token": _game_token
|
||||
}
|
||||
|
||||
_socket_client.emit("login", login_data)
|
||||
print("📤 发送登录消息")
|
||||
|
||||
# 处理错误
|
||||
func _handle_error(error_code: String, error_message: String) -> void:
|
||||
print("❌ ChatManager 错误: [", error_code, "] ", error_message)
|
||||
|
||||
# 获取用户友好的错误消息
|
||||
var user_message := CHAT_ERROR_MESSAGES.get(error_code, error_message)
|
||||
|
||||
# 发射信号
|
||||
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 _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 +0,0 @@
|
||||
uid://b6lnbss2i3pss
|
||||
@@ -38,10 +38,17 @@ signal request_completed(request_id: String, success: bool, data: Dictionary)
|
||||
# message: String - 错误消息
|
||||
signal request_failed(request_id: String, error_type: String, message: String)
|
||||
|
||||
# 公告列表接收信号
|
||||
signal notices_received(data: Array)
|
||||
|
||||
# ============ 常量定义 ============
|
||||
|
||||
# API基础URL - 所有请求的根地址
|
||||
const API_BASE_URL = "https://whaletownend.xinghangee.icu"
|
||||
# [Remote] 正式环境地址 (实际正式项目用此地址)
|
||||
# const API_BASE_URL = "https://whaletownend.xinghangee.icu"
|
||||
|
||||
# [Local] 本地调试地址 (本地调试用此地址)
|
||||
const API_BASE_URL = "http://localhost:3000"
|
||||
|
||||
# 默认请求超时时间(秒)
|
||||
const DEFAULT_TIMEOUT = 30.0
|
||||
@@ -114,6 +121,7 @@ var request_counter: int = 0 # 请求计数器,用于
|
||||
# 初始化网络管理器
|
||||
# 在节点准备就绪时调用
|
||||
func _ready():
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
print("NetworkManager 已初始化")
|
||||
|
||||
# ============ 公共API接口 ============
|
||||
@@ -436,6 +444,18 @@ func github_login(github_id: String, username: String, nickname: String, email:
|
||||
|
||||
return post_request("/auth/github", data, callback)
|
||||
|
||||
# TODO: 获取公告列表
|
||||
func request_notices():
|
||||
# 发送 GET 请求到 /notices 接口
|
||||
get_request("/notices", _on_notices_response)
|
||||
|
||||
func _on_notices_response(success: bool, data: Dictionary, error_info: Dictionary):
|
||||
if success and data.has("data"):
|
||||
notices_received.emit(data["data"])
|
||||
else:
|
||||
# 失败或无数据时发送空数组
|
||||
notices_received.emit([])
|
||||
|
||||
# ============ 核心请求处理 ============
|
||||
|
||||
# 发送请求的核心方法
|
||||
|
||||
@@ -36,6 +36,8 @@ signal scene_change_started(scene_name: String)
|
||||
# 场景状态
|
||||
var current_scene_name: String = "" # 当前场景名称
|
||||
var is_changing_scene: bool = false # 是否正在切换场景
|
||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||
|
||||
# 场景路径映射表
|
||||
# 将场景名称映射到实际的文件路径
|
||||
@@ -47,7 +49,9 @@ var scene_paths: Dictionary = {
|
||||
"battle": "res://scenes/maps/battle_scene.tscn", # 战斗场景 - 战斗系统
|
||||
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
|
||||
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
|
||||
"settings": "res://scenes/ui/SettingsWindow.tscn" # 设置界面
|
||||
"settings": "res://scenes/ui/SettingsWindow.tscn", # 设置界面
|
||||
"room": "res://scenes/Maps/room.tscn", # 房间场景
|
||||
"square": "res://scenes/Maps/square.tscn" # 广场场景
|
||||
}
|
||||
|
||||
# ============ 生命周期方法 ============
|
||||
@@ -106,15 +110,18 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
||||
if use_transition:
|
||||
await show_transition()
|
||||
|
||||
# 更新场景名称(在切换之前设置,确保新场景的 _ready 能获取正确的名称)
|
||||
current_scene_name = scene_name
|
||||
|
||||
# 执行场景切换
|
||||
var error = get_tree().change_scene_to_file(scene_path)
|
||||
if error != OK:
|
||||
print("场景切换失败: ", error)
|
||||
current_scene_name = "" # 恢复为空
|
||||
is_changing_scene = false
|
||||
return false
|
||||
|
||||
# 更新状态
|
||||
current_scene_name = scene_name
|
||||
is_changing_scene = false
|
||||
scene_changed.emit(scene_name)
|
||||
|
||||
@@ -134,6 +141,27 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
||||
func get_current_scene_name() -> String:
|
||||
return current_scene_name
|
||||
|
||||
# 设置下一个场景的初始位置
|
||||
func set_next_scene_position(pos: Vector2) -> void:
|
||||
_next_scene_position = pos
|
||||
|
||||
# 获取并清除下一个场景的初始位置
|
||||
func get_next_scene_position() -> Variant:
|
||||
var pos = _next_scene_position
|
||||
_next_scene_position = null
|
||||
return pos
|
||||
|
||||
# 设置下一个场景的出生点名称
|
||||
func set_next_spawn_name(spawn_name: String) -> void:
|
||||
_next_spawn_name = spawn_name
|
||||
|
||||
# 获取并清除下一个场景的出生点名称
|
||||
func get_next_spawn_name() -> String:
|
||||
var name = _next_spawn_name
|
||||
_next_spawn_name = ""
|
||||
return name
|
||||
|
||||
|
||||
# ============ 场景注册方法 ============
|
||||
|
||||
# 注册新场景
|
||||
|
||||
@@ -1,335 +1,172 @@
|
||||
extends Node
|
||||
|
||||
# ============================================================================
|
||||
# WebSocketManager.gd - WebSocket 连接生命周期管理
|
||||
# WebSocketManager.gd - WebSocket连接管理器
|
||||
# ============================================================================
|
||||
# 管理 WebSocket 连接状态、自动重连和错误恢复
|
||||
# 负责与后端 Native WebSocket 服务进行实时通信
|
||||
#
|
||||
# 核心职责:
|
||||
# - 连接状态管理(断开、连接中、已连接、重连中)
|
||||
# - 自动重连机制(指数退避)
|
||||
# - 连接错误恢复
|
||||
# - Socket.IO 客户端封装
|
||||
#
|
||||
# 使用方式:
|
||||
# WebSocketManager.connect_to_game_server()
|
||||
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
|
||||
#
|
||||
# 注意事项:
|
||||
# - 作为自动加载单例,全局可访问
|
||||
# - 自动处理连接断开和重连
|
||||
# - 通过信号通知连接状态变化
|
||||
# 协议文档: new_docs/game_architecture_design.md
|
||||
# 后端地址: ws://localhost:3000/location-broadcast
|
||||
# ============================================================================
|
||||
|
||||
class_name WebSocketManager
|
||||
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 # 秒
|
||||
|
||||
# 连接状态变化信号
|
||||
# 参数:
|
||||
# new_state: ConnectionState - 新的连接状态
|
||||
signal connection_state_changed(new_state: ConnectionState)
|
||||
var _socket: WebSocketPeer
|
||||
var _connected: bool = false
|
||||
var _ping_timer: float = 0.0
|
||||
var _auth_token: String = ""
|
||||
|
||||
# 连接丢失信号
|
||||
signal connection_lost()
|
||||
func _ready():
|
||||
_socket = WebSocketPeer.new()
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
|
||||
print("WebSocketManager Initialized")
|
||||
|
||||
# 重连成功信号
|
||||
signal reconnection_succeeded()
|
||||
|
||||
# 重连失败信号
|
||||
# 参数:
|
||||
# attempt: int - 当前重连尝试次数
|
||||
# max_attempts: int - 最大重连次数
|
||||
signal reconnection_failed(attempt: int, max_attempts: int)
|
||||
|
||||
# ============================================================================
|
||||
# 枚举定义
|
||||
# ============================================================================
|
||||
|
||||
# 连接状态枚举
|
||||
enum ConnectionState {
|
||||
DISCONNECTED, # 未连接
|
||||
CONNECTING, # 连接中
|
||||
CONNECTED, # 已连接
|
||||
RECONNECTING, # 重连中
|
||||
ERROR # 错误状态
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 常量定义
|
||||
# ============================================================================
|
||||
|
||||
# WebSocket 服务器 URL
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
# ============================================================================
|
||||
|
||||
# Socket.IO 客户端
|
||||
var _socket_client: SocketIOClient
|
||||
|
||||
# 当前连接状态
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
|
||||
# 初始化
|
||||
func _ready() -> void:
|
||||
print("WebSocketManager 初始化完成")
|
||||
|
||||
# 创建 Socket.IO 客户端
|
||||
_socket_client = SocketIOClient.new()
|
||||
add_child(_socket_client)
|
||||
|
||||
# 连接信号
|
||||
_socket_client.connected.connect(_on_socket_connected)
|
||||
_socket_client.disconnected.connect(_on_socket_disconnected)
|
||||
_socket_client.error_occurred.connect(_on_socket_error)
|
||||
|
||||
# 设置重连定时器
|
||||
_setup_reconnect_timer()
|
||||
|
||||
# 清理
|
||||
func _exit_tree() -> void:
|
||||
if is_instance_valid(_reconnect_timer):
|
||||
_reconnect_timer.stop()
|
||||
_reconnect_timer.queue_free()
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 连接管理
|
||||
# ============================================================================
|
||||
|
||||
# 连接到游戏服务器
|
||||
func connect_to_game_server() -> void:
|
||||
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
|
||||
push_warning("已经在连接或已连接状态")
|
||||
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:
|
||||
print("WebSocket 已经是连接状态")
|
||||
return
|
||||
|
||||
print("=== WebSocketManager 开始连接 ===")
|
||||
_set_connection_state(ConnectionState.CONNECTING)
|
||||
_clean_close = true
|
||||
_reconnect_attempt = 0
|
||||
|
||||
_socket_client.connect_to_server(WEBSOCKET_URL)
|
||||
|
||||
# 断开连接
|
||||
func disconnect() -> void:
|
||||
print("=== WebSocketManager 断开连接 ===")
|
||||
_clean_close = true
|
||||
|
||||
# 停止重连定时器
|
||||
_reconnect_timer.stop()
|
||||
|
||||
# 断开客户端
|
||||
_socket_client.disconnect_from_server()
|
||||
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||
|
||||
# 检查是否已连接
|
||||
#
|
||||
# 返回值:
|
||||
# bool - 是否已连接
|
||||
func is_connected() -> bool:
|
||||
return _connection_state == ConnectionState.CONNECTED
|
||||
|
||||
# 获取当前连接状态
|
||||
#
|
||||
# 返回值:
|
||||
# ConnectionState - 当前连接状态
|
||||
func get_connection_state() -> ConnectionState:
|
||||
return _connection_state
|
||||
|
||||
# ============================================================================
|
||||
# 公共 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
|
||||
|
||||
print("自动重连: ", "启用" if enabled else "禁用")
|
||||
print("最大重连次数: ", _max_reconnect_attempts)
|
||||
print("基础重连延迟: ", _reconnect_base_delay, " 秒")
|
||||
|
||||
# 获取 Socket.IO 客户端
|
||||
#
|
||||
# 返回值:
|
||||
# SocketIOClient - Socket.IO 客户端实例
|
||||
#
|
||||
# 使用示例:
|
||||
# var socket = WebSocketManager.get_socket_client()
|
||||
# socket.emit("chat", {"t": "chat", "content": "Hello"})
|
||||
func get_socket_client() -> SocketIOClient:
|
||||
return _socket_client
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 连接状态管理
|
||||
# ============================================================================
|
||||
|
||||
# 设置连接状态
|
||||
func _set_connection_state(new_state: ConnectionState) -> void:
|
||||
if _connection_state == new_state:
|
||||
return
|
||||
|
||||
_connection_state = new_state
|
||||
print("📡 连接状态变更: ", ConnectionState.keys()[new_state])
|
||||
|
||||
# 发射信号
|
||||
connection_state_changed.emit(new_state)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - Socket 事件处理
|
||||
# ============================================================================
|
||||
|
||||
# Socket 连接成功处理
|
||||
func _on_socket_connected() -> void:
|
||||
print("✅ WebSocketManager: Socket 连接成功")
|
||||
|
||||
# 如果是重连,发射重连成功信号
|
||||
if _connection_state == ConnectionState.RECONNECTING:
|
||||
_reconnect_attempt = 0
|
||||
reconnection_succeeded.emit()
|
||||
print("🔄 重连成功")
|
||||
|
||||
_set_connection_state(ConnectionState.CONNECTED)
|
||||
|
||||
# Socket 连接断开处理
|
||||
func _on_socket_disconnected(clean_close: bool) -> void:
|
||||
print("🔌 WebSocketManager: Socket 连接断开")
|
||||
print(" 正常关闭: ", clean_close)
|
||||
|
||||
_clean_close = clean_close
|
||||
|
||||
# 如果是异常断开且启用了自动重连
|
||||
if not clean_close and _auto_reconnect_enabled:
|
||||
connection_lost.emit()
|
||||
_attempt_reconnect()
|
||||
|
||||
print("正在连接 WebSocket: ", WS_URL)
|
||||
var err = _socket.connect_to_url(WS_URL)
|
||||
if err != OK:
|
||||
print("WebSocket 连接请求失败: ", err)
|
||||
connection_error.emit()
|
||||
else:
|
||||
_set_connection_state(ConnectionState.DISCONNECTED)
|
||||
# Godot WebSocket connect is non-blocking, wait for state change in _process
|
||||
pass
|
||||
|
||||
# Socket 错误处理
|
||||
func _on_socket_error(error: String) -> void:
|
||||
print("❌ WebSocketManager: Socket 错误 - ", error)
|
||||
_set_connection_state(ConnectionState.ERROR)
|
||||
func close_connection():
|
||||
_socket.close()
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 重连机制
|
||||
# ============================================================================
|
||||
func set_auth_token(token: String):
|
||||
_auth_token = token
|
||||
|
||||
# 设置重连定时器
|
||||
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:
|
||||
print("❌ 达到最大重连次数 (", _max_reconnect_attempts, "),停止重连")
|
||||
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
|
||||
_set_connection_state(ConnectionState.ERROR)
|
||||
func send_packet(event: String, data: Dictionary):
|
||||
if _socket.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||
return
|
||||
|
||||
_reconnect_attempt += 1
|
||||
_set_connection_state(ConnectionState.RECONNECTING)
|
||||
|
||||
# 计算重连延迟(指数退避)
|
||||
var delay := _calculate_reconnect_delay()
|
||||
print("🔄 尝试重连 (", _reconnect_attempt, "/", _max_reconnect_attempts, ")")
|
||||
print(" 延迟: ", 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:
|
||||
print("⏰ 重连定时器超时,开始重连...")
|
||||
_socket_client.connect_to_server(WEBSOCKET_URL)
|
||||
|
||||
# ============================================================================
|
||||
# 工具方法
|
||||
# ============================================================================
|
||||
|
||||
# 获取连接状态描述
|
||||
#
|
||||
# 返回值:
|
||||
# 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 "未知状态"
|
||||
|
||||
# 获取重连信息
|
||||
#
|
||||
# 返回值:
|
||||
# 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
|
||||
|
||||
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
|
||||
}
|
||||
print("发送加入会话请求: ", map_id, " mapId Payload: ", data.initialPosition.mapId)
|
||||
send_packet("join_session", data)
|
||||
|
||||
func leave_session(map_id: String):
|
||||
print("发送离开会话请求: ", map_id)
|
||||
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
|
||||
}
|
||||
# print("发送位置更新: ", map_id)
|
||||
if map_id == "":
|
||||
print("WARNING: Sending position update with EMPTY mapId! Pos: ", pos)
|
||||
send_packet("position_update", data)
|
||||
|
||||
func _send_heartbeat():
|
||||
send_packet("heartbeat", {"timestamp": Time.get_unix_time_from_system()})
|
||||
|
||||
# ============ 事件处理 ============
|
||||
|
||||
func _on_connected():
|
||||
_connected = true
|
||||
print("WebSocket 连接成功!")
|
||||
connected_to_server.emit()
|
||||
|
||||
func _on_disconnected():
|
||||
_connected = false
|
||||
var code = _socket.get_close_code()
|
||||
var reason = _socket.get_close_reason()
|
||||
print("WebSocket 连接断开. Code: %d, Reason: %s" % [code, reason])
|
||||
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:
|
||||
print("JSON 解析失败: ", 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", {})
|
||||
|
||||
if event != "heartbeat_response":
|
||||
print("WebSocket Rx: ", event) # Debug logs for all events
|
||||
|
||||
match event:
|
||||
"session_joined":
|
||||
session_joined.emit(data)
|
||||
print("加入会话成功,当前房间人数: ", data.get("users", []).size())
|
||||
"user_joined":
|
||||
user_joined.emit(data)
|
||||
print("用户加入: ", data.get("userId"))
|
||||
"user_left":
|
||||
user_left.emit(data)
|
||||
print("用户离开: ", data.get("userId"))
|
||||
"position_update":
|
||||
print("WebSocket Rx position_update: ", data.get("userId", "unknown"))
|
||||
position_updated.emit(data)
|
||||
"heartbeat_response":
|
||||
pass # 静默处理
|
||||
"error":
|
||||
print("WebSocket 错误事件: ", JSON.stringify(data))
|
||||
_:
|
||||
print("未处理的 WebSocket 事件: ", event)
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://dmbgtbf6gyk6t
|
||||
uid://stpl2jdeqo0d
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
extends Node
|
||||
|
||||
# ============================================================================
|
||||
# SocketIOClient.gd - Socket.IO 协议封装
|
||||
# ============================================================================
|
||||
# 封装 Godot 的 WebSocketPeer,实现简化的 Socket.IO 协议
|
||||
#
|
||||
# 核心职责:
|
||||
# - WebSocket 连接管理
|
||||
# - Socket.IO 消息协议(简化版 JSON 格式)
|
||||
# - 事件监听器管理
|
||||
# - 消息发送/接收
|
||||
#
|
||||
# 注意事项:
|
||||
# - 后端使用简化版 Socket.IO(纯 JSON,无二进制协议)
|
||||
# - 发送消息使用 "t" 字段标识事件类型
|
||||
# - 所有消息通过 JSON 序列化
|
||||
# ============================================================================
|
||||
|
||||
class_name SocketIOClient
|
||||
|
||||
# ============================================================================
|
||||
# 信号定义
|
||||
# ============================================================================
|
||||
|
||||
# 连接成功信号
|
||||
signal connected()
|
||||
|
||||
# 连接断开信号
|
||||
# 参数:
|
||||
# clean_close: bool - 是否为正常关闭
|
||||
signal disconnected(clean_close: bool)
|
||||
|
||||
# 事件接收信号
|
||||
# 参数:
|
||||
# event_name: String - 事件名称(从 "t" 字段提取)
|
||||
# data: Dictionary - 事件数据
|
||||
signal event_received(event_name: String, data: Dictionary)
|
||||
|
||||
# 错误发生信号
|
||||
# 参数:
|
||||
# error: String - 错误信息
|
||||
signal error_occurred(error: String)
|
||||
|
||||
# ============================================================================
|
||||
# 常量定义
|
||||
# ============================================================================
|
||||
|
||||
# 连接状态枚举
|
||||
enum ConnectionState {
|
||||
DISCONNECTED, # 未连接
|
||||
CONNECTING, # 连接中
|
||||
CONNECTED # 已连接
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
# ============================================================================
|
||||
|
||||
# WebSocket 客户端
|
||||
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
|
||||
|
||||
# 连接状态
|
||||
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
# 服务器 URL
|
||||
var _server_url: String = ""
|
||||
|
||||
# 事件监听器: {event_name: [Callable, ...]}
|
||||
var _event_listeners: Dictionary = {}
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
# ============================================================================
|
||||
|
||||
# 初始化
|
||||
func _ready() -> void:
|
||||
print("SocketIOClient 初始化完成")
|
||||
|
||||
# 处理进程 - 轮询 WebSocket 消息
|
||||
func _process(_delta: float) -> void:
|
||||
# 轮询 WebSocket 状态
|
||||
_websocket_peer.poll()
|
||||
|
||||
# 检查连接状态变化
|
||||
var new_state: ConnectionState = _get_connection_state()
|
||||
if new_state != _connection_state:
|
||||
_connection_state = new_state
|
||||
_on_state_changed(_connection_state)
|
||||
|
||||
# 处理接收到的消息
|
||||
_process_incoming_messages()
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 连接管理
|
||||
# ============================================================================
|
||||
|
||||
# 连接到服务器
|
||||
#
|
||||
# 参数:
|
||||
# url: String - WebSocket 服务器 URL (ws:// 或 wss://)
|
||||
#
|
||||
# 使用示例:
|
||||
# socket_client.connect_to_server("wss://example.com/game")
|
||||
func connect_to_server(url: String) -> void:
|
||||
if _connection_state == ConnectionState.CONNECTED:
|
||||
push_warning("已经连接到服务器,无需重复连接")
|
||||
return
|
||||
|
||||
_server_url = url
|
||||
print("=== SocketIOClient 开始连接 ===")
|
||||
print("服务器 URL: ", _server_url)
|
||||
|
||||
# 创建 WebSocket 客户端
|
||||
_websocket_peer = WebSocketPeer.new()
|
||||
|
||||
# 发起连接
|
||||
var error := _websocket_peer.connect_to_url(url)
|
||||
if error != OK:
|
||||
push_error("WebSocket 连接失败: %s" % error)
|
||||
error_occurred.emit("WebSocket 连接失败")
|
||||
return
|
||||
|
||||
_connection_state = ConnectionState.CONNECTING
|
||||
print("WebSocket 连接中...")
|
||||
|
||||
# 断开连接
|
||||
func disconnect_from_server() -> void:
|
||||
if _connection_state == ConnectionState.DISCONNECTED:
|
||||
return
|
||||
|
||||
print("=== SocketIOClient 断开连接 ===")
|
||||
_websocket_peer.close()
|
||||
_connection_state = ConnectionState.DISCONNECTED
|
||||
disconnected.emit(true)
|
||||
|
||||
# 检查是否已连接
|
||||
#
|
||||
# 返回值:
|
||||
# bool - 是否已连接
|
||||
func is_connected() -> bool:
|
||||
return _connection_state == ConnectionState.CONNECTED
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 事件发送
|
||||
# ============================================================================
|
||||
|
||||
# 发送事件(对应 socket.emit)
|
||||
#
|
||||
# 参数:
|
||||
# event_name: String - 事件名称(如 "login", "chat")
|
||||
# data: Dictionary - 事件数据
|
||||
#
|
||||
# 使用示例:
|
||||
# socket_client.emit("login", {"type": "login", "token": "abc123"})
|
||||
# socket_client.emit("chat", {"t": "chat", "content": "Hello", "scope": "local"})
|
||||
func emit(event_name: String, data: Dictionary) -> void:
|
||||
if not is_connected():
|
||||
push_error("无法发送事件: 未连接到服务器")
|
||||
error_occurred.emit("未连接到服务器")
|
||||
return
|
||||
|
||||
# 序列化为 JSON
|
||||
var json_string := JSON.stringify(data)
|
||||
if json_string.is_empty():
|
||||
push_error("JSON 序列化失败")
|
||||
error_occurred.emit("JSON 序列化失败")
|
||||
return
|
||||
|
||||
# 发送数据包
|
||||
var packet := json_string.to_utf8_buffer()
|
||||
var error := _websocket_peer.send(packet)
|
||||
|
||||
if error != OK:
|
||||
push_error("发送数据包失败: %s" % error)
|
||||
error_occurred.emit("发送数据包失败")
|
||||
return
|
||||
|
||||
print("📤 发送事件: ", event_name)
|
||||
print(" 数据: ", json_string if json_string.length() < 200 else json_string.substr(0, 200) + "...")
|
||||
|
||||
# ============================================================================
|
||||
# 公共 API - 事件监听
|
||||
# ============================================================================
|
||||
|
||||
# 添加事件监听器(对应 socket.on)
|
||||
#
|
||||
# 参数:
|
||||
# event_name: String - 事件名称
|
||||
# callback: Callable - 回调函数,接收 data: Dictionary 参数
|
||||
#
|
||||
# 使用示例:
|
||||
# socket_client.add_event_listener("chat_render", func(data):
|
||||
# print("收到消息: ", data.txt)
|
||||
# )
|
||||
func add_event_listener(event_name: String, callback: Callable) -> void:
|
||||
if not _event_listeners.has(event_name):
|
||||
_event_listeners[event_name] = []
|
||||
|
||||
_event_listeners[event_name].append(callback)
|
||||
print("注册事件监听器: ", event_name, " -> ", callback)
|
||||
|
||||
# 移除事件监听器
|
||||
#
|
||||
# 参数:
|
||||
# event_name: String - 事件名称
|
||||
# callback: Callable - 要移除的回调函数
|
||||
func remove_event_listener(event_name: String, callback: Callable) -> void:
|
||||
if not _event_listeners.has(event_name):
|
||||
return
|
||||
|
||||
var listeners: Array = _event_listeners[event_name]
|
||||
listeners.erase(callback)
|
||||
|
||||
if listeners.is_empty():
|
||||
_event_listeners.erase(event_name)
|
||||
|
||||
print("移除事件监听器: ", event_name, " -> ", callback)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 消息处理
|
||||
# ============================================================================
|
||||
|
||||
# 处理接收到的消息
|
||||
func _process_incoming_messages() -> void:
|
||||
# 检查是否有可用数据包
|
||||
while _websocket_peer.get_available_packet_count() > 0:
|
||||
# 接收数据包
|
||||
var packet: PackedByteArray = _websocket_peer.get_packet()
|
||||
|
||||
# 解析为字符串
|
||||
var json_string: String = packet.get_string_from_utf8()
|
||||
|
||||
# 解析 JSON
|
||||
var json := JSON.new()
|
||||
var parse_result := json.parse(json_string)
|
||||
|
||||
if parse_result != OK:
|
||||
push_error("JSON 解析失败: " + json_string)
|
||||
error_occurred.emit("JSON 解析失败")
|
||||
continue
|
||||
|
||||
var data: Dictionary = json.data
|
||||
|
||||
# 提取事件类型(从 "t" 字段)
|
||||
var event_name: String = data.get("t", "")
|
||||
if event_name.is_empty():
|
||||
# 如果没有 "t" 字段,尝试其他方式识别
|
||||
if data.has("type"):
|
||||
event_name = data["type"]
|
||||
elif data.has("code"):
|
||||
event_name = "error"
|
||||
else:
|
||||
push_warning("收到未知格式消息: " + json_string)
|
||||
continue
|
||||
|
||||
print("📨 收到事件: ", event_name)
|
||||
print(" 数据: ", json_string if json_string.length() < 200 else json_string.substr(0, 200) + "...")
|
||||
|
||||
# 调用事件监听器
|
||||
_notify_event_listeners(event_name, data)
|
||||
|
||||
# 发射通用信号
|
||||
event_received.emit(event_name, data)
|
||||
|
||||
# 通知事件监听器
|
||||
func _notify_event_listeners(event_name: String, data: Dictionary) -> void:
|
||||
if not _event_listeners.has(event_name):
|
||||
return
|
||||
|
||||
var listeners: Array = _event_listeners[event_name]
|
||||
for callback in listeners:
|
||||
if callback.is_valid():
|
||||
callback.call(data)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 状态管理
|
||||
# ============================================================================
|
||||
|
||||
# 获取当前连接状态
|
||||
func _get_connection_state() -> ConnectionState:
|
||||
match _websocket_peer.get_ready_state():
|
||||
WebSocketPeer.STATE_CONNECTING:
|
||||
return ConnectionState.CONNECTING
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
return ConnectionState.CONNECTED
|
||||
WebSocketPeer.STATE_CLOSING:
|
||||
return ConnectionState.CONNECTING
|
||||
WebSocketPeer.STATE_CLOSED:
|
||||
return ConnectionState.DISCONNECTED
|
||||
_:
|
||||
return ConnectionState.DISCONNECTED
|
||||
|
||||
# 连接状态变化处理
|
||||
func _on_state_changed(new_state: ConnectionState) -> void:
|
||||
match new_state:
|
||||
ConnectionState.CONNECTED:
|
||||
print("✅ WebSocket 连接成功")
|
||||
connected.emit()
|
||||
ConnectionState.DISCONNECTED:
|
||||
print("🔌 WebSocket 连接断开")
|
||||
disconnected.emit(false)
|
||||
ConnectionState.CONNECTING:
|
||||
print("⏳ WebSocket 连接中...")
|
||||
@@ -1 +0,0 @@
|
||||
uid://d0b3aiagnuhxx
|
||||
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/characters/payer_44_30.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
40
assets/characters/payer_44_30.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://krfed1r4qmnp"
|
||||
path="res://.godot/imported/payer_44_30.png-100395b4756c93dec9ce9baa5c5df0f3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/payer_44_30.png"
|
||||
dest_files=["res://.godot/imported/payer_44_30.png-100395b4756c93dec9ce9baa5c5df0f3.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/characters/player_spritesheet.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
40
assets/characters/player_spritesheet.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cghab1hkx5lg5"
|
||||
path="res://.godot/imported/player_spritesheet.png-7e76f97a17946ec512358d45f2527da8.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/player_spritesheet.png"
|
||||
dest_files=["res://.godot/imported/player_spritesheet.png-7e76f97a17946ec512358d45f2527da8.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/characters/player_spritesheet_backup.png
Normal file
|
After Width: | Height: | Size: 856 KiB |
40
assets/characters/player_spritesheet_backup.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dyimi462hj6r2"
|
||||
path="res://.godot/imported/player_spritesheet_backup.png-6d8c966a56b0ced39cf1ecf0c772847f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/characters/player_spritesheet_backup.png"
|
||||
dest_files=["res://.godot/imported/player_spritesheet_backup.png-6d8c966a56b0ced39cf1ecf0c772847f.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: 142 KiB |
40
assets/materials/NoticeBoard.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b4aildrnhbpl4"
|
||||
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=1
|
||||
BIN
assets/materials/WelcomeBoard.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
40
assets/materials/WelcomeBoard.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://v7loa3smfkrd"
|
||||
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=1
|
||||
BIN
assets/sprites/environment/board.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
40
assets/sprites/environment/board.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c7jx40leuy6q1"
|
||||
path="res://.godot/imported/board.png-4f7a101e7a1b1cbdc8a75666c78e8907.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/board.png"
|
||||
dest_files=["res://.godot/imported/board.png-4f7a101e7a1b1cbdc8a75666c78e8907.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/sprites/environment/community.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
40
assets/sprites/environment/community.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://7j3n0nhg8atb"
|
||||
path="res://.godot/imported/community.png-a8c4bd53b7eaad8a751801ba0eb4ea69.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/community.png"
|
||||
dest_files=["res://.godot/imported/community.png-a8c4bd53b7eaad8a751801ba0eb4ea69.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/sprites/environment/community_512_512.png
Normal file
|
After Width: | Height: | Size: 458 KiB |
40
assets/sprites/environment/community_512_512.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cle66our01dq1"
|
||||
path="res://.godot/imported/community_512_512.png-fa162180b6884ce89074ec9b8b445a11.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/community_512_512.png"
|
||||
dest_files=["res://.godot/imported/community_512_512.png-fa162180b6884ce89074ec9b8b445a11.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/sprites/environment/deck_256_111.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
40
assets/sprites/environment/deck_256_111.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bxk7qx15ks23n"
|
||||
path="res://.godot/imported/deck_256_111.png-1f5516606f281e4ce47eda14a0f195f6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_256_111.png"
|
||||
dest_files=["res://.godot/imported/deck_256_111.png-1f5516606f281e4ce47eda14a0f195f6.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/sprites/environment/deck_256_93.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
40
assets/sprites/environment/deck_256_93.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cuoondqo7wpvm"
|
||||
path="res://.godot/imported/deck_256_93.png-53cc7596920e943ad680f551cecde9c5.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_256_93.png"
|
||||
dest_files=["res://.godot/imported/deck_256_93.png-53cc7596920e943ad680f551cecde9c5.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/sprites/environment/deck_2784_1536.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
40
assets/sprites/environment/deck_2784_1536.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dth4pwye1huv1"
|
||||
path="res://.godot/imported/deck_2784_1536.png-7209e3c01bcb29b96850c5bfe2118eb5.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_2784_1536.png"
|
||||
dest_files=["res://.godot/imported/deck_2784_1536.png-7209e3c01bcb29b96850c5bfe2118eb5.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/sprites/environment/deck_384_167.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
40
assets/sprites/environment/deck_384_167.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d3w3fncsm32oi"
|
||||
path="res://.godot/imported/deck_384_167.png-f28a21a5574e4d3fa7c0565d6c292757.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_384_167.png"
|
||||
dest_files=["res://.godot/imported/deck_384_167.png-f28a21a5574e4d3fa7c0565d6c292757.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/sprites/environment/deck_512_164.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
40
assets/sprites/environment/deck_512_164.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://j0twhfkpj15i"
|
||||
path="res://.godot/imported/deck_512_164.png-c98703495d73d104671911f6dada9aca.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_512_164.png"
|
||||
dest_files=["res://.godot/imported/deck_512_164.png-c98703495d73d104671911f6dada9aca.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/sprites/environment/deck_512_282.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
40
assets/sprites/environment/deck_512_282.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://blre1srim52hs"
|
||||
path="res://.godot/imported/deck_512_282.png-ec2cdd543ebb499e3623cd89dff86aa4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_512_282.png"
|
||||
dest_files=["res://.godot/imported/deck_512_282.png-ec2cdd543ebb499e3623cd89dff86aa4.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/sprites/environment/deck_512_512.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
40
assets/sprites/environment/deck_512_512.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://drdfggxi5ecw7"
|
||||
path="res://.godot/imported/deck_512_512.png-14cca0360b4e43a84ff23febdf688dce.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/deck_512_512.png"
|
||||
dest_files=["res://.godot/imported/deck_512_512.png-14cca0360b4e43a84ff23febdf688dce.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/sprites/environment/e2f5aff78fae12f979d3456eca0896b4.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ci5myym3wxvej"
|
||||
path="res://.godot/imported/e2f5aff78fae12f979d3456eca0896b4.jpg-09ffb25e15f901ba90adc883741736d3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/e2f5aff78fae12f979d3456eca0896b4.jpg"
|
||||
dest_files=["res://.godot/imported/e2f5aff78fae12f979d3456eca0896b4.jpg-09ffb25e15f901ba90adc883741736d3.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/sprites/environment/fountain_256_192.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
40
assets/sprites/environment/fountain_256_192.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dujutnr03apoj"
|
||||
path="res://.godot/imported/fountain_256_192.png-6fb4e69b74642a426b29631ef7156f53.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/fountain_256_192.png"
|
||||
dest_files=["res://.godot/imported/fountain_256_192.png-6fb4e69b74642a426b29631ef7156f53.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/sprites/environment/grass_128_128.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
40
assets/sprites/environment/grass_128_128.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://h1kqvkvshfxo"
|
||||
path="res://.godot/imported/grass_128_128.png-f99428f7721484fc94b70ea8f73a140d.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/grass_128_128.png"
|
||||
dest_files=["res://.godot/imported/grass_128_128.png-f99428f7721484fc94b70ea8f73a140d.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/sprites/environment/grass_256_256.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
40
assets/sprites/environment/grass_256_256.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dwlnclqw6lsa7"
|
||||
path="res://.godot/imported/grass_256_256.png-480baf97b2b792db085b32018ac813a5.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/grass_256_256.png"
|
||||
dest_files=["res://.godot/imported/grass_256_256.png-480baf97b2b792db085b32018ac813a5.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/sprites/environment/ground.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
40
assets/sprites/environment/ground.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ccqxsarxnnf4e"
|
||||
path="res://.godot/imported/ground.png-2205e043de9d3b8a38f01788dff375d3.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/ground.png"
|
||||
dest_files=["res://.godot/imported/ground.png-2205e043de9d3b8a38f01788dff375d3.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/sprites/environment/house_256_192.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
40
assets/sprites/environment/house_256_192.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dtev6yddbjtvp"
|
||||
path="res://.godot/imported/house_256_192.png-939b43001a6826dd2fb03b0e500d6b91.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/house_256_192.png"
|
||||
dest_files=["res://.godot/imported/house_256_192.png-939b43001a6826dd2fb03b0e500d6b91.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/sprites/environment/house_384_256.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
40
assets/sprites/environment/house_384_256.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bxmbnywn7pd35"
|
||||
path="res://.godot/imported/house_384_256.png-9c21ec19000d397cf04e3d4c46d9fd67.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/house_384_256.png"
|
||||
dest_files=["res://.godot/imported/house_384_256.png-9c21ec19000d397cf04e3d4c46d9fd67.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/sprites/environment/house_384_288.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
40
assets/sprites/environment/house_384_288.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://drl6vecqinsgw"
|
||||
path="res://.godot/imported/house_384_288.png-2c4bb2980ec70d9c801e9d8f5c9da5e2.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/house_384_288.png"
|
||||
dest_files=["res://.godot/imported/house_384_288.png-2c4bb2980ec70d9c801e9d8f5c9da5e2.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/sprites/environment/river.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
40
assets/sprites/environment/river.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c7v86fkrcb4go"
|
||||
path="res://.godot/imported/river.png-5a4acbf78dd4e08f27a3192f37afc708.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/river.png"
|
||||
dest_files=["res://.godot/imported/river.png-5a4acbf78dd4e08f27a3192f37afc708.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/sprites/environment/river2_256_256.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
40
assets/sprites/environment/river2_256_256.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://df3klfat72qro"
|
||||
path="res://.godot/imported/river2_256_256.png-4b34411e3a9844aea328c2b5fdd9f6d7.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/river2_256_256.png"
|
||||
dest_files=["res://.godot/imported/river2_256_256.png-4b34411e3a9844aea328c2b5fdd9f6d7.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/sprites/environment/river2_512_512.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
40
assets/sprites/environment/river2_512_512.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://5r75q24ww18f"
|
||||
path="res://.godot/imported/river2_512_512.png-af87f8da62bcadc69167d888dc2932d0.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/river2_512_512.png"
|
||||
dest_files=["res://.godot/imported/river2_512_512.png-af87f8da62bcadc69167d888dc2932d0.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/sprites/environment/river_256_256.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
40
assets/sprites/environment/river_256_256.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://devfvbybifga6"
|
||||
path="res://.godot/imported/river_256_256.png-9a57f752ac6003da70cffda69b91371a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/river_256_256.png"
|
||||
dest_files=["res://.godot/imported/river_256_256.png-9a57f752ac6003da70cffda69b91371a.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/sprites/environment/river_512_512.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
40
assets/sprites/environment/river_512_512.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b4wt8paqrevg2"
|
||||
path="res://.godot/imported/river_512_512.png-218f1dc6fae57e80762824a68b0c08f6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/river_512_512.png"
|
||||
dest_files=["res://.godot/imported/river_512_512.png-218f1dc6fae57e80762824a68b0c08f6.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/sprites/environment/room_512_384.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
40
assets/sprites/environment/room_512_384.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bjcij2ncikeyw"
|
||||
path="res://.godot/imported/room_512_384.png-339d4ab4d8dc5972ef1c8a09d0380694.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/room_512_384.png"
|
||||
dest_files=["res://.godot/imported/room_512_384.png-339d4ab4d8dc5972ef1c8a09d0380694.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/sprites/environment/standard_brick.png.png
Normal file
|
After Width: | Height: | Size: 569 KiB |
40
assets/sprites/environment/standard_brick.png.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://icto1uyw4hj1"
|
||||
path="res://.godot/imported/standard_brick.png.png-f806a78b5dfedf81aa2e413ced30aa6a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/standard_brick.png.png"
|
||||
dest_files=["res://.godot/imported/standard_brick.png.png-f806a78b5dfedf81aa2e413ced30aa6a.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/sprites/environment/standard_brick_128_128.jpg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
40
assets/sprites/environment/standard_brick_128_128.jpg.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://baa5wkuyqouh6"
|
||||
path="res://.godot/imported/standard_brick_128_128.jpg-0dc76f792db60d64e5610aa75364c4ef.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/standard_brick_128_128.jpg"
|
||||
dest_files=["res://.godot/imported/standard_brick_128_128.jpg-0dc76f792db60d64e5610aa75364c4ef.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/sprites/environment/water.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
40
assets/sprites/environment/water.png.import
Normal file
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://balpojbve2n4f"
|
||||
path="res://.godot/imported/water.png-28a245d0248e5e7513f6a266dcca901f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sprites/environment/water.png"
|
||||
dest_files=["res://.godot/imported/water.png-28a245d0248e5e7513f6a266dcca901f.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
|
||||
86
claude.md
@@ -27,10 +27,10 @@
|
||||
|
||||
## 4. 📋 Coding Standards (The Law)
|
||||
- **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.
|
||||
- Variables/Functions: `snake_case`. Constants: `SCREAMING_SNAKE_CASE`.
|
||||
- Private members: Prefix with underscore `_` (e.g., `var _health: int`).
|
||||
- Variables/Functions: `camelCase` (e.g., `var moveSpeed`, `func updateMovement()`). Constants: `UPPER_CASE`.
|
||||
- Private members: Prefix with underscore `_` (e.g., `var _velocity: Vector2`).
|
||||
- **Node Access**: Use `%UniqueName` for UI and internal scene components.
|
||||
- **Signals**: Use "Signal Up, Call Down". Parent calls child methods; Child emits signals.
|
||||
- **Forbidden Patterns**:
|
||||
@@ -72,13 +72,74 @@
|
||||
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.
|
||||
- **Zero Magic Numbers**: All speeds/timers MUST be `@export` or defined in `Config/`.
|
||||
- **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.
|
||||
|
||||
## 9. 📝 Code Template (Entity Pattern)
|
||||
## 10. 📝 Code Template (Entity Pattern)
|
||||
```gdscript
|
||||
extends CharacterBody2D
|
||||
class_name Player
|
||||
@@ -97,17 +158,4 @@ func _physics_process(delta: float) -> void:
|
||||
func _move(_delta: float) -> void:
|
||||
var dir := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
||||
velocity = dir * move_speed
|
||||
move_and_slide()
|
||||
```
|
||||
## 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.
|
||||
move_and_slide()
|
||||
@@ -1,852 +0,0 @@
|
||||
# WhaleTown 聊天系统实施计划
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
为 WhaleTown 游戏实现基于 Socket.IO 的实时聊天系统,对接现有的 Zulip 集成后端。
|
||||
|
||||
**后端地址**: `wss://whaletownend.xinghangee.icu/game`
|
||||
|
||||
**技术限制**: Godot 原生支持 WebSocket 但不支持 Socket.IO 协议,需要实现轻量级 Socket.IO 协议封装。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心架构原则
|
||||
|
||||
严格遵循项目规范:
|
||||
- **Signal Up, Call Down** - 高层通过事件通知低层
|
||||
- **严格分层** - `_Core`(框架层)、`scenes`(游戏层)、`UI`(界面层)
|
||||
- **类型安全** - 所有变量使用严格类型标注
|
||||
- **命名规范** - `class_name PascalCase`,函数/变量 `snake_case`,常量 `SCREAMING_SNAKE_CASE`
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
### 新建文件
|
||||
|
||||
```
|
||||
_Core/
|
||||
systems/
|
||||
SocketIOClient.gd # Socket.IO 协议封装(核心)
|
||||
managers/
|
||||
ChatManager.gd # 聊天系统业务逻辑管理器
|
||||
WebSocketManager.gd # WebSocket 连接生命周期管理
|
||||
|
||||
scenes/
|
||||
ui/
|
||||
ChatUI.tscn # 聊天界面场景
|
||||
ChatUI.gd # 聊天界面控制器
|
||||
prefabs/ui/
|
||||
ChatMessage.tscn # 单条消息气泡预制体
|
||||
ChatMessage.gd # 消息气泡组件
|
||||
|
||||
tests/
|
||||
unit/
|
||||
test_chat_manager.gd # ChatManager 单元测试
|
||||
test_socketio_client.gd # SocketIOClient 单元测试
|
||||
```
|
||||
|
||||
### 修改文件
|
||||
|
||||
- [_Core/EventNames.gd](_Core/EventNames.gd) - 添加聊天事件常量
|
||||
- [project.godot](project.godot) - 添加 ChatManager 到自动加载
|
||||
|
||||
---
|
||||
|
||||
## 🔧 核心组件设计
|
||||
|
||||
### 1. SocketIOClient.gd - 协议封装层
|
||||
|
||||
**位置**: `_Core/systems/SocketIOClient.gd`
|
||||
|
||||
**职责**:
|
||||
- 封装 Godot 的 `WebSocketPeer`
|
||||
- 实现 Socket.IO 消息协议(简化版 JSON 格式)
|
||||
- 管理事件监听器
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name SocketIOClient
|
||||
extends Node
|
||||
|
||||
# 信号
|
||||
signal connected()
|
||||
signal disconnected()
|
||||
signal event_received(event_name: String, data: Dictionary)
|
||||
signal error_occurred(error: String)
|
||||
|
||||
# 连接管理
|
||||
func connect_to_server(url: String) -> void
|
||||
func disconnect_from_server() -> void
|
||||
func is_connected() -> bool
|
||||
|
||||
# 事件发送(对应 socket.emit)
|
||||
func emit(event_name: String, data: Dictionary) -> void
|
||||
|
||||
# 事件监听(对应 socket.on)
|
||||
func add_event_listener(event_name: String, callback: Callable) -> void
|
||||
|
||||
# 内部处理
|
||||
func _process(delta: float) -> void # 轮询 WebSocket 消息
|
||||
```
|
||||
|
||||
**协议实现要点**:
|
||||
- 后端使用简化版 Socket.IO(纯 JSON,无二进制协议)
|
||||
- 发送消息: `{"type": "login", "token": "..."}` 或 `{"t": "chat", "content": "..."}`
|
||||
- 接收消息: 通过 `"t"` 字段识别事件类型
|
||||
- 所有消息使用 `JSON.stringify()` 序列化
|
||||
|
||||
---
|
||||
|
||||
### 2. WebSocketManager.gd - 连接管理
|
||||
|
||||
**位置**: `_Core/managers/WebSocketManager.gd`
|
||||
|
||||
**职责**:
|
||||
- 管理连接状态(断开、连接中、已连接、重连中)
|
||||
- 自动重连(指数退避:3s, 6s, 12s, 24s, 30s)
|
||||
- 错误恢复
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name WebSocketManager
|
||||
extends Node
|
||||
|
||||
enum ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
RECONNECTING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
# 信号
|
||||
signal connection_state_changed(new_state: ConnectionState)
|
||||
|
||||
# 连接管理
|
||||
func connect_to_game_server() -> void
|
||||
func disconnect() -> void
|
||||
func is_connected() -> bool
|
||||
|
||||
# 自动重连
|
||||
func enable_auto_reconnect(enabled: bool, max_attempts: int = 5, base_delay: float = 3.0)
|
||||
|
||||
# 访问 Socket.IO 客户端
|
||||
func get_socket_client() -> SocketIOClient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ChatManager.gd - 业务逻辑核心
|
||||
|
||||
**位置**: `_Core/managers/ChatManager.gd`
|
||||
|
||||
**职责**:
|
||||
- 聊天消息发送/接收协调
|
||||
- 客户端频率限制(10条/分钟)
|
||||
- 消息历史管理(最多100条)
|
||||
- **Signal Up**: 通过信号和 EventSystem 向上通知
|
||||
- 整合 AuthManager 获取 token
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name ChatManager
|
||||
extends Node
|
||||
|
||||
# 信号(Signal Up)
|
||||
signal chat_message_sent(message_id: String, timestamp: float)
|
||||
signal chat_message_received(from_user: String, content: String, show_bubble: bool, timestamp: float)
|
||||
signal chat_error_occurred(error_code: String, message: String)
|
||||
signal chat_connection_state_changed(state: WebSocketManager.ConnectionState)
|
||||
|
||||
# 聊天操作
|
||||
func send_chat_message(content: String, scope: String = "local") -> void
|
||||
func update_player_position(x: float, y: float, map_id: String) -> void
|
||||
|
||||
# 连接管理
|
||||
func connect_to_chat_server() -> void
|
||||
func disconnect_from_chat_server() -> void
|
||||
|
||||
# 频率限制
|
||||
func can_send_message() -> bool
|
||||
func get_time_until_next_message() -> float
|
||||
|
||||
# 内部事件处理
|
||||
func _on_socket_connected() -> void
|
||||
func _on_socket_event_received(event_name: String, data: Dictionary) -> void
|
||||
func _handle_login_success(data: Dictionary) -> void
|
||||
func _handle_chat_render(data: Dictionary) -> void
|
||||
func _handle_error_response(data: Dictionary) -> void
|
||||
```
|
||||
|
||||
**关键实现**:
|
||||
- **登录流程**: 从 AuthManager 获取 token → 发送 login 消息 → 等待 login_success
|
||||
- **消息发送**: 检查频率限制 → 通过 SocketIOClient 发送 → 记录历史
|
||||
- **消息接收**: 接收 chat_render → 通过 EventSystem 发送事件(Signal Up)
|
||||
|
||||
---
|
||||
|
||||
### 4. EventNames.gd - 事件注册表
|
||||
|
||||
**位置**: [_Core/EventNames.gd](_Core/EventNames.gd)
|
||||
|
||||
**添加内容**:
|
||||
```gdscript
|
||||
# ============================================================================
|
||||
# 聊天事件
|
||||
# ============================================================================
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ChatUI.tscn & ChatUI.gd - 用户界面
|
||||
|
||||
**位置**: `scenes/ui/ChatUI.tscn` 和 `scenes/ui/ChatUI.gd`
|
||||
|
||||
**UI 结构**:
|
||||
```
|
||||
ChatUI (Control)
|
||||
├── ChatPanel (Panel) - 主容器
|
||||
│ ├── ChatHistory (ScrollContainer) - 消息历史
|
||||
│ │ └── MessageList (VBoxContainer) - 消息列表
|
||||
│ ├── InputContainer (HBoxContainer)
|
||||
│ │ ├── ChatInput (LineEdit) - 输入框
|
||||
│ │ └── SendButton (Button) - 发送按钮
|
||||
│ └── StatusLabel (Label) - 连接状态
|
||||
```
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
extends Control
|
||||
|
||||
# 节点引用
|
||||
@onready var chat_history: ScrollContainer = %ChatHistory
|
||||
@onready var message_list: VBoxContainer = %MessageList
|
||||
@onready var chat_input: LineEdit = %ChatInput
|
||||
@onready var send_button: Button = %SendButton
|
||||
@onready var status_label: Label = %StatusLabel
|
||||
|
||||
# 生命周期
|
||||
func _ready() -> void:
|
||||
_subscribe_to_events() # Call Down - 订阅 EventSystem
|
||||
|
||||
# UI 事件
|
||||
func _on_send_button_pressed() -> void:
|
||||
var content: String = chat_input.text
|
||||
ChatManager.send_chat_message(content, "local")
|
||||
|
||||
# 订阅事件(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)
|
||||
|
||||
# 事件处理器
|
||||
func _on_chat_message_received(data: Dictionary) -> void:
|
||||
var from_user: String = data["from_user"]
|
||||
var content: String = data["content"]
|
||||
add_message_to_history(from_user, content, data["timestamp"], false)
|
||||
|
||||
func add_message_to_history(from_user: String, content: String, timestamp: float, is_self: bool) -> void:
|
||||
var message_node: ChatMessage = chat_message_scene.instantiate()
|
||||
message_list.add_child(message_node)
|
||||
message_node.set_message(from_user, content, timestamp, is_self)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. ChatMessage.tscn & ChatMessage.gd - 消息气泡
|
||||
|
||||
**位置**: `scenes/prefabs/ui/ChatMessage.tscn`
|
||||
|
||||
**UI 结构**:
|
||||
```
|
||||
ChatMessage (Panel)
|
||||
├── UserInfo (HBoxContainer)
|
||||
│ ├── UsernameLabel (Label)
|
||||
│ └── TimestampLabel (Label)
|
||||
└── ContentLabel (RichTextLabel)
|
||||
```
|
||||
|
||||
**核心接口**:
|
||||
```gdscript
|
||||
class_name ChatMessage
|
||||
extends Panel
|
||||
|
||||
@export var max_width: int = 400
|
||||
|
||||
@onready var username_label: Label = %UsernameLabel
|
||||
@onready var timestamp_label: Label = %TimestampLabel
|
||||
@onready var content_label: RichTextLabel = %ContentLabel
|
||||
|
||||
func set_message(from_user: String, content: String, timestamp: float, is_self: bool = false) -> void:
|
||||
username_label.text = from_user
|
||||
content_label.text = content
|
||||
# 格式化时间戳和样式
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 数据流与事件通信
|
||||
|
||||
### 发送消息流程
|
||||
|
||||
```
|
||||
用户点击发送按钮
|
||||
↓
|
||||
ChatUI._on_send_button_pressed()
|
||||
↓
|
||||
ChatManager.send_chat_message(content, "local")
|
||||
↓
|
||||
检查频率限制
|
||||
↓
|
||||
SocketIOClient.emit("chat", {t: "chat", content: "...", scope: "local"})
|
||||
↓
|
||||
WebSocketPeer.put_packet(json_bytes)
|
||||
↓
|
||||
服务器响应 chat_sent
|
||||
↓
|
||||
ChatManager._handle_chat_sent()
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_MESSAGE_SENT, data) ← Signal Up
|
||||
↓
|
||||
ChatUI 可以订阅此事件更新 UI
|
||||
```
|
||||
|
||||
### 接收消息流程
|
||||
|
||||
```
|
||||
WebSocketPeer 接收数据
|
||||
↓
|
||||
SocketIOClient._process() 轮询
|
||||
↓
|
||||
解析 JSON,提取 "t" 字段(事件类型)
|
||||
↓
|
||||
event_received.emit("chat_render", data)
|
||||
↓
|
||||
ChatManager._on_socket_event_received()
|
||||
↓
|
||||
_handle_chat_render(data)
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_MESSAGE_RECEIVED, data) ← Signal Up
|
||||
↓
|
||||
ChatUI._on_chat_message_received(data) ← Call Down via EventSystem
|
||||
↓
|
||||
创建 ChatMessage 节点并添加到 UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 错误处理策略
|
||||
|
||||
### 错误码映射(在 ChatManager.gd 中实现)
|
||||
|
||||
```gdscript
|
||||
const CHAT_ERROR_MESSAGES: Dictionary = {
|
||||
"AUTH_FAILED": "聊天认证失败,请重新登录",
|
||||
"RATE_LIMIT": "消息发送过于频繁,请稍后再试",
|
||||
"CONTENT_FILTERED": "消息内容包含违规内容",
|
||||
"CONTENT_TOO_LONG": "消息内容过长(最大1000字符)",
|
||||
"PERMISSION_DENIED": "您没有权限发送消息",
|
||||
"SESSION_EXPIRED": "会话已过期,请重新连接",
|
||||
"ZULIP_ERROR": "消息服务暂时不可用",
|
||||
"INTERNAL_ERROR": "服务器内部错误"
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理流程
|
||||
|
||||
```
|
||||
服务器返回 error
|
||||
↓
|
||||
ChatManager._handle_error_response(data)
|
||||
↓
|
||||
提取 error_code 和 message
|
||||
↓
|
||||
映射为用户友好的错误消息
|
||||
↓
|
||||
EventSystem.emit_event(CHAT_ERROR_OCCURRED, {...}) ← Signal Up
|
||||
↓
|
||||
ChatUI._on_chat_error(data) ← Call Down
|
||||
↓
|
||||
显示错误提示(Toast 或 Label)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置与常量
|
||||
|
||||
### ChatManager.gd 常量
|
||||
|
||||
```gdscript
|
||||
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_MESSAGE_HISTORY: int = 100
|
||||
```
|
||||
|
||||
### project.godot 自动加载
|
||||
|
||||
```ini
|
||||
[autoload]
|
||||
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 集成点
|
||||
|
||||
### 1. AuthManager 集成
|
||||
|
||||
**需求**: ChatManager 需要获取游戏 token
|
||||
|
||||
**解决方案**: 在 AuthManager 中添加方法
|
||||
```gdscript
|
||||
# AuthManager.gd - 添加此方法
|
||||
func get_game_token() -> String:
|
||||
# 返回登录时保存的 token
|
||||
return _game_token if _game_token != null else ""
|
||||
```
|
||||
|
||||
**注意事项**: 需要在 `/auth/login` 成功后保存 token 到 AuthManager
|
||||
|
||||
### 2. EventSystem 集成
|
||||
|
||||
**ChatManager 发送事件**(Signal Up):
|
||||
```gdscript
|
||||
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
|
||||
"from_user": from_user,
|
||||
"content": content,
|
||||
"show_bubble": show_bubble,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
```
|
||||
|
||||
**ChatUI 订阅事件**(Call Down):
|
||||
```gdscript
|
||||
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
```
|
||||
|
||||
### 3. 自动连接时机
|
||||
|
||||
在游戏进入主场景时自动连接聊天:
|
||||
```gdscript
|
||||
# MainScene.gd 或 GameManager.gd
|
||||
func _ready():
|
||||
ChatManager.connect_to_chat_server()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 API 规范(来自 api.md)
|
||||
|
||||
### 消息类型
|
||||
|
||||
#### 1. 登录
|
||||
```json
|
||||
// 发送
|
||||
{"type": "login", "token": "user_game_token"}
|
||||
|
||||
// 成功响应
|
||||
{"t": "login_success", "sessionId": "...", "currentMap": "...", "username": "..."}
|
||||
|
||||
// 失败响应
|
||||
{"t": "error", "code": "AUTH_FAILED", "message": "..."}
|
||||
```
|
||||
|
||||
#### 2. 发送聊天
|
||||
```json
|
||||
// 发送
|
||||
{"t": "chat", "content": "Hello", "scope": "local"}
|
||||
|
||||
// 成功响应
|
||||
{"t": "chat_sent", "messageId": "...", "timestamp": 1703500800000}
|
||||
```
|
||||
|
||||
#### 3. 接收聊天
|
||||
```json
|
||||
// 服务器推送
|
||||
{"t": "chat_render", "from": "other_player", "txt": "Hi!", "bubble": true, "timestamp": 1703500800000}
|
||||
```
|
||||
|
||||
#### 4. 位置更新
|
||||
```json
|
||||
// 发送
|
||||
{"t": "position", "x": 150, "y": 200, "mapId": "novice_village"}
|
||||
|
||||
// 响应
|
||||
{"t": "position_updated", "stream": "Novice Village", "topic": "General"}
|
||||
```
|
||||
|
||||
#### 5. 登出
|
||||
```json
|
||||
// 发送
|
||||
{"type": "logout"}
|
||||
|
||||
// 响应
|
||||
{"t": "logout_success"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
### 单元测试
|
||||
|
||||
**test_socketio_client.gd**:
|
||||
- 测试消息格式化(JSON 序列化)
|
||||
- 测试事件监听器注册
|
||||
- 测试连接状态管理
|
||||
|
||||
**test_chat_manager.gd**:
|
||||
- 测试消息发送流程
|
||||
- 测试频率限制(10条/分钟)
|
||||
- 测试消息历史管理(最多100条)
|
||||
|
||||
### 集成测试
|
||||
|
||||
**test_chat_integration.gd**:
|
||||
- 测试完整的连接 → 登录 → 发送消息 → 接收消息流程
|
||||
- 测试自动重连机制
|
||||
- 测试错误处理流程
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 成功连接到游戏服务器
|
||||
- [ ] 使用有效 token 登录成功
|
||||
- [ ] 发送聊天消息成功
|
||||
- [ ] 接收到其他玩家消息
|
||||
- [ ] 位置更新发送成功
|
||||
- [ ] 频率限制生效(10条/分钟)
|
||||
- [ ] 连接状态在 UI 正确显示
|
||||
- [ ] 断线后自动重连成功
|
||||
- [ ] 错误消息正确显示
|
||||
- [ ] 消息历史正确显示
|
||||
|
||||
---
|
||||
|
||||
## 📅 实施顺序
|
||||
|
||||
### 阶段 1: 基础设施(第1-2步)
|
||||
1. 创建 `_Core/systems/SocketIOClient.gd` - WebSocket 协议封装
|
||||
2. 创建 `_Core/managers/WebSocketManager.gd` - 连接管理
|
||||
3. 测试与后端的 WebSocket 连接
|
||||
|
||||
### 阶段 2: 业务逻辑(第3-4步)
|
||||
4. 创建 `_Core/managers/ChatManager.gd` - 聊天管理器
|
||||
5. 实现登录流程(从 AuthManager 获取 token)
|
||||
6. 实现消息发送/接收逻辑
|
||||
7. 添加频率限制和错误处理
|
||||
|
||||
### 阶段 3: 用户界面(第5-6步)
|
||||
8. 创建 `scenes/prefabs/ui/ChatMessage.tscn` - 消息气泡
|
||||
9. 创建 `scenes/ui/ChatUI.tscn` - 聊天界面
|
||||
10. 实现 `ChatUI.gd` - 事件订阅和 UI 交互
|
||||
|
||||
### 阶段 4: 集成(第7步)
|
||||
11. 更新 `_Core/EventNames.gd` - 添加聊天事件
|
||||
12. 更新 `project.godot` - 添加 ChatManager 到自动加载
|
||||
13. 集成 AuthManager(添加 get_game_token 方法)
|
||||
14. 在主场景中初始化聊天连接
|
||||
|
||||
### 阶段 5: 测试与优化(第8-9步)
|
||||
15. 编写单元测试
|
||||
16. 编写集成测试
|
||||
17. 手动测试清单验证
|
||||
18. 性能优化(消息历史限制、UI 更新优化)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键注意事项
|
||||
|
||||
1. **Token 获取**: 需要确认 `/auth/login` 返回的 token 是否就是 WebSocket 登录需要的 token
|
||||
2. **协议简化**: 后端使用简化版 Socket.IO(纯 JSON),不需要实现完整的 Socket.IO 二进制协议
|
||||
3. **频率限制**: 客户端和服务器都会限制,客户端检查是为了更好的用户体验
|
||||
4. **消息历史**: 限制在内存中保存最多 100 条消息,避免内存泄漏
|
||||
5. **UI 更新**: 使用 `@onready` 缓存节点引用,避免在 `_process` 中使用 `get_node()`
|
||||
6. **类型安全**: 所有变量必须使用严格类型标注(`var name: String = ""`)
|
||||
7. **Signal Up, Call Down**: ChatManager 通过 EventSystem 发送事件,ChatUI 通过 EventSystem 订阅事件
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- [api.md](api.md) - Zulip 集成 API 文档
|
||||
- [test_zulip.js](test_zulip.js) - 后端测试客户端(Node.js + Socket.IO)
|
||||
- [_Core/EventNames.gd](_Core/EventNames.gd) - 事件名称常量
|
||||
- [_Core/systems/EventSystem.gd](_Core/systems/EventSystem.gd) - 事件系统实现
|
||||
- [_Core/managers/NetworkManager.gd](_Core/managers/NetworkManager.gd) - HTTP 请求管理器(参考模式)
|
||||
- [scenes/ui/AuthScene.gd](scenes/ui/AuthScene.gd) - UI 控制器参考模式
|
||||
|
||||
---
|
||||
|
||||
## ✅ 实施进度(2025-01-06)
|
||||
|
||||
### 已完成 ✅
|
||||
|
||||
#### 阶段 1: 基础设施 ✅
|
||||
- [x] **SocketIOClient.gd** - Socket.IO 协议封装(284 行)
|
||||
- WebSocket 连接管理
|
||||
- 消息发送/接收(JSON 格式)
|
||||
- 事件监听器系统
|
||||
- 连接状态管理
|
||||
|
||||
- [x] **WebSocketManager.gd** - 连接生命周期管理(329 行)
|
||||
- 连接状态枚举(DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, ERROR)
|
||||
- 自动重连机制(指数退避:3s → 6s → 12s → 24s → 30s)
|
||||
- 错误恢复逻辑
|
||||
|
||||
#### 阶段 2: 业务逻辑 ✅
|
||||
- [x] **ChatManager.gd** - 聊天业务逻辑核心(641 行)
|
||||
- Token 管理(set_game_token / get_game_token)
|
||||
- 消息发送/接收协调
|
||||
- 客户端频率限制(10条/分钟)
|
||||
- **会话与历史分离**:
|
||||
- 当前会话缓存:最多 100 条消息(内存中,性能优化)
|
||||
- 历史消息:存储在 Zulip 后端,按需加载(每次 100 条)
|
||||
- 会话重置:每次登录/重连时清空缓存,重新接收消息
|
||||
- 会话管理方法:
|
||||
- `reset_session()` - 清空当前会话缓存
|
||||
- `load_history(count)` - 从 Zulip 加载历史消息
|
||||
- `_on_history_loaded(messages)` - 历史消息加载完成回调
|
||||
- **Signal Up**: 通过 EventSystem 发送事件
|
||||
- 错误处理和映射
|
||||
|
||||
- [x] **EventNames.gd** - 添加 7 个聊天事件常量
|
||||
```gdscript
|
||||
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"
|
||||
```
|
||||
|
||||
- [x] **project.godot** - 添加 ChatManager 到自动加载
|
||||
```ini
|
||||
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||
```
|
||||
|
||||
- [x] **AuthManager.gd** - Token 管理集成
|
||||
- 添加 `_game_token: String` 成员变量
|
||||
- 添加 `set_game_token()` 方法
|
||||
- 添加 `get_game_token()` 方法
|
||||
|
||||
#### 阶段 3: 用户界面 ✅
|
||||
- [x] **ChatMessage.tscn & ChatMessage.gd** - 消息气泡组件(185 行)
|
||||
- 区分自己/他人消息样式(不同背景色和对齐)
|
||||
- 自动格式化时间戳(HH:MM)
|
||||
- 响应式布局(最大宽度 400px)
|
||||
|
||||
- [x] **ChatUI.tscn & ChatUI.gd** - 聊天界面(279 行脚本 + 场景)
|
||||
- 消息历史显示(ScrollContainer)
|
||||
- 输入框和发送按钮
|
||||
- 连接状态显示
|
||||
- 最小化/最大化功能
|
||||
- **Call Down**: 通过 EventSystem 订阅事件
|
||||
|
||||
#### 阶段 4: 测试 ✅ (MANDATORY)
|
||||
- [x] **test_socketio_client.gd** - SocketIOClient 单元测试(361 行,42 个测试用例)
|
||||
- 初始化测试
|
||||
- 连接状态管理测试
|
||||
- 事件监听器管理测试
|
||||
- JSON 序列化测试(含 Unicode)
|
||||
- 信号测试
|
||||
- 边界条件测试
|
||||
|
||||
- [x] **test_websocket_manager.gd** - WebSocketManager 单元测试(331 行,38 个测试用例)
|
||||
- 初始化测试
|
||||
- 连接状态管理测试
|
||||
- 自动重连机制测试
|
||||
- 重连延迟计算测试(指数退避)
|
||||
- Socket.IO 客户端访问测试
|
||||
- 常量测试
|
||||
- 状态转换测试
|
||||
|
||||
- [x] **test_chat_manager.gd** - ChatManager 单元测试(432 行,48 个测试用例)
|
||||
- 初始化测试
|
||||
- Token 管理测试
|
||||
- 频率限制测试(10条/分钟)
|
||||
- 消息历史管理测试(最多100条)
|
||||
- 错误处理测试
|
||||
- 信号测试
|
||||
- 边界条件测试(空消息、超长消息、Unicode)
|
||||
|
||||
**测试覆盖统计**:
|
||||
- 总测试文件: 3 个
|
||||
- 总测试用例: 128 个
|
||||
- 测试代码行数: 1,124 行
|
||||
|
||||
---
|
||||
|
||||
### 待完成 ⚠️
|
||||
|
||||
#### 集成工作
|
||||
- [ ] **主场景集成**
|
||||
- 在 MainScene.gd 或 GameManager.gd 中添加 `ChatManager.connect_to_chat_server()`
|
||||
- 在用户登录成功后设置 token:`ChatManager.set_game_token(token)`
|
||||
- 添加聊天 UI 到游戏界面
|
||||
|
||||
- [ ] **Token 获取流程**
|
||||
- 需要确认 `/auth/login` 返回的数据中是否包含 token
|
||||
- 如果包含,在登录成功回调中保存并设置到 ChatManager
|
||||
- 如果不包含,需要单独获取游戏 token 的接口
|
||||
|
||||
#### 测试与验证
|
||||
- [ ] **手动测试**
|
||||
- [ ] 成功连接到游戏服务器
|
||||
- [ ] 使用有效 token 登录成功
|
||||
- [ ] 发送聊天消息成功
|
||||
- [ ] 接收到其他玩家消息
|
||||
- [ ] 位置更新发送成功
|
||||
- [ ] 频率限制生效(10条/分钟)
|
||||
- [ ] 连接状态在 UI 正确显示
|
||||
- [ ] 断线后自动重连成功
|
||||
- [ ] 错误消息正确显示
|
||||
- [ ] 消息历史正确显示
|
||||
|
||||
- [ ] **运行单元测试**
|
||||
```bash
|
||||
godot --headless -s addons/gut/gut_cmdline.gd -gdir=res://tests/unit -ginclude_subdirs
|
||||
```
|
||||
|
||||
#### 功能增强(可选)
|
||||
- [ ] **世界内聊天气泡**
|
||||
- 在玩家头顶显示聊天气泡
|
||||
- 根据 `bubble` 字段决定是否显示
|
||||
- 自动消失机制(3-5 秒)
|
||||
|
||||
- [ ] **聊天命令系统**
|
||||
- 支持 `/help`, `/whisper`, `/invite` 等命令
|
||||
- 命令解析和执行
|
||||
|
||||
- [ ] **聊天历史持久化**
|
||||
- 保存到本地存储
|
||||
- 重启后恢复聊天记录
|
||||
|
||||
- [ ] **消息搜索功能**
|
||||
- 在聊天历史中搜索关键字
|
||||
|
||||
---
|
||||
|
||||
### 使用指南
|
||||
|
||||
#### 基本使用流程
|
||||
|
||||
```gdscript
|
||||
# 1. 用户登录成功后,设置 token
|
||||
func _on_login_success(token: String, username: String):
|
||||
# 设置游戏 token
|
||||
ChatManager.set_game_token(token)
|
||||
|
||||
# 连接到聊天服务器
|
||||
ChatManager.connect_to_chat_server()
|
||||
|
||||
# 2. 显示聊天界面
|
||||
func _show_chat_ui():
|
||||
var chat_ui := preload("res://scenes/ui/ChatUI.tscn").instantiate()
|
||||
add_child(chat_ui)
|
||||
|
||||
# 3. 订阅聊天事件(可选)
|
||||
func _subscribe_to_chat_events():
|
||||
EventSystem.connect_event(EventNames.CHAT_MESSAGE_RECEIVED, _on_message_received)
|
||||
EventSystem.connect_event(EventNames.CHAT_ERROR_OCCURRED, _on_chat_error)
|
||||
|
||||
func _on_message_received(data: Dictionary):
|
||||
print("收到消息: ", data["from_user"], " -> ", data["content"])
|
||||
|
||||
func _on_chat_error(data: Dictionary):
|
||||
print("聊天错误: ", data["message"])
|
||||
|
||||
# 4. 发送消息(通过 UI 或代码)
|
||||
func send_test_message():
|
||||
ChatManager.send_chat_message("Hello, world!", "local")
|
||||
|
||||
# 5. 更新玩家位置(可选,用于切换地图聊天频道)
|
||||
func _on_player_moved_to_new_map(position: Vector2, map_id: String):
|
||||
ChatManager.update_player_position(position.x, position.y, map_id)
|
||||
```
|
||||
|
||||
#### Token 配置说明
|
||||
|
||||
根据 [test_zulip.js](test_zulip.js) 的测试代码,游戏 token 就是用户的 Zulip API Key。
|
||||
|
||||
**测试 token**(来自 test_zulip.js):
|
||||
```
|
||||
Ke8BYpbWBUhRrkCUW8kGlnhAWE3jBauf
|
||||
```
|
||||
|
||||
**测试方法**:
|
||||
```gdscript
|
||||
# 在开发阶段,可以手动设置测试 token
|
||||
func _ready():
|
||||
if OS.has_feature("editor"):
|
||||
# 编辑器模式下使用测试 token
|
||||
ChatManager.set_game_token("Ke8BYpbWBUhRrkCUW8kGlnhAWE3jBauf")
|
||||
ChatManager.connect_to_chat_server()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 文件清单
|
||||
|
||||
#### 核心文件(9 个)
|
||||
- `_Core/systems/SocketIOClient.gd` - 284 行
|
||||
- `_Core/managers/WebSocketManager.gd` - 329 行
|
||||
- `_Core/managers/ChatManager.gd` - 643 行(含会话/历史分离)
|
||||
- `_Core/managers/AuthManager.gd` - 修改(添加 token 管理)
|
||||
- `_Core/EventNames.gd` - 修改(添加 7 个常量)
|
||||
- `scenes/prefabs/ui/ChatMessage.tscn` - 场景文件
|
||||
- `scenes/prefabs/ui/ChatMessage.gd` - 185 行
|
||||
- `scenes/ui/ChatUI.tscn` - 场景文件
|
||||
- `scenes/ui/ChatUI.gd` - 279 行
|
||||
|
||||
#### 测试文件(3 个)
|
||||
- `tests/unit/test_socketio_client.gd` - 361 行,42 个测试
|
||||
- `tests/unit/test_websocket_manager.gd` - 331 行,38 个测试
|
||||
- `tests/unit/test_chat_manager.gd` - 432 行,48 个测试
|
||||
|
||||
#### 配置文件(1 个)
|
||||
- `project.godot` - 修改(添加 ChatManager 到 autoload)
|
||||
|
||||
**总计**: 13 个文件,2,843 行代码(不含配置),128 个测试用例
|
||||
|
||||
---
|
||||
|
||||
### 下一步行动
|
||||
|
||||
1. **立即执行**:
|
||||
- 在 MainScene.gd 中集成 ChatManager
|
||||
- 在登录成功后设置 token
|
||||
- 运行单元测试验证功能
|
||||
|
||||
2. **短期目标**:
|
||||
- 完成手动测试清单
|
||||
- 修复发现的 bug
|
||||
- 优化性能和用户体验
|
||||
|
||||
3. **长期目标**:
|
||||
- 添加世界内聊天气泡
|
||||
- 实现聊天命令系统
|
||||
- 添加聊天历史持久化
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025-01-06
|
||||
**实施状态**: 核心功能完成(含会话/历史分离),待集成测试
|
||||
**测试覆盖**: ✅ 100% (所有 Core 组件都有单元测试)
|
||||
**最新功能**: ✅ 会话与历史消息分离架构实现
|
||||
- 当前会话:最多 100 条消息(内存缓存)
|
||||
- 历史消息:Zulip 后端存储,按需加载(每次 100 条)
|
||||
- 会话重置:每次登录/重连时自动清空缓存
|
||||
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,7 +22,7 @@ SceneManager="*res://_Core/managers/SceneManager.gd"
|
||||
EventSystem="*res://_Core/systems/EventSystem.gd"
|
||||
NetworkManager="*res://_Core/managers/NetworkManager.gd"
|
||||
ResponseHandler="*res://_Core/managers/ResponseHandler.gd"
|
||||
ChatManager="*res://_Core/managers/ChatManager.gd"
|
||||
WebSocketManager="*res://_Core/managers/WebSocketManager.gd"
|
||||
|
||||
[debug]
|
||||
|
||||
@@ -30,10 +30,10 @@ gdscript/warnings/treat_warnings_as_errors=false
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1376
|
||||
window/size/viewport_height=768
|
||||
window/size/viewport_width=1920
|
||||
window/size/viewport_height=1440
|
||||
window/size/mode=2
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/mode="viewport"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
[gui]
|
||||
@@ -86,6 +86,8 @@ locale/test="zh_CN"
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
2d/snap/snap_2d_transforms_to_pixel=true
|
||||
2d/snap/snap_2d_vertices_to_pixel=true
|
||||
fonts/dynamic_fonts/use_oversampling=true
|
||||
debug/disable_vsync=false
|
||||
debug/settings/stdout/print_fps=false
|
||||
|
||||
@@ -50,6 +50,9 @@ func _ready():
|
||||
# 初始化游戏状态
|
||||
setup_game()
|
||||
|
||||
# [TEST] 临时绕过登录
|
||||
# call_deferred("_on_login_success", "LocalTester")
|
||||
|
||||
# 连接登录成功信号
|
||||
auth_scene.login_success.connect(_on_login_success)
|
||||
|
||||
@@ -74,8 +77,36 @@ func show_main_game():
|
||||
auth_scene.visible = false
|
||||
main_game_ui.visible = true
|
||||
user_label.text = "当前用户: " + current_user
|
||||
update_player_status()
|
||||
print("进入主游戏界面")
|
||||
# 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. 加载玩家 - 交由 BaseLevel 或场景脚本动态处理
|
||||
# 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():
|
||||
level_label.text = "等级: " + str(player_level)
|
||||
@@ -83,10 +114,15 @@ func update_player_status():
|
||||
exp_label.text = "经验: " + str(player_exp) + "/" + str(player_max_exp)
|
||||
energy_label.text = "体力: " + str(player_energy) + "/" + str(player_max_energy)
|
||||
|
||||
func _on_login_success(username: String):
|
||||
func _on_login_success(username: String, token: String):
|
||||
# 登录成功后的处理
|
||||
current_user = username
|
||||
print("用户 ", username, " 登录成功!")
|
||||
|
||||
# 连接到游戏服务器
|
||||
WebSocketManager.set_auth_token(token)
|
||||
WebSocketManager.connect_to_server()
|
||||
|
||||
show_main_game()
|
||||
|
||||
func _on_logout_pressed():
|
||||
@@ -124,4 +160,5 @@ func _input(event):
|
||||
get_tree().quit()
|
||||
GameState.MAIN_GAME:
|
||||
# 在游戏中按ESC可能显示菜单或返回登录
|
||||
show_auth_scene()
|
||||
# show_auth_scene()
|
||||
pass
|
||||
|
||||
318
scenes/Maps/BaseLevel.gd
Normal file
@@ -0,0 +1,318 @@
|
||||
class_name BaseLevel
|
||||
extends Node2D
|
||||
|
||||
# 基础关卡脚本
|
||||
# 负责处理通用的关卡逻辑,如玩家生成
|
||||
|
||||
@onready var spawn_points = $SpawnPoints if has_node("SpawnPoints") else null
|
||||
@onready var players_container = $Objects/Players if has_node("Objects/Players") else self
|
||||
|
||||
func _ready():
|
||||
# 延时一帧确保所有子节点就绪
|
||||
call_deferred("_spawn_player")
|
||||
|
||||
# 连接到多人会话
|
||||
# 获取当前场景名字作为 Session ID
|
||||
_current_session_id = SceneManager.get_current_scene_name()
|
||||
if _current_session_id == "":
|
||||
_current_session_id = "square" # Fallback for direct run
|
||||
|
||||
# 如果是私人场景,生成唯一的 Session ID (e.g., room_123)
|
||||
if _current_session_id in PRIVATE_SCENES:
|
||||
_current_session_id = _current_session_id + "_" + str(AuthManager.current_user_id)
|
||||
print("BaseLevel: 进入私密房间实例: ", _current_session_id)
|
||||
|
||||
print("BaseLevel: Preparing to join session: ", _current_session_id)
|
||||
|
||||
# 如果 WebSocket 已连接,直接加入
|
||||
if WebSocketManager._socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
|
||||
_join_session_with_player(_current_session_id)
|
||||
else:
|
||||
# 否则等待连接成功信号
|
||||
WebSocketManager.connected_to_server.connect(func(): _join_session_with_player(_current_session_id))
|
||||
|
||||
# 连接远程玩家相关信号
|
||||
WebSocketManager.session_joined.connect(_on_session_joined)
|
||||
WebSocketManager.user_joined.connect(_on_user_joined)
|
||||
WebSocketManager.user_left.connect(_on_user_left)
|
||||
WebSocketManager.position_updated.connect(_on_position_updated)
|
||||
|
||||
# Debug: Simulate fake player to verify rendering
|
||||
# get_tree().create_timer(2.0).timeout.connect(_debug_fake_activity)
|
||||
|
||||
func _exit_tree():
|
||||
# 场景卸载时不需要再做操作,交给 DoorTeleport 或其他切换逻辑显示处理
|
||||
pass
|
||||
|
||||
func _debug_fake_activity():
|
||||
print("BaseLevel: Starting fake player simulation")
|
||||
var fake_id = "fake_ghost"
|
||||
_on_user_joined({"user": {"userId": fake_id, "username": "Ghost"}, "position": {"x": 500, "y": 500}})
|
||||
|
||||
for i in range(20):
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
var new_pos = Vector2(500 + i * 20, 500 + i * 10)
|
||||
_on_position_updated({"userId": fake_id, "position": {"x": new_pos.x, "y": new_pos.y}})
|
||||
print("BaseLevel: Ghost moved to ", new_pos)
|
||||
|
||||
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"
|
||||
|
||||
print("BaseLevel: 同步会话 - 当前场景: ", current_map, ", 收到用户数: ", data.users.size())
|
||||
|
||||
# 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)
|
||||
print("BaseLevel: 用户 ", user.userId, " 的位置数据: ", user.position)
|
||||
else:
|
||||
print("BaseLevel: 用户 ", user.userId, " 没有位置数据")
|
||||
|
||||
print("BaseLevel: 用户 ", user.userId, " mapId=", user_map_id, ", 当前场景=", current_map)
|
||||
|
||||
if user_map_id == current_map:
|
||||
var uid = str(user.userId)
|
||||
valid_user_ids.append(uid)
|
||||
valid_users[uid] = user
|
||||
print("BaseLevel: 添加有效用户: ", uid)
|
||||
else:
|
||||
# mapId 不匹配,这是"幽灵玩家"
|
||||
print("BaseLevel: 跳过 mapId 不匹配的玩家: ", user.get("userId"), " (mapId=", user_map_id, ", expected=", current_map, ")")
|
||||
|
||||
# 2. 清理幽灵:移除本地有但不在有效列表中的玩家
|
||||
var local_user_ids = remote_players.keys()
|
||||
for user_id in local_user_ids:
|
||||
if str(user_id) not in valid_user_ids:
|
||||
print("BaseLevel: 清理幽灵玩家: ", user_id)
|
||||
_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:
|
||||
# 不存在,创建新玩家
|
||||
print("BaseLevel: 创建远程玩家: ", uid)
|
||||
_add_remote_player(user)
|
||||
|
||||
func _on_user_joined(data: Dictionary):
|
||||
var user = data.get("user", {})
|
||||
if user.has("userId"):
|
||||
print("BaseLevel: 新玩家加入: ", user.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:
|
||||
print("BaseLevel: 玩家离开: ", 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 # 兼容扁平格式
|
||||
|
||||
# print("BaseLevel: 收到位置更新: ", user_id, " Data: ", pos_data) # Debug log
|
||||
|
||||
# 检查 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:
|
||||
print("BaseLevel: 收到异地位置更新,移除幽灵玩家: ", user_id, " (在该玩家在 ", pos_data.mapId, ")")
|
||||
_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:
|
||||
# Fallback: 手动设置属性 (如果脚本没更新)
|
||||
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
|
||||
print("BaseLevel: 已创建远程玩家: ", user_id)
|
||||
|
||||
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 WebSocketManager._auth_token == "":
|
||||
print("BaseLevel: Token not ready, waiting...")
|
||||
# 轮询等待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
|
||||
|
||||
WebSocketManager.join_session(session_id, pos)
|
||||
|
||||
# 强制广播一次位置更新,确保旧房间的玩家立即收到 "已切换地图" 的通知
|
||||
# 这能解决"需要移动两步幽灵才消失"的问题
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
WebSocketManager.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 WebSocketManager._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
|
||||
# 使用 _current_session_id 确保有正确的 fallback
|
||||
var map_id = _current_session_id if _current_session_id != "" else "square"
|
||||
WebSocketManager.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()
|
||||
|
||||
print("BaseLevel: Checking spawn point for name: '", 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
|
||||
print("BaseLevel: Found spawn marker '", target_node_name, "' at ", spawn_pos)
|
||||
else:
|
||||
# 策略 C: 检查 SceneManager 是否有备用坐标 (兼容旧逻辑)
|
||||
var pos_param = SceneManager.get_next_scene_position()
|
||||
if pos_param != null:
|
||||
spawn_pos = pos_param
|
||||
print("BaseLevel: Using explicit position from SceneManager: ", spawn_pos)
|
||||
else:
|
||||
print("BaseLevel: Warning - Could not find marker '", target_node_name, "' and no explicit position set. Using (0,0)")
|
||||
|
||||
# 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)
|
||||
|
||||
print("BaseLevel: Player spawned at ", player.global_position)
|
||||
_local_player = player # Save reference
|
||||
_player_spawned = true
|
||||
1
scenes/Maps/BaseLevel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c5ml4722ptwp2
|
||||
39
scenes/Maps/DoorTeleport.gd
Normal file
@@ -0,0 +1,39 @@
|
||||
extends Area2D
|
||||
|
||||
# 场景名称
|
||||
@export var target_scene_name: String = ""
|
||||
@export var target_position: Vector2 = Vector2.ZERO # 兼容旧逻辑
|
||||
@export var target_spawn_name: String = "" # 新逻辑:指定目标场景的 Marker2D 名称 (例如 "FromSquare") # 目标场景的生成位置 (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:
|
||||
if target_scene_name == "":
|
||||
print("Error: Target scene name is empty!")
|
||||
return
|
||||
|
||||
print("Teleporting to scene: ", target_scene_name)
|
||||
|
||||
# 设置参数
|
||||
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://b068cnbw3a8wt
|
||||
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
scenes/Maps/door_teleport.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
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)
|
||||
727
scenes/Maps/room.tscn
Normal file
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
|
||||
92
scenes/characters/PlayerController.gd
Normal file
@@ -0,0 +1,92 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
# 信号定义
|
||||
signal player_moved(position: Vector2)
|
||||
|
||||
# 常量定义
|
||||
const MOVE_SPEED = 200.0
|
||||
|
||||
# 节点引用
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
@onready var sprite: Sprite2D = $Sprite2D
|
||||
@onready var ray_cast: RayCast2D = $RayCast2D
|
||||
|
||||
var last_direction := "down"
|
||||
|
||||
func _ready() -> void:
|
||||
# 检查是否有初始位置设置
|
||||
call_deferred("_check_spawn_position")
|
||||
|
||||
# 播放初始动画
|
||||
if animation_player.has_animation("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 _check_spawn_position() -> void:
|
||||
var spawn_pos = SceneManager.get_next_scene_position()
|
||||
if spawn_pos != null:
|
||||
global_position = spawn_pos
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_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:
|
||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||
var direction := Input.get_vector(
|
||||
"move_left", "move_right",
|
||||
"move_up", "move_down"
|
||||
)
|
||||
|
||||
# 应用移动
|
||||
if direction != Vector2.ZERO:
|
||||
velocity = direction * MOVE_SPEED
|
||||
_update_animation_state(direction)
|
||||
else:
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# 发送移动事件 (如果位置发生明显变化)
|
||||
if velocity.length() > 0:
|
||||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||||
"position": global_position
|
||||
})
|
||||
|
||||
func _update_animation_state(direction: Vector2) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
# Determine primary direction
|
||||
if abs(direction.x) > abs(direction.y):
|
||||
if direction.x > 0:
|
||||
last_direction = "right"
|
||||
ray_cast.target_position = Vector2(60, 0)
|
||||
else:
|
||||
last_direction = "left"
|
||||
ray_cast.target_position = Vector2(-60, 0)
|
||||
else:
|
||||
if direction.y > 0:
|
||||
last_direction = "down"
|
||||
ray_cast.target_position = Vector2(0, 60)
|
||||
else:
|
||||
last_direction = "up"
|
||||
ray_cast.target_position = Vector2(0, -60)
|
||||
|
||||
animation_player.play("walk_" + last_direction)
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
if animation_player:
|
||||
animation_player.play("idle_" + last_direction)
|
||||
1
scenes/characters/PlayerController.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://fdswi18nel8n
|
||||
87
scenes/characters/RemotePlayer.gd
Normal file
@@ -0,0 +1,87 @@
|
||||
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
|
||||
|
||||
# 如果有名字显示需求,可在此扩展
|
||||
# if data.has("username") and has_node("Label"):
|
||||
# $Label.text = data.username
|
||||
|
||||
# 更新目标位置
|
||||
func update_position(new_pos: Vector2):
|
||||
target_position = new_pos
|
||||
|
||||
# 动画更新逻辑 (复用 PlayerController 的命名规范)
|
||||
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)
|
||||