84 Commits

Author SHA1 Message Date
WhaleTown Developer
603e7d9fc6 修bug 2026-01-20 20:11:07 +08:00
王浩
e989b4adf1 docs: 删除 chat_system.md 计划文档 2026-01-14 17:11:48 +08:00
王浩
6e70aac0b9 fix: 修复聊天消息显示问题
- AuthScene: 修复节点路径错误 (WhaleFrame, UsernameInput)
- ChatManager: 修复 timestamp 类型转换 (String -> float)
- ChatMessage: 修复节点引用获取方式和 UI 显示
- ChatUI: 优化消息列表布局对齐
2026-01-14 17:10:48 +08:00
625fe0ff6c merge upstream 2026-01-14 15:19:15 +08:00
WhaleTown Developer
0e5b9f947b refactor: 移除SocketIOClient并更新认证场景
- 移除SocketIOClient.gd及.uid文件
- 更新ChatManager以适配新的架构
- 添加认证UI图片资源和背景音乐
- 优化AuthScene布局和配置
- 更新MainScene和项目配置
2026-01-11 23:12:35 +08:00
7cca58cb07 Merge pull request 'feature/whaletown-developer-skill' (#12) from feature/whaletown-developer-skill into main
Reviewed-on: datawhale/whale-town-front#12
2026-01-10 19:31:05 +08:00
王浩
749e2c257b assets: 添加认证UI图片资源 2026-01-09 23:24:32 +08:00
王浩
e335a35f6c feat(chat-ui): 更新聊天UI和场景配置
- 优化聊天消息显示
- 调整UI布局
2026-01-09 23:23:41 +08:00
王浩
136e1344a0 fix(chat): 修复WebSocket连接和消息格式
- 修复连接状态检测时机问题
- 修复聊天消息格式为 {t: chat, content, scope}
- 添加 _send_login_message 函数
- 移除空消息心跳避免服务器错误
2026-01-09 23:21:12 +08:00
WhaleTown Developer
25a21f92be feat(chat): 优化聊天UI布局和WebSocket连接
- 更新 WebSocket URL 以支持 Socket.IO 握手参数 (EIO=4)
- 重构聊天面板布局,使用绝对定位和百分比锚点
- 优化输入框样式,添加装饰元素
- 修复输入框焦点释放的事件冲突问题
- 将 ChatUI 集成到主场景中
- 改进主场景容器布局设置
2026-01-08 23:59:21 +08:00
王浩
9c2e3bf15a refactor(chat-ui): 移除HeaderContainer和SendButton,添加装饰图片,修复Enter键监听
- 删除 HeaderContainer 和 StatusLabel(状态显示)
- 删除 SendButton(发送按钮)
- 添加聊天框背景图和装饰图片
- 设置 TextureRect modulate 为白色,修复黑框问题
- 移除 ChatPanel 背景色,使背景透明
- 修复 is_key_pressed 错误,改用 InputEventKey 类型检查
- 移除 _update_connection_status 函数及相关调用
2026-01-08 18:14:48 +08:00
WhaleTown Developer
9e288dbb62 docs: 更新聊天系统实施进度
- 简化文档,移除详细修复记录
- 更新实施状态:所有编译错误已修复
- 记录待后端解决的 Zulip 集成问题
2026-01-08 00:31:07 +08:00
WhaleTown Developer
c8e73bec59 fix: 修复聊天系统编译错误
- 修复 WebSocketManager/SocketIOClient 函数缩进错误
- 重命名 is_connected() 避免与 Object 基类冲突
- 修复 tscn 文件多余前导空格
- 修复测试文件 GUT 断言函数调用
- 添加 GUT 测试框架
2026-01-08 00:11:12 +08:00
王浩
16f24ab26f feat:添加聊天UI资源文件
- 添加聊天界面UI资源(缩略框背景、输入框背景、装饰图片)
- 修复 GrassTile.gd.uid 文件缺失

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 17:46:26 +08:00
王浩
414225e8c1 docs:更新项目规范文档
- 在 claude.md 添加 Plan Mode Protocol 章节
- 明确规划阶段的输出要求和执行报告流程
- 强制要求在每个 TODO 完成后更新文档并等待用户确认

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 17:42:48 +08:00
王浩
fb7cba4088 feat:实现聊天系统核心功能
- 添加 SocketIOClient.gd 实现 Socket.IO 协议封装
- 添加 WebSocketManager.gd 管理连接生命周期和自动重连
- 添加 ChatManager.gd 实现聊天业务逻辑与会话管理
  - 支持当前会话缓存(最多 100 条消息)
  - 支持历史消息按需加载(每次 100 条)
  - 每次登录/重连自动重置会话缓存
  - 客户端频率限制(10 条/分钟)
  - Token 管理与认证
- 添加 ChatMessage.gd/tscn 消息气泡 UI 组件
- 添加 ChatUI.gd/tscn 聊天界面
- 在 EventNames.gd 添加 7 个聊天事件常量
- 在 AuthManager.gd 添加 game_token 管理方法
- 添加完整的单元测试(128 个测试用例)
  - test_socketio_client.gd (42 个测试)
  - test_websocket_manager.gd (38 个测试)
  - test_chat_manager.gd (48 个测试)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 17:42:31 +08:00
7b85147994 chore: 合并 main 分支,统一 CLAUDE.md 格式规范
合并 main 分支对 CLAUDE.md 的格式改进,同时保留 feature 分支新增的标准开发工作流(第 8 节)。

主要改动:
- 更新 Godot 版本要求从 4.2+ 到 4.5+
- 规范化 Markdown 格式(代码块、粗体、列表)
- 保留新增的「Standard Development Workflow」章节
- 调整章节编号(第 9、10 节)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 19:41:29 +08:00
e3c4d08021 Merge pull request '修正语法错误' (#11) from qbb0530/whale-town-front:main into main
Reviewed-on: datawhale/whale-town-front#11
2026-01-05 11:28:13 +08:00
王浩
3bdda47191 修正语法错误 2026-01-04 17:17:34 +08:00
43e0c2b928 feat:添加whaletown-developer标准开发工作流技能
- 创建whaletown-developer skill自动化7步开发流程
- 添加完整的使用说明文档和质量检查清单
- 更新CLAUDE.md集成标准开发工作流说明
- 新增标准开发工作流详细文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-04 00:46:48 +08:00
3d6c4e5356 Merge pull request 'feature/网格瓦片系统' (#9) from feature/网格瓦片系统 into main
Reviewed-on: datawhale/whale-town-front#9
2026-01-03 22:40:55 +08:00
c621d70475 chore:清理空的占位文件
- 删除sprites目录下的空.gitkeep文件
- 删除tools目录下的空README.md文件
2026-01-03 22:37:04 +08:00
f527fa3c38 docs:添加网格瓦片系统功能文档
- 详细说明32x32网格系统的使用方法
- 包含核心组件介绍和API参考
- 提供编辑器和代码使用示例
2026-01-03 22:35:36 +08:00
e9fa21280e scene:创建广场地图场景并添加环境瓦片资源
- 新增square.tscn广场地图场景
- 添加多种环境瓦片纹理资源
- 包含草地、地板、路缘等瓦片素材
2026-01-03 22:35:13 +08:00
a3d384d39d style:统一代码文件末尾换行格式 2026-01-03 22:33:56 +08:00
ced69fd4b6 scene:创建草地瓦片预制体
- 实现GrassTile组件,支持32x32网格对齐
- 添加自动纹理验证和占位符生成
- 提供网格位置设置和世界坐标转换
- 包含位置变化信号和调试功能
2026-01-03 22:30:03 +08:00
7a6e5be4f8 config:更新核心配置支持网格系统
- EventNames添加网格相关事件定义
- ProjectPaths添加网格系统和地形资源路径
2026-01-03 22:28:29 +08:00
ba5b0daa13 feat:实现32x32网格系统核心功能
- 添加GridSystem类提供网格坐标转换
- 支持世界坐标与网格坐标互转
- 提供位置吸附和距离计算方法
- 包含网格区域和边界检查功能
2026-01-03 22:28:06 +08:00
83404d031e Merge pull request 'feature/auth-system-refactor' (#8) from feature/auth-system-refactor into main
Reviewed-on: datawhale/whale-town-front#8
2026-01-02 21:25:30 +08:00
709242d223 docs:更新项目结构说明和路径配置
- 更新ProjectPaths.gd中的路径常量
- 更新项目结构说明文档
- 清理临时测试文件的uid引用

确保文档与实际项目结构保持一致
2026-01-02 21:20:31 +08:00
2f1ccbc2cd docs:为核心管理器添加详细中文注释
- GameManager.gd:游戏状态管理注释
- NetworkManager.gd:网络请求管理注释
- SceneManager.gd:场景切换管理注释
- StringUtils.gd:字符串工具函数注释

按照docs注释规范,添加文件头、函数说明、参数描述和使用示例
方便协同开发者快速理解和调用
2026-01-02 21:19:53 +08:00
aaaf2b31a8 fix:修复EventSystem中的GDScript语法错误
- 移除不支持的try/except语句
- 改为直接调用回调函数
- 确保EventSystem能正常编译运行
2026-01-02 21:19:25 +08:00
93baf1a5b5 refactor:统一AuthScene命名规范
- 将LoginWindow.tscn重命名为AuthScene.tscn
- 更新MainScene.tscn中的场景引用路径
- 实现命名一致性:
  - 场景文件:AuthScene.tscn
  - 脚本文件:AuthScene.gd
  - 节点名称:AuthScene
- AuthScene比LoginWindow更准确描述功能(登录+注册)
2026-01-02 21:19:04 +08:00
5f915c61b6 refactor:AuthScene解耦重构,实现视图与业务逻辑分离
- 创建AuthManager.gd:负责所有认证业务逻辑
  - 用户登录/注册逻辑
  - 表单验证逻辑
  - 验证码管理逻辑
  - 网络请求管理

- 创建ToastManager.gd:负责Toast消息管理
  - Toast创建和显示
  - 动画和生命周期管理
  - 支持成功/失败样式

- 重构AuthScene.gd:纯视图层实现
  - 只负责UI交互和显示
  - 通过信号与业务层通信
  - 移除所有业务逻辑代码

- 修复GDScript警告:
  - 未使用参数添加下划线前缀
  - 修复变量名与基类方法冲突
  - 修复EventSystem中的try语法错误
  - 修复AuthManager中的方法名不匹配错误

符合docs中的架构要求,实现完全解耦
2026-01-02 21:18:38 +08:00
d256249789 refactor:将MainScene移动到scenes根目录
- 将MainScene从scenes/Maps/移动到scenes/根目录
- 更新project.godot中的主场景路径配置
- 符合项目结构规范,MainScene作为图像显示入口文件
2026-01-02 21:17:56 +08:00
29c6740870 Merge pull request 'feature/project-structure-refactor' (#7) from feature/project-structure-refactor into main
Reviewed-on: datawhale/whale-town-front#7
2026-01-02 01:03:07 +08:00
7b1affa360 Merge branch 'main' into feature/project-structure-refactor 2026-01-02 01:02:59 +08:00
fa360e1c78 docs:更新项目文档以反映新的结构变更
- 更新 README.md 中的项目结构说明
- 修订项目结构说明文档,反映最新的目录组织
- 确保文档与实际项目结构保持同步
2026-01-02 00:59:25 +08:00
d80feaa02b test:更新测试文件以适配新的项目结构
- 更新 auth_ui_test.tscn 中的场景引用路径
- 修复 enhanced_toast_test.gd 中的脚本路径引用
- 确保测试文件与重构后的项目结构保持一致
2026-01-02 00:59:05 +08:00
f1a60137e1 fix:修复代码警告和UID冲突问题
- 更新 ProjectPaths.gd 中的路径引用,适配新的目录结构
- 修复 SceneManager.gd 中的场景路径问题
- 更新 project.godot 配置,修复 AutoLoad 路径
- 修复 MainScene 相关文件的 UID 冲突
- 解决代码中的路径引用警告
2026-01-02 00:58:51 +08:00
3175c98ea3 refactor:实现新的项目结构组织
- 添加 _Core/components/ 和 _Core/utils/ 目录
- 重新组织 scenes/ 目录结构,按功能分类
- 迁移 StringUtils.gd 到新的 _Core/utils/ 位置
- 迁移 AuthScene.gd 到新的 scenes/ui/ 位置
- 添加 AI 文档支持目录 docs/AI_docs/
- 添加开发参考文档 claude.md
2026-01-02 00:58:34 +08:00
a18c7a54b1 refactor:重构项目结构,删除旧的文件组织
- 删除旧的 UI/Windows/ 目录下的认证相关文件
- 删除旧的 Utils/ 目录下的工具类文件
- 删除旧的 scenes/Components/ 目录结构
- 为新的目录结构让路
2026-01-02 00:58:16 +08:00
fca3eb79dd chore:清理临时文档文件
- 删除迁移完成标记文件 MIGRATION_COMPLETE.md
- 删除重构说明文件 REFACTORING.md
- 删除结构对比文件 STRUCTURE_COMPARISON.md
- 删除临时文档 cloude.md
2026-01-02 00:57:53 +08:00
e128328d93 Merge pull request 'fix: 修复GDScript警告和UID冲突问题' (#6) from fix/code-warnings-and-uid-conflicts into main
Reviewed-on: datawhale/whale-town-front#6
2025-12-31 19:36:48 +08:00
fdedb21cbd fix: 修复GDScript警告和UID冲突问题
代码修复:
- NetworkManager.gd: 修复参数名冲突和未使用变量警告
- StringUtils.gd: 修复变量名与内置函数char冲突
- ResponseHandler.gd: 移除static关键字,改为实例方法
- AuthScene.gd: 恢复正确的ResponseHandler调用方式

 资源清理:
- 删除assets/sprites/icon/下的重复图标文件
- 删除UI/Theme/下的重复字体和主题文件
- 统一使用assets/路径下的资源文件

 配置修复:
- 修复LoginWindow.tscn和main_scene.tscn中的UID引用
- 更新chinese_theme.tres中的字体路径引用
- 添加project.godot调试设置以减少渲染器警告

 文档更新:
- 更新项目设置指南中的主题和字体路径引用

解决问题:
-  修复所有GDScript编译警告
-  解决UID重复冲突警告
-  统一资源文件路径结构
-  保持Web部署兼容性
2025-12-31 19:35:20 +08:00
d49983079a Merge pull request 'fix/verification-code-button-state' (#5) from fix/verification-code-button-state into main
Reviewed-on: datawhale/whale-town-front#5
2025-12-31 19:00:19 +08:00
51e79c6c6d Merge branch 'main' into fix/verification-code-button-state 2025-12-31 19:00:09 +08:00
0edd1c740b docs: 完善项目文档和README,修复字符显示问题
- 修复README.md中的emoji字符显示问题
- 移除文档质量评级系统
- 添加贡献者致谢部分,创建详细的CONTRIBUTORS.md
- 创建核心系统文件EventNames.gd和ProjectPaths.gd
- 更新项目配置文件project.godot,添加输入映射
- 完善各模块文档,修正路径引用问题
- 创建文档更新日志CHANGELOG.md
- 优化文档结构和导航系统
2025-12-31 18:58:38 +08:00
a85a7b4d0e docs: 完善项目入门README,强调输入映射配置的重要性
发现的关键问题:
- 项目代码中使用了输入映射,但project.godot中未配置
- 缺少输入映射会导致游戏无法正常运行

 主要改进:
- 强调输入映射配置为必需步骤
- 添加重要提醒和警告标识
- 新增常见启动问题排查指南
- 明确列出必需的6个输入动作
- 完善完成检查清单

 问题排查:
- 游戏无法响应输入  输入映射未配置
- Invalid action错误  缺少必需输入动作
- AutoLoad单例报错  配置验证问题

 确保新人成功:
- 明确标注必需步骤的优先级
- 提供具体的错误解决方案
- 强调验证配置的重要性
2025-12-31 18:17:58 +08:00
51d2ad1629 docs: 修正项目设置指南,确保信息准确
修正的问题:
- 路径错误: core/managers/  _Core/managers/
- 补充完整的AutoLoad配置列表 (5个单例)
- 修正NetworkManager.login()方法调用方式
- 添加实际的project.godot配置验证

 新增内容:
- 完整的AutoLoad配置表格
- 其他重要项目设置 (主题、渲染、窗口)
- 详细的验证测试代码
- 常见问题排查指南
- 配置检查清单

 确保准确性:
- 所有路径基于实际项目结构验证
- 代码示例基于实际API接口
- 配置信息与project.godot文件一致
2025-12-31 18:13:08 +08:00
6f545b04e9 docs: 更新项目结构说明,匹配实际项目结构
主要更新:
- 根据实际项目目录结构重写文档
- 明确三类团队协作模式:开发、美术、策划
- 详细说明每个目录的职责和使用方式
- 添加团队协作指南和工作流程
- 提供实际的代码示例和配置示例

 新增内容:
- 团队协作模式说明 (开发/美术/策划)
- 详细的目录结构和文件说明
- 开发工作流和版本发布流程
- 最佳实践和规范要求

 确保准确性:
- 所有目录结构都基于实际项目检查
- 文件路径和命名完全匹配当前状态
- 团队职责划分清晰明确
2025-12-31 18:08:31 +08:00
1ff677b3b2 docs: 重新组织文档结构,按开发阶段分类
新的目录结构:
  01-项目入门/     # 新人必读,项目基础
  02-开发规范/     # 编码标准和规范
  03-技术实现/     # 具体开发指导
  04-高级开发/     # 进阶开发技巧
  05-部署运维/     # 发布和部署
  06-功能模块/     # 特定功能文档

 新增导航文档:
- docs/README.md - 完整的文档导航和使用指南
- 各目录下的README.md - 分类说明和使用指导

 优化效果:
- 开发者可以按阶段快速定位需要的文档
- 新人有清晰的学习路径
- 不同角色有针对性的文档推荐
- 提供了问题导向的快速查找功能
2025-12-31 18:02:16 +08:00
2998fd2d11 docs: 补充开发规范相关文档
新增文档:
- docs/输入映射配置.md - 游戏输入配置指南
- docs/架构与通信规范.md - 项目架构和组件通信规范
- docs/实现细节规范.md - 游戏对象具体实现要求
- docs/开发哲学与最佳实践.md - 开发理念和编程最佳实践

 覆盖内容:
- 输入映射的配置方法和验证
- EventSystem事件系统使用规范
- 玩家、NPC、TileMap的实现标准
- 代码质量标准和审查清单
- 性能优化和资源管理指导

这些文档补充了开发规范.md中提到但在docs目录中缺失的内容
2025-12-31 17:50:19 +08:00
60edcc9868 docs: 文档中文化和清理
新增:
- 开发规范.md (翻译自CLAUDE.md)

 重命名为中文:
- project_structure.md  项目结构说明.md
- naming_convention.md  命名规范.md
- code_comment_guide.md  代码注释规范.md
- git_commit_guide.md  Git提交规范.md
- api-documentation.md  API接口文档.md
- network_manager_setup.md  网络管理器设置.md
- setup.md  项目设置指南.md
- testing_guide.md  测试指南.md
- web_deployment_guide.md  Web部署指南.md
- module_development.md  模块开发指南.md
- performance_optimization.md  性能优化指南.md
- scene_design.md  场景设计规范.md
- auth/form_validation.md  auth/表单验证规范.md
- auth/testing_guide.md  auth/认证测试指南.md

 删除总结性文档:
- final_update_summary.md
- web_deployment_changelog.md
- CLAUDE.md
2025-12-31 17:45:04 +08:00
d25d8d4dd3 Merge pull request 'fix/verification-code-button-state' (#4) from fix/verification-code-button-state into main
Reviewed-on: datawhale/whale-town-front#4
2025-12-31 17:28:45 +08:00
190b6c9a66 docs: 清理总结性文档,保留规范类文档
- 删除 docs/CONTRIBUTORS.md (总结性文档)
- 删除 docs/api_update_log.md (已不存在)
- 删除 docs/cleanup_summary.md (已不存在)
- 保留 docs/module_development.md (开发规范)
- 保留 docs/performance_optimization.md (性能规范)
- 保留 docs/scene_design.md (设计规范)
2025-12-31 17:27:13 +08:00
899bc5d5d0 merge: 解决README.md合并冲突,保留main分支内容并整合当前分支特性 2025-12-31 17:21:53 +08:00
e657cfce0e Merge pull request 'refactor:重构项目架构为分层结构' (#3) from qbb0530/whale-town-front:refactor/项目结构重构 into main
Reviewed-on: datawhale/whale-town-front#3
2025-12-31 16:35:24 +08:00
王浩
b9182bbc2e docs:完善 CLAUDE.md 为 AI 开发规范文档
## 🎯 主要改进

### 1. 明确项目定位
- 清晰定义 "WhaleTown" 为 2D 俯视像素风 RPG
- 指定 Godot 4.2+ 引擎(禁止 3.x 语法)
- 强调分层架构:`_Core`(框架)、`Scenes`(玩法)、`UI`(界面)

### 2. 完善开发规范
-  添加 Input Map 配置(WASD、交互键、暂停键)
-  明确文件路径规则(STRICT LOWERCASE)
-  添加 EventNames.gd 示例代码
-  完善测试代码示例(GUT 框架)
-  优化代码模板(分区注释)

### 3. 强化技术约束
- 📋 严格类型系统(强制静态类型)
- 🏛 事件驱动架构(EventSystem 解耦)
- 🚫 禁止模式列表(yield、Linear Filter 等)
-  必须测试覆盖(所有 Core 管理器)

### 4. 代码模板改进
- 添加结构化注释(Exports、References、Lifecycle、Methods)
- 展示最佳实践(@onready、%UniqueName、私有方法前缀)
- 包含完整的玩家移动示例

### 5. AI 指令优化
- 直接对 AI 说话("Claude: Root folders MUST be lowercase")
- 明确的优先级(STRICT、The Law、MANDATORY)
- 提供决策指南(Area2D vs Body、Enum vs StateChart)

## 📊 文档结构
- 90 行,精炼完整
- Emoji 视觉层级(🛠 命令、📂 文件、📋 标准、🏛 架构)
- 覆盖:文件组织、编码标准、架构设计、测试要求、代码模板

##  质量提升
- 统一路径大小写(tests/ 而非 Tests/)
- 添加 EventNames.gd 完整示例
- 完善测试代码(watch_signals、assert_signal_emitted)
- 明确 Godot 版本约束(4.2+)

## 🎯 目标
为 AI 提供清晰、严格、可执行的开发规范,
确保代码质量和架构一致性。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 16:32:54 +08:00
王浩
642a99970c docs:添加项目开发规范文档 CLAUDE.md
## 📋 新增内容

添加了完整的 Claude Code 开发规范文档,包括:

### 核心规范
- **项目概述**: 2D 俯视像素风 RPG 游戏架构
- **编码标准**:
  - 严格类型系统
  - 统一命名规范(PascalCase, snake_case, SCREAMING_SNAKE_CASE)
  - 节点访问规范(@onready, %UniqueName)
  - 最佳实践(await, Callable, Signal Up/Call Down)

### 架构规则
- **解耦原则**: 低层级实体通过 EventSystem 通信
- **场景管理**: 统一使用 SceneManager.change_scene()
- **组件化**: 可复用逻辑封装到 Scenes/Components/

### 实现规范
- **实体结构**:
  - Player: CharacterBody2D + Camera2D
  - NPC: StaticBody2D/CharacterBody2D + InteractionArea
  - Interactables: 共享 InteractableComponent
- **交互系统**: 通过 EventSystem.notify_interaction_triggered()
- **TileMap 规则**: 分层设计(地面、障碍、装饰)

### 文件组织
- 地图: Scenes/Maps/[map_name].tscn
- 实体: Scenes/Entities/[entity_name]/[entity_name].tscn
- 脚本: 与场景文件同目录
- 资源: res://Assets/Sprites/

### 测试标准
- 使用 GUT 测试框架
- 测试文件位置: res://test/unit/ 或 res://test/integration/
- 文件命名: test_*.gd
- 核心逻辑必须有单元测试
- 玩家移动和 NPC 交互需要集成测试

### 开发理念
- 简约至上:函数单一职责
- "栅栏后规则": 即使不可见的代码也要干净优美
- 反馈循环:每个交互都要有特效/音效占位
- 零硬编码:所有路径和配置使用常量或 @export
- "自动工作"的相机:自动检测 TileMap 边界

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 16:07:54 +08:00
王浩
0b533189ec refactor:重构项目架构为分层结构
## 🏗️ 主要变更

### 目录结构重构
- 将 core/ 迁移到 _Core/(框架层)
- 将 scenes/ 重构为 Scenes/(玩法层)和 UI/(界面层)
- 将 data/ 迁移到 Config/(配置层)
- 添加 Assets/ 资源层和 Utils/ 工具层
- 将 scripts/ 迁移到 tools/(开发工具)

### 架构分层
- **_Core/**: 框架层 - 全局单例和管理器
- **Scenes/**: 玩法层 - 游戏场景和实体
- **UI/**: 界面层 - HUD、窗口、对话系统
- **Assets/**: 资源层 - 精灵图、音频、字体
- **Config/**: 配置层 - 游戏配置和本地化
- **Utils/**: 工具层 - 通用辅助脚本

### 文件更新
- 更新 project.godot 中的所有路径引用
- 更新自动加载脚本路径
- 更新测试文件的引用路径
- 添加 REFACTORING.md 详细说明
- 添加 MIGRATION_COMPLETE.md 迁移完成标记
- 更新 README.md 反映新架构

### 设计原则
-  清晰的分层(框架/玩法/界面)
-  场景内聚(脚本紧邻场景文件)
-  组件化设计(可复用组件)
-  职责单一(每个目录职责明确)

## 📋 详细信息
查看 REFACTORING.md 了解完整的重构说明和迁移映射表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 11:36:01 +08:00
b1f3c0feff Merge pull request 'feature/网络管理和Web部署系统' (#2) from feature/网络管理和Web部署系统 into main
Reviewed-on: datawhale/whale-town-front#2
2025-12-25 23:17:30 +08:00
021c8623f8 docs:更新主要项目文档
- 更新README添加Web部署说明
- 精简API文档,移除冗余内容
- 添加新功能使用指南
- 完善项目架构说明
2025-12-25 23:14:29 +08:00
a1b867dfd7 chore:清理废弃的网络测试文件
- 删除旧的NetworkTest.gd测试文件
- 移除对应的UID文件
- 统一使用新的ApiTestScript替代
2025-12-25 23:14:09 +08:00
d17c766246 test:增强认证模块测试覆盖
- 添加认证UI自动化测试
- 实现增强型Toast消息测试
- 完善用户交互测试用例
- 提高测试代码覆盖率
2025-12-25 23:13:49 +08:00
92c4eaed07 refactor:重构认证场景和网络集成
- 重构AuthScene脚本,集成新的NetworkManager
- 优化用户界面布局和交互逻辑
- 改进错误处理和用户反馈机制
- 统一网络请求处理流程
2025-12-25 23:12:49 +08:00
abd683c766 config:更新项目配置支持Web导出
- 启用Web导出功能
- 配置中文字体支持
- 更新项目设置和资源路径
- 优化Web版本性能配置
2025-12-25 23:10:28 +08:00
a05bac6f05 docs:添加完整的项目文档体系
- 添加Web部署完整指南和更新日志
- 创建网络管理器配置文档
- 完善项目设置和测试指南
- 添加API更新日志和清理总结
- 更新脚本使用说明文档
2025-12-25 23:09:59 +08:00
77af0bda39 test:完善API测试框架
- 添加Godot内置API测试脚本
- 实现Python API客户端测试套件
- 添加快速测试和完整测试脚本
- 支持跨平台测试运行(Windows/Linux)
- 更新测试文档和使用指南
2025-12-25 23:09:12 +08:00
8980e3d558 feat:添加字符串工具类
- 实现邮箱格式验证功能
- 添加密码强度检查
- 提供字符串清理和格式化方法
- 支持中文字符处理
2025-12-25 23:07:47 +08:00
1294529d13 asset:添加UI资源和中文字体支持
- 添加微软雅黑字体文件支持中文显示
- 导入Datawhale官方Logo图标
- 创建中文主题配置文件
- 完善游戏图标资源目录
2025-12-25 23:06:50 +08:00
0935c5fd76 feat:添加Web版本自动化部署系统
- 实现跨平台Web导出脚本(Windows/Linux/macOS)
- 添加本地测试服务器启动脚本
- 配置Godot Web导出预设
- 创建Web资源目录结构
- 支持一键导出和本地测试
2025-12-25 23:06:29 +08:00
405710bdde feat:实现统一网络请求管理系统
- 添加NetworkManager核心网络管理器
- 实现ResponseHandler统一响应处理
- 支持GET、POST、PUT、DELETE、PATCH请求类型
- 完善错误处理和超时机制
- 提供统一的信号回调接口
2025-12-25 23:05:49 +08:00
7413574672 Merge pull request 'fix/verification-code-button-state' (#1) from fix/verification-code-button-state into main
Reviewed-on: datawhale/whale-town-front#1
2025-12-24 20:58:43 +08:00
c0f5d6a537 docs:添加性能优化指南
- 创建全面的性能优化文档
- 涵盖渲染、内存、脚本、网络等各方面优化
- 提供具体的代码示例和最佳实践
- 包含性能监控和调试工具使用方法
- 为开发者提供系统的性能优化指导
2025-12-24 20:51:45 +08:00
0b6b1c2040 docs:完善前端项目文档体系
- 重构README文档,参考后端结构优化内容组织
- 添加模块开发指南,详细说明模块化开发流程
- 创建场景设计规范,规范场景架构和最佳实践
- 建立贡献者名单,记录团队成员和贡献统计
- 完善技术栈介绍和功能特性说明
- 优化文档结构和导航,提升开发者体验
2025-12-24 20:50:31 +08:00
8d071cb2ed chore:清理空目录占位文件
- 删除不再需要的.gitkeep文件
- 目录结构已通过实际文件维护,无需占位符
2025-12-24 20:40:26 +08:00
370cffbdd8 config:更新项目配置和文档
- 更新Godot项目配置,添加自动加载脚本
- 完善.gitignore文件,排除不必要的文件
- 更新README文档,添加项目介绍和使用说明
- 更新主场景配置,集成认证系统
- 添加开发规范文档和项目结构说明
2025-12-24 20:39:33 +08:00
5b67771bbc asset:添加游戏资源目录和测试框架
- 创建音频、字体、材质、着色器等资源目录
- 添加精灵图片资源管理结构
- 建立集成测试、性能测试、单元测试框架
- 为后续资源导入和测试开发做准备
2025-12-24 20:39:14 +08:00
73478c0500 chore:完善项目目录结构和基础框架
- 添加核心系统框架目录结构
- 创建游戏模块化组织架构
- 添加数据配置和本地化支持
- 建立脚本分类管理体系
- 创建场景预制件管理目录
2025-12-24 20:38:57 +08:00
15548ebb52 docs:添加API接口文档
- 详细记录认证相关API接口规范
- 包含请求参数、响应格式和错误代码说明
- 提供完整的使用示例和测试用例
- 涵盖登录、注册、验证码等核心功能接口
2025-12-24 20:38:40 +08:00
47cfc14f68 test:添加认证系统测试套件
- 添加UI测试场景,支持模拟各种认证场景
- 实现API测试脚本,覆盖登录、注册、验证码等接口
- 添加测试文档,说明测试用例和预期结果
- 支持自动化测试和手动测试验证
2025-12-24 20:37:33 +08:00
c6bcca4e7f feat:实现用户认证系统核心功能
- 实现用户登录和注册的完整流程
- 添加邮箱验证码发送和验证功能
- 实现基于邮箱地址的验证码冷却机制
- 添加表单验证和错误提示系统
- 集成Toast消息提示系统
- 支持网络请求处理和错误处理
- 实现按钮状态管理和加载状态显示
2025-12-24 20:37:00 +08:00
f11479f2cc scene:创建用户认证场景和UI资源
- 添加认证场景 auth_scene.tscn,包含登录和注册界面
- 添加认证相关的UI背景图片和框架素材
- 实现响应式布局,支持登录/注册界面切换
- 包含完整的表单输入控件和验证码输入区域
2025-12-24 20:36:35 +08:00
468 changed files with 47236 additions and 91 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Skill(whaletown-developer)"
]
}
}

View 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

View 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 项目的质量标准!✅

View 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 功能" 即可!

55
.gitignore vendored
View File

@@ -1,3 +1,58 @@
# Godot 4+ specific ignores
.godot/
/android/
# Python cache files
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Godot executable files (should not be in version control)
Godot/
*.exe
*.app
*.dmg
# Logs and temporary files
*.log
*.tmp
*.temp
# Build outputs
build/
dist/
*.zip
*.tar.gz
# Environment files
.env
.env.local
.env.*.local
# Test coverage reports
coverage/
*.coverage
.nyc_output/
# Dependency directories
node_modules/
vendor/

29
Config/game_config.json Normal file
View File

@@ -0,0 +1,29 @@
{
"game": {
"name": "whaleTown",
"version": "1.0.0",
"debug_mode": true
},
"network": {
"api_base_url": "https://whaletownend.xinghangee.icu",
"timeout": 30,
"retry_count": 3
},
"ui": {
"default_font_size": 14,
"toast_duration": 2.0,
"transition_duration": 0.3
},
"gameplay": {
"auto_save_interval": 300,
"max_inventory_slots": 50,
"default_player_stats": {
"level": 1,
"coins": 100,
"exp": 0,
"max_exp": 100,
"energy": 100,
"max_energy": 100
}
}
}

36
Config/zh_CN.json Normal file
View File

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

460
README.md
View File

@@ -1,101 +1,395 @@
# whaleTown
# 🐋 WhaleTown - 现代化像素游戏
一个使用 Godot 4.5 引擎开发的游戏项目
> 一个基于 Godot 4.5 引擎开发的企业级 2D 像素风游戏,采用模块化架构设计,集成完整的用户认证系统和游戏核心功能
## 项目信息
[![Godot](https://img.shields.io/badge/Godot-4.5+-blue.svg)](https://godotengine.org/)
[![GDScript](https://img.shields.io/badge/GDScript-Latest-green.svg)](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/index.html)
[![Documentation](https://img.shields.io/badge/Documentation-Complete-brightgreen.svg)](./docs/)
[![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Web-lightgrey.svg)](https://godotengine.org/download)
- **引擎版本**: Godot 4.5
- **渲染器**: Forward Plus
- **项目类型**: 2D 游戏
## 🎯 项目简介
## 项目结构
WhaleTown 是一个功能完整的现代化像素游戏,具有以下特色:
```
whaleTown/
├── addons/ # Godot 插件目录
├── assets/ # 游戏资源文件(图片、音频等)
├── data/ # 游戏数据文件(配置、关卡数据等)
├── docs/ # 项目文档
├── scenes/ # 游戏场景文件
│ └── main_scene.tscn # 主场景
├── scripts/ # GDScript 脚本文件
├── tests/ # 测试文件
├── icon.svg # 项目图标
└── project.godot # Godot 项目配置文件
```
- 🏗️ **企业级架构** - 模块化设计,高度解耦,易于扩展
- 🔐 **完整认证系统** - 登录、注册、邮箱验证、密码管理
- 🎮 **丰富游戏功能** - 角色系统、场景管理、事件通信
- 🌐 **网络通信** - RESTful API集成支持实时数据交互
- 📚 **企业级文档** - 18个文档覆盖开发全流程
- 🧪 **完整测试体系** - API测试、UI测试、性能测试
- 🚀 **一键部署** - 支持Web、桌面多平台发布
## 开始使用
---
### 前置要求
## 🚀 5分钟快速体验
- [Godot Engine 4.5](https://godotengine.org/download) 或更高版本
### 📋 准备工作
### 运行项目
**你需要安装:**
- [Godot Engine 4.5+](https://godotengine.org/download) - 游戏引擎
- [Git](https://git-scm.com/) - 版本控制工具
1. 克隆或下载此项目
2. 使用 Godot 编辑器打开项目
3. 在编辑器中点击"运行"按钮或按 F5 键启动游戏
### 开发指南
- **场景文件**: 所有场景文件存放在 `scenes/` 目录
- **脚本文件**: 所有 GDScript 脚本存放在 `scripts/` 目录
- **资源文件**: 图片、音频等资源存放在 `assets/` 目录
- **游戏数据**: 配置文件、关卡数据等存放在 `data/` 目录
### 命名规范
本项目遵循统一的命名规范以保持代码一致性:
**核心规则**
- **场景文件**`下划线_scene.tscn``下划线_prefab.tscn`
- 示例:`main_scene.tscn``player_prefab.tscn`
- **脚本文件**`PascalCase.gd`(大驼峰)
- 示例:`PlayerController.gd``UI_MainMenu.gd`
- **节点名称**`camelCase`(小驼峰)
- 示例:`playerHpBar``startButton`
- **变量/函数**`camelCase`(小驼峰)
- 示例:`var moveSpeed``func getPlayerPos()`
- **常量**`UPPER_CASE`(全大写 + 下划线)
- 示例:`const MAX_HEALTH = 100`
- **资源文件**`lower_case`(小写 + 下划线)
- 示例:`bg_main_menu.png``sound_jump.wav`
📖 查看完整的 [命名规范文档](docs/naming_convention.md)
### Git 提交规范
本项目遵循统一的 Git 提交信息格式:`<类型><描述>`
**常用提交类型**
- `init`:项目初始化
- `feat`:新增功能
- `fix`:修复 Bug
- `docs`:文档更新
- `scene`:场景文件相关
- `asset`:资源文件相关
- `ui`UI 界面相关
- `gameplay`:游戏玩法相关
- `refactor`:代码重构
- `perf`:性能优化
**提交示例**
### 🛠️ 启动项目
```bash
git commit -m "init项目初始化搭建Godot文件结构"
git commit -m "feat实现玩家角色的移动和跳跃"
git commit -m "fix修复敌人穿墙的碰撞问题"
git commit -m "scene创建战斗场景并配置相机"
# 1⃣ 获取项目
git clone <repository-url>
cd whale-town
# 2⃣ 打开项目
# 双击 project.godot 文件或在Godot编辑器中选择"导入项目"
# 3⃣ 运行游戏
# 在Godot编辑器中按 F5 或点击"运行项目"按钮
```
📖 查看完整的 [Git 提交规范文档](docs/git_commit_guide.md)
🎉 **成功!** 你应该看到游戏的认证界面
## 贡献
### 🎮 体验功能
欢迎提交 Issue 和 Pull Request
1. **注册新用户** - 体验完整的邮箱验证流程
2. **登录系统** - 尝试用户名/邮箱登录
3. **游戏界面** - 探索主游戏场景
## 许可证
### 🧪 测试API可选
[在此添加许可证信息]
```bash
# 安装Python依赖
pip install requests
# 快速API测试
python tests/api/quick_test.py
# 完整功能测试
python tests/api/api_client_test.py
```
---
## 📚 新手开发指南
### 🎯 第一步:了解项目
**⚠️ 重要:开始开发前必读**
1. **[📖 项目入门总览](docs/01-项目入门/README.md)** - 5分钟了解项目
2. **[🏗️ 项目结构说明](docs/01-项目入门/项目结构说明.md)** - 理解架构设计
3. **[⚙️ 项目设置指南](docs/01-项目入门/项目设置指南.md)** - 配置开发环境
4. **[🤖 AI开发指南](docs/AI_docs/README.md)** - AI编程助手专用文档
### 🎯 第二步:学习规范
**代码质量保证**
1. **[📝 命名规范](docs/02-开发规范/命名规范.md)** - 统一命名标准
2. **[🏛️ 架构与通信规范](docs/02-开发规范/架构与通信规范.md)** - 组件通信方式
3. **[💬 代码注释规范](docs/02-开发规范/代码注释规范.md)** - 注释标准
4. **[🔄 Git提交规范](docs/02-开发规范/Git提交规范.md)** - 版本控制规范
### 🎯 第三步:开始开发
**技术实现指导**
1. **[🔧 实现细节规范](docs/03-技术实现/实现细节规范.md)** - 游戏对象实现
2. **[🌐 API接口文档](docs/03-技术实现/API接口文档.md)** - 后端接口使用
3. **[🧪 测试指南](docs/03-技术实现/测试指南.md)** - 测试方法和工具
### 🎯 第四步:高级开发
**进阶技能**
1. **[🚀 性能优化指南](docs/04-高级开发/性能优化指南.md)** - 性能调优
2. **[🎬 场景设计规范](docs/04-高级开发/场景设计规范.md)** - 场景架构
3. **[🧩 模块开发指南](docs/04-高级开发/模块开发指南.md)** - 模块化开发
### 🎯 第五步:项目发布
**部署和运维**
1. **[🌐 Web部署指南](docs/05-部署运维/Web部署指南.md)** - 完整部署流程
---
## 🏗️ 项目架构一览
### 📁 目录结构
```
WhaleTown/ # 🐋 项目根目录
├── 📚 docs/ # 📖 完整文档系统18个文档
│ ├── 01-项目入门/ # 👋 新人必读
│ ├── 02-开发规范/ # 📋 编码标准
│ ├── 03-技术实现/ # 🔧 开发指导
│ ├── 04-高级开发/ # 🚀 进阶技巧
│ ├── 05-部署运维/ # 🌐 发布部署
│ ├── 06-功能模块/ # 🎮 功能文档
│ └── AI_docs/ # 🤖 AI专用文档执行规范、代码模板
├── 🔧 _Core/ # ⚙️ 核心底层实现
│ ├── managers/ # 🎯 全局管理器(游戏状态、场景、网络等)
│ ├── systems/ # 🔄 系统组件(事件系统、输入系统等)
│ ├── components/ # 🧩 基础组件实现
│ ├── utils/ # <20> 核件心工具类(字符串处理、数学计算等)
│ ├── EventNames.gd # 📝 事件名称定义
│ └── ProjectPaths.gd # <20> 路径组统一管理
├── 🎬 scenes/ # 🎭 场景与视觉呈现
│ ├── maps/ # <20> 地图一场景(游戏关卡、世界地图)
│ ├── characters/ # 👤 人物场景角色、NPC、敌人
│ ├── ui/ # 🖼️ UI界面场景菜单、HUD、对话框
│ ├── effects/ # ✨ 特效场景(粒子效果、动画)
│ └── prefabs/ # 🧩 预制体组件
├── 🎨 assets/ # 🖼️ 静态资源存储
│ ├── sprites/ # 🎨 精灵图片(角色、物品、环境)
│ ├── audio/ # 🎵 音频资源(音乐、音效)
│ ├── fonts/ # 🔤 字体文件
│ ├── materials/ # 🎭 材质资源
│ ├── shaders/ # 🌈 着色器文件
│ ├── ui/ # 🖼️ UI素材按钮、图标、背景
│ └── icon/ # 📱 应用图标
├── ⚙️ Config/ # 📋 配置文件管理
│ ├── game_config.json # 🎮 游戏配置(难度、设置等)
│ ├── zh_CN.json # 🌐 本地化配置
│ └── environment/ # 🔧 环境配置(开发、测试、生产)
├── 🧪 tests/ # 🔬 测试文件系统
│ ├── unit/ # 🔍 单元测试(组件功能测试)
│ ├── integration/ # 🔗 集成测试(系统交互测试)
│ ├── performance/ # ⚡ 性能测试(帧率、内存优化)
│ └── api/ # 🌐 API接口测试
└── 🌐 web_assets/ # 🌍 Web导出资源
├── html/ # 📄 HTML模板文件
├── css/ # 🎨 样式文件
└── js/ # 📜 JavaScript脚本
```
### 🔧 核心架构说明
| 目录 | 作用 | 详细说明 |
|------|------|----------|
| **_Core** | 🔧 功能实现与组件实现 | 项目最基本的底层实现,包含所有核心系统和基础组件 |
| **scenes** | 🎭 场景与视觉呈现 | 包含地图场景、人物场景等一系列视觉呈现部分主要是UI的实现 |
| **assets** | 🎨 静态资源存储 | 所有静态资源的存储,包括图片、音乐、视频、贴图等素材 |
| **Config** | ⚙️ 配置文件管理 | 主要用来配置各类环境,包括游戏设置、本地化等配置 |
| **tests** | 🧪 测试文件系统 | 放置所有对应组件的测试代码,方便快速进行功能性与性能测试 |
| **web_assets** | 🌐 Web导出资源 | 专门用于Web平台导出的相关资源和配置文件 |
| **docs/AI_docs** | 🤖 AI专用文档 | 专门为AI编程助手准备的执行规范和代码模板提升vibe coding效率 |
### 🎮 核心组件
| 组件 | 位置 | 作用 | 文档链接 |
|------|------|------|----------|
| **EventSystem** | _Core/systems/ | 全局事件通信系统 | [架构规范](docs/02-开发规范/架构与通信规范.md) |
| **GameManager** | _Core/managers/ | 游戏状态管理器 | [实现细节](docs/03-技术实现/实现细节规范.md) |
| **SceneManager** | _Core/managers/ | 场景切换管理器 | [场景设计](docs/04-高级开发/场景设计规范.md) |
| **NetworkManager** | _Core/managers/ | 网络请求管理器 | [网络管理器](docs/03-技术实现/网络管理器设置.md) |
| **ProjectPaths** | _Core/ | 路径统一管理工具 | [项目结构](docs/01-项目入门/项目结构说明.md) |
---
## 🎮 核心功能
### 🔐 用户认证系统
**完整的用户管理功能**
- ✅ 用户注册(用户名+邮箱验证)
- ✅ 多方式登录(用户名/邮箱/验证码)
- ✅ 密码管理(修改/重置)
- ✅ 表单验证(实时验证+友好提示)
- ✅ 错误处理(网络异常+业务错误)
**技术特色**
- 📱 响应式UI设计
- 🔄 实时表单验证
- ⏰ 验证码冷却机制
- 🎨 流畅动画效果
### 🎮 游戏核心系统
**模块化游戏架构**
- 🎭 场景管理系统
- 🔄 事件通信系统
- 🎯 状态管理系统
- 🌐 网络通信系统
**开发友好特性**
- 🧩 高度模块化
- 📝 完整文档覆盖
- 🧪 测试用例齐全
- 🔧 开发工具完善
---
## 🧪 测试系统
### 🔬 测试类型
| 测试类型 | 工具 | 覆盖范围 | 文档 |
|----------|------|----------|------|
| **API测试** | Python脚本 | 17个接口全覆盖 | [测试指南](docs/03-技术实现/测试指南.md) |
| **UI测试** | Godot场景 | 认证流程完整测试 | [认证测试](docs/06-功能模块/auth/认证测试指南.md) |
| **单元测试** | GUT框架 | 核心组件测试 | [测试指南](docs/03-技术实现/测试指南.md) |
### 🚀 快速测试
```bash
# 🔌 API接口测试30秒
python tests/api/quick_test.py
# 🔍 完整功能测试2-3分钟
python tests/api/api_client_test.py
# 🎮 UI交互测试在Godot中运行
# 打开 tests/auth/auth_ui_test.tscn 场景
```
---
## 🚀 部署发布
### 🖥️ 桌面版本
```bash
# Windows
godot --export "Windows Desktop" build/WhaleTown.exe
# Linux
godot --export "Linux/X11" build/WhaleTown.x86_64
# macOS
godot --export "macOS" build/WhaleTown.app
```
### 🌐 Web版本
```bash
# 使用自动化脚本
scripts/build_web.bat # Windows
scripts/build_web.sh # Linux/macOS
# 本地测试
scripts/serve_web.bat # 启动本地服务器
```
**详细部署流程**: [Web部署指南](docs/05-部署运维/Web部署指南.md)
---
## 📊 项目统计
### 📚 文档系统
| 类别 | 文档数 | 完成度 |
|------|--------|--------|
| 项目入门 | 3 | 100% |
| 开发规范 | 5 | 100% |
| 技术实现 | 4 | 100% |
| 高级开发 | 3 | 100% |
| 部署运维 | 1 | 100% |
| 功能模块 | 2 | 100% |
| **总计** | **18** | **100%** |
### 🧪 测试覆盖
- **API接口**: 17个接口 ✅
- **认证流程**: 完整测试 ✅
- **错误处理**: 边界测试 ✅
- **性能监控**: 帧率/内存 ✅
---
## 🤝 参与贡献
### 🌟 贡献方式
1. **🐛 Bug修复** - 发现并修复问题
2. **✨ 新功能** - 添加有价值的功能
3. **📚 文档改进** - 完善项目文档
4. **🧪 测试用例** - 提高代码覆盖率
5. **🎨 UI/UX改进** - 提升用户体验
### 📋 贡献流程
```bash
# 1⃣ Fork项目到你的账户
# 2⃣ 克隆到本地
git clone <your-fork-url>
cd whale-town
# 3⃣ 创建功能分支
git checkout -b feature/your-feature
# 4⃣ 开发功能(遵循项目规范)
# 参考: docs/02-开发规范/
# 5⃣ 添加测试用例
# 参考: docs/03-技术实现/测试指南.md
# 6⃣ 提交代码
git commit -m "feat添加新功能"
# 参考: docs/02-开发规范/Git提交规范.md
# 7⃣ 推送分支
git push origin feature/your-feature
# 8⃣ 创建Pull Request
```
### 📖 开发规范
**必读文档**
- [命名规范](docs/02-开发规范/命名规范.md) - 代码命名标准
- [Git提交规范](docs/02-开发规范/Git提交规范.md) - 提交信息格式
- [代码注释规范](docs/02-开发规范/代码注释规范.md) - 注释标准
### 🙏 贡献者致谢
感谢所有为 WhaleTown 项目做出贡献的开发者们!详细的贡献者信息和统计请查看:
**[📖 贡献者详细信息](docs/CONTRIBUTORS.md)**
---
## 📞 获取帮助
### 🔍 问题解决
| 问题类型 | 解决方案 |
|----------|----------|
| **🤔 不知道从哪开始** | [项目入门总览](docs/01-项目入门/README.md) |
| **🏗️ 不理解项目架构** | [项目结构说明](docs/01-项目入门/项目结构说明.md) |
| **🔧 开发环境问题** | [项目设置指南](docs/01-项目入门/项目设置指南.md) |
| **📝 不知道怎么命名** | [命名规范](docs/02-开发规范/命名规范.md) |
| **🔄 组件通信问题** | [架构与通信规范](docs/02-开发规范/架构与通信规范.md) |
| **🌐 API调用问题** | [API接口文档](docs/03-技术实现/API接口文档.md) |
| **🧪 测试相关问题** | [测试指南](docs/03-技术实现/测试指南.md) |
| **🚀 部署发布问题** | [Web部署指南](docs/05-部署运维/Web部署指南.md) |
### 📚 文档导航
- **[📖 完整文档中心](docs/README.md)** - 所有文档的导航页面
- **[📋 文档更新日志](docs/CHANGELOG.md)** - 文档版本变更记录
### 💬 联系方式
- **项目地址**: [Gitea Repository](https://gitea.xinghangee.icu/datawhale/whale-town)
- **问题反馈**: [Issues](https://gitea.xinghangee.icu/datawhale/whale-town/issues)
- **功能建议**: [Discussions](https://gitea.xinghangee.icu/datawhale/whale-town/discussions)
---
## 📄 许可证
本项目采用 [MIT License](./LICENSE) 开源协议。
---
<div align="center">
**🐋 WhaleTown - 企业级像素游戏开发框架**
*让游戏开发更简单,让代码质量更优秀*
[⭐ Star](https://gitea.xinghangee.icu/datawhale/whale-town) | [🍴 Fork](https://gitea.xinghangee.icu/datawhale/whale-town/fork) | [📖 文档](./docs/) | [🐛 反馈](https://gitea.xinghangee.icu/datawhale/whale-town/issues)
**文档版本**: v1.2.0 | **最后更新**: 2025-12-31
</div>

71
_Core/EventNames.gd Normal file
View File

@@ -0,0 +1,71 @@
# ============================================================================
# 事件名称定义 - EventNames.gd
#
# 定义项目中所有事件的名称常量,确保事件名称的一致性和可维护性
#
# 使用方式:
# EventSystem.emit_event(EventNames.PLAYER_MOVED, data)
# EventSystem.connect_event(EventNames.INTERACT_PRESSED, callback)
# ============================================================================
class_name EventNames
# ============================================================================
# 玩家相关事件
# ============================================================================
const PLAYER_MOVED = "player_moved"
const PLAYER_SPAWNED = "player_spawned"
const PLAYER_HEALTH_CHANGED = "player_health_changed"
const PLAYER_DIED = "player_died"
const PLAYER_RESPAWNED = "player_respawned"
const PLAYER_ATTACK = "player_attack"
# ============================================================================
# 交互事件
# ============================================================================
const INTERACT_PRESSED = "interact_pressed"
const NPC_TALKED = "npc_talked"
const ITEM_COLLECTED = "item_collected"
const OBJECT_INTERACTED = "object_interacted"
# ============================================================================
# UI事件
# ============================================================================
const UI_BUTTON_CLICKED = "ui_button_clicked"
const DIALOG_OPENED = "dialog_opened"
const DIALOG_CLOSED = "dialog_closed"
const MENU_OPENED = "menu_opened"
const MENU_CLOSED = "menu_closed"
# ============================================================================
# 游戏状态事件
# ============================================================================
const GAME_PAUSED = "game_paused"
const GAME_RESUMED = "game_resumed"
const SCENE_CHANGED = "scene_changed"
const SCENE_DATA_TRANSFER = "scene_data_transfer"
# ============================================================================
# 系统事件
# ============================================================================
const TILEMAP_READY = "tilemap_ready"
const COMPONENT_MESSAGE = "component_message"
const POSITION_UPDATE = "position_update"
const GRID_POSITION_CHANGED = "grid_position_changed"
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"

1
_Core/EventNames.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://qn0imbklx1m0

118
_Core/ProjectPaths.gd Normal file
View File

@@ -0,0 +1,118 @@
# ============================================================================
# 项目路径配置 - ProjectPaths.gd
#
# 统一管理项目中所有路径常量,确保路径的一致性和可维护性
#
# 使用方式:
# var scene_path = ProjectPaths.SCENES_COMPONENTS + "ui/Button.tscn"
# var config_path = ProjectPaths.DATA_CONFIG + "game_config.json"
# ============================================================================
class_name ProjectPaths
# ============================================================================
# 核心系统路径
# ============================================================================
const CORE_ROOT = "res://_Core/"
const CORE_MANAGERS = CORE_ROOT + "managers/"
const CORE_SYSTEMS = CORE_ROOT + "systems/"
const CORE_COMPONENTS = CORE_ROOT + "components/"
const CORE_UTILS = CORE_ROOT + "utils/"
# 系统文件路径
const GRID_SYSTEM = CORE_SYSTEMS + "GridSystem.gd"
const EVENT_SYSTEM = CORE_SYSTEMS + "EventSystem.gd"
const TILE_SYSTEM = CORE_SYSTEMS + "TileSystem.gd"
# ============================================================================
# 场景路径
# ============================================================================
const SCENES_ROOT = "res://scenes/"
const SCENES_MAPS = SCENES_ROOT + "Maps/"
const SCENES_COMPONENTS = SCENES_ROOT + "Components/"
const SCENES_UI_COMPONENTS = SCENES_COMPONENTS + "ui/"
const SCENES_CHARACTER_COMPONENTS = SCENES_COMPONENTS + "characters/"
const SCENES_EFFECT_COMPONENTS = SCENES_COMPONENTS + "effects/"
const SCENES_ITEM_COMPONENTS = SCENES_COMPONENTS + "items/"
# ============================================================================
# UI路径
# ============================================================================
const UI_ROOT = "res://scenes/ui/"
const UI_WINDOWS = UI_ROOT
# ============================================================================
# 资源路径
# ============================================================================
const ASSETS_ROOT = "res://assets/"
const ASSETS_SPRITES = ASSETS_ROOT + "sprites/"
const ASSETS_AUDIO = ASSETS_ROOT + "audio/"
const ASSETS_FONTS = ASSETS_ROOT + "fonts/"
const ASSETS_MATERIALS = ASSETS_ROOT + "materials/"
const ASSETS_SHADERS = ASSETS_ROOT + "shaders/"
# 地形资源路径
const ASSETS_TERRAIN = ASSETS_SPRITES + "terrain/"
const ASSETS_GRASS = ASSETS_TERRAIN + "grass/"
# ============================================================================
# 数据路径
# ============================================================================
const DATA_ROOT = "res://data/"
const DATA_CONFIG = "res://Config/"
const DATA_SCENES = DATA_ROOT + "scenes/"
const DATA_LEVELS = DATA_ROOT + "levels/"
const DATA_DIALOGUES = DATA_ROOT + "dialogues/"
# ============================================================================
# Web资源路径
# ============================================================================
const WEB_ASSETS = "res://web_assets/"
# ============================================================================
# 测试路径
# ============================================================================
const TESTS_ROOT = "res://tests/"
const TESTS_UNIT = TESTS_ROOT + "unit/"
const TESTS_INTEGRATION = TESTS_ROOT + "integration/"
const TESTS_AUTH = TESTS_ROOT + "auth/"
# ============================================================================
# 工具路径
# ============================================================================
const UTILS_ROOT = "res://_Core/utils/"
# ============================================================================
# 模块路径
# ============================================================================
const MODULES_ROOT = "res://module/"
# ============================================================================
# 辅助方法
# ============================================================================
# 获取场景组件路径
static func get_component_path(category: String, component_name: String) -> String:
match category:
"ui":
return SCENES_UI_COMPONENTS + component_name + ".tscn"
"characters":
return SCENES_CHARACTER_COMPONENTS + component_name + ".tscn"
"effects":
return SCENES_EFFECT_COMPONENTS + component_name + ".tscn"
"items":
return SCENES_ITEM_COMPONENTS + component_name + ".tscn"
_:
return SCENES_COMPONENTS + component_name + ".tscn"
# 获取模块路径
static func get_module_path(module_name: String) -> String:
return MODULES_ROOT + module_name + "/"
# 获取模块配置路径
static func get_module_config_path(module_name: String) -> String:
return get_module_path(module_name) + "data/module_config.json"
# 获取场景数据路径
static func get_scene_data_path(scene_name: String) -> String:
return DATA_SCENES + scene_name.to_lower() + ".json"

View File

@@ -0,0 +1 @@
uid://dybcuscku7tyl

View File

@@ -0,0 +1,2 @@
# 基础组件实现目录
# 存放项目的基础组件类

View File

@@ -0,0 +1,771 @@
class_name AuthManager
# ============================================================================
# AuthManager.gd - 认证管理器
# ============================================================================
# 认证系统的业务逻辑管理器,负责处理所有认证相关的业务逻辑
#
# 核心职责:
# - 用户登录业务逻辑(密码登录 + 验证码登录)
# - 用户注册业务逻辑
# - 表单验证逻辑
# - 验证码管理逻辑
# - 网络请求管理
# - 响应处理和状态管理
#
# 使用方式:
# var auth_manager = AuthManager.new()
# auth_manager.login_success.connect(_on_login_success)
# auth_manager.execute_password_login(username, password)
#
# 注意事项:
# - 这是业务逻辑层不包含任何UI相关代码
# - 通过信号与UI层通信
# - 所有验证逻辑都在这里实现
# ============================================================================
extends RefCounted
# ============ 信号定义 ============
# 登录成功信号
signal login_success(username: String)
# 登录失败信号
signal login_failed(message: String)
# 注册成功信号
signal register_success(message: String)
# 注册失败信号
signal register_failed(message: String)
# 验证码发送成功信号
signal verification_code_sent(message: String)
# 验证码发送失败信号
signal verification_code_failed(message: String)
# 表单验证失败信号
signal form_validation_failed(field: String, message: String)
# 网络状态变化信号
signal network_status_changed(is_connected: bool, message: String)
# 按钮状态变化信号
signal button_state_changed(button_name: String, is_loading: bool, text: String)
# Toast消息信号
signal show_toast_message(message: String, is_success: bool)
# ============ 枚举定义 ============
# 登录模式枚举
enum LoginMode {
PASSWORD, # 密码登录模式
VERIFICATION # 验证码登录模式
}
# ============ 成员变量 ============
# 登录状态
var current_login_mode: LoginMode = LoginMode.PASSWORD
var is_processing: bool = false
# 验证码管理
var verification_codes_sent: Dictionary = {}
var code_cooldown: float = 60.0
var current_email: String = ""
# 网络请求管理
var active_request_ids: Array = []
# ============ Token 管理 ============
# 本地存储路径常量
const AUTH_CONFIG_PATH: String = "user://auth.cfg"
# Token 存储(内存中,用于快速访问)
var _access_token: String = "" # JWT访问令牌短期用于API和WebSocket
var _refresh_token: String = "" # JWT刷新令牌长期用于获取新access_token
var _user_info: Dictionary = {} # 用户信息
var _token_expiry: float = 0.0 # access_token过期时间Unix时间戳
# 游戏 token兼容旧代码保留但标记为废弃
var _game_token: String = "" # @deprecated 使用 _access_token 替代
# ============ 生命周期方法 ============
# 初始化管理器
func _init() -> void:
print("AuthManager 初始化完成")
_load_auth_data()
# 清理资源
func cleanup() -> void:
# 取消所有活动的网络请求
for request_id in active_request_ids:
NetworkManager.cancel_request(request_id)
active_request_ids.clear()
# ============ Token 管理 ============
# 保存 Token 到内存
#
# 参数:
# data: Dictionary - 登录响应数据
#
# 功能:
# - 从登录响应中提取 access_token 和 refresh_token
# - 保存到内存变量中
# - 保存用户信息
func _save_tokens_to_memory(data: Dictionary) -> void:
if not data.has("data"):
print("⚠️ 登录响应中没有 data 字段")
return
var token_data: Dictionary = data.data
_access_token = token_data.get("access_token", "")
_refresh_token = token_data.get("refresh_token", "")
_user_info = token_data.get("user", {})
_token_expiry = Time.get_unix_time_from_system() + float(token_data.get("expires_in", 0))
# 保持兼容性:设置 _game_token
_game_token = _access_token
print("✅ Token已保存到内存")
print(" Access Token: ", _access_token.substr(0, 20) + "...")
print(" 用户: ", _user_info.get("username", "未知"))
# 保存 Token 到本地ConfigFile
#
# 参数:
# data: Dictionary - 登录响应数据
#
# 功能:
# - 将 refresh_token 和用户信息保存到 ConfigFile
# - access_token 不保存到本地,仅保存在内存中
func _save_tokens_to_local(data: Dictionary) -> void:
if not data.has("data"):
return
var token_data: Dictionary = data.data
var auth_data: Dictionary = {
"refresh_token": token_data.get("refresh_token", ""),
"user_id": token_data.get("user", {}).get("id", ""),
"username": token_data.get("user", {}).get("username", ""),
"saved_at": Time.get_unix_time_from_system()
}
var config: ConfigFile = ConfigFile.new()
config.load(AUTH_CONFIG_PATH)
config.set_value("auth", "refresh_token", auth_data["refresh_token"])
config.set_value("auth", "user_id", auth_data["user_id"])
config.set_value("auth", "username", auth_data["username"])
config.set_value("auth", "saved_at", auth_data["saved_at"])
var error: Error = config.save(AUTH_CONFIG_PATH)
if error == OK:
print("✅ Token已保存到本地: ", AUTH_CONFIG_PATH)
else:
print("❌ 保存Token到本地失败错误码: ", error)
# 从本地加载 Token游戏启动时调用
#
# 功能:
# - 从 ConfigFile 加载 refresh_token 和用户信息
# - access_token 需要通过 refresh_token 刷新获取
func _load_auth_data() -> void:
if not FileAccess.file_exists(AUTH_CONFIG_PATH):
print(" 本地不存在认证数据")
return
var config: ConfigFile = ConfigFile.new()
var error: Error = config.load(AUTH_CONFIG_PATH)
if error != OK:
print("❌ 加载本地认证数据失败,错误码: ", error)
return
_refresh_token = config.get_value("auth", "refresh_token", "")
var user_id: String = config.get_value("auth", "user_id", "")
var username: String = config.get_value("auth", "username", "")
if not _refresh_token.is_empty():
_user_info = {
"id": user_id,
"username": username
}
print("✅ 已从本地加载认证数据")
print(" 用户: ", username)
else:
print("⚠️ 本地认证数据无效(没有 refresh_token")
# 清除本地认证数据(登出时调用)
#
# 功能:
# - 清除内存中的 Token
# - 删除本地 ConfigFile
func _clear_auth_data() -> void:
_access_token = ""
_refresh_token = ""
_user_info = {}
_token_expiry = 0.0
_game_token = ""
if FileAccess.file_exists(AUTH_CONFIG_PATH):
DirAccess.remove_absolute(AUTH_CONFIG_PATH)
print("✅ 已清除本地认证数据")
# ============ Token 访问方法 ============
# 设置游戏 token兼容旧代码推荐使用 _save_tokens_to_memory
#
# 参数:
# token: String - 游戏认证 token
#
# 使用场景:
# - 登录成功后设置 token
# - 从服务器响应中获取 token
func set_game_token(token: String) -> void:
_game_token = token
_access_token = token # 同步更新 access_token
print("AuthManager: 游戏 token 已设置")
# 获取游戏 token
#
# 返回值:
# String - access_token如果未设置则返回空字符串
#
# 使用场景:
# - ChatManager 连接 WebSocket 时需要 token
# - 其他需要游戏认证的场景
func get_game_token() -> String:
return _access_token
# 获取 access token
#
# 返回值:
# String - JWT访问令牌
#
# 使用场景:
# - API请求认证
# - WebSocket聊天认证
func get_access_token() -> String:
return _access_token
# 获取 refresh token
#
# 返回值:
# String - JWT刷新令牌
#
# 使用场景:
# - 刷新过期的 access token
func get_refresh_token() -> String:
return _refresh_token
# 获取用户信息
#
# 返回值:
# Dictionary - 用户信息字典
func get_user_info() -> Dictionary:
return _user_info
# ============ 登录相关方法 ============
# 执行密码登录
#
# 参数:
# username: String - 用户名/邮箱
# password: String - 密码
#
# 功能:
# - 验证输入参数
# - 发送登录请求
# - 处理响应结果
func execute_password_login(username: String, password: String):
if is_processing:
show_toast_message.emit("请等待当前操作完成", false)
return
# 验证输入
var validation_result = validate_login_inputs(username, password)
if not validation_result.valid:
form_validation_failed.emit(validation_result.field, validation_result.message)
return
# 设置处理状态
is_processing = true
button_state_changed.emit("main_btn", true, "登录中...")
show_toast_message.emit("正在验证登录信息...", true)
# 发送网络请求
var request_id = NetworkManager.login(username, password, _on_login_response)
if request_id != "":
active_request_ids.append(request_id)
else:
_reset_login_state()
show_toast_message.emit("网络请求失败", false)
# 执行验证码登录
#
# 参数:
# identifier: String - 用户标识符
# verification_code: String - 验证码
func execute_verification_login(identifier: String, verification_code: String):
if is_processing:
show_toast_message.emit("请等待当前操作完成", false)
return
# 验证输入
if identifier.is_empty():
form_validation_failed.emit("username", "请输入用户名/手机/邮箱")
return
if verification_code.is_empty():
form_validation_failed.emit("verification", "请输入验证码")
return
# 设置处理状态
is_processing = true
button_state_changed.emit("main_btn", true, "登录中...")
show_toast_message.emit("正在验证验证码...", true)
# 发送网络请求
var request_id = NetworkManager.verification_code_login(identifier, verification_code, _on_verification_login_response)
if request_id != "":
active_request_ids.append(request_id)
else:
_reset_login_state()
show_toast_message.emit("网络请求失败", false)
# 切换登录模式
func toggle_login_mode():
if current_login_mode == LoginMode.PASSWORD:
current_login_mode = LoginMode.VERIFICATION
else:
current_login_mode = LoginMode.PASSWORD
# 获取当前登录模式
func get_current_login_mode() -> LoginMode:
return current_login_mode
# ============ 注册相关方法 ============
# 执行用户注册
#
# 参数:
# username: String - 用户名
# email: String - 邮箱
# password: String - 密码
# confirm_password: String - 确认密码
# verification_code: String - 邮箱验证码
func execute_register(username: String, email: String, password: String, confirm_password: String, verification_code: String):
if is_processing:
show_toast_message.emit("请等待当前操作完成", false)
return
# 验证注册表单
var validation_result = validate_register_form(username, email, password, confirm_password, verification_code)
if not validation_result.valid:
form_validation_failed.emit(validation_result.field, validation_result.message)
return
# 设置处理状态
is_processing = true
button_state_changed.emit("register_btn", true, "注册中...")
show_toast_message.emit("正在创建账户...", true)
# 发送注册请求
var request_id = NetworkManager.register(username, password, username, email, verification_code, _on_register_response)
if request_id != "":
active_request_ids.append(request_id)
else:
_reset_register_state()
show_toast_message.emit("网络请求失败", false)
# ============ 验证码相关方法 ============
# 发送邮箱验证码
#
# 参数:
# email: String - 邮箱地址
func send_email_verification_code(email: String):
# 验证邮箱格式
var email_validation = validate_email(email)
if not email_validation.valid:
form_validation_failed.emit("email", email_validation.message)
return
# 检查冷却时间
if not _can_send_verification_code(email):
var remaining = get_remaining_cooldown_time(email)
show_toast_message.emit("该邮箱请等待 %d 秒后再次发送" % remaining, false)
return
# 记录发送状态
_record_verification_code_sent(email)
# 发送请求
var request_id = NetworkManager.send_email_verification(email, _on_send_code_response)
if request_id != "":
active_request_ids.append(request_id)
else:
_reset_verification_code_state(email)
show_toast_message.emit("网络请求失败", false)
# 发送登录验证码
#
# 参数:
# identifier: String - 用户标识符
func send_login_verification_code(identifier: String):
if identifier.is_empty():
form_validation_failed.emit("username", "请先输入用户名/手机/邮箱")
return
button_state_changed.emit("get_code_btn", true, "发送中...")
show_toast_message.emit("正在发送登录验证码...", true)
var request_id = NetworkManager.send_login_verification_code(identifier, _on_send_login_code_response)
if request_id != "":
active_request_ids.append(request_id)
else:
button_state_changed.emit("get_code_btn", false, "获取验证码")
show_toast_message.emit("网络请求失败", false)
# 发送密码重置验证码
#
# 参数:
# identifier: String - 用户标识符
func send_password_reset_code(identifier: String):
if identifier.is_empty():
show_toast_message.emit("请先输入邮箱或手机号", false)
return
if not _is_valid_identifier(identifier):
show_toast_message.emit("请输入有效的邮箱或手机号", false)
return
button_state_changed.emit("forgot_password_btn", true, "发送中...")
show_toast_message.emit("正在发送密码重置验证码...", true)
var request_id = NetworkManager.forgot_password(identifier, _on_forgot_password_response)
if request_id != "":
active_request_ids.append(request_id)
else:
button_state_changed.emit("forgot_password_btn", false, "忘记密码")
show_toast_message.emit("网络请求失败", false)
# ============ 验证方法 ============
# 验证登录输入
func validate_login_inputs(username: String, password: String) -> Dictionary:
var result = {"valid": false, "field": "", "message": ""}
if username.is_empty():
result.field = "username"
result.message = "用户名不能为空"
return result
if password.is_empty():
result.field = "password"
result.message = "密码不能为空"
return result
result.valid = true
return result
# 验证注册表单
func validate_register_form(username: String, email: String, password: String, confirm_password: String, verification_code: String) -> Dictionary:
var result = {"valid": false, "field": "", "message": ""}
# 验证用户名
var username_validation = validate_username(username)
if not username_validation.valid:
result.field = "username"
result.message = username_validation.message
return result
# 验证邮箱
var email_validation = validate_email(email)
if not email_validation.valid:
result.field = "email"
result.message = email_validation.message
return result
# 验证密码
var password_validation = validate_password(password)
if not password_validation.valid:
result.field = "password"
result.message = password_validation.message
return result
# 验证确认密码
var confirm_validation = validate_confirm_password(password, confirm_password)
if not confirm_validation.valid:
result.field = "confirm"
result.message = confirm_validation.message
return result
# 验证验证码
var code_validation = validate_verification_code(verification_code)
if not code_validation.valid:
result.field = "verification"
result.message = code_validation.message
return result
# 检查是否已发送验证码
if not _has_sent_verification_code(email):
result.field = "verification"
result.message = "请先获取邮箱验证码"
return result
result.valid = true
return result
# 验证用户名
func validate_username(username: String) -> Dictionary:
var result = {"valid": false, "message": ""}
if username.is_empty():
result.message = "用户名不能为空"
return result
if not StringUtils.is_valid_username(username):
if username.length() > 50:
result.message = "用户名长度不能超过50字符"
else:
result.message = "用户名只能包含字母、数字和下划线"
return result
result.valid = true
return result
# 验证邮箱
func validate_email(email: String) -> Dictionary:
var result = {"valid": false, "message": ""}
if email.is_empty():
result.message = "邮箱不能为空"
return result
if not StringUtils.is_valid_email(email):
result.message = "请输入有效的邮箱地址"
return result
result.valid = true
return result
# 验证密码
func validate_password(password: String) -> Dictionary:
return StringUtils.validate_password_strength(password)
# 验证确认密码
func validate_confirm_password(password: String, confirm: String) -> Dictionary:
var result = {"valid": false, "message": ""}
if confirm.is_empty():
result.message = "确认密码不能为空"
return result
if password != confirm:
result.message = "两次输入的密码不一致"
return result
result.valid = true
return result
# 验证验证码
func validate_verification_code(code: String) -> Dictionary:
var result = {"valid": false, "message": ""}
if code.is_empty():
result.message = "验证码不能为空"
return result
if code.length() != 6:
result.message = "验证码必须是6位数字"
return result
for i in range(code.length()):
var character = code[i]
if not (character >= '0' and character <= '9'):
result.message = "验证码必须是6位数字"
return result
result.valid = true
return result
# ============ 网络响应处理 ============
# 处理登录响应
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
_reset_login_state()
var result = ResponseHandler.handle_login_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
if result.success:
# 保存 Token 到内存和本地
_save_tokens_to_memory(data)
_save_tokens_to_local(data)
var username: String = _user_info.get("username", "")
# 延迟发送登录成功信号
await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username)
else:
login_failed.emit(result.message)
# 处理验证码登录响应
func _on_verification_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
_reset_login_state()
var result = ResponseHandler.handle_verification_code_login_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
if result.success:
# 保存 Token 到内存和本地
_save_tokens_to_memory(data)
_save_tokens_to_local(data)
var username: String = _user_info.get("username", "")
await Engine.get_main_loop().create_timer(1.0).timeout
login_success.emit(username)
else:
login_failed.emit(result.message)
# 处理注册响应
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary):
_reset_register_state()
var result = ResponseHandler.handle_register_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
if result.success:
register_success.emit(result.message)
else:
register_failed.emit(result.message)
# 处理发送验证码响应
func _on_send_code_response(success: bool, data: Dictionary, error_info: Dictionary):
var result = ResponseHandler.handle_send_verification_code_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
if result.success:
verification_code_sent.emit(result.message)
else:
verification_code_failed.emit(result.message)
_reset_verification_code_state(current_email)
# 处理发送登录验证码响应
func _on_send_login_code_response(success: bool, data: Dictionary, error_info: Dictionary):
button_state_changed.emit("get_code_btn", false, "获取验证码")
var result = ResponseHandler.handle_send_login_code_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
# 处理忘记密码响应
func _on_forgot_password_response(success: bool, data: Dictionary, error_info: Dictionary):
button_state_changed.emit("forgot_password_btn", false, "忘记密码")
var result = ResponseHandler.handle_send_login_code_response(success, data, error_info)
if result.should_show_toast:
show_toast_message.emit(result.message, result.success)
# ============ 网络测试 ============
# 测试网络连接
func test_network_connection():
var request_id = NetworkManager.get_app_status(_on_network_test_response)
if request_id != "":
active_request_ids.append(request_id)
# 处理网络测试响应
func _on_network_test_response(success: bool, data: Dictionary, error_info: Dictionary):
var result = ResponseHandler.handle_network_test_response(success, data, error_info)
network_status_changed.emit(result.success, result.message)
# ============ 私有辅助方法 ============
# 重置登录状态
func _reset_login_state():
is_processing = false
button_state_changed.emit("main_btn", false, "进入小镇")
# 重置注册状态
func _reset_register_state():
is_processing = false
button_state_changed.emit("register_btn", false, "注册")
# 检查是否可以发送验证码
func _can_send_verification_code(email: String) -> bool:
if not verification_codes_sent.has(email):
return true
var email_data = verification_codes_sent[email]
if not email_data.sent:
return true
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
return (current_timestamp - email_data.time) >= code_cooldown
# 获取剩余冷却时间
func get_remaining_cooldown_time(email: String) -> int:
if not verification_codes_sent.has(email):
return 0
var email_data = verification_codes_sent[email]
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
return int(code_cooldown - (current_timestamp - email_data.time))
# 记录验证码发送状态
func _record_verification_code_sent(email: String):
var current_time = Time.get_time_dict_from_system()
var current_timestamp = current_time.hour * 3600 + current_time.minute * 60 + current_time.second
if not verification_codes_sent.has(email):
verification_codes_sent[email] = {}
verification_codes_sent[email].sent = true
verification_codes_sent[email].time = current_timestamp
current_email = email
# 重置验证码状态
func _reset_verification_code_state(email: String):
if verification_codes_sent.has(email):
verification_codes_sent[email].sent = false
# 检查是否已发送验证码
func _has_sent_verification_code(email: String) -> bool:
if not verification_codes_sent.has(email):
return false
return verification_codes_sent[email].get("sent", false)
# 验证标识符格式
func _is_valid_identifier(identifier: String) -> bool:
return StringUtils.is_valid_email(identifier) or _is_valid_phone(identifier)
# 验证手机号格式
func _is_valid_phone(phone: String) -> bool:
var regex = RegEx.new()
regex.compile("^\\+?[1-9]\\d{1,14}$")
return regex.search(phone) != null

View File

@@ -0,0 +1 @@
uid://bpdyraefv0yta

View File

@@ -0,0 +1,773 @@
extends Node
# ============================================================================
# ChatManager.gd - 聊天系统业务逻辑核心
# ============================================================================
# 管理聊天功能的核心业务逻辑
#
# 核心职责:
# - 聊天消息发送/接收协调
# - 客户端频率限制10条/分钟)
# - 消息历史管理最多100条
# - Signal Up: 通过信号和 EventSystem 向上通知
# - 整合 AuthManager 获取 token
#
# 使用方式:
# ChatManager.connect_to_chat_server()
# ChatManager.send_chat_message("Hello", "local")
# ChatManager.chat_message_received.connect(_on_message_received)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 遵循 "Signal Up, Call Down" 架构
# - 所有聊天事件通过 EventSystem 广播
# ============================================================================
# ============================================================================
# 信号定义 (Signal Up)
# ============================================================================
# 聊天消息已发送信号
# 参数:
# message_id: String - 消息 ID
# timestamp: float - 时间戳
signal chat_message_sent(message_id: String, timestamp: float)
# 聊天消息已接收信号
# 参数:
# from_user: String - 发送者用户名
# content: String - 消息内容
# show_bubble: bool - 是否显示气泡
# timestamp: float - 时间戳
signal chat_message_received(from_user: String, content: String, show_bubble: bool, timestamp: float)
# 聊天错误发生信号
# 参数:
# error_code: String - 错误代码
# message: String - 错误消息
signal chat_error_occurred(error_code: String, message: String)
# 聊天连接状态变化信号
# 参数:
# state: int - 连接状态0=DISCONNECTED, 1=CONNECTING, 2=CONNECTED, 3=RECONNECTING, 4=ERROR
signal chat_connection_state_changed(state: int)
# 位置更新成功信号
# 参数:
# stream: String - Stream 名称
# topic: String - Topic 名称
signal chat_position_updated(stream: String, topic: String)
# ============================================================================
# 常量定义
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 重连配置
const RECONNECT_MAX_ATTEMPTS: int = 5
const RECONNECT_BASE_DELAY: float = 3.0
# 频率限制配置
const RATE_LIMIT_MESSAGES: int = 10
const RATE_LIMIT_WINDOW: float = 60.0 # 秒
# 消息限制
const MAX_MESSAGE_LENGTH: int = 1000
# 当前会话消息限制(当前游戏会话,超过后删除最旧的)
const MAX_SESSION_MESSAGES: int = 100
# 历史消息分页大小(从 Zulip 后端每次加载的数量)
const HISTORY_PAGE_SIZE: int = 100
# 错误消息映射
const CHAT_ERROR_MESSAGES: Dictionary = {
"AUTH_FAILED": "聊天认证失败,请重新登录",
"RATE_LIMIT": "消息发送过于频繁,请稍后再试",
"CONTENT_FILTERED": "消息内容包含违规内容",
"CONTENT_TOO_LONG": "消息内容过长最大1000字符",
"PERMISSION_DENIED": "您没有权限发送消息",
"SESSION_EXPIRED": "会话已过期,请重新连接",
"ZULIP_ERROR": "消息服务暂时不可用",
"INTERNAL_ERROR": "服务器内部错误"
}
# ============================================================================
# 成员变量
# ============================================================================
# WebSocket 管理器
var _websocket_manager: WebSocketManager
# 是否已登录
var _is_logged_in: bool = false
# 消息历史记录当前会话最多100条超过后删除最旧的
var _message_history: Array[Dictionary] = []
# 历史消息加载状态
var _history_loading: bool = false
var _has_more_history: bool = true
var _oldest_message_timestamp: float = 0.0
# 消息发送时间戳(用于频率限制)
var _message_timestamps: Array[float] = []
# 当前用户信息
var _current_username: String = ""
var _current_map: String = ""
# 游戏 token
var _game_token: String = ""
# 发送后本地回显去重(避免服务端也回发导致重复显示)
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
var _pending_self_messages: Array[Dictionary] = []
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
print("ChatManager 初始化完成")
# 创建 WebSocket 管理器
_websocket_manager = WebSocketManager.new()
add_child(_websocket_manager)
# 连接信号
_connect_signals()
# 清理
func _exit_tree() -> void:
if is_instance_valid(_websocket_manager):
_websocket_manager.queue_free()
# ============================================================================
# 公共 API - Token 管理
# ============================================================================
# 设置游戏 token
#
# 参数:
# token: String - 游戏认证 token
#
# 使用示例:
# ChatManager.set_game_token("your_game_token")
func set_game_token(token: String) -> void:
_game_token = token
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_websocket_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"}
_websocket_manager.send_message(JSON.stringify(logout_data))
_is_logged_in = false
# 断开连接
_websocket_manager.disconnect_websocket()
# 检查是否已连接
#
# 返回值:
# bool - 是否已连接
func is_chat_connected() -> bool:
return _websocket_manager.is_websocket_connected()
# ============================================================================
# 公共 API - 聊天操作
# ============================================================================
# 发送聊天消息
#
# 参数:
# content: String - 消息内容
# scope: String - 消息范围("local" 或具体 topic 名称)
#
# 使用示例:
# ChatManager.send_chat_message("Hello, world!", "local")
func send_chat_message(content: String, scope: String = "local") -> void:
# 检查连接状态
if not _websocket_manager.is_websocket_connected():
_handle_error("NOT_CONNECTED", "未连接到聊天服务器")
return
# 检查登录状态
if not _is_logged_in:
_handle_error("NOT_LOGGED_IN", "尚未登录聊天服务器")
return
# 检查消息长度
if content.length() > MAX_MESSAGE_LENGTH:
_handle_error("CONTENT_TOO_LONG", "消息内容过长")
return
# 检查频率限制
if not can_send_message():
var wait_time := get_time_until_next_message()
_handle_error("RATE_LIMIT", "请等待 %.1f 秒后再试" % wait_time)
return
# 构建消息数据
var message_data := {
"type": "chat",
"content": content,
"scope": scope
}
# 发送消息JSON 字符串)
var json_string := JSON.stringify(message_data)
var send_err: Error = _websocket_manager.send_message(json_string)
if send_err != OK:
_handle_error("SEND_FAILED", "WebSocket send failed: %s" % error_string(send_err))
return
# 记录发送时间
_record_message_timestamp()
# 添加到历史
_add_message_to_history({
"from_user": _current_username,
"content": content,
"timestamp": Time.get_unix_time_from_system(),
"is_self": true
})
var now_timestamp: float = Time.get_unix_time_from_system()
# 记录待去重的“自己消息”(如果服务端也回发 chat_render则避免重复显示
_pending_self_messages.append({
"content": content,
"expires_at": now_timestamp + SELF_ECHO_DEDUPE_WINDOW
})
# 本地回显UI 目前只订阅 CHAT_MESSAGE_RECEIVED所以这里也发一次 received
chat_message_received.emit(_current_username, content, true, now_timestamp)
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
"from_user": _current_username,
"content": content,
"show_bubble": true,
"timestamp": now_timestamp,
"is_self": true
})
print("📤 发送聊天消息: ", content)
# 消息发送完成回调
func _on_chat_message_sent(request_id: String, success: bool, data: Dictionary, error_info: Dictionary) -> void:
if success:
print("✅ 消息发送成功: ", data)
var message_id: String = str(data.get("data", {}).get("id", ""))
var timestamp: float = Time.get_unix_time_from_system()
chat_message_sent.emit(message_id, timestamp)
EventSystem.emit_event(EventNames.CHAT_MESSAGE_SENT, {
"message_id": message_id,
"timestamp": timestamp
})
else:
print("❌ 消息发送失败: ", error_info)
_handle_error("SEND_FAILED", error_info.get("message", "发送失败"))
# 更新玩家位置
#
# 参数:
# x: float - X 坐标
# y: float - Y 坐标
# map_id: String - 地图 ID
#
# 使用示例:
# ChatManager.update_player_position(150.0, 200.0, "novice_village")
func update_player_position(x: float, y: float, map_id: String) -> void:
if not _websocket_manager.is_websocket_connected():
return
var position_data := {
"type": "position",
"x": x,
"y": y,
"mapId": map_id
}
# 发送消息JSON 字符串)
var json_string := JSON.stringify(position_data)
_websocket_manager.send_message(json_string)
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()
# 清理过期的时间戳
var filter_func := func(timestamp: float) -> bool:
return current_time - timestamp < RATE_LIMIT_WINDOW
_message_timestamps = _message_timestamps.filter(filter_func)
# 检查数量
return _message_timestamps.size() < RATE_LIMIT_MESSAGES
# 获取距离下次可发送消息的时间
#
# 返回值:
# float - 等待时间(秒)
func get_time_until_next_message() -> float:
if _message_timestamps.is_empty():
return 0.0
if _message_timestamps.size() < RATE_LIMIT_MESSAGES:
return 0.0
# 找到最早的时间戳
var earliest_timestamp: float = _message_timestamps[0]
var current_time := Time.get_unix_time_from_system()
var elapsed := current_time - earliest_timestamp
if elapsed >= RATE_LIMIT_WINDOW:
return 0.0
return RATE_LIMIT_WINDOW - elapsed
# ============================================================================
# 公共 API - 消息历史
# ============================================================================
# 获取消息历史
#
# 返回值:
# Array[Dictionary] - 消息历史数组
func get_message_history() -> Array[Dictionary]:
return _message_history.duplicate()
# 清空消息历史
func clear_message_history() -> void:
_message_history.clear()
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_self": (not _current_username.is_empty() and message.get("from_user", "") == _current_username),
"is_history": true # 标记为历史消息
})
# 更新最旧消息时间戳
var oldest: Dictionary = messages.back()
if oldest.has("timestamp"):
_oldest_message_timestamp = oldest.timestamp
# 检查是否还有更多历史
if messages.size() < HISTORY_PAGE_SIZE:
_has_more_history = false
# ============================================================================
# 内部方法 - 信号连接
# ============================================================================
# 连接信号
func _connect_signals() -> void:
# WebSocket 管理器信号
_websocket_manager.connection_state_changed.connect(_on_connection_state_changed)
_websocket_manager.data_received.connect(_on_data_received)
# 发送登录消息
func _send_login_message() -> void:
print("📤 发送登录消息...")
var login_data := {
"type": "login",
"token": _game_token
}
var json_string := JSON.stringify(login_data)
_websocket_manager.send_message(json_string)
print(" Token: ", _game_token.left(20) + "..." if _game_token.length() > 20 else _game_token)
# 连接状态变化
func _on_connection_state_changed(state: int) -> void:
var state_names := ["DISCONNECTED", "CONNECTING", "CONNECTED", "RECONNECTING", "ERROR"]
print("📡 ChatManager: 连接状态变化 - ", state_names[state])
# 发射信号
chat_connection_state_changed.emit(state)
# 通过 EventSystem 广播Signal Up
EventSystem.emit_event(EventNames.CHAT_CONNECTION_STATE_CHANGED, {
"state": state
})
# 如果连接成功,发送登录消息
if state == 2: # CONNECTED
_send_login_message()
# ============================================================================
# 内部方法 - 消息处理
# ============================================================================
# WebSocket 数据接收
func _on_data_received(message: String) -> void:
# 解析 JSON 消息
var json := JSON.new()
var parse_result := json.parse(message)
if parse_result != OK:
print("❌ ChatManager: JSON 解析失败 - ", message)
return
var data: Dictionary = json.data
# 检查消息类型字段
var message_type: String = data.get("t", "")
match message_type:
"login_success":
_handle_login_success(data)
"login_error":
_handle_login_error(data)
"chat":
_handle_chat_render(data)
"chat_sent":
_handle_chat_sent(data)
"chat_error":
_handle_chat_error(data)
"chat_render":
_handle_chat_render(data)
"position_updated":
_handle_position_updated(data)
_:
print("⚠️ ChatManager: 未处理的消息类型 - ", message_type)
print(" 消息内容: ", data)
# 处理登录成功
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_login_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "登录失败")
print("❌ ChatManager: 登录失败 - ", error_message)
_is_logged_in = false
# 通过 EventSystem 广播错误Signal Up
EventSystem.emit_event(EventNames.CHAT_LOGIN_FAILED, {
"error_code": "LOGIN_FAILED",
"message": error_message
})
# 处理聊天消息发送成功
func _handle_chat_sent(data: Dictionary) -> void:
var message_id: String = str(data.get("messageId", ""))
var timestamp: float = data.get("timestamp", 0.0)
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_error(data: Dictionary) -> void:
var error_message: String = data.get("message", "消息发送失败")
print("❌ ChatManager: 聊天错误 - ", error_message)
# 通过 EventSystem 广播错误Signal Up
EventSystem.emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": "CHAT_SEND_FAILED",
"message": error_message
})
# 处理接收到的聊天消息
func _handle_chat_render(data: Dictionary) -> void:
# 兼容不同后端字段命名:
# - chat_render: {from, txt, bubble, timestamp}
# - chat: {content, scope, (可选 from/username/timestamp)}
var from_user: String = data.get("from", data.get("from_user", data.get("username", "")))
var content: String = data.get("txt", data.get("content", ""))
var show_bubble: bool = bool(data.get("bubble", data.get("show_bubble", false)))
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
if is_self and _consume_pending_self_message(content):
# 已经本地回显过,避免重复显示
return
# 如果服务端没带发送者信息,但内容匹配最近自己发送的消息,则认为是自己消息
if from_user.is_empty() and _consume_pending_self_message(content):
from_user = _current_username
is_self = true
print("📨 收到聊天消息: ", from_user, " -> ", content)
# 添加到历史
_add_message_to_history({
"from_user": from_user,
"content": content,
"timestamp": timestamp,
"is_self": is_self
})
# 发射信号
chat_message_received.emit(from_user, content, show_bubble, timestamp)
# 通过 EventSystem 广播Signal Up
EventSystem.emit_event(EventNames.CHAT_MESSAGE_RECEIVED, {
"from_user": from_user,
"content": content,
"show_bubble": show_bubble,
"timestamp": timestamp,
"is_self": is_self
})
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var ts := float(timestamp_raw)
return ts if ts > 0.0 else Time.get_unix_time_from_system()
var ts_str := str(timestamp_raw)
if ts_str.strip_edges().is_empty():
return Time.get_unix_time_from_system()
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(ts_str) != null:
var ts_num := float(ts_str)
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
# ISO 8601: 2026-01-19T15:15:43.930Z
var regex := RegEx.new()
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var result := regex.search(ts_str)
if result == null:
return Time.get_unix_time_from_system()
var utc_dict := {
"year": int(result.get_string(1)),
"month": int(result.get_string(2)),
"day": int(result.get_string(3)),
"hour": int(result.get_string(4)),
"minute": int(result.get_string(5)),
"second": int(result.get_string(6))
}
return Time.get_unix_time_from_datetime_dict(utc_dict)
# 处理位置更新成功
func _handle_position_updated(data: Dictionary) -> void:
var stream: String = data.get("stream", "")
var topic: String = data.get("topic", "")
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 _handle_error(error_code: String, error_message: String) -> void:
print("❌ ChatManager 错误: [", error_code, "] ", error_message)
# 获取用户友好的错误消息
var user_message: String = CHAT_ERROR_MESSAGES.get(error_code, error_message) as String
# 发射信号
chat_error_occurred.emit(error_code, user_message)
# 通过 EventSystem 广播Signal Up
EventSystem.emit_event(EventNames.CHAT_ERROR_OCCURRED, {
"error_code": error_code,
"message": user_message
})
# 特殊处理认证失败
if error_code == "AUTH_FAILED" or error_code == "SESSION_EXPIRED":
_is_logged_in = false
EventSystem.emit_event(EventNames.CHAT_LOGIN_FAILED, {
"error_code": error_code
})
# 记录消息发送时间戳
func _record_message_timestamp() -> void:
var current_time := Time.get_unix_time_from_system()
_message_timestamps.append(current_time)
# 消费一个待去重的“自己消息”(允许相同内容多次发送:每次消费一个)
func _consume_pending_self_message(content: String) -> bool:
var now := Time.get_unix_time_from_system()
# 先清理过期项
for i in range(_pending_self_messages.size() - 1, -1, -1):
var item: Dictionary = _pending_self_messages[i]
if float(item.get("expires_at", 0.0)) < now:
_pending_self_messages.remove_at(i)
# 再匹配内容
for i in range(_pending_self_messages.size() - 1, -1, -1):
if str(_pending_self_messages[i].get("content", "")) == content:
_pending_self_messages.remove_at(i)
return true
return false
# 添加消息到当前会话历史
func _add_message_to_history(message: Dictionary) -> void:
_message_history.append(message)
# 更新最旧消息时间戳(用于历史消息加载)
if _oldest_message_timestamp == 0.0 or message.timestamp < _oldest_message_timestamp:
_oldest_message_timestamp = message.timestamp
# 限制当前会话消息数量(超过后删除最旧的)
if _message_history.size() > MAX_SESSION_MESSAGES:
_message_history.pop_front()

View File

@@ -0,0 +1 @@
uid://b6lnbss2i3pss

View File

@@ -0,0 +1,142 @@
extends Node
# ============================================================================
# GameManager.gd - 游戏管理器
# ============================================================================
# 全局单例管理器,负责游戏状态管理和生命周期控制
#
# 核心职责:
# - 游戏状态切换 (加载、认证、游戏中、暂停等)
# - 用户信息管理
# - 全局配置访问
# - 系统初始化和清理
#
# 使用方式:
# GameManager.change_state(GameManager.GameState.IN_GAME)
# GameManager.set_current_user("player123")
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 状态变更会触发 game_state_changed 信号
# - 状态切换应该通过 change_state() 方法进行
# ============================================================================
# ============ 信号定义 ============
# 游戏状态变更信号
# 参数: new_state - 新的游戏状态
signal game_state_changed(new_state: GameState)
# ============ 枚举定义 ============
# 游戏状态枚举
# 定义了游戏的各种运行状态
enum GameState {
LOADING, # 加载中 - 游戏启动时的初始化状态
AUTH, # 认证状态 - 用户登录/注册界面
MAIN_MENU, # 主菜单 - 游戏主界面
IN_GAME, # 游戏中 - 正在进行游戏
PAUSED, # 暂停 - 游戏暂停状态
SETTINGS # 设置 - 设置界面
}
# ============ 成员变量 ============
# 状态管理
var current_state: GameState = GameState.LOADING # 当前游戏状态
var previous_state: GameState = GameState.LOADING # 上一个游戏状态
# 用户信息
var current_user: String = "" # 当前登录用户名
# 游戏配置
var game_version: String = "1.0.0" # 游戏版本号
# ============ 生命周期方法 ============
# 初始化游戏管理器
# 在节点准备就绪时调用,设置初始状态
func _ready():
print("GameManager 初始化完成")
change_state(GameState.AUTH) # 启动时进入认证状态
# ============ 状态管理方法 ============
# 切换游戏状态
#
# 参数:
# new_state: GameState - 要切换到的新状态
#
# 功能:
# - 检查状态是否需要切换
# - 记录状态变更历史
# - 发送状态变更信号
# - 输出状态变更日志
func change_state(new_state: GameState):
# 避免重复切换到相同状态
if current_state == new_state:
return
# 记录状态变更
previous_state = current_state
current_state = new_state
# 输出状态变更日志
print("游戏状态变更: ", GameState.keys()[previous_state], " -> ", GameState.keys()[current_state])
# 发送状态变更信号
game_state_changed.emit(new_state)
# 获取当前游戏状态
#
# 返回值:
# GameState - 当前的游戏状态
func get_current_state() -> GameState:
return current_state
# 获取上一个游戏状态
#
# 返回值:
# GameState - 上一个游戏状态
#
# 使用场景:
# - 从暂停状态恢复时,返回到之前的状态
# - 错误处理时回退到安全状态
func get_previous_state() -> GameState:
return previous_state
# ============ 用户管理方法 ============
# 设置当前登录用户
#
# 参数:
# username: String - 用户名
#
# 功能:
# - 存储当前登录用户信息
# - 输出用户设置日志
#
# 注意事项:
# - 用户登录成功后调用此方法
# - 用户登出时应传入空字符串
func set_current_user(username: String):
current_user = username
print("当前用户设置为: ", username)
# 获取当前登录用户
#
# 返回值:
# String - 当前登录的用户名,未登录时为空字符串
func get_current_user() -> String:
return current_user
# 检查用户是否已登录
#
# 返回值:
# bool - true表示已登录false表示未登录
#
# 使用场景:
# - 进入需要登录的功能前检查
# - UI显示逻辑判断
func is_user_logged_in() -> bool:
return not current_user.is_empty()

View File

@@ -0,0 +1 @@
uid://cd8fn73ysjxh8

View File

@@ -0,0 +1,701 @@
extends Node
# ============================================================================
# NetworkManager.gd - 网络请求管理器
# ============================================================================
# 全局单例管理器统一处理所有HTTP请求
#
# 核心职责:
# - 统一的HTTP请求接口 (GET, POST, PUT, DELETE, PATCH)
# - 认证相关API封装 (登录、注册、验证码等)
# - 请求状态管理和错误处理
# - 支持API v1.1.1规范的响应处理
#
# 使用方式:
# NetworkManager.login("user@example.com", "password", callback)
# var request_id = NetworkManager.get_request("/api/data", callback)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 所有请求都是异步的,通过回调函数或信号处理结果
# - 支持请求超时和取消功能
# - 自动处理JSON序列化和反序列化
# ============================================================================
# ============ 信号定义 ============
# 请求完成信号
# 参数:
# request_id: String - 请求唯一标识符
# success: bool - 请求是否成功
# data: Dictionary - 响应数据
signal request_completed(request_id: String, success: bool, data: Dictionary)
# 请求失败信号
# 参数:
# request_id: String - 请求唯一标识符
# error_type: String - 错误类型名称
# message: String - 错误消息
signal request_failed(request_id: String, error_type: String, message: String)
# ============ 常量定义 ============
# API基础URL - 所有请求的根地址
const API_BASE_URL = "https://whaletownend.xinghangee.icu"
# 默认请求超时时间(秒)
const DEFAULT_TIMEOUT = 30.0
# ============ 枚举定义 ============
# HTTP请求方法枚举
enum RequestType {
GET, # 获取数据
POST, # 创建数据
PUT, # 更新数据
DELETE, # 删除数据
PATCH # 部分更新数据
}
# 错误类型枚举
# 用于分类不同类型的网络错误
enum ErrorType {
NETWORK_ERROR, # 网络连接错误 - 无法连接到服务器
TIMEOUT_ERROR, # 请求超时 - 服务器响应时间过长
PARSE_ERROR, # JSON解析错误 - 服务器返回格式错误
HTTP_ERROR, # HTTP状态码错误 - 4xx, 5xx状态码
BUSINESS_ERROR # 业务逻辑错误 - API返回的业务错误
}
# ============ 请求信息类 ============
# 请求信息封装类
# 存储单个HTTP请求的所有相关信息
class RequestInfo:
var id: String # 请求唯一标识符
var url: String # 完整的请求URL
var method: RequestType # HTTP请求方法
var headers: PackedStringArray # 请求头数组
var body: String # 请求体内容
var timeout: float # 超时时间(秒)
var start_time: float # 请求开始时间戳
var http_request: HTTPRequest # Godot HTTPRequest节点引用
var callback: Callable # 完成时的回调函数
# 构造函数
#
# 参数:
# request_id: String - 请求唯一标识符
# request_url: String - 请求URL
# request_method: RequestType - HTTP方法
# request_headers: PackedStringArray - 请求头(可选)
# request_body: String - 请求体(可选)
# request_timeout: float - 超时时间可选默认使用DEFAULT_TIMEOUT
func _init(request_id: String, request_url: String, request_method: RequestType,
request_headers: PackedStringArray = [], request_body: String = "",
request_timeout: float = DEFAULT_TIMEOUT):
id = request_id
url = request_url
method = request_method
headers = request_headers
body = request_body
timeout = request_timeout
# 记录请求开始时间(简化版时间戳)
start_time = Time.get_time_dict_from_system().hour * 3600 + Time.get_time_dict_from_system().minute * 60 + Time.get_time_dict_from_system().second
# ============ 成员变量 ============
# 活动请求管理
var active_requests: Dictionary = {} # 存储所有活动请求 {request_id: RequestInfo}
var request_counter: int = 0 # 请求计数器用于生成唯一ID
# ============ 生命周期方法 ============
# 初始化网络管理器
# 在节点准备就绪时调用
func _ready():
print("NetworkManager 已初始化")
# ============ 公共API接口 ============
# 发送GET请求
#
# 参数:
# endpoint: String - API端点路径如: "/api/users"
# callback: Callable - 完成时的回调函数(可选)
# timeout: float - 超时时间可选默认30秒
#
# 返回值:
# String - 请求ID可用于取消请求或跟踪状态
#
# 使用示例:
# var request_id = NetworkManager.get_request("/api/users", my_callback)
func get_request(endpoint: String, callback: Callable = Callable(), timeout: float = DEFAULT_TIMEOUT) -> String:
return send_request(endpoint, RequestType.GET, [], "", callback, timeout)
# 发送POST请求
#
# 参数:
# endpoint: String - API端点路径
# data: Dictionary - 要发送的数据将自动转换为JSON
# callback: Callable - 完成时的回调函数(可选)
# timeout: float - 超时时间(可选)
#
# 返回值:
# String - 请求ID
#
# 使用示例:
# var data = {"name": "张三", "age": 25}
# var request_id = NetworkManager.post_request("/api/users", data, my_callback)
func post_request(endpoint: String, data: Dictionary, callback: Callable = Callable(), timeout: float = DEFAULT_TIMEOUT) -> String:
var body = JSON.stringify(data)
var headers = ["Content-Type: application/json"]
return send_request(endpoint, RequestType.POST, headers, body, callback, timeout)
# 发送PUT请求
#
# 参数:
# endpoint: String - API端点路径
# data: Dictionary - 要更新的数据
# callback: Callable - 完成时的回调函数(可选)
# timeout: float - 超时时间(可选)
#
# 返回值:
# String - 请求ID
func put_request(endpoint: String, data: Dictionary, callback: Callable = Callable(), timeout: float = DEFAULT_TIMEOUT) -> String:
var body = JSON.stringify(data)
var headers = ["Content-Type: application/json"]
return send_request(endpoint, RequestType.PUT, headers, body, callback, timeout)
# 发送DELETE请求
#
# 参数:
# endpoint: String - API端点路径
# callback: Callable - 完成时的回调函数(可选)
# timeout: float - 超时时间(可选)
#
# 返回值:
# String - 请求ID
func delete_request(endpoint: String, callback: Callable = Callable(), timeout: float = DEFAULT_TIMEOUT) -> String:
return send_request(endpoint, RequestType.DELETE, [], "", callback, timeout)
# ============ 认证相关API ============
# 用户登录
#
# 参数:
# identifier: String - 用户标识符(邮箱或手机号)
# password: String - 用户密码
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 回调函数签名:
# func callback(success: bool, data: Dictionary, error_info: Dictionary)
#
# 使用示例:
# NetworkManager.login("user@example.com", "password123", func(success, data, error):
# if success:
# print("登录成功: ", data)
# else:
# print("登录失败: ", error.message)
# )
func login(identifier: String, password: String, callback: Callable = Callable()) -> String:
var data = {
"identifier": identifier,
"password": password
}
return post_request("/auth/login", data, callback)
# 验证码登录
#
# 参数:
# identifier: String - 用户标识符(邮箱或手机号)
# verification_code: String - 验证码
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 使用场景:
# - 用户忘记密码时的替代登录方式
# - 提供更安全的登录选项
func verification_code_login(identifier: String, verification_code: String, callback: Callable = Callable()) -> String:
var data = {
"identifier": identifier,
"verification_code": verification_code
}
return post_request("/auth/verification-code-login", data, callback)
# 发送登录验证码
#
# 参数:
# identifier: String - 用户标识符(邮箱或手机号)
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 向已注册用户发送登录验证码
# - 支持邮箱和手机号
# - 有频率限制保护
func send_login_verification_code(identifier: String, callback: Callable = Callable()) -> String:
var data = {"identifier": identifier}
return post_request("/auth/send-login-verification-code", data, callback)
# 用户注册
#
# 参数:
# username: String - 用户名
# password: String - 密码
# nickname: String - 昵称
# email: String - 邮箱地址(可选)
# email_verification_code: String - 邮箱验证码(可选)
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 注意事项:
# - 如果提供邮箱,建议同时提供验证码
# - 用户名和邮箱必须唯一
# - 密码需要符合安全要求
func register(username: String, password: String, nickname: String, email: String = "",
email_verification_code: String = "", callback: Callable = Callable()) -> String:
var data = {
"username": username,
"password": password,
"nickname": nickname
}
# 可选参数处理
if email != "":
data["email"] = email
if email_verification_code != "":
data["email_verification_code"] = email_verification_code
return post_request("/auth/register", data, callback)
# 发送邮箱验证码
#
# 参数:
# email: String - 邮箱地址
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 向指定邮箱发送验证码
# - 用于注册时的邮箱验证
# - 支持测试模式(开发环境)
func send_email_verification(email: String, callback: Callable = Callable()) -> String:
var data = {"email": email}
return post_request("/auth/send-email-verification", data, callback)
# 验证邮箱
#
# 参数:
# email: String - 邮箱地址
# verification_code: String - 验证码
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 验证邮箱验证码的有效性
# - 通常在注册流程中使用
func verify_email(email: String, verification_code: String, callback: Callable = Callable()) -> String:
var data = {
"email": email,
"verification_code": verification_code
}
return post_request("/auth/verify-email", data, callback)
# 获取应用状态
#
# 参数:
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 检查API服务器状态
# - 获取应用基本信息
# - 用于网络连接测试
func get_app_status(callback: Callable = Callable()) -> String:
return get_request("/", callback)
# 重新发送邮箱验证码
#
# 参数:
# email: String - 邮箱地址
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 使用场景:
# - 用户未收到验证码时重新发送
# - 验证码过期后重新获取
func resend_email_verification(email: String, callback: Callable = Callable()) -> String:
var data = {"email": email}
return post_request("/auth/resend-email-verification", data, callback)
# 忘记密码 - 发送重置验证码
#
# 参数:
# identifier: String - 用户标识符(邮箱或手机号)
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 向用户发送密码重置验证码
# - 用于密码找回流程的第一步
func forgot_password(identifier: String, callback: Callable = Callable()) -> String:
var data = {"identifier": identifier}
return post_request("/auth/forgot-password", data, callback)
# 重置密码
#
# 参数:
# identifier: String - 用户标识符
# verification_code: String - 重置验证码
# new_password: String - 新密码
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 使用验证码重置用户密码
# - 密码找回流程的第二步
func reset_password(identifier: String, verification_code: String, new_password: String, callback: Callable = Callable()) -> String:
var data = {
"identifier": identifier,
"verification_code": verification_code,
"new_password": new_password
}
return post_request("/auth/reset-password", data, callback)
# 修改密码
#
# 参数:
# user_id: String - 用户ID
# old_password: String - 旧密码
# new_password: String - 新密码
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 已登录用户修改密码
# - 需要验证旧密码
func change_password(user_id: String, old_password: String, new_password: String, callback: Callable = Callable()) -> String:
var data = {
"user_id": user_id,
"old_password": old_password,
"new_password": new_password
}
return put_request("/auth/change-password", data, callback)
# GitHub OAuth登录
#
# 参数:
# github_id: String - GitHub用户ID
# username: String - GitHub用户名
# nickname: String - 显示昵称
# email: String - GitHub邮箱
# avatar_url: String - 头像URL可选
# callback: Callable - 完成时的回调函数(可选)
#
# 返回值:
# String - 请求ID
#
# 功能:
# - 通过GitHub账号登录或注册
# - 支持第三方OAuth认证
func github_login(github_id: String, username: String, nickname: String, email: String, avatar_url: String = "", callback: Callable = Callable()) -> String:
var data = {
"github_id": github_id,
"username": username,
"nickname": nickname,
"email": email
}
# 可选头像URL
if avatar_url != "":
data["avatar_url"] = avatar_url
return post_request("/auth/github", data, callback)
# ============ 核心请求处理 ============
# 发送请求的核心方法
func send_request(endpoint: String, method: RequestType, headers: PackedStringArray,
body: String, callback: Callable, timeout: float) -> String:
# 生成请求ID
request_counter += 1
var request_id = "req_" + str(request_counter)
# 构建完整URL
var full_url = API_BASE_URL + endpoint
# 创建HTTPRequest节点
var http_request = HTTPRequest.new()
add_child(http_request)
# 设置超时
http_request.timeout = timeout
# 创建请求信息
var request_info = RequestInfo.new(request_id, full_url, method, headers, body, timeout)
request_info.http_request = http_request
request_info.callback = callback
# 存储请求信息
active_requests[request_id] = request_info
# 连接信号
http_request.request_completed.connect(func(result: int, response_code: int, response_headers: PackedStringArray, response_body: PackedByteArray):
_on_request_completed(request_id, result, response_code, response_headers, response_body)
)
# 发送请求
var godot_method = _convert_to_godot_method(method)
var error = http_request.request(full_url, headers, godot_method, body)
print("=== 发送网络请求 ===")
print("请求ID: ", request_id)
print("URL: ", full_url)
print("方法: ", RequestType.keys()[method])
print("Headers: ", headers)
print("Body: ", body if body.length() < 200 else body.substr(0, 200) + "...")
print("发送结果: ", error)
if error != OK:
print("请求发送失败,错误代码: ", error)
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络请求发送失败: " + str(error))
return ""
return request_id
# 请求完成回调
func _on_request_completed(request_id: String, result: int, response_code: int,
headers: PackedStringArray, body: PackedByteArray):
print("=== 网络请求完成 ===")
print("请求ID: ", request_id)
print("结果: ", result)
print("状态码: ", response_code)
print("响应头: ", headers)
# 获取请求信息
if not active_requests.has(request_id):
print("警告: 未找到请求ID ", request_id)
return
var _request_info = active_requests[request_id]
var response_text = body.get_string_from_utf8()
print("响应体长度: ", body.size(), " 字节")
print("响应内容: ", response_text if response_text.length() < 500 else response_text.substr(0, 500) + "...")
# 处理网络连接失败
if response_code == 0:
_handle_request_error(request_id, ErrorType.NETWORK_ERROR, "网络连接失败,请检查网络连接")
return
# 解析JSON响应
var json = JSON.new()
var parse_result = json.parse(response_text)
if parse_result != OK:
_handle_request_error(request_id, ErrorType.PARSE_ERROR, "服务器响应格式错误")
return
var response_data = json.data
# 处理响应
_handle_response(request_id, response_code, response_data)
# 处理响应 - 支持API v1.1.1的状态码
func _handle_response(request_id: String, response_code: int, data: Dictionary):
var request_info = active_requests[request_id]
# 检查业务成功标志
var success = data.get("success", true) # 默认true保持向后兼容
var error_code = data.get("error_code", "")
var message = data.get("message", "")
# 判断请求是否成功
var is_success = false
# HTTP成功状态码且业务成功
if (response_code >= 200 and response_code < 300) and success:
is_success = true
# 特殊情况206测试模式 - 根据API文档这是成功的测试模式响应
elif response_code == 206 and error_code == "TEST_MODE_ONLY":
is_success = true
print("🧪 测试模式响应: ", message)
# 201创建成功
elif response_code == 201:
is_success = true
if is_success:
print("✅ 请求成功: ", request_id)
# 发送成功信号
request_completed.emit(request_id, true, data)
# 调用回调函数
if request_info.callback.is_valid():
request_info.callback.call(true, data, {})
else:
print("❌ 请求失败: ", request_id, " - HTTP:", response_code, " 错误码:", error_code, " 消息:", message)
# 确定错误类型
var error_type = _determine_error_type(response_code, error_code)
# 发送失败信号
request_failed.emit(request_id, ErrorType.keys()[error_type], message)
# 调用回调函数
if request_info.callback.is_valid():
var error_info = {
"response_code": response_code,
"error_code": error_code,
"message": message,
"error_type": error_type
}
request_info.callback.call(false, data, error_info)
# 清理请求
_cleanup_request(request_id)
# 处理请求错误
func _handle_request_error(request_id: String, error_type: ErrorType, message: String):
print("❌ 请求错误: ", request_id, " - ", message)
# 发送错误信号
request_failed.emit(request_id, ErrorType.keys()[error_type], message)
# 调用回调函数
if active_requests.has(request_id):
var request_info = active_requests[request_id]
if request_info.callback.is_valid():
var error_info = {
"error_type": error_type,
"message": message
}
request_info.callback.call(false, {}, error_info)
# 清理请求
_cleanup_request(request_id)
# 确定错误类型 - 支持更多状态码
func _determine_error_type(response_code: int, error_code: String) -> ErrorType:
# 根据错误码判断
match error_code:
"SERVICE_UNAVAILABLE":
return ErrorType.BUSINESS_ERROR
"TOO_MANY_REQUESTS":
return ErrorType.BUSINESS_ERROR
"TEST_MODE_ONLY":
return ErrorType.BUSINESS_ERROR
"SEND_EMAIL_VERIFICATION_FAILED", "REGISTER_FAILED":
# 这些可能是409冲突或其他业务错误
return ErrorType.BUSINESS_ERROR
_:
# 根据HTTP状态码判断
match response_code:
409: # 资源冲突
return ErrorType.BUSINESS_ERROR
206: # 测试模式
return ErrorType.BUSINESS_ERROR
429: # 频率限制
return ErrorType.BUSINESS_ERROR
_:
if response_code >= 400 and response_code < 500:
return ErrorType.HTTP_ERROR
elif response_code >= 500:
return ErrorType.HTTP_ERROR
else:
return ErrorType.BUSINESS_ERROR
# 清理请求资源
func _cleanup_request(request_id: String):
if active_requests.has(request_id):
var request_info = active_requests[request_id]
# 移除HTTPRequest节点
if is_instance_valid(request_info.http_request):
request_info.http_request.queue_free()
# 从活动请求中移除
active_requests.erase(request_id)
print("🧹 清理请求: ", request_id)
# 转换请求方法
func _convert_to_godot_method(method: RequestType) -> HTTPClient.Method:
match method:
RequestType.GET:
return HTTPClient.METHOD_GET
RequestType.POST:
return HTTPClient.METHOD_POST
RequestType.PUT:
return HTTPClient.METHOD_PUT
RequestType.DELETE:
return HTTPClient.METHOD_DELETE
RequestType.PATCH:
return HTTPClient.METHOD_PATCH
_:
return HTTPClient.METHOD_GET
# ============ 工具方法 ============
# 取消请求
func cancel_request(request_id: String) -> bool:
if active_requests.has(request_id):
print("🚫 取消请求: ", request_id)
_cleanup_request(request_id)
return true
return false
# 取消所有请求
func cancel_all_requests():
print("🚫 取消所有请求")
var request_ids = active_requests.keys()
for request_id in request_ids:
cancel_request(request_id)
# 获取活动请求数量
func get_active_request_count() -> int:
return active_requests.size()
# 检查请求是否活动
func is_request_active(request_id: String) -> bool:
return active_requests.has(request_id)
# 获取请求信息
func get_request_info(request_id: String) -> Dictionary:
if active_requests.has(request_id):
var info = active_requests[request_id]
return {
"id": info.id,
"url": info.url,
"method": RequestType.keys()[info.method],
"start_time": info.start_time,
"timeout": info.timeout
}
return {}
func _notification(what):
if what == NOTIFICATION_WM_CLOSE_REQUEST:
# 应用关闭时取消所有请求
cancel_all_requests()

View File

@@ -0,0 +1 @@
uid://dr7v30wheetca

View File

@@ -0,0 +1,590 @@
extends Node
# 响应处理器 - 统一处理API响应和错误
# 响应处理结果
class ResponseResult:
var success: bool
var message: String
var toast_type: String # "success" 或 "error"
var data: Dictionary
var should_show_toast: bool
var custom_action: Callable
func _init():
success = false
message = ""
toast_type = "error"
data = {}
should_show_toast = true
custom_action = Callable()
# 错误码映射表 - 根据API v1.1.1更新
const ERROR_CODE_MESSAGES = {
# 登录相关
"LOGIN_FAILED": "登录失败",
"VERIFICATION_CODE_LOGIN_FAILED": "验证码错误或已过期",
"EMAIL_NOT_VERIFIED": "请先验证邮箱",
# 注册相关
"REGISTER_FAILED": "注册失败",
# 验证码相关
"SEND_CODE_FAILED": "发送验证码失败",
"SEND_LOGIN_CODE_FAILED": "发送登录验证码失败",
"SEND_EMAIL_VERIFICATION_FAILED": "发送邮箱验证码失败",
"RESEND_EMAIL_VERIFICATION_FAILED": "重新发送验证码失败",
"EMAIL_VERIFICATION_FAILED": "邮箱验证失败",
"RESET_PASSWORD_FAILED": "重置密码失败",
"CHANGE_PASSWORD_FAILED": "修改密码失败",
"VERIFICATION_CODE_EXPIRED": "验证码已过期",
"VERIFICATION_CODE_INVALID": "验证码无效",
"VERIFICATION_CODE_ATTEMPTS_EXCEEDED": "验证码尝试次数过多",
"VERIFICATION_CODE_RATE_LIMITED": "验证码发送过于频繁",
"VERIFICATION_CODE_HOURLY_LIMIT": "验证码每小时发送次数已达上限",
# 用户状态相关
"USER_NOT_FOUND": "用户不存在",
"INVALID_IDENTIFIER": "请输入有效的邮箱或手机号",
"USER_STATUS_UPDATE_FAILED": "用户状态更新失败",
# 系统状态相关
"TEST_MODE_ONLY": "测试模式",
"TOO_MANY_REQUESTS": "请求过于频繁,请稍后再试",
"SERVICE_UNAVAILABLE": "系统维护中,请稍后再试",
# 权限相关
"UNAUTHORIZED": "未授权访问",
"FORBIDDEN": "权限不足",
"ADMIN_LOGIN_FAILED": "管理员登录失败",
# 其他
"VALIDATION_FAILED": "参数验证失败",
"UNSUPPORTED_MEDIA_TYPE": "不支持的请求格式",
"REQUEST_TIMEOUT": "请求超时"
}
# HTTP状态码消息映射 - 根据API v1.1.1更新
const HTTP_STATUS_MESSAGES = {
200: "请求成功",
201: "创建成功",
206: "测试模式",
400: "请求参数错误",
401: "认证失败",
403: "权限不足",
404: "资源不存在",
408: "请求超时",
409: "资源冲突",
415: "不支持的媒体类型",
429: "请求过于频繁",
500: "服务器内部错误",
503: "服务不可用"
}
# ============ 主要处理方法 ============
# 处理登录响应
func handle_login_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "登录成功!正在进入鲸鱼镇..."
result.toast_type = "success"
result.data = data
# 自定义动作:延迟发送登录成功信号
result.custom_action = func():
await Engine.get_main_loop().create_timer(1.0).timeout
# 这里可以发送登录成功信号或执行其他逻辑
else:
result = _handle_login_error(data, error_info)
return result
# 处理验证码登录响应
func handle_verification_code_login_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "验证码登录成功!正在进入鲸鱼镇..."
result.toast_type = "success"
result.data = data
result.custom_action = func():
await Engine.get_main_loop().create_timer(1.0).timeout
# 登录成功后的处理逻辑
else:
result = _handle_verification_code_login_error(data, error_info)
return result
# 处理发送验证码响应 - 支持邮箱冲突检测
func handle_send_verification_code_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY":
result.success = true
result.message = "🧪 测试模式:验证码已生成,请查看控制台"
result.toast_type = "success"
# 在控制台显示验证码
if data.has("data") and data.data.has("verification_code"):
print("🔑 测试模式验证码: ", data.data.verification_code)
result.message += "\n验证码: " + str(data.data.verification_code)
else:
result.success = true
result.message = "📧 验证码已发送到您的邮箱,请查收"
result.toast_type = "success"
# 开发环境下显示验证码
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境验证码: ", data.data.verification_code)
else:
result = _handle_send_code_error(data, error_info)
return result
# 处理发送登录验证码响应
func handle_send_login_code_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY":
result.success = true
result.message = "测试模式:登录验证码已生成,请查看控制台"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("测试模式登录验证码: ", data.data.verification_code)
else:
result.success = true
result.message = "登录验证码已发送,请查收"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("开发环境登录验证码: ", data.data.verification_code)
else:
result = _handle_send_login_code_error(data, error_info)
return result
# 处理注册响应
func handle_register_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "注册成功!欢迎加入鲸鱼镇"
result.toast_type = "success"
result.data = data
# 自定义动作:清空表单,切换到登录界面
result.custom_action = func():
# 这里可以执行清空表单、切换界面等操作
pass
else:
result = _handle_register_error(data, error_info)
return result
# 处理邮箱验证响应
func handle_verify_email_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "邮箱验证成功,正在注册..."
result.toast_type = "success"
result.data = data
else:
result = _handle_verify_email_error(data, error_info)
return result
# 处理重新发送邮箱验证码响应
func handle_resend_email_verification_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY":
result.success = true
result.message = "🧪 测试模式:验证码已重新生成,请查看控制台"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 测试模式重新发送验证码: ", data.data.verification_code)
else:
result.success = true
result.message = "📧 验证码已重新发送到您的邮箱,请查收"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境重新发送验证码: ", data.data.verification_code)
else:
result = _handle_resend_email_verification_error(data, error_info)
return result
# 处理忘记密码响应
func handle_forgot_password_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
var error_code = data.get("error_code", "")
if error_code == "TEST_MODE_ONLY":
result.success = true
result.message = "🧪 测试模式:重置验证码已生成,请查看控制台"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 测试模式重置验证码: ", data.data.verification_code)
else:
result.success = true
result.message = "📧 重置验证码已发送,请查收"
result.toast_type = "success"
if data.has("data") and data.data.has("verification_code"):
print("🔑 开发环境重置验证码: ", data.data.verification_code)
else:
result = _handle_forgot_password_error(data, error_info)
return result
# 处理重置密码响应
func handle_reset_password_response(success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "🔒 密码重置成功,请使用新密码登录"
result.toast_type = "success"
result.data = data
else:
result = _handle_reset_password_error(data, error_info)
return result
# ============ 错误处理方法 ============
# 处理登录错误
func _handle_login_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "登录失败")
match error_code:
"LOGIN_FAILED":
# 根据消息内容进一步判断用户状态
if "账户已锁定" in message or "locked" in message.to_lower():
result.message = "账户已被锁定,请联系管理员"
elif "账户已禁用" in message or "banned" in message.to_lower():
result.message = "账户已被禁用,请联系管理员"
elif "账户待审核" in message or "pending" in message.to_lower():
result.message = "账户待审核,请等待管理员审核"
elif "邮箱未验证" in message or "inactive" in message.to_lower():
result.message = "请先验证邮箱后再登录"
else:
result.message = "用户名或密码错误,请检查后重试"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理验证码登录错误
func _handle_verification_code_login_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "验证码登录失败")
match error_code:
"VERIFICATION_CODE_LOGIN_FAILED":
result.message = "验证码错误或已过期"
"EMAIL_NOT_VERIFIED":
result.message = "邮箱未验证,请先验证邮箱后再使用验证码登录"
"USER_NOT_FOUND":
result.message = "用户不存在,请先注册"
"INVALID_IDENTIFIER":
result.message = "请输入有效的邮箱或手机号"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理发送验证码错误 - 支持邮箱冲突检测和频率限制
func _handle_send_code_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "发送验证码失败")
var response_code = error_info.get("response_code", 0)
match error_code:
"SEND_EMAIL_VERIFICATION_FAILED":
# 检查是否是邮箱冲突409状态码
if response_code == 409:
result.message = "⚠️ 邮箱已被注册,请使用其他邮箱或直接登录"
result.toast_type = "error"
elif "邮箱格式" in message:
result.message = "📧 请输入有效的邮箱地址"
else:
result.message = message
"TOO_MANY_REQUESTS":
# 处理频率限制,提供重试建议
result.toast_type = "error"
# 如果有throttle_info显示更详细的信息
if data.has("throttle_info"):
var throttle_info = data.throttle_info
var reset_time = throttle_info.get("reset_time", "")
if reset_time != "":
var relative_time = StringUtils.get_relative_time_until(reset_time)
var local_time = StringUtils.format_utc_to_local_time(reset_time)
result.message = "⏰ 验证码发送过于频繁"
result.message += "\n" + relative_time + "再试"
result.message += "\n重试时间: " + local_time
else:
result.message = "⏰ 验证码发送过于频繁,请稍后再试"
else:
result.message = "⏰ 验证码发送过于频繁,请稍后再试"
"VERIFICATION_CODE_RATE_LIMITED":
result.message = "⏰ 验证码发送过于频繁,请稍后再试"
"VERIFICATION_CODE_HOURLY_LIMIT":
result.message = "⏰ 每小时发送次数已达上限,请稍后再试"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理发送登录验证码错误
func _handle_send_login_code_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "发送登录验证码失败")
match error_code:
"SEND_LOGIN_CODE_FAILED":
if "用户不存在" in message:
result.message = "用户不存在,请先注册"
else:
result.message = "发送登录验证码失败"
"USER_NOT_FOUND":
result.message = "用户不存在,请先注册"
"INVALID_IDENTIFIER":
result.message = "请输入有效的邮箱或手机号"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理注册错误 - 支持409冲突状态码
func _handle_register_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "注册失败")
var response_code = error_info.get("response_code", 0)
match error_code:
"REGISTER_FAILED":
# 检查409冲突状态码
if response_code == 409:
if "邮箱已存在" in message or "邮箱已被使用" in message:
result.message = "📧 邮箱已被注册,请使用其他邮箱或直接登录"
elif "用户名已存在" in message or "用户名已被使用" in message:
result.message = "👤 用户名已被使用,请换一个"
else:
result.message = "⚠️ 资源冲突:" + message
elif "邮箱验证码" in message or "verification_code" in message:
result.message = "🔑 请先获取并输入邮箱验证码"
elif "用户名" in message:
result.message = "👤 用户名格式不正确"
elif "邮箱" in message:
result.message = "📧 邮箱格式不正确"
elif "密码" in message:
result.message = "🔒 密码格式不符合要求"
elif "验证码" in message:
result.message = "🔑 验证码错误或已过期"
else:
result.message = message
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理邮箱验证错误
func _handle_verify_email_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "邮箱验证失败")
match error_code:
"EMAIL_VERIFICATION_FAILED":
if "验证码错误" in message:
result.message = "🔑 验证码错误"
elif "验证码已过期" in message:
result.message = "🔑 验证码已过期,请重新获取"
else:
result.message = message
"VERIFICATION_CODE_INVALID":
result.message = "🔑 验证码错误或已过期"
"VERIFICATION_CODE_EXPIRED":
result.message = "🔑 验证码已过期,请重新获取"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理网络测试响应
func handle_network_test_response(success: bool, _data: Dictionary, _error_info: Dictionary = {}) -> ResponseResult:
var result = ResponseResult.new()
if success:
result.success = true
result.message = "🌐 网络连接正常"
result.toast_type = "success"
else:
result.success = false
result.message = "🌐 网络连接异常"
result.toast_type = "error"
return result
# 处理重新发送邮箱验证码错误
func _handle_resend_email_verification_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "重新发送验证码失败")
match error_code:
"RESEND_EMAIL_VERIFICATION_FAILED":
if "邮箱已验证" in message:
result.message = "📧 邮箱已验证,无需重复验证"
else:
result.message = message
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理忘记密码错误
func _handle_forgot_password_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "发送重置验证码失败")
match error_code:
"SEND_CODE_FAILED":
if "用户不存在" in message:
result.message = "👤 用户不存在,请检查邮箱或手机号"
else:
result.message = message
"USER_NOT_FOUND":
result.message = "👤 用户不存在,请检查邮箱或手机号"
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# 处理重置密码错误
func _handle_reset_password_error(data: Dictionary, error_info: Dictionary) -> ResponseResult:
var result = ResponseResult.new()
var error_code = data.get("error_code", "")
var message = data.get("message", "重置密码失败")
match error_code:
"RESET_PASSWORD_FAILED":
if "验证码" in message:
result.message = "🔑 验证码错误或已过期"
else:
result.message = message
_:
result.message = _get_error_message(error_code, message, error_info)
return result
# ============ 工具方法 ============
# 获取错误消息 - 支持更多状态码和错误处理
func _get_error_message(error_code: String, original_message: String, error_info: Dictionary) -> String:
# 优先使用错误码映射
if ERROR_CODE_MESSAGES.has(error_code):
return ERROR_CODE_MESSAGES[error_code]
# 处理频率限制
if error_code == "TOO_MANY_REQUESTS":
return _handle_rate_limit_message(original_message, error_info)
# 处理维护模式
if error_code == "SERVICE_UNAVAILABLE":
return _handle_maintenance_message(original_message, error_info)
# 处理测试模式
if error_code == "TEST_MODE_ONLY":
return "🧪 测试模式:" + original_message
# 根据HTTP状态码处理
if error_info.has("response_code"):
var response_code = error_info.response_code
match response_code:
409:
return "⚠️ 资源冲突:" + original_message
206:
return "🧪 测试模式:" + original_message
429:
return "⏰ 请求过于频繁,请稍后再试"
_:
if HTTP_STATUS_MESSAGES.has(response_code):
return HTTP_STATUS_MESSAGES[response_code] + "" + original_message
# 返回原始消息
return original_message if original_message != "" else "操作失败"
# 处理频率限制消息
func _handle_rate_limit_message(message: String, _error_info: Dictionary) -> String:
# 可以根据throttle_info提供更详细的信息
return message + ",请稍后再试"
# 处理维护模式消息
func _handle_maintenance_message(_message: String, _error_info: Dictionary) -> String:
# 可以根据maintenance_info提供更详细的信息
return "系统维护中,请稍后再试"
# 通用响应处理器 - 支持更多操作类型
func handle_response(operation_type: String, success: bool, data: Dictionary, error_info: Dictionary = {}) -> ResponseResult:
match operation_type:
"login":
return handle_login_response(success, data, error_info)
"verification_code_login":
return handle_verification_code_login_response(success, data, error_info)
"send_code":
return handle_send_verification_code_response(success, data, error_info)
"send_login_code":
return handle_send_login_code_response(success, data, error_info)
"register":
return handle_register_response(success, data, error_info)
"verify_email":
return handle_verify_email_response(success, data, error_info)
"resend_email_verification":
return handle_resend_email_verification_response(success, data, error_info)
"forgot_password":
return handle_forgot_password_response(success, data, error_info)
"reset_password":
return handle_reset_password_response(success, data, error_info)
"network_test":
return handle_network_test_response(success, data, error_info)
_:
# 通用处理
var result = ResponseResult.new()
if success:
result.success = true
result.message = "✅ 操作成功"
result.toast_type = "success"
else:
result.success = false
result.message = _get_error_message(data.get("error_code", ""), data.get("message", "操作失败"), error_info)
result.toast_type = "error"
return result

View File

@@ -0,0 +1 @@
uid://nseguk2ytiw6

View File

@@ -0,0 +1,187 @@
extends Node
# ============================================================================
# SceneManager.gd - 场景管理器
# ============================================================================
# 全局单例管理器,负责场景切换和管理
#
# 核心职责:
# - 场景切换的统一接口
# - 场景路径映射管理
# - 场景切换过渡效果
# - 场景状态跟踪
#
# 使用方式:
# SceneManager.change_scene("main")
# SceneManager.register_scene("custom", "res://scenes/custom.tscn")
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 场景切换是异步操作,支持过渡效果
# - 场景名称必须在 scene_paths 中注册
# ============================================================================
# ============ 信号定义 ============
# 场景切换完成信号
# 参数: scene_name - 切换到的场景名称
signal scene_changed(scene_name: String)
# 场景切换开始信号
# 参数: scene_name - 即将切换到的场景名称
signal scene_change_started(scene_name: String)
# ============ 成员变量 ============
# 场景状态
var current_scene_name: String = "" # 当前场景名称
var is_changing_scene: bool = false # 是否正在切换场景
# 场景路径映射表
# 将场景名称映射到实际的文件路径
# 便于统一管理和修改场景路径
var scene_paths: Dictionary = {
"main": "res://scenes/MainScene.tscn", # 主场景 - 游戏入口
"auth": "res://scenes/ui/LoginWindow.tscn", # 认证场景 - 登录窗口
"game": "res://scenes/maps/game_scene.tscn", # 游戏场景 - 主要游戏内容
"battle": "res://scenes/maps/battle_scene.tscn", # 战斗场景 - 战斗系统
"inventory": "res://scenes/ui/InventoryWindow.tscn", # 背包界面
"shop": "res://scenes/ui/ShopWindow.tscn", # 商店界面
"settings": "res://scenes/ui/SettingsWindow.tscn" # 设置界面
}
# ============ 生命周期方法 ============
# 初始化场景管理器
# 在节点准备就绪时调用
func _ready():
print("SceneManager 初始化完成")
# ============ 场景切换方法 ============
# 切换到指定场景
#
# 参数:
# scene_name: String - 要切换到的场景名称必须在scene_paths中注册
# use_transition: bool - 是否使用过渡效果默认为true
#
# 返回值:
# bool - 切换是否成功
#
# 功能:
# - 检查场景切换状态和场景是否存在
# - 显示过渡效果(可选)
# - 执行场景切换
# - 更新当前场景状态
# - 发送相关信号
#
# 使用示例:
# var success = SceneManager.change_scene("main", true)
# if success:
# print("场景切换成功")
#
# 注意事项:
# - 场景切换是异步操作
# - 切换过程中会阻止新的切换请求
# - 场景名称必须预先注册
func change_scene(scene_name: String, use_transition: bool = true):
# 防止重复切换
if is_changing_scene:
print("场景切换中,忽略新的切换请求")
return false
# 检查场景是否存在
if not scene_paths.has(scene_name):
print("错误: 未找到场景 ", scene_name)
return false
var scene_path = scene_paths[scene_name]
print("开始切换场景: ", current_scene_name, " -> ", scene_name)
# 设置切换状态
is_changing_scene = true
scene_change_started.emit(scene_name)
# 显示过渡效果
if use_transition:
await show_transition()
# 执行场景切换
var error = get_tree().change_scene_to_file(scene_path)
if error != OK:
print("场景切换失败: ", error)
is_changing_scene = false
return false
# 更新状态
current_scene_name = scene_name
is_changing_scene = false
scene_changed.emit(scene_name)
# 隐藏过渡效果
if use_transition:
await hide_transition()
print("场景切换完成: ", scene_name)
return true
# ============ 查询方法 ============
# 获取当前场景名称
#
# 返回值:
# String - 当前场景的名称
func get_current_scene_name() -> String:
return current_scene_name
# ============ 场景注册方法 ============
# 注册新场景
#
# 参数:
# scene_name: String - 场景名称(用于切换时引用)
# scene_path: String - 场景文件路径
#
# 功能:
# - 将场景名称和路径添加到映射表
# - 支持运行时动态注册场景
#
# 使用示例:
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
func register_scene(scene_name: String, scene_path: String):
scene_paths[scene_name] = scene_path
print("注册场景: ", scene_name, " -> ", scene_path)
# ============ 过渡效果方法 ============
# 显示场景切换过渡效果
#
# 功能:
# - 显示场景切换时的过渡动画
# - 为用户提供视觉反馈
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 当前实现为简单的延时,可扩展为复杂动画
#
# TODO: 实现淡入淡出、滑动等过渡效果
func show_transition():
# TODO: 实现场景切换过渡效果
print("显示场景切换过渡效果")
await get_tree().create_timer(0.2).timeout
# 隐藏场景切换过渡效果
#
# 功能:
# - 隐藏场景切换完成后的过渡动画
# - 恢复正常的游戏显示
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 与show_transition()配对使用
#
# TODO: 实现与show_transition()对应的隐藏效果
func hide_transition():
# TODO: 隐藏场景切换过渡效果
print("隐藏场景切换过渡效果")
await get_tree().create_timer(0.2).timeout

View File

@@ -0,0 +1 @@
uid://d3l286ti5gqhw

View File

@@ -0,0 +1,234 @@
class_name ToastManager
# ============================================================================
# ToastManager.gd - Toast消息管理器
# ============================================================================
# 负责创建和管理Toast消息的显示
#
# 核心功能:
# - 创建Toast消息实例
# - 管理Toast动画和生命周期
# - 支持多个Toast同时显示
# - 自动排列和清理Toast
# - 支持中文字体显示
#
# 使用方式:
# var toast_manager = ToastManager.new()
# toast_manager.setup(toast_container)
# toast_manager.show_toast("消息内容", true)
#
# 注意事项:
# - 需要提供一个容器节点来承载Toast
# - 自动处理Toast的位置计算和动画
# - 支持Web平台的字体处理
# ============================================================================
extends RefCounted
# ============ 成员变量 ============
# Toast容器和管理
var toast_container: Control # Toast消息容器
var active_toasts: Array = [] # 当前显示的Toast消息列表
var toast_counter: int = 0 # Toast计数器用于生成唯一ID
# ============ 初始化方法 ============
# 设置Toast管理器
#
# 参数:
# container: Control - Toast消息的容器节点
func setup(container: Control):
toast_container = container
print("ToastManager 初始化完成")
# ============ 公共方法 ============
# 显示Toast消息
#
# 参数:
# message: String - 消息内容
# is_success: bool - 是否为成功消息(影响颜色)
func show_toast(message: String, is_success: bool = true):
if toast_container == null:
print("错误: toast_container 节点不存在")
return
print("显示Toast消息: ", message, " 成功: ", is_success)
_create_toast_instance(message, is_success)
# 清理所有Toast
func clear_all_toasts():
for toast in active_toasts:
if is_instance_valid(toast):
toast.queue_free()
active_toasts.clear()
# ============ 私有方法 ============
# 创建Toast实例
func _create_toast_instance(message: String, is_success: bool):
toast_counter += 1
# Web平台字体处理
var is_web = OS.get_name() == "Web"
# 1. 创建Toast Panel方框UI
var toast_panel = Panel.new()
toast_panel.name = "Toast_" + str(toast_counter)
# 设置Toast样式
var style = StyleBoxFlat.new()
if is_success:
style.bg_color = Color(0.15, 0.7, 0.15, 0.95)
style.border_color = Color(0.2, 0.9, 0.2, 0.9)
else:
style.bg_color = Color(0.7, 0.15, 0.15, 0.95)
style.border_color = Color(0.9, 0.2, 0.2, 0.9)
style.border_width_left = 3
style.border_width_top = 3
style.border_width_right = 3
style.border_width_bottom = 3
style.corner_radius_top_left = 12
style.corner_radius_top_right = 12
style.corner_radius_bottom_left = 12
style.corner_radius_bottom_right = 12
style.shadow_color = Color(0, 0, 0, 0.3)
style.shadow_size = 4
style.shadow_offset = Vector2(2, 2)
toast_panel.add_theme_stylebox_override("panel", style)
# 设置Toast基本尺寸
var toast_width = 320
toast_panel.size = Vector2(toast_width, 60)
# 2. 创建VBoxContainer
var vbox = VBoxContainer.new()
vbox.add_theme_constant_override("separation", 0)
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
# 3. 创建CenterContainer
var center_container = CenterContainer.new()
center_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
center_container.size_flags_vertical = Control.SIZE_SHRINK_CENTER
# 4. 创建Label文字控件
var text_label = Label.new()
text_label.text = message
text_label.add_theme_color_override("font_color", Color(1, 1, 1, 1))
text_label.add_theme_font_size_override("font_size", 14)
# 平台特定的字体处理
if is_web:
print("Web平台Toast字体处理")
# Web平台使用主题文件
var chinese_theme = load("res://assets/ui/chinese_theme.tres")
if chinese_theme:
text_label.theme = chinese_theme
print("Web平台应用中文主题")
else:
print("Web平台中文主题加载失败")
else:
print("桌面平台Toast字体处理")
# 桌面平台直接加载中文字体
var desktop_chinese_font = load("res://assets/fonts/msyh.ttc")
if desktop_chinese_font:
text_label.add_theme_font_override("font", desktop_chinese_font)
print("桌面平台使用中文字体")
text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
text_label.custom_minimum_size = Vector2(280, 0)
text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
text_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
# 组装控件层级
center_container.add_child(text_label)
vbox.add_child(center_container)
toast_panel.add_child(vbox)
# 计算位置
var margin = 20
var start_x = toast_container.get_viewport().get_visible_rect().size.x
var final_x = toast_container.get_viewport().get_visible_rect().size.x - toast_width - margin
# 计算Y位置
var y_position = margin
for existing_toast in active_toasts:
if is_instance_valid(existing_toast):
y_position += existing_toast.size.y + 15
# 设置初始位置
toast_panel.position = Vector2(start_x, y_position)
# 添加到容器
toast_container.add_child(toast_panel)
active_toasts.append(toast_panel)
# 等待一帧让布局系统计算尺寸
await toast_container.get_tree().process_frame
# 让Toast高度自适应内容
var content_size = vbox.get_combined_minimum_size()
var final_height = max(60, content_size.y + 20) # 最小60加20像素边距
toast_panel.size.y = final_height
# 重新排列所有Toast
_rearrange_toasts()
# 开始动画
_animate_toast_in(toast_panel, final_x)
# Toast入场动画
func _animate_toast_in(toast_panel: Panel, final_x: float):
var tween = toast_container.create_tween()
tween.set_ease(Tween.EASE_OUT)
tween.set_trans(Tween.TRANS_BACK)
tween.parallel().tween_property(toast_panel, "position:x", final_x, 0.6)
tween.parallel().tween_property(toast_panel, "modulate:a", 1.0, 0.4)
toast_panel.modulate.a = 0.0
# 等待3秒后开始退场动画
await toast_container.get_tree().create_timer(3.0).timeout
_animate_toast_out(toast_panel)
# Toast退场动画
func _animate_toast_out(toast_panel: Panel):
if not is_instance_valid(toast_panel):
return
var tween = toast_container.create_tween()
tween.set_ease(Tween.EASE_IN)
tween.set_trans(Tween.TRANS_QUART)
var end_x = toast_container.get_viewport().get_visible_rect().size.x + 50
tween.parallel().tween_property(toast_panel, "position:x", end_x, 0.4)
tween.parallel().tween_property(toast_panel, "modulate:a", 0.0, 0.3)
await tween.finished
_cleanup_toast(toast_panel)
# 清理Toast
func _cleanup_toast(toast_panel: Panel):
if not is_instance_valid(toast_panel):
return
active_toasts.erase(toast_panel)
_rearrange_toasts()
toast_panel.queue_free()
# 重新排列Toast位置
func _rearrange_toasts():
var margin = 20
var current_y = margin
for i in range(active_toasts.size()):
var toast = active_toasts[i]
if is_instance_valid(toast):
var tween = toast_container.create_tween()
tween.tween_property(toast, "position:y", current_y, 0.2)
current_y += toast.size.y + 15

View File

@@ -0,0 +1 @@
uid://buk7d21cag262

View File

@@ -0,0 +1,461 @@
extends Node
# ============================================================================
# WebSocketManager.gd - WebSocket 连接生命周期管理(原生 WebSocket 版本)
# ============================================================================
# 管理 WebSocket 连接状态、自动重连和错误恢复
#
# 核心职责:
# - 连接状态管理(断开、连接中、已连接、重连中)
# - 自动重连机制(指数退避)
# - 连接错误恢复
# - WebSocket 消息发送/接收
# ============================================================================
# 使用方式:
# WebSocketManager.connect_to_game_server()
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 自动处理连接断开和重连
# - 通过信号通知连接状态变化
# ============================================================================
class_name WebSocketManager
# ============================================================================
# 信号定义
# ============================================================================
# 连接状态变化信号
# 参数:
# new_state: ConnectionState - 新的连接状态
signal connection_state_changed(new_state: ConnectionState)
# 连接丢失信号
signal connection_lost()
# 重连成功信号
signal reconnection_succeeded()
# 重连失败信号
# 参数:
# attempt: int - 当前重连尝试次数
# max_attempts: int - 最大重连次数
signal reconnection_failed(attempt: int, max_attempts: int)
# WebSocket 消息接收信号
# 参数:
# message: String - 接收到的消息内容JSON 字符串)
signal data_received(message: String)
# ============================================================================
# 枚举定义
# ============================================================================
# 连接状态枚举
enum ConnectionState {
DISCONNECTED, # 未连接
CONNECTING, # 连接中
CONNECTED, # 已连接
RECONNECTING, # 重连中
ERROR # 错误状态
}
# ============================================================================
# 常量定义
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
# 默认重连基础延迟(秒)
const DEFAULT_RECONNECT_BASE_DELAY: float = 3.0
# 最大重连延迟(秒)
const MAX_RECONNECT_DELAY: float = 30.0
# ============================================================================
# 成员变量
# ============================================================================
# WebSocket peer
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
# 当前连接状态
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
# 自动重连启用标志
var _auto_reconnect_enabled: bool = true
# 最大重连次数
var _max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
# 重连基础延迟
var _reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY
# 当前重连尝试次数
var _reconnect_attempt: int = 0
# 重连定时器
var _reconnect_timer: Timer = Timer.new()
# 是否为正常关闭(非异常断开)
var _clean_close: bool = true
# 心跳定时器
var _heartbeat_timer: Timer = Timer.new()
# 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
print("WebSocketManager 初始化完成")
# 设置重连定时器
_setup_reconnect_timer()
# 设置心跳定时器
_setup_heartbeat_timer()
# 启动处理循环
set_process(true)
# 处理每帧
func _process(_delta: float) -> void:
# 检查 WebSocket 状态变化
_check_websocket_state()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
# 调试:打印状态变化
if _connection_state == ConnectionState.CONNECTING:
var peer_state_name = ["DISCONNECTED", "CONNECTING", "OPEN", "CLOSING", "CLOSED"][state]
print("📡 WebSocket 状态: peer=%s, manager=%s" % [peer_state_name, ConnectionState.keys()[_connection_state]])
if state == WebSocketPeer.STATE_OPEN:
# 接收数据
_websocket_peer.poll()
# 处理收到的数据
while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8()
# 发射消息接收信号
data_received.emit(message)
# 打印调试信息
print("📨 WebSocket 收到消息: ", message)
# 清理
func _exit_tree() -> void:
_disconnect()
if is_instance_valid(_reconnect_timer):
_reconnect_timer.stop()
_reconnect_timer.queue_free()
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
_heartbeat_timer.queue_free()
# ============================================================================
# 公共 API - 连接管理
# ============================================================================
# 连接到游戏服务器
func connect_to_game_server() -> void:
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
push_warning("已经在连接或已连接状态")
return
print("=== WebSocketManager 开始连接 ===")
print("服务器 URL: ", WEBSOCKET_URL)
print("WebSocket 连接中...")
_set_connection_state(ConnectionState.CONNECTING)
_clean_close = true
_reconnect_attempt = 0
var err: Error = _websocket_peer.connect_to_url(WEBSOCKET_URL)
if err != OK:
print("❌ WebSocket 连接失败: ", error_string(err))
_set_connection_state(ConnectionState.ERROR)
return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接
func disconnect_websocket() -> void:
print("=== WebSocketManager 断开连接 ===")
_disconnect()
# 断开连接(内部方法)
func _disconnect() -> void:
_clean_close = true
# 停止重连定时器
_reconnect_timer.stop()
# 停止心跳
_heartbeat_timer.stop()
# 关闭 WebSocket
if _websocket_peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
_websocket_peer.close()
_set_connection_state(ConnectionState.DISCONNECTED)
# 检查 WebSocket 是否已连接
#
# 返回值:
# bool - WebSocket 是否已连接
func is_websocket_connected() -> bool:
return _connection_state == ConnectionState.CONNECTED
# 获取当前连接状态
#
# 返回值:
# ConnectionState - 当前连接状态
func get_connection_state() -> ConnectionState:
return _connection_state
# ============================================================================
# 公共 API - 消息发送
# ============================================================================
# 发送 WebSocket 消息
#
# 参数:
# message: String - 要发送的消息内容JSON 字符串)
#
# 返回值:
# Error - 错误码OK 表示成功
func send_message(message: String) -> Error:
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
print("❌ WebSocket 未连接,无法发送消息")
return ERR_UNCONFIGURED
var err: Error = _websocket_peer.send_text(message)
if err != OK:
print("❌ WebSocket 发送消息失败: ", error_string(err))
return err
print("📤 发送 WebSocket 消息: ", message)
return OK
# ============================================================================
# 公共 API - 自动重连
# ============================================================================
# 启用/禁用自动重连
#
# 参数:
# enabled: bool - 是否启用自动重连
# max_attempts: int - 最大重连次数(默认 5
# base_delay: float - 基础重连延迟,秒(默认 3.0
#
# 使用示例:
# WebSocketManager.enable_auto_reconnect(true, 5, 3.0)
func enable_auto_reconnect(enabled: bool, max_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS, base_delay: float = DEFAULT_RECONNECT_BASE_DELAY) -> void:
_auto_reconnect_enabled = enabled
_max_reconnect_attempts = max_attempts
_reconnect_base_delay = base_delay
print("自动重连: ", "启用" if enabled else "禁用")
print("最大重连次数: ", _max_reconnect_attempts)
print("基础重连延迟: ", _reconnect_base_delay, "")
# 获取重连信息
#
# 返回值:
# Dictionary - 重连信息 {enabled, attempt, max_attempts, delay}
func get_reconnect_info() -> Dictionary:
return {
"enabled": _auto_reconnect_enabled,
"attempt": _reconnect_attempt,
"max_attempts": _max_reconnect_attempts,
"next_delay": _calculate_reconnect_delay() if _connection_state == ConnectionState.RECONNECTING else 0.0
}
# ============================================================================
# 内部方法 - 连接状态管理
# ============================================================================
# 设置连接状态
func _set_connection_state(new_state: ConnectionState) -> void:
if _connection_state == new_state:
return
_connection_state = new_state
print("📡 连接状态变更: ", ConnectionState.keys()[new_state])
# 发射信号
connection_state_changed.emit(new_state)
# ============================================================================
# 内部方法 - WebSocket 状态监控
# ============================================================================
# 检查 WebSocket 状态变化
func _check_websocket_state() -> void:
# 必须先 poll 才能获取最新状态
_websocket_peer.poll()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
match state:
WebSocketPeer.STATE_CONNECTING:
# 正在连接
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
_set_connection_state(ConnectionState.CONNECTING)
WebSocketPeer.STATE_OPEN:
# 连接成功
if _connection_state != ConnectionState.CONNECTED:
_on_websocket_connected()
WebSocketPeer.STATE_CLOSING:
# 正在关闭
pass
WebSocketPeer.STATE_CLOSED:
# 连接关闭
var code: int = _websocket_peer.get_close_code()
var reason: String = _websocket_peer.get_close_reason()
print("🔌 WebSocket 关闭: code=%d, reason=%s" % [code, reason])
_on_websocket_closed(code != 0) # code=0 表示正常关闭
# WebSocket 连接成功处理
func _on_websocket_connected() -> void:
print("✅ WebSocketManager: WebSocket 连接成功")
# 如果是重连,发射重连成功信号
if _connection_state == ConnectionState.RECONNECTING:
_reconnect_attempt = 0
reconnection_succeeded.emit()
print("🔄 重连成功")
_set_connection_state(ConnectionState.CONNECTED)
# WebSocket 连接关闭处理
func _on_websocket_closed(clean_close: bool) -> void:
print("🔌 WebSocketManager: WebSocket 连接断开")
print(" 正常关闭: ", clean_close)
_clean_close = clean_close
# 如果是异常断开且启用了自动重连
if not clean_close and _auto_reconnect_enabled:
connection_lost.emit()
_attempt_reconnect()
else:
_set_connection_state(ConnectionState.DISCONNECTED)
# ============================================================================
# 内部方法 - 重连机制
# ============================================================================
# 设置重连定时器
func _setup_reconnect_timer() -> void:
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.autostart = false
add_child(_reconnect_timer)
_reconnect_timer.timeout.connect(_on_reconnect_timeout)
# 尝试重连
func _attempt_reconnect() -> void:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
print("❌ 达到最大重连次数 (", _max_reconnect_attempts, "),停止重连")
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return
_reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING)
# 计算重连延迟(指数退避)
var delay: float = _calculate_reconnect_delay()
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("⏰ 重连定时器超时,开始重连...")
_clean_close = false
connect_to_game_server()
# ============================================================================
# 内部方法 - 心跳机制
# ============================================================================
# 设置心跳定时器
func _setup_heartbeat_timer() -> void:
_heartbeat_timer = Timer.new()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
_heartbeat_timer.one_shot = false
_heartbeat_timer.autostart = false
add_child(_heartbeat_timer)
_heartbeat_timer.timeout.connect(_on_heartbeat)
# 启动心跳
func _start_heartbeat() -> void:
_heartbeat_timer.start()
# 停止心跳
func _stop_heartbeat() -> void:
_heartbeat_timer.stop()
# 心跳超时处理
func _on_heartbeat() -> void:
# 不发送心跳,避免服务器返回 "消息格式错误"
# 如果需要心跳,服务器应该支持特定格式
pass
# ============================================================================
# 工具方法
# ============================================================================
# 获取连接状态描述
#
# 返回值:
# String - 连接状态描述
func get_state_description() -> String:
match _connection_state:
ConnectionState.DISCONNECTED:
return "未连接"
ConnectionState.CONNECTING:
return "连接中"
ConnectionState.CONNECTED:
return "已连接"
ConnectionState.RECONNECTING:
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
ConnectionState.ERROR:
return "错误"
_:
return "未知状态"

View File

@@ -0,0 +1 @@
uid://dmbgtbf6gyk6t

View File

@@ -0,0 +1,195 @@
extends Node
# ============================================================================
# EventSystem.gd - 全局事件系统
# ============================================================================
# 全局单例管理器,提供解耦的事件通信机制
#
# 核心职责:
# - 事件监听器注册和管理
# - 事件发送和分发
# - 自动清理无效监听器
# - 支持带参数的事件通信
#
# 使用方式:
# EventSystem.connect_event("player_moved", _on_player_moved)
# EventSystem.emit_event("player_moved", {"position": Vector2(100, 200)})
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 监听器会自动检查目标节点的有效性
# - 建议使用EventNames类中定义的事件名称常量
# ============================================================================
# ============ 成员变量 ============
# 事件监听器存储
# 结构: {event_name: [{"callback": Callable, "target": Node}, ...]}
var event_listeners: Dictionary = {}
# ============ 生命周期方法 ============
# 初始化事件系统
# 在节点准备就绪时调用
func _ready():
print("EventSystem 初始化完成")
# ============ 事件监听器管理 ============
# 注册事件监听器
#
# 参数:
# event_name: String - 事件名称建议使用EventNames中的常量
# callback: Callable - 回调函数
# target: Node - 目标节点(可选,用于自动清理)
#
# 功能:
# - 将回调函数注册到指定事件
# - 支持同一事件多个监听器
# - 自动管理监听器生命周期
#
# 使用示例:
# EventSystem.connect_event(EventNames.PLAYER_MOVED, _on_player_moved, self)
#
# 注意事项:
# - 如果提供target参数当target节点被销毁时会自动清理监听器
# - 同一个callback可以监听多个事件
func connect_event(event_name: String, callback: Callable, target: Node = null):
# 初始化事件监听器数组
if not event_listeners.has(event_name):
event_listeners[event_name] = []
# 创建监听器信息
var listener_info = {
"callback": callback,
"target": target
}
# 添加到监听器列表
event_listeners[event_name].append(listener_info)
print("注册事件监听器: ", event_name, " -> ", callback)
# 移除事件监听器
#
# 参数:
# event_name: String - 事件名称
# callback: Callable - 要移除的回调函数
# target: Node - 目标节点(可选,用于精确匹配)
#
# 功能:
# - 从指定事件中移除特定的监听器
# - 支持精确匹配callback + target
#
# 使用示例:
# EventSystem.disconnect_event(EventNames.PLAYER_MOVED, _on_player_moved, self)
func disconnect_event(event_name: String, callback: Callable, target: Node = null):
if not event_listeners.has(event_name):
return
var listeners = event_listeners[event_name]
# 从后往前遍历,避免删除元素时索引问题
for i in range(listeners.size() - 1, -1, -1):
var listener = listeners[i]
# 匹配callback和target
if listener.callback == callback and listener.target == target:
listeners.remove_at(i)
print("移除事件监听器: ", event_name, " -> ", callback)
break
# ============ 事件发送 ============
# 发送事件
#
# 参数:
# event_name: String - 事件名称
# data: Variant - 事件数据(可选)
#
# 功能:
# - 向所有注册的监听器发送事件
# - 自动跳过无效的监听器
# - 支持任意类型的事件数据
#
# 使用示例:
# EventSystem.emit_event(EventNames.PLAYER_MOVED, {"position": Vector2(100, 200)})
# EventSystem.emit_event(EventNames.GAME_PAUSED) # 无数据事件
#
# 注意事项:
# - 事件发送是同步的,所有监听器会立即执行
# - 如果监听器执行出错,不会影响其他监听器
func emit_event(event_name: String, data: Variant = null):
print("发送事件: ", event_name, " 数据: ", data)
# 检查是否有监听器
if not event_listeners.has(event_name):
return
var listeners = event_listeners[event_name]
for listener_info in listeners:
var target = listener_info.target
var callback = listener_info.callback
# 检查目标节点是否仍然有效
if target != null and not is_instance_valid(target):
continue
# 调用回调函数
if data != null:
callback.call(data)
else:
callback.call()
# ============ 维护方法 ============
# 清理无效的监听器
#
# 功能:
# - 遍历所有监听器,移除已销毁节点的监听器
# - 防止内存泄漏
# - 建议定期调用或在场景切换时调用
#
# 使用场景:
# - 场景切换时清理
# - 定期维护(如每分钟一次)
# - 内存优化时调用
func cleanup_invalid_listeners():
for event_name in event_listeners.keys():
var listeners = event_listeners[event_name]
# 从后往前遍历,避免删除元素时索引问题
for i in range(listeners.size() - 1, -1, -1):
var listener = listeners[i]
var target = listener.target
# 如果目标节点无效,移除监听器
if target != null and not is_instance_valid(target):
listeners.remove_at(i)
print("清理无效监听器: ", event_name)
# ============ 查询方法 ============
# 获取事件监听器数量
#
# 参数:
# event_name: String - 事件名称
#
# 返回值:
# int - 监听器数量
#
# 使用场景:
# - 调试时检查监听器数量
# - 性能分析
func get_listener_count(event_name: String) -> int:
if not event_listeners.has(event_name):
return 0
return event_listeners[event_name].size()
# 清空所有事件监听器
#
# 功能:
# - 移除所有已注册的事件监听器
# - 通常在游戏重置或退出时使用
#
# 警告:
# - 这是一个危险操作,会影响所有模块
# - 使用前请确保所有模块都能正确处理监听器丢失
func clear_all_listeners():
event_listeners.clear()
print("清空所有事件监听器")

View File

@@ -0,0 +1 @@
uid://bfheblucmti24

143
_Core/systems/GridSystem.gd Normal file
View File

@@ -0,0 +1,143 @@
# ============================================================================
# 网格系统 - GridSystem.gd
#
# 提供32x32像素的最小网格单元控制用于规范地图大小和位置计算
#
# 使用方式:
# var grid_pos = GridSystem.world_to_grid(world_position)
# var world_pos = GridSystem.grid_to_world(grid_position)
# var snapped_pos = GridSystem.snap_to_grid(position)
# ============================================================================
class_name GridSystem
extends RefCounted
# ============================================================================
# 常量定义
# ============================================================================
const GRID_SIZE: int = 32 # 网格单元大小 32x32 像素
const HALF_GRID_SIZE: float = GRID_SIZE * 0.5 # 网格中心偏移
# ============================================================================
# 坐标转换方法
# ============================================================================
# 世界坐标转换为网格坐标
static func world_to_grid(world_pos: Vector2) -> Vector2i:
return Vector2i(
int(world_pos.x / GRID_SIZE),
int(world_pos.y / GRID_SIZE)
)
# 网格坐标转换为世界坐标(返回网格左上角)
static func grid_to_world(grid_pos: Vector2i) -> Vector2:
return Vector2(
grid_pos.x * GRID_SIZE,
grid_pos.y * GRID_SIZE
)
# 网格坐标转换为世界坐标(返回网格中心)
static func grid_to_world_center(grid_pos: Vector2i) -> Vector2:
return Vector2(
grid_pos.x * GRID_SIZE + HALF_GRID_SIZE,
grid_pos.y * GRID_SIZE + HALF_GRID_SIZE
)
# 将位置吸附到最近的网格点(左上角)
static func snap_to_grid(position: Vector2) -> Vector2:
return Vector2(
floor(position.x / GRID_SIZE) * GRID_SIZE,
floor(position.y / GRID_SIZE) * GRID_SIZE
)
# 将位置吸附到最近的网格中心
static func snap_to_grid_center(position: Vector2) -> Vector2:
var grid_pos = world_to_grid(position)
return grid_to_world_center(grid_pos)
# ============================================================================
# 距离和区域计算
# ============================================================================
# 计算两个网格坐标之间的曼哈顿距离
static func grid_distance_manhattan(grid_pos1: Vector2i, grid_pos2: Vector2i) -> int:
return abs(grid_pos1.x - grid_pos2.x) + abs(grid_pos1.y - grid_pos2.y)
# 计算两个网格坐标之间的欧几里得距离
static func grid_distance_euclidean(grid_pos1: Vector2i, grid_pos2: Vector2i) -> float:
var diff = grid_pos1 - grid_pos2
return sqrt(diff.x * diff.x + diff.y * diff.y)
# 获取指定网格坐标周围的邻居网格4方向
static func get_grid_neighbors_4(grid_pos: Vector2i) -> Array[Vector2i]:
return [
Vector2i(grid_pos.x, grid_pos.y - 1), # 上
Vector2i(grid_pos.x + 1, grid_pos.y), # 右
Vector2i(grid_pos.x, grid_pos.y + 1), # 下
Vector2i(grid_pos.x - 1, grid_pos.y) # 左
]
# 获取指定网格坐标周围的邻居网格8方向
static func get_grid_neighbors_8(grid_pos: Vector2i) -> Array[Vector2i]:
var neighbors: Array[Vector2i] = []
for x in range(-1, 2):
for y in range(-1, 2):
if x == 0 and y == 0:
continue
neighbors.append(Vector2i(grid_pos.x + x, grid_pos.y + y))
return neighbors
# ============================================================================
# 区域和边界检查
# ============================================================================
# 检查网格坐标是否在指定矩形区域内
static func is_grid_in_bounds(grid_pos: Vector2i, min_grid: Vector2i, max_grid: Vector2i) -> bool:
return (grid_pos.x >= min_grid.x and grid_pos.x <= max_grid.x and
grid_pos.y >= min_grid.y and grid_pos.y <= max_grid.y)
# 获取矩形区域内的所有网格坐标
static func get_grids_in_rect(min_grid: Vector2i, max_grid: Vector2i) -> Array[Vector2i]:
var grids: Array[Vector2i] = []
for x in range(min_grid.x, max_grid.x + 1):
for y in range(min_grid.y, max_grid.y + 1):
grids.append(Vector2i(x, y))
return grids
# ============================================================================
# 地图尺寸规范化
# ============================================================================
# 将像素尺寸规范化为网格尺寸的倍数
static func normalize_size_to_grid(pixel_size: Vector2i) -> Vector2i:
return Vector2i(
int(ceil(float(pixel_size.x) / GRID_SIZE)) * GRID_SIZE,
int(ceil(float(pixel_size.y) / GRID_SIZE)) * GRID_SIZE
)
# 计算指定像素尺寸需要多少个网格单元
static func get_grid_count(pixel_size: Vector2i) -> Vector2i:
return Vector2i(
int(ceil(float(pixel_size.x) / GRID_SIZE)),
int(ceil(float(pixel_size.y) / GRID_SIZE))
)
# ============================================================================
# 调试和可视化辅助
# ============================================================================
# 获取网格的边界矩形(用于调试绘制)
static func get_grid_rect(grid_pos: Vector2i) -> Rect2:
var world_pos = grid_to_world(grid_pos)
return Rect2(world_pos, Vector2(GRID_SIZE, GRID_SIZE))
# 打印网格信息(调试用)
static func print_grid_info(world_pos: Vector2) -> void:
var grid_pos = world_to_grid(world_pos)
var snapped_pos = snap_to_grid(world_pos)
var center_pos = grid_to_world_center(grid_pos)
print("世界坐标: ", world_pos)
print("网格坐标: ", grid_pos)
print("吸附位置: ", snapped_pos)
print("网格中心: ", center_pos)

View File

@@ -0,0 +1 @@
uid://dceqpffgti4jb

2
_Core/utils/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# 核心工具类目录
# 存放字符串处理、数学计算等工具类

386
_Core/utils/StringUtils.gd Normal file
View File

@@ -0,0 +1,386 @@
class_name StringUtils
# ============================================================================
# StringUtils.gd - 字符串工具类
# ============================================================================
# 静态工具类,提供常用的字符串处理功能
#
# 核心功能:
# - 输入验证(邮箱、用户名、密码)
# - 字符串格式化和转换
# - 时间格式化和相对时间计算
# - 文件大小格式化
#
# 使用方式:
# var is_valid = StringUtils.is_valid_email("user@example.com")
# var formatted_time = StringUtils.format_utc_to_local_time(utc_string)
#
# 注意事项:
# - 所有方法都是静态的,无需实例化
# - 验证方法返回布尔值或包含详细信息的字典
# - 时间处理方法支持UTC到本地时间的转换
# ============================================================================
# ============ 输入验证方法 ============
# 验证邮箱格式
#
# 参数:
# email: String - 待验证的邮箱地址
#
# 返回值:
# bool - true表示格式正确false表示格式错误
#
# 验证规则:
# - 必须包含@符号
# - @前后都必须有内容
# - 域名部分必须包含至少一个点
# - 顶级域名至少2个字符
#
# 使用示例:
# if StringUtils.is_valid_email("user@example.com"):
# print("邮箱格式正确")
static func is_valid_email(email: String) -> bool:
var regex = RegEx.new()
regex.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
return regex.search(email) != null
# 验证用户名格式
#
# 参数:
# username: String - 待验证的用户名
#
# 返回值:
# bool - true表示格式正确false表示格式错误
#
# 验证规则:
# - 只能包含字母、数字、下划线
# - 长度不能为空且不超过50个字符
# - 不能包含空格或特殊字符
#
# 使用示例:
# if StringUtils.is_valid_username("user_123"):
# print("用户名格式正确")
static func is_valid_username(username: String) -> bool:
# 检查长度
if username.is_empty() or username.length() > 50:
return false
# 检查字符组成
var regex = RegEx.new()
regex.compile("^[a-zA-Z0-9_]+$")
return regex.search(username) != null
# 验证密码强度
#
# 参数:
# password: String - 待验证的密码
#
# 返回值:
# Dictionary - 包含验证结果的详细信息
# {
# "valid": bool, # 是否符合最低要求
# "message": String, # 验证结果消息
# "strength": int # 强度等级 (1-4)
# }
#
# 验证规则:
# - 最少8位最多128位
# - 必须包含字母和数字
# - 强度评级:包含字母(+1)、数字(+1)、特殊字符(+1)、长度>=12(+1)
#
# 使用示例:
# var result = StringUtils.validate_password_strength("MyPass123!")
# if result.valid:
# print("密码强度: ", result.message)
static func validate_password_strength(password: String) -> Dictionary:
var result = {"valid": false, "message": "", "strength": 0}
# 检查长度限制
if password.length() < 8:
result.message = "密码长度至少8位"
return result
if password.length() > 128:
result.message = "密码长度不能超过128位"
return result
# 检查字符类型
var has_letter = false # 是否包含字母
var has_digit = false # 是否包含数字
var has_special = false # 是否包含特殊字符
for i in range(password.length()):
var character = password[i]
if character >= 'a' and character <= 'z' or character >= 'A' and character <= 'Z':
has_letter = true
elif character >= '0' and character <= '9':
has_digit = true
elif character in "!@#$%^&*()_+-=[]{}|;:,.<>?":
has_special = true
# 计算强度等级
var strength = 0
if has_letter:
strength += 1
if has_digit:
strength += 1
if has_special:
strength += 1
if password.length() >= 12:
strength += 1
result.strength = strength
# 检查最低要求
if not (has_letter and has_digit):
result.message = "密码必须包含字母和数字"
return result
# 密码符合要求
result.valid = true
result.message = "密码强度: " + ["", "", "", "很强"][min(strength - 1, 3)]
return result
# ============ 字符串格式化方法 ============
# 截断字符串
#
# 参数:
# text: String - 原始字符串
# max_length: int - 最大长度
# suffix: String - 截断后缀(默认为"..."
#
# 返回值:
# String - 截断后的字符串
#
# 功能:
# - 如果字符串长度超过限制,截断并添加后缀
# - 如果字符串长度未超过限制,返回原字符串
#
# 使用示例:
# var short_text = StringUtils.truncate("这是一个很长的文本", 10, "...")
# # 结果: "这是一个很长..."
static func truncate(text: String, max_length: int, suffix: String = "...") -> String:
if text.length() <= max_length:
return text
return text.substr(0, max_length - suffix.length()) + suffix
# 首字母大写
#
# 参数:
# text: String - 原始字符串
#
# 返回值:
# String - 首字母大写的字符串
#
# 使用示例:
# var capitalized = StringUtils.capitalize_first("hello world")
# # 结果: "Hello world"
static func capitalize_first(text: String) -> String:
if text.is_empty():
return text
return text[0].to_upper() + text.substr(1)
# 转换为标题格式
#
# 参数:
# text: String - 原始字符串
#
# 返回值:
# String - 每个单词首字母大写的字符串
#
# 功能:
# - 将每个单词的首字母转换为大写
# - 其余字母转换为小写
# - 以空格分隔单词
#
# 使用示例:
# var title = StringUtils.to_title_case("hello world game")
# # 结果: "Hello World Game"
static func to_title_case(text: String) -> String:
var words = text.split(" ")
var result = []
for word in words:
if not word.is_empty():
result.append(capitalize_first(word.to_lower()))
return " ".join(result)
# 移除HTML标签
#
# 参数:
# html: String - 包含HTML标签的字符串
#
# 返回值:
# String - 移除HTML标签后的纯文本
#
# 功能:
# - 使用正则表达式移除所有HTML标签
# - 保留标签之间的文本内容
#
# 使用示例:
# var plain_text = StringUtils.strip_html_tags("<p>Hello <b>World</b></p>")
# # 结果: "Hello World"
static func strip_html_tags(html: String) -> String:
var regex = RegEx.new()
regex.compile("<[^>]*>")
return regex.sub(html, "", true)
# 格式化文件大小
#
# 参数:
# bytes: int - 文件大小(字节)
#
# 返回值:
# String - 格式化后的文件大小字符串
#
# 功能:
# - 自动选择合适的单位B, KB, MB, GB, TB
# - 保留一位小数(除了字节)
# - 使用1024作为换算基数
#
# 使用示例:
# var size_text = StringUtils.format_file_size(1536)
# # 结果: "1.5 KB"
static func format_file_size(bytes: int) -> String:
var units = ["B", "KB", "MB", "GB", "TB"]
var size = float(bytes)
var unit_index = 0
# 自动选择合适的单位
while size >= 1024.0 and unit_index < units.size() - 1:
size /= 1024.0
unit_index += 1
# 格式化输出
if unit_index == 0:
return str(int(size)) + " " + units[unit_index]
else:
return "%.1f %s" % [size, units[unit_index]]
# ============ 时间处理方法 ============
# 将UTC时间字符串转换为本地时间显示
#
# 参数:
# utc_time_str: String - UTC时间字符串格式: 2025-12-25T11:23:52.175Z
#
# 返回值:
# String - 格式化的本地时间字符串
#
# 功能:
# - 解析ISO 8601格式的UTC时间
# - 转换为本地时区时间
# - 格式化为易读的中文时间格式
#
# 使用示例:
# var local_time = StringUtils.format_utc_to_local_time("2025-12-25T11:23:52.175Z")
# # 结果: "2025年12月25日 19:23:52" (假设本地时区为UTC+8)
static func format_utc_to_local_time(utc_time_str: String) -> String:
# 解析UTC时间字符串 (格式: 2025-12-25T11:23:52.175Z)
var regex = RegEx.new()
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var result = regex.search(utc_time_str)
if result == null:
return utc_time_str # 如果解析失败,返回原字符串
# 提取时间组件
var year = int(result.get_string(1))
var month = int(result.get_string(2))
var day = int(result.get_string(3))
var hour = int(result.get_string(4))
var minute = int(result.get_string(5))
var second = int(result.get_string(6))
# 创建UTC时间字典
var utc_dict = {
"year": year,
"month": month,
"day": day,
"hour": hour,
"minute": minute,
"second": second
}
# 转换为Unix时间戳UTC
var utc_timestamp = Time.get_unix_time_from_datetime_dict(utc_dict)
# 获取本地时间Godot会自动处理时区转换
var local_dict = Time.get_datetime_dict_from_unix_time(utc_timestamp)
# 格式化为易读的本地时间
return "%04d%02d%02d%02d:%02d:%02d" % [
local_dict.year,
local_dict.month,
local_dict.day,
local_dict.hour,
local_dict.minute,
local_dict.second
]
# 获取相对时间描述
#
# 参数:
# utc_time_str: String - UTC时间字符串
#
# 返回值:
# String - 相对时间描述(如"5分钟后"、"2小时30分钟后"
#
# 功能:
# - 计算指定时间与当前时间的差值
# - 返回人性化的相对时间描述
# - 支持秒、分钟、小时的组合显示
#
# 使用示例:
# var relative_time = StringUtils.get_relative_time_until("2025-12-25T12:00:00Z")
# # 结果: "30分钟后" 或 "现在可以重试"
static func get_relative_time_until(utc_time_str: String) -> String:
# 解析UTC时间字符串
var regex = RegEx.new()
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var result = regex.search(utc_time_str)
if result == null:
return "时间格式错误"
# 提取时间组件
var year = int(result.get_string(1))
var month = int(result.get_string(2))
var day = int(result.get_string(3))
var hour = int(result.get_string(4))
var minute = int(result.get_string(5))
var second = int(result.get_string(6))
# 创建UTC时间字典
var utc_dict = {
"year": year,
"month": month,
"day": day,
"hour": hour,
"minute": minute,
"second": second
}
# 转换为Unix时间戳
var target_timestamp = Time.get_unix_time_from_datetime_dict(utc_dict)
var current_timestamp = Time.get_unix_time_from_system()
# 计算时间差(秒)
var diff_seconds = target_timestamp - current_timestamp
# 格式化相对时间
if diff_seconds <= 0:
return "现在可以重试"
elif diff_seconds < 60:
return "%d秒后" % diff_seconds
elif diff_seconds < 3600:
var minutes = int(diff_seconds / 60)
return "%d分钟后" % minutes
else:
var hours = int(diff_seconds / 3600)
var minutes = int((diff_seconds % 3600) / 60)
if minutes > 0:
return "%d小时%d分钟后" % [hours, minutes]
else:
return "%d小时后" % hours

View File

@@ -0,0 +1 @@
uid://bu8onmk6q8wic

132
addons/gut/GutScene.gd Normal file
View File

@@ -0,0 +1,132 @@
extends Node2D
# ##############################################################################
# This is a wrapper around the normal and compact gui controls and serves as
# the interface between gut.gd and the gui. The GutRunner creates an instance
# of this and then this takes care of managing the different GUI controls.
# ##############################################################################
@onready var _normal_gui = $Normal
@onready var _compact_gui = $Compact
var gut = null :
set(val):
gut = val
_set_gut(val)
func _ready():
_normal_gui.switch_modes.connect(use_compact_mode.bind(true))
_compact_gui.switch_modes.connect(use_compact_mode.bind(false))
_normal_gui.set_title("GUT")
_compact_gui.set_title("GUT")
_normal_gui.align_right()
_compact_gui.to_bottom_right()
use_compact_mode(false)
if(get_parent() == get_tree().root):
_test_running_setup()
func _test_running_setup():
set_font_size(100)
_normal_gui.get_textbox().text = "hello world, how are you doing?"
# ------------------------
# Private
# ------------------------
func _set_gut(val):
if(_normal_gui.get_gut() == val):
return
_normal_gui.set_gut(val)
_compact_gui.set_gut(val)
val.start_run.connect(_on_gut_start_run)
val.end_run.connect(_on_gut_end_run)
val.start_pause_before_teardown.connect(_on_gut_pause)
val.end_pause_before_teardown.connect(_on_pause_end)
func _set_both_titles(text):
_normal_gui.set_title(text)
_compact_gui.set_title(text)
# ------------------------
# Events
# ------------------------
func _on_gut_start_run():
_set_both_titles('Running')
func _on_gut_end_run():
_set_both_titles('Finished')
func _on_gut_pause():
_set_both_titles('-- Paused --')
func _on_pause_end():
_set_both_titles('Running')
# ------------------------
# Public
# ------------------------
func get_textbox():
return _normal_gui.get_textbox()
func set_font_size(new_size):
var rtl = _normal_gui.get_textbox()
rtl.set('theme_override_font_sizes/bold_italics_font_size', new_size)
rtl.set('theme_override_font_sizes/bold_font_size', new_size)
rtl.set('theme_override_font_sizes/italics_font_size', new_size)
rtl.set('theme_override_font_sizes/normal_font_size', new_size)
func set_font(font_name):
_set_all_fonts_in_rtl(_normal_gui.get_textbox(), font_name)
func _set_font(rtl, font_name, custom_name):
if(font_name == null):
rtl.remove_theme_font_override(custom_name)
else:
var font_path = 'res://addons/gut/fonts/' + font_name + '.ttf'
if(FileAccess.file_exists(font_path)):
var dyn_font = FontFile.new()
dyn_font.load_dynamic_font('res://addons/gut/fonts/' + font_name + '.ttf')
rtl.add_theme_font_override(custom_name, dyn_font)
func _set_all_fonts_in_rtl(rtl, base_name):
if(base_name == 'Default'):
_set_font(rtl, null, 'normal_font')
_set_font(rtl, null, 'bold_font')
_set_font(rtl, null, 'italics_font')
_set_font(rtl, null, 'bold_italics_font')
else:
_set_font(rtl, base_name + '-Regular', 'normal_font')
_set_font(rtl, base_name + '-Bold', 'bold_font')
_set_font(rtl, base_name + '-Italic', 'italics_font')
_set_font(rtl, base_name + '-BoldItalic', 'bold_italics_font')
func set_default_font_color(color):
_normal_gui.get_textbox().set('custom_colors/default_color', color)
func set_background_color(color):
_normal_gui.set_bg_color(color)
func use_compact_mode(should=true):
_compact_gui.visible = should
_normal_gui.visible = !should
func set_opacity(val):
_normal_gui.modulate.a = val
_compact_gui.modulate.a = val
func set_title(text):
_set_both_titles(text)

View File

@@ -0,0 +1 @@
uid://bw7tukh738kw1

16
addons/gut/GutScene.tscn Normal file
View File

@@ -0,0 +1,16 @@
[gd_scene load_steps=4 format=3 uid="uid://m28heqtswbuq"]
[ext_resource type="Script" uid="uid://bw7tukh738kw1" path="res://addons/gut/GutScene.gd" id="1_b4m8y"]
[ext_resource type="PackedScene" uid="uid://duxblir3vu8x7" path="res://addons/gut/gui/NormalGui.tscn" id="2_j6ywb"]
[ext_resource type="PackedScene" uid="uid://cnqqdfsn80ise" path="res://addons/gut/gui/MinGui.tscn" id="3_3glw1"]
[node name="GutScene" type="Node2D"]
script = ExtResource("1_b4m8y")
[node name="Normal" parent="." instance=ExtResource("2_j6ywb")]
[node name="Compact" parent="." instance=ExtResource("3_3glw1")]
offset_left = 5.0
offset_top = 273.0
offset_right = 265.0
offset_bottom = 403.0

22
addons/gut/LICENSE.md Normal file
View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
=====================
Copyright (c) 2018 Tom "Butch" Wesley
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,52 @@
extends Window
@onready var rtl = $TextDisplay/RichTextLabel
func _get_file_as_text(path):
var to_return = null
var f = FileAccess.open(path, FileAccess.READ)
if(f != null):
to_return = f.get_as_text()
else:
to_return = str('ERROR: Could not open file. Error code ', FileAccess.get_open_error())
return to_return
func _ready():
rtl.clear()
func _on_OpenFile_pressed():
$FileDialog.popup_centered()
func _on_FileDialog_file_selected(path):
show_file(path)
func _on_Close_pressed():
self.hide()
func show_file(path):
var text = _get_file_as_text(path)
if(text == ''):
text = '<Empty File>'
rtl.set_text(text)
self.window_title = path
func show_open():
self.popup_centered()
$FileDialog.popup_centered()
func get_rich_text_label():
return $TextDisplay/RichTextLabel
func _on_Home_pressed():
rtl.scroll_to_line(0)
func _on_End_pressed():
rtl.scroll_to_line(rtl.get_line_count() -1)
func _on_Copy_pressed():
return
# OS.clipboard = rtl.text
func _on_file_dialog_visibility_changed():
if rtl.text.length() == 0 and not $FileDialog.visible:
self.hide()

View File

@@ -0,0 +1 @@
uid://x51wilphva3d

View File

@@ -0,0 +1,92 @@
[gd_scene load_steps=2 format=3 uid="uid://bsm7wtt1gie4v"]
[ext_resource type="Script" uid="uid://x51wilphva3d" path="res://addons/gut/UserFileViewer.gd" id="1"]
[node name="UserFileViewer" type="Window"]
exclusive = true
script = ExtResource("1")
[node name="FileDialog" type="FileDialog" parent="."]
access = 1
show_hidden_files = true
__meta__ = {
"_edit_use_anchors_": false
}
[node name="TextDisplay" type="ColorRect" parent="."]
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 8.0
offset_right = -10.0
offset_bottom = -65.0
color = Color(0.2, 0.188235, 0.188235, 1)
[node name="RichTextLabel" type="RichTextLabel" parent="TextDisplay"]
anchor_right = 1.0
anchor_bottom = 1.0
focus_mode = 2
text = "In publishing and graphic design, Lorem ipsum is a placeholder text commonly used to demonstrate the visual form of a document or a typeface without relying on meaningful content. Lorem ipsum may be used before final copy is available, but it may also be used to temporarily replace copy in a process called greeking, which allows designers to consider form without the meaning of the text influencing the design.
Lorem ipsum is typically a corrupted version of De finibus bonorum et malorum, a first-century BCE text by the Roman statesman and philosopher Cicero, with words altered, added, and removed to make it nonsensical, improper Latin.
Versions of the Lorem ipsum text have been used in typesetting at least since the 1960s, when it was popularized by advertisements for Letraset transfer sheets. Lorem ipsum was introduced to the digital world in the mid-1980s when Aldus employed it in graphic and word-processing templates for its desktop publishing program PageMaker. Other popular word processors including Pages and Microsoft Word have since adopted Lorem ipsum as well."
selection_enabled = true
[node name="OpenFile" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -158.0
offset_top = -50.0
offset_right = -84.0
offset_bottom = -30.0
text = "Open File"
[node name="Home" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -478.0
offset_top = -50.0
offset_right = -404.0
offset_bottom = -30.0
text = "Home"
[node name="Copy" type="Button" parent="."]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 160.0
offset_top = -50.0
offset_right = 234.0
offset_bottom = -30.0
text = "Copy"
[node name="End" type="Button" parent="."]
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -318.0
offset_top = -50.0
offset_right = -244.0
offset_bottom = -30.0
text = "End"
[node name="Close" type="Button" parent="."]
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 10.0
offset_top = -50.0
offset_right = 80.0
offset_bottom = -30.0
text = "Close"
[connection signal="file_selected" from="FileDialog" to="." method="_on_FileDialog_file_selected"]
[connection signal="visibility_changed" from="FileDialog" to="." method="_on_file_dialog_visibility_changed"]
[connection signal="pressed" from="OpenFile" to="." method="_on_OpenFile_pressed"]
[connection signal="pressed" from="Home" to="." method="_on_Home_pressed"]
[connection signal="pressed" from="Copy" to="." method="_on_Copy_pressed"]
[connection signal="pressed" from="End" to="." method="_on_End_pressed"]
[connection signal="pressed" from="Close" to="." method="_on_Close_pressed"]

86
addons/gut/autofree.gd Normal file
View File

@@ -0,0 +1,86 @@
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################
# Class used to keep track of objects to be freed and utilities to free them.
# ##############################################################################
var _to_free = []
var _to_queue_free = []
var _ref_counted_doubles = []
var _all_instance_ids = []
func _add_instance_id(thing):
if(thing.has_method("get_instance_id")):
_all_instance_ids.append(thing.get_instance_id())
func add_free(thing):
if(typeof(thing) == TYPE_OBJECT):
_add_instance_id(thing)
if(!thing is RefCounted):
_to_free.append(thing)
elif(GutUtils.is_double(thing)):
_ref_counted_doubles.append(thing)
func add_queue_free(thing):
if(typeof(thing) == TYPE_OBJECT):
_add_instance_id(thing)
_to_queue_free.append(thing)
func get_queue_free_count():
return _to_queue_free.size()
func get_free_count():
return _to_free.size()
func free_all():
for node in _to_free:
if(is_instance_valid(node)):
if(GutUtils.is_double(node)):
node.__gutdbl_done()
node.free()
_to_free.clear()
for i in range(_to_queue_free.size()):
if(is_instance_valid(_to_queue_free[i])):
_to_queue_free[i].queue_free()
_to_queue_free.clear()
for ref_dbl in _ref_counted_doubles:
ref_dbl.__gutdbl_done()
_ref_counted_doubles.clear()
_all_instance_ids.clear()
func has_instance_id(id):
return _all_instance_ids.has(id)

View File

@@ -0,0 +1 @@
uid://bxjfriqxgwe0r

201
addons/gut/awaiter.gd Normal file
View File

@@ -0,0 +1,201 @@
extends Node
class AwaitLogger:
var _time_waited = 0.0
var logger = GutUtils.get_logger()
var waiting_on = "nothing"
var logged_initial_message = false
var wait_log_delay := 1.0
var disabled = false
func waited(x):
_time_waited += x
if(!logged_initial_message and _time_waited >= wait_log_delay):
log_it()
logged_initial_message = true
func reset():
_time_waited = 0.0
logged_initial_message = false
func log_it():
if(!disabled):
var msg = str("--- Awaiting ", waiting_on, " ---")
logger.wait_msg(msg)
signal timeout
signal wait_started
var await_logger = AwaitLogger.new()
var _wait_time := 0.0
var _wait_process_frames := 0
var _wait_physics_frames := 0
var _signal_to_wait_on = null
var _predicate_method = null
var _waiting_for_predicate_to_be = null
var _predicate_time_between := 0.0
var _predicate_time_between_elpased := 0.0
var _elapsed_time := 0.0
var _elapsed_frames := 0
var _did_last_wait_timeout = false
var did_last_wait_timeout = false :
get: return _did_last_wait_timeout
set(val): push_error("Cannot set did_last_wait_timeout")
func _ready() -> void:
get_tree().process_frame.connect(_on_tree_process_frame)
get_tree().physics_frame.connect(_on_tree_physics_frame)
func _on_tree_process_frame():
# Count frames here instead of in _process so that tree order never
# makes a difference and the count/signaling happens outside of
# _process being called.
if(_wait_process_frames > 0):
_elapsed_frames += 1
if(_elapsed_frames > _wait_process_frames):
_end_wait()
func _on_tree_physics_frame():
# Count frames here instead of in _physics_process so that tree order never
# makes a difference and the count/signaling happens outside of
# _physics_process being called.
if(_wait_physics_frames != 0):
_elapsed_frames += 1
if(_elapsed_frames > _wait_physics_frames):
_end_wait()
func _physics_process(delta):
if(is_waiting()):
await_logger.waited(delta)
if(_wait_time != 0.0):
_elapsed_time += delta
if(_elapsed_time >= _wait_time):
_end_wait()
if(_predicate_method != null):
_predicate_time_between_elpased += delta
if(_predicate_time_between_elpased >= _predicate_time_between):
_predicate_time_between_elpased = 0.0
var result = _predicate_method.call()
if(_waiting_for_predicate_to_be == false):
if(typeof(result) != TYPE_BOOL or result != true):
_end_wait()
else:
if(typeof(result) == TYPE_BOOL and result == _waiting_for_predicate_to_be):
_end_wait()
func _end_wait():
await_logger.reset()
# Check for time before checking for frames so that the extra frames added
# when waiting on a signal do not cause a false negative for timing out.
if(_wait_time > 0):
_did_last_wait_timeout = _elapsed_time >= _wait_time
elif(_wait_physics_frames > 0):
_did_last_wait_timeout = _elapsed_frames >= _wait_physics_frames
elif(_wait_process_frames > 0):
_did_last_wait_timeout = _elapsed_frames >= _wait_process_frames
if(_signal_to_wait_on != null and \
is_instance_valid(_signal_to_wait_on.get_object()) and \
_signal_to_wait_on.is_connected(_signal_callback)):
_signal_to_wait_on.disconnect(_signal_callback)
_wait_process_frames = 0
_wait_time = 0.0
_wait_physics_frames = 0
_signal_to_wait_on = null
_predicate_method = null
_elapsed_time = 0.0
_elapsed_frames = 0
timeout.emit()
const ARG_NOT_SET = '_*_argument_*_is_*_not_set_*_'
func _signal_callback(
_arg1=ARG_NOT_SET, _arg2=ARG_NOT_SET, _arg3=ARG_NOT_SET,
_arg4=ARG_NOT_SET, _arg5=ARG_NOT_SET, _arg6=ARG_NOT_SET,
_arg7=ARG_NOT_SET, _arg8=ARG_NOT_SET, _arg9=ARG_NOT_SET):
_signal_to_wait_on.disconnect(_signal_callback)
# DO NOT _end_wait here. For other parts of the test to get the signal that
# was waited on, we have to wait for another frames. For example, the
# signal_watcher doesn't get the signal in time if we don't do this.
_wait_process_frames = 1
func wait_seconds(x, msg=''):
await_logger.waiting_on = str(x, " seconds ", msg)
_did_last_wait_timeout = false
_wait_time = x
wait_started.emit()
func wait_process_frames(x, msg=''):
await_logger.waiting_on = str(x, " idle frames ", msg)
_did_last_wait_timeout = false
_wait_process_frames = x
wait_started.emit()
func wait_physics_frames(x, msg=''):
await_logger.waiting_on = str(x, " physics frames ", msg)
_did_last_wait_timeout = false
_wait_physics_frames = x
wait_started.emit()
func wait_for_signal(the_signal : Signal, max_time, msg=''):
await_logger.waiting_on = str("signal ", the_signal.get_name(), " or ", max_time, "s ", msg)
_did_last_wait_timeout = false
the_signal.connect(_signal_callback)
_signal_to_wait_on = the_signal
_wait_time = max_time
wait_started.emit()
func wait_until(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
await_logger.waiting_on = str("callable to return TRUE or ", max_time, "s. ", msg)
_predicate_time_between = time_between_calls
_predicate_method = predicate_function
_wait_time = max_time
_waiting_for_predicate_to_be = true
_predicate_time_between_elpased = 0.0
_did_last_wait_timeout = false
wait_started.emit()
func wait_while(predicate_function: Callable, max_time, time_between_calls:=0.0, msg=''):
await_logger.waiting_on = str("callable to return FALSE or ", max_time, "s. ", msg)
_predicate_time_between = time_between_calls
_predicate_method = predicate_function
_wait_time = max_time
_waiting_for_predicate_to_be = false
_predicate_time_between_elpased = 0.0
_did_last_wait_timeout = false
wait_started.emit()
func is_waiting():
return _wait_time != 0.0 || \
_wait_physics_frames != 0 || \
_wait_process_frames != 0

View File

@@ -0,0 +1 @@
uid://ccu4ww35edtdi

View File

@@ -0,0 +1,239 @@
extends SceneTree
var Optparse = load('res://addons/gut/cli/optparse.gd')
var WarningsManager = load("res://addons/gut/warnings_manager.gd")
const WARN_VALUE_PRINT_POSITION = 36
var godot_default_warnings = {
"assert_always_false": 1, "assert_always_true": 1, "confusable_identifier": 1,
"confusable_local_declaration": 1, "confusable_local_usage": 1, "constant_used_as_function": 1,
"deprecated_keyword": 1, "empty_file": 1, "enable": true,
"exclude_addons": true, "function_used_as_property": 1, "get_node_default_without_onready": 2,
"incompatible_ternary": 1, "inference_on_variant": 2, "inferred_declaration": 0,
"int_as_enum_without_cast": 1, "int_as_enum_without_match": 1, "integer_division": 1,
"narrowing_conversion": 1, "native_method_override": 2, "onready_with_export": 2,
"property_used_as_function": 1, "redundant_await": 1, "redundant_static_unload": 1,
"renamed_in_godot_4_hint": 1, "return_value_discarded": 0, "shadowed_global_identifier": 1,
"shadowed_variable": 1, "shadowed_variable_base_class": 1, "standalone_expression": 1,
"standalone_ternary": 1, "static_called_on_instance": 1, "unassigned_variable": 1,
"unassigned_variable_op_assign": 1, "unreachable_code": 1, "unreachable_pattern": 1,
"unsafe_call_argument": 0, "unsafe_cast": 0, "unsafe_method_access": 0,
"unsafe_property_access": 0, "unsafe_void_return": 1, "untyped_declaration": 0,
"unused_local_constant": 1, "unused_parameter": 1, "unused_private_class_variable": 1,
"unused_signal": 1, "unused_variable": 1
}
var gut_default_changes = {
"exclude_addons": false, "redundant_await": 0,
}
var warning_settings = {}
func _setup_warning_settings():
warning_settings["godot_default"] = godot_default_warnings
warning_settings["current"] = WarningsManager.create_warnings_dictionary_from_project_settings()
warning_settings["all_warn"] = WarningsManager.create_warn_all_warnings_dictionary()
var gut_default = godot_default_warnings.duplicate()
gut_default.merge(gut_default_changes, true)
warning_settings["gut_default"] = gut_default
func _warn_value_to_s(value):
var readable = str(value).capitalize()
if(typeof(value) == TYPE_INT):
readable = WarningsManager.WARNING_LOOKUP.get(value, str(readable, ' ???'))
readable = readable.capitalize()
return readable
func _human_readable(warnings):
var to_return = ""
for key in warnings:
var readable = _warn_value_to_s(warnings[key])
to_return += str(key.capitalize().rpad(35, ' '), readable, "\n")
return to_return
func _dump_settings(which):
if(warning_settings.has(which)):
GutUtils.pretty_print(warning_settings[which])
else:
print("UNKNOWN print option ", which)
func _print_settings(which):
if(warning_settings.has(which)):
print(_human_readable(warning_settings[which]))
else:
print("UNKNOWN print option ", which)
func _apply_settings(which):
if(!warning_settings.has(which)):
print("UNKNOWN set option ", which)
return
var pre_settings = warning_settings["current"]
var new_settings = warning_settings[which]
if(new_settings == pre_settings):
print("-- Settings are the same, no changes were made --")
return
WarningsManager.apply_warnings_dictionary(new_settings)
ProjectSettings.save()
print("-- Project Warning Settings have been updated --")
print(_diff_changes_text(pre_settings))
func _diff_text(w1, w2, diff_col_pad=10):
var to_return = ""
for key in w1:
var v1_text = _warn_value_to_s(w1[key])
var v2_text = _warn_value_to_s(w2[key])
var diff_text = v1_text
var prefix = " "
if(v1_text != v2_text):
var diff_prefix = " "
if(w1[key] > w2[key]):
diff_prefix = "-"
else:
diff_prefix = "+"
prefix = "* "
diff_text = str(v1_text.rpad(diff_col_pad, ' '), diff_prefix, v2_text)
to_return += str(str(prefix, key.capitalize()).rpad(WARN_VALUE_PRINT_POSITION, ' '), diff_text, "\n")
return to_return.rstrip("\n")
func _diff_changes_text(pre_settings):
var orig_diff_text = _diff_text(
pre_settings,
WarningsManager.create_warnings_dictionary_from_project_settings(),
0)
# these next two lines are fragile and brute force...enjoy
var diff_text = orig_diff_text.replace("-", " -> ")
diff_text = diff_text.replace("+", " -> ")
if(orig_diff_text == diff_text):
diff_text += "\n-- No changes were made --"
else:
diff_text += "\nChanges will not be visible in Godot until it is restarted.\n"
diff_text += "Even if it asks you to reload...Maybe. Probably."
return diff_text
func _diff(name_1, name_2):
if(warning_settings.has(name_1) and warning_settings.has(name_2)):
var c2_pad = name_1.length() + 2
var heading = str(" ".repeat(WARN_VALUE_PRINT_POSITION), name_1.rpad(c2_pad, ' '), name_2, "\n")
heading += str(
" ".repeat(WARN_VALUE_PRINT_POSITION),
"-".repeat(name_1.length()).rpad(c2_pad, " "),
"-".repeat(name_2.length()),
"\n")
var text = _diff_text(warning_settings[name_1], warning_settings[name_2], c2_pad)
print(heading)
print(text)
var diff_count = 0
for line in text.split("\n"):
if(!line.begins_with(" ")):
diff_count += 1
if(diff_count == 0):
print('-- [', name_1, "] and [", name_2, "] are the same --")
else:
print('-- There are ', diff_count, ' differences between [', name_1, "] and [", name_2, "] --")
else:
print("One or more unknown Warning Level Names:, [", name_1, "] [", name_2, "]")
func _set_settings(nvps):
var pre_settings = warning_settings["current"]
for i in range(nvps.size()/2):
var s_name = nvps[i * 2]
var s_value = nvps[i * 2 + 1]
if(godot_default_warnings.has(s_name)):
var t = typeof(godot_default_warnings[s_name])
if(t == TYPE_INT):
s_value = s_value.to_int()
elif(t == TYPE_BOOL):
s_value = s_value.to_lower() == 'true'
WarningsManager.set_project_setting_warning(s_name, s_value)
ProjectSettings.save()
print(_diff_changes_text(pre_settings))
func _setup_options():
var opts = Optparse.new()
opts.banner = """
This script prints info about or sets the warning settings for the project.
Each action requires one or more Warning Level Names.
Warning Level Names:
* current The current settings for the project.
* godot_default The default settings for Godot.
* gut_default The warning settings that is used when developing GUT.
* all_warn Everything set to warn.
""".dedent()
opts.add('-h', false, 'Print this help')
opts.add('-set', [], "Sets a single setting in the project settings and saves.\n" +
"Use -dump to see a list of setting names and values.\n" +
"Example: -set enabled,true -set unsafe_cast,2 -set unreachable_code,0")
opts.add_heading(" Actions (require Warning Level Name)")
opts.add('-diff', [], "Shows the difference between two Warning Level Names.\n" +
"Example: -diff current,all_warn")
opts.add('-dump', 'none', "Prints a dictionary of the warning values.")
opts.add('-print', 'none', "Print human readable warning values.")
opts.add('-apply', 'none', "Applys one of the Warning Level Names to the project settings. You should restart after using this")
return opts
func _print_help(opts):
opts.print_help()
func _init():
# Testing might set this flag but it should never be disabled for this tool
# or it cannot save project settings, but says it did. Sneakily use the
# private property to get around this property being read-only. Don't
# try this at home.
WarningsManager._disabled = false
_setup_warning_settings()
var opts = _setup_options()
opts.parse()
if(opts.unused.size() != 0):
opts.print_help()
print("Unknown arguments ", opts.unused)
if(opts.values.h):
opts.print_help()
elif(opts.values.print != 'none'):
_print_settings(opts.values.print)
elif(opts.values.dump != 'none'):
_dump_settings(opts.values.dump)
elif(opts.values.apply != 'none'):
_apply_settings(opts.values.apply )
elif(opts.values.diff.size() == 2):
_diff(opts.values.diff[0], opts.values.diff[1])
elif(opts.values.set.size() % 2 == 0):
_set_settings(opts.values.set)
else:
opts.print_help()
print("You didn't specify any options or too many or not the right size or something invalid. I don't know what you want to do.")
quit()

View File

@@ -0,0 +1 @@
uid://1pauyfnd1cre

315
addons/gut/cli/gut_cli.gd Normal file
View File

@@ -0,0 +1,315 @@
extends Node
var Optparse = load('res://addons/gut/cli/optparse.gd')
var Gut = load('res://addons/gut/gut.gd')
var GutRunner = load('res://addons/gut/gui/GutRunner.tscn')
# ------------------------------------------------------------------------------
# Helper class to resolve the various different places where an option can
# be set. Using the get_value method will enforce the order of precedence of:
# 1. command line value
# 2. config file value
# 3. default value
#
# The idea is that you set the base_opts. That will get you a copies of the
# hash with null values for the other types of values. Lower precedented hashes
# will punch through null values of higher precedented hashes.
# ------------------------------------------------------------------------------
class OptionResolver:
var base_opts = {}
var cmd_opts = {}
var config_opts = {}
func get_value(key):
return _nvl(cmd_opts[key], _nvl(config_opts[key], base_opts[key]))
func set_base_opts(opts):
base_opts = opts
cmd_opts = _null_copy(opts)
config_opts = _null_copy(opts)
# creates a copy of a hash with all values null.
func _null_copy(h):
var new_hash = {}
for key in h:
new_hash[key] = null
return new_hash
func _nvl(a, b):
if(a == null):
return b
else:
return a
func _string_it(h):
var to_return = ''
for key in h:
to_return += str('(',key, ':', _nvl(h[key], 'NULL'), ')')
return to_return
func to_s():
return str("base:\n", _string_it(base_opts), "\n", \
"config:\n", _string_it(config_opts), "\n", \
"cmd:\n", _string_it(cmd_opts), "\n", \
"resolved:\n", _string_it(get_resolved_values()))
func get_resolved_values():
var to_return = {}
for key in base_opts:
to_return[key] = get_value(key)
return to_return
func to_s_verbose():
var to_return = ''
var resolved = get_resolved_values()
for key in base_opts:
to_return += str(key, "\n")
to_return += str(' default: ', _nvl(base_opts[key], 'NULL'), "\n")
to_return += str(' config: ', _nvl(config_opts[key], ' --'), "\n")
to_return += str(' cmd: ', _nvl(cmd_opts[key], ' --'), "\n")
to_return += str(' final: ', _nvl(resolved[key], 'NULL'), "\n")
return to_return
# ------------------------------------------------------------------------------
# Here starts the actual script that uses the Options class to kick off Gut
# and run your tests.
# ------------------------------------------------------------------------------
var _gut_config = load('res://addons/gut/gut_config.gd').new()
# array of command line options specified
var _final_opts = []
func setup_options(options, font_names):
var opts = Optparse.new()
opts.banner =\
"""
The GUT CLI
-----------
The default behavior for GUT is to load options from a res://.gutconfig.json if
it exists. Any options specified on the command line will take precedence over
options specified in the gutconfig file. You can specify a different gutconfig
file with the -gconfig option.
To generate a .gutconfig.json file you can use -gprint_gutconfig_sample
To see the effective values of a CLI command and a gutconfig use -gpo
Values for options can be supplied using:
option=value # no space around "="
option value # a space between option and value w/o =
Options whose values are lists/arrays can be specified multiple times:
-gdir=a,b
-gdir c,d
-gdir e
# results in -gdir equaling [a, b, c, d, e]
To not use an empty value instead of a default value, specifiy the option with
an immediate "=":
-gconfig=
"""
opts.add_heading("Test Config:")
opts.add('-gdir', options.dirs, 'List of directories to search for test scripts in.')
opts.add('-ginclude_subdirs', false, 'Flag to include all subdirectories specified with -gdir.')
opts.add('-gtest', [], 'List of full paths to test scripts to run.')
opts.add('-gprefix', options.prefix, 'Prefix used to find tests when specifying -gdir. Default "[default]".')
opts.add('-gsuffix', options.suffix, 'Test script suffix, including .gd extension. Default "[default]".')
opts.add('-gconfig', 'res://.gutconfig.json', 'The config file to load options from. The default is [default]. Use "-gconfig=" to not use a config file.')
opts.add('-gpre_run_script', '', 'pre-run hook script path')
opts.add('-gpost_run_script', '', 'post-run hook script path')
opts.add('-gerrors_do_not_cause_failure', false, 'When an internal GUT error occurs tests will fail. With this option set, that does not happen.')
opts.add('-gdouble_strategy', 'SCRIPT_ONLY', 'Default strategy to use when doubling. Valid values are [INCLUDE_NATIVE, SCRIPT_ONLY]. Default "[default]"')
opts.add_heading("Run Options:")
opts.add('-gselect', '', 'All scripts that contain the specified string in their filename will be ran')
opts.add('-ginner_class', '', 'Only run inner classes that contain the specified string in their name.')
opts.add('-gunit_test_name', '', 'Any test that contains the specified text will be run, all others will be skipped.')
opts.add('-gexit', false, 'Exit after running tests. If not specified you have to manually close the window.')
opts.add('-gexit_on_success', false, 'Only exit if zero tests fail.')
opts.add('-gignore_pause', false, 'Ignores any calls to pause_before_teardown.')
opts.add('-gno_error_tracking', false, 'Disable error tracking.')
opts.add('-gfailure_error_types', options.failure_error_types, 'Error types that will cause tests to fail if the are encountered during the execution of a test. Default "[default]"')
opts.add_heading("Display Settings:")
opts.add('-glog', options.log_level, 'Log level [0-3]. Default [default]')
opts.add('-ghide_orphans', false, 'Display orphan counts for tests and scripts. Default [default].')
opts.add('-gmaximize', false, 'Maximizes test runner window to fit the viewport.')
opts.add('-gcompact_mode', false, 'The runner will be in compact mode. This overrides -gmaximize.')
opts.add('-gopacity', options.opacity, 'Set opacity of test runner window. Use range 0 - 100. 0 = transparent, 100 = opaque.')
opts.add('-gdisable_colors', false, 'Disable command line colors.')
opts.add('-gfont_name', options.font_name, str('Valid values are: ', font_names, '. Default "[default]"'))
opts.add('-gfont_size', options.font_size, 'Font size, default "[default]"')
opts.add('-gbackground_color', options.background_color, 'Background color as an html color, default "[default]"')
opts.add('-gfont_color',options.font_color, 'Font color as an html color, default "[default]"')
opts.add('-gpaint_after', options.paint_after, 'Delay before GUT will add a 1 frame pause to paint the screen/GUI. default [default]')
opts.add('-gwait_log_delay', options.wait_log_delay, 'Delay before GUT will print a message to indicate a test is awaiting one of the wait_* methods. Default [default]')
opts.add_heading("Result Export:")
opts.add('-gjunit_xml_file', options.junit_xml_file, 'Export results of run to this file in the Junit XML format.')
opts.add('-gjunit_xml_timestamp', options.junit_xml_timestamp, 'Include a timestamp in the -gjunit_xml_file, default [default]')
opts.add_heading("Help:")
opts.add('-gh', false, 'Print this help. You did this to see this, so you probably understand.')
opts.add('-gpo', false, 'Print option values from all sources and the value used.')
opts.add('-gprint_gutconfig_sample', false, 'Print out json that can be used to make a gutconfig file.')
# run as in editor, for shelling out purposes through Editor.
var o = opts.add('-graie', false, 'do not use')
o.show_in_help = false
return opts
# Parses options, applying them to the _tester or setting values
# in the options struct.
func extract_command_line_options(from, to):
to.compact_mode = from.get_value_or_null('-gcompact_mode')
to.config_file = from.get_value_or_null('-gconfig')
to.dirs = from.get_value_or_null('-gdir')
to.disable_colors = from.get_value_or_null('-gdisable_colors')
to.double_strategy = from.get_value_or_null('-gdouble_strategy')
to.errors_do_not_cause_failure = from.get_value_or_null('-gerrors_do_not_cause_failure')
to.hide_orphans = from.get_value_or_null('-ghide_orphans')
to.ignore_pause = from.get_value_or_null('-gignore_pause')
to.include_subdirs = from.get_value_or_null('-ginclude_subdirs')
to.inner_class = from.get_value_or_null('-ginner_class')
to.log_level = from.get_value_or_null('-glog')
to.opacity = from.get_value_or_null('-gopacity')
to.post_run_script = from.get_value_or_null('-gpost_run_script')
to.pre_run_script = from.get_value_or_null('-gpre_run_script')
to.prefix = from.get_value_or_null('-gprefix')
to.selected = from.get_value_or_null('-gselect')
to.should_exit = from.get_value_or_null('-gexit')
to.should_exit_on_success = from.get_value_or_null('-gexit_on_success')
to.should_maximize = from.get_value_or_null('-gmaximize')
to.suffix = from.get_value_or_null('-gsuffix')
to.tests = from.get_value_or_null('-gtest')
to.unit_test_name = from.get_value_or_null('-gunit_test_name')
to.wait_log_delay = from.get_value_or_null('-gwait_log_delay')
to.background_color = from.get_value_or_null('-gbackground_color')
to.font_color = from.get_value_or_null('-gfont_color')
to.font_name = from.get_value_or_null('-gfont_name')
to.font_size = from.get_value_or_null('-gfont_size')
to.paint_after = from.get_value_or_null('-gpaint_after')
to.junit_xml_file = from.get_value_or_null('-gjunit_xml_file')
to.junit_xml_timestamp = from.get_value_or_null('-gjunit_xml_timestamp')
to.failure_error_types = from.get_value_or_null('-gfailure_error_types')
to.no_error_tracking = from.get_value_or_null('-gno_error_tracking')
to.raie = from.get_value_or_null('-graie')
func _print_gutconfigs(values):
var header = """Here is a sample of a full .gutconfig.json file.
You do not need to specify all values in your own file. The values supplied in
this sample are what would be used if you ran gut w/o the -gprint_gutconfig_sample
option. Option priority is: command-line, .gutconfig, default)."""
print("\n", header.replace("\n", ' '), "\n")
var resolved = values
# remove_at some options that don't make sense to be in config
resolved.erase("config_file")
resolved.erase("show_help")
print(JSON.stringify(resolved, ' '))
for key in resolved:
resolved[key] = null
print("\n\nAnd here's an empty config for you fill in what you want.")
print(JSON.stringify(resolved, ' '))
func _run_tests(opt_resolver):
_final_opts = opt_resolver.get_resolved_values();
_gut_config.options = _final_opts
var runner = GutRunner.instantiate()
runner.set_gut_config(_gut_config)
get_tree().root.add_child(runner)
if(opt_resolver.cmd_opts.raie):
runner.run_from_editor()
else:
runner.run_tests()
# parse options and run Gut
func main():
var opt_resolver = OptionResolver.new()
opt_resolver.set_base_opts(_gut_config.default_options)
var cli_opts = setup_options(_gut_config.default_options, _gut_config.valid_fonts)
cli_opts.parse()
var all_options_valid = cli_opts.unused.size() == 0
extract_command_line_options(cli_opts, opt_resolver.cmd_opts)
var config_path = opt_resolver.get_value('config_file')
var load_result = 1
# Checking for an empty config path allows us to not use a config file via
# the -gconfig_file option since using "-gconfig_file=" or -gconfig_file=''"
# will result in an empty string.
if(config_path != ''):
load_result = _gut_config.load_options_no_defaults(config_path)
# SHORTCIRCUIT
if(!all_options_valid):
print('Unknown arguments: ', cli_opts.unused)
get_tree().quit(1)
elif(load_result == -1):
print('Invalid gutconfig ', load_result)
get_tree().quit(1)
else:
opt_resolver.config_opts = _gut_config.options
if(cli_opts.get_value('-gh')):
print(GutUtils.version_numbers.get_version_text())
cli_opts.print_help()
get_tree().quit(0)
elif(cli_opts.get_value('-gpo')):
print('All config options and where they are specified. ' +
'The "final" value shows which value will actually be used ' +
'based on order of precedence (default < .gutconfig < cmd line).' + "\n")
print(opt_resolver.to_s_verbose())
get_tree().quit(0)
elif(cli_opts.get_value('-gprint_gutconfig_sample')):
_print_gutconfigs(opt_resolver.get_resolved_values())
get_tree().quit(0)
else:
_run_tests(opt_resolver)
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://bhuudqinp4bth

678
addons/gut/cli/optparse.gd Normal file
View File

@@ -0,0 +1,678 @@
## Parses command line arguments, as one might expect.
##
## Parses command line arguments with a bunch of options including generating
## text that displays all the arguments your script accepts. This
## is included in the GUT ClassRef since it might be usable by others and is
## portable (everything it needs is in this one file).
## [br]
## This does alot, if you want to see it in action have a look at
## [url=https://github.com/bitwes/Gut/blob/main/scratch/optparse_example.gd]scratch/optparse_example.gd[/url]
## [codeblock lang=text]
##
## Godot Argument Lists
## -------------------------
## There are two sets of command line arguments that Godot populates:
## OS.get_cmdline_args
## OS.get_cmdline_user_args.
##
## OS.get_cmdline_args contains any arguments that are not used by the engine
## itself. This means options like --help and -d will never appear in this list
## since these are used by the engine. The one exception is the -s option which
## is always included as the first entry and the script path as the second.
## Optparse ignores these values for argument processing but can be accessed
## with my_optparse.options.script_option. This list does not contain any
## arguments that appear in OS.get_cmdline_user_args.
##
## OS.get_cmdline_user_args contains any arguments that appear on the command
## line AFTER " -- " or " ++ ". This list CAN contain options that the engine
## would otherwise use, and are ignored completely by the engine.
##
## The parse method, by default, includes arguments from OS.get_cmdline_args and
## OS.get_cmdline_user_args. You can optionally pass one of these to the parse
## method to limit which arguments are parsed. You can also conjure up your own
## array of arguments and pass that to parse.
##
## See Godot's documentation for get_cmdline_args and get_cmdline_user_args for
## more information.
##
##
## Adding Options
## --------------
## Use the following to add options to be parsed. These methods return the
## created Option instance. See that class above for more info. You can use
## the returned instance to get values, or use get_value/get_value_or_null.
## add("--name", "default", "Description goes here")
## add(["--name", "--aliases"], "default", "Description goes here")
## add_required(["--name", "--aliases"], "default", "Description goes here")
## add_positional("--name", "default", "Description goes here")
## add_positional_required("--name", "default", "Description goes here")
##
## get_value will return the value of the option or the default if it was not
## set. get_value_or_null will return the value of the option or null if it was
## not set.
##
## The Datatype for an option is determined from the default value supplied to
## the various add methods. Supported types are
## String
## Int
## Float
## Array of strings
## Boolean
##
##
## Value Parsing
## -------------
## optparse uses option_name_prefix to differentiate between option names and
## values. Any argument that starts with this value will be treated as an
## argument name. The default is "-". Set this before calling parse if you want
## to change it.
##
## Values for options can be supplied on the command line with or without an "=":
## option=value # no space around "="
## option value # a space between option and value w/o =
## There is no way to escape "=" at this time.
##
## Array options can be specified multiple times and/or set from a comma delimited
## list.
## -gdir=a,b
## -gdir c,d
## -gdir e
## Results in -gdir equaling [a, b, c, d, e]. There is no way to escape commas
## at this time.
##
## To specify an empty list via the command line follow the option with an equal
## sign
## -gdir=
##
## Boolean options will have thier value set to !default when they are supplied
## on the command line. Boolean options cannot have a value on the command line.
## They are either supplied or not.
##
## If a value is not an array and is specified multiple times on the command line
## then the last entry will be used as the value.
##
## Positional argument values are parsed after all named arguments are parsed.
## This means that other options can appear before, between, and after positional
## arguments.
## --foo=bar positional_0_value --disabled --bar foo positional_1_value --a_flag
##
## Anything that is not used by named or positional arguments will appear in the
## unused property. You can use this to detect unrecognized arguments or treat
## everything else provided as a list of things, or whatever you want. You can
## use is_option on the elements of unused (or whatever you want really) to see
## if optparse would treat it as an option name.
##
## Use get_missing_required_options to get an array of Option with all required
## options that were not found when parsing.
##
## The parsed_args property holds the list of arguments that were parsed.
##
##
## Help Generation
## ---------------
## You can call get_help to generate help text, or you can just call print_help
## and this will print it for you.
##
## Set the banner property to any text you want to appear before the usage and
## options sections.
##
## Options are printed in the order they are added. You can add a heading for
## different options sections with add_heading.
## add("--asdf", 1, "This will have no heading")
## add_heading("foo")
## add("--foo", false, "This will have the foo heading")
## add("--another_foo", 1.5, "This too.")
## add_heading("This is after foo")
## add("--bar", true, "You probably get it by now.")
##
## If you include "[default]" in the description of a option, then the help will
## substitue it with the default value.
## [/codeblock]
#-------------------------------------------------------------------------------
# Holds all the properties of a command line option
#
# value will return the default when it has not been set.
#-------------------------------------------------------------------------------
class Option:
var _has_been_set = false
var _value = null
# REMEMBER that when this option is an array, you have to set the value
# before you alter the contents of the array (append etc) or has_been_set
# will return false and it might not be used right. For example
# get_value_or_null will return null when you've actually changed the value.
var value = _value:
get:
return _value
set(val):
_has_been_set = true
_value = val
var option_name = ''
var default = null
var description = ''
var required = false
var aliases: Array[String] = []
var show_in_help = true
func _init(name,default_value,desc=''):
option_name = name
default = default_value
description = desc
_value = default
func wrap_text(text, left_indent, max_length, wiggle_room=15):
var line_indent = str("\n", " ".repeat(left_indent + 1))
var wrapped = ''
var position = 0
var split_length = max_length
while(position < text.length()):
if(position > 0):
wrapped += line_indent
var split_by = split_length
if(position + split_by + wiggle_room >= text.length()):
split_by = text.length() - position
else:
var min_space = text.rfind(' ', position + split_length)
var max_space = text.find(' ', position + split_length)
if(max_space <= position + split_length + wiggle_room):
split_by = max_space - position
else:
split_by = min_space - position
wrapped += text.substr(position, split_by).lstrip(' ')
if(position == 0):
split_length = max_length - left_indent
position += split_by
return wrapped
func to_s(min_space=0, wrap_length=100):
var line_indent = str("\n", " ".repeat(min_space + 1))
var subbed_desc = description
if not aliases.is_empty():
subbed_desc += "\naliases: " + ", ".join(aliases)
subbed_desc = subbed_desc.replace('[default]', str(default))
subbed_desc = subbed_desc.replace("\n", line_indent)
var final = str(option_name.rpad(min_space), ' ', subbed_desc)
if(wrap_length != -1):
final = wrap_text(final, min_space, wrap_length)
return final
func has_been_set():
return _has_been_set
#-------------------------------------------------------------------------------
# A struct for organizing options by a heading
#-------------------------------------------------------------------------------
class OptionHeading:
var options = []
var display = 'default'
#-------------------------------------------------------------------------------
# Organizes options by order, heading, position. Also responsible for all
# help related text generation.
#-------------------------------------------------------------------------------
class Options:
var options = []
var positional = []
var default_heading = OptionHeading.new()
var script_option = Option.new('-s', '?', 'script option provided by Godot')
var _options_by_name = {"--script": script_option, "-s": script_option}
var _options_by_heading = [default_heading]
var _cur_heading = default_heading
func add_heading(display):
var heading = OptionHeading.new()
heading.display = display
_cur_heading = heading
_options_by_heading.append(heading)
func add(option, aliases=null):
options.append(option)
_options_by_name[option.option_name] = option
_cur_heading.options.append(option)
if aliases != null:
for a in aliases:
_options_by_name[a] = option
option.aliases.assign(aliases)
func add_positional(option):
positional.append(option)
_options_by_name[option.option_name] = option
func get_by_name(option_name):
var found_param = null
if(_options_by_name.has(option_name)):
found_param = _options_by_name[option_name]
return found_param
func get_help_text():
var longest = 0
var text = ""
for i in range(options.size()):
if(options[i].option_name.length() > longest):
longest = options[i].option_name.length()
for heading in _options_by_heading:
if(heading != default_heading):
text += str("\n", heading.display, "\n")
for option in heading.options:
if(option.show_in_help):
text += str(' ', option.to_s(longest + 2).replace("\n", "\n "), "\n")
return text
func get_option_value_text():
var text = ""
var i = 0
for option in positional:
text += str(i, '. ', option.option_name, ' = ', option.value)
if(!option.has_been_set()):
text += " (default)"
text += "\n"
i += 1
for option in options:
text += str(option.option_name, ' = ', option.value)
if(!option.has_been_set()):
text += " (default)"
text += "\n"
return text
func print_option_values():
print(get_option_value_text())
func get_missing_required_options():
var to_return = []
for opt in options:
if(opt.required and !opt.has_been_set()):
to_return.append(opt)
for opt in positional:
if(opt.required and !opt.has_been_set()):
to_return.append(opt)
return to_return
func get_usage_text():
var pos_text = ""
for opt in positional:
pos_text += str("[", opt.description, "] ")
if(pos_text != ""):
pos_text += " [opts] "
return "<path to godot> -s " + script_option.value + " [opts] " + pos_text
#-------------------------------------------------------------------------------
#
# optarse
#
#-------------------------------------------------------------------------------
## @ignore
var options := Options.new()
## Set the banner property to any text you want to appear before the usage and
## options sections when printing the options help.
var banner := ''
## optparse uses option_name_prefix to differentiate between option names and
## values. Any argument that starts with this value will be treated as an
## argument name. The default is "-". Set this before calling parse if you want
## to change it.
var option_name_prefix := '-'
## @ignore
var unused = []
## @ignore
var parsed_args = []
## @ignore
var values: Dictionary = {}
func _populate_values_dictionary():
for entry in options.options:
var value_key = entry.option_name.lstrip('-')
values[value_key] = entry.value
for entry in options.positional:
var value_key = entry.option_name.lstrip('-')
values[value_key] = entry.value
func _convert_value_to_array(raw_value):
var split = raw_value.split(',')
# This is what an empty set looks like from the command line. If we do
# not do this then we will always get back [''] which is not what it
# shoudl be.
if(split.size() == 1 and split[0] == ''):
split = []
return split
# REMEMBER raw_value not used for bools.
func _set_option_value(option, raw_value):
var t = typeof(option.default)
# only set values that were specified at the command line so that
# we can punch through default and config values correctly later.
# Without this check, you can't tell the difference between the
# defaults and what was specified, so you can't punch through
# higher level options.
if(t == TYPE_INT):
option.value = int(raw_value)
elif(t == TYPE_STRING):
option.value = str(raw_value)
elif(t == TYPE_ARRAY):
var values = _convert_value_to_array(raw_value)
if(!option.has_been_set()):
option.value = []
option.value.append_array(values)
elif(t == TYPE_BOOL):
option.value = !option.default
elif(t == TYPE_FLOAT):
option.value = float(raw_value)
elif(t == TYPE_NIL):
print(option.option_name + ' cannot be processed, it has a nil datatype')
else:
print(option.option_name + ' cannot be processed, it has unknown datatype:' + str(t))
func _parse_command_line_arguments(args):
var parsed_opts = args.duplicate()
var i = 0
var positional_index = 0
while i < parsed_opts.size():
var opt = ''
var value = ''
var entry = parsed_opts[i]
if(is_option(entry)):
if(entry.find('=') != -1):
var parts = entry.split('=')
opt = parts[0]
value = parts[1]
var the_option = options.get_by_name(opt)
if(the_option != null):
parsed_opts.remove_at(i)
_set_option_value(the_option, value)
else:
i += 1
else:
var the_option = options.get_by_name(entry)
if(the_option != null):
parsed_opts.remove_at(i)
if(typeof(the_option.default) == TYPE_BOOL):
_set_option_value(the_option, null)
elif(i < parsed_opts.size() and !is_option(parsed_opts[i])):
value = parsed_opts[i]
parsed_opts.remove_at(i)
_set_option_value(the_option, value)
else:
i += 1
else:
if(positional_index < options.positional.size()):
_set_option_value(options.positional[positional_index], entry)
parsed_opts.remove_at(i)
positional_index += 1
else:
i += 1
# this is the leftovers that were not extracted.
return parsed_opts
## Test if something is an existing argument. If [code]str(arg)[/code] begins
## with the [member option_name_prefix], it will considered true,
## otherwise it will be considered false.
func is_option(arg) -> bool:
return str(arg).begins_with(option_name_prefix)
## Adds a command line option.
## If [param op_names] is a String, this is set as the argument's name.
## If [param op_names] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add(op_names, default, desc: String) -> Option:
var op_name: String
var aliases: Array[String] = []
var new_op: Option = null
if(typeof(op_names) == TYPE_STRING):
op_name = op_names
else:
op_name = op_names[0]
aliases.assign(op_names.slice(1))
var bad_alias: int = aliases.map(
func (a: String) -> bool: return options.get_by_name(a) != null
).find(true)
if(options.get_by_name(op_name) != null):
push_error(str('Option [', op_name, '] already exists.'))
elif bad_alias != -1:
push_error(str('Option [', aliases[bad_alias], '] already exists.'))
else:
new_op = Option.new(op_name, default, desc)
options.add(new_op, aliases)
return new_op
## Adds a required command line option.
## Required options that have not been set may be collected after parsing
## by calling [method get_missing_required_options].
## If [param op_names] is a String, this is set as the argument's name.
## If [param op_names] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_required(op_names, default, desc: String) -> Option:
var op := add(op_names, default, desc)
if(op != null):
op.required = true
return op
## Adds a positional command line option.
## Positional options are parsed by their position in the list of arguments
## are are not assigned by name by the user.
## If [param op_name] is a String, this is set as the argument's name.
## If [param op_name] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_positional(op_name, default, desc: String) -> Option:
var new_op = null
if(options.get_by_name(op_name) != null):
push_error(str('Positional option [', op_name, '] already exists.'))
else:
new_op = Option.new(op_name, default, desc)
options.add_positional(new_op)
return new_op
## Adds a required positional command line option.
## If [param op_name] is a String, this is set as the argument's name.
## Required options that have not been set may be collected after parsing
## by calling [method get_missing_required_options].
## Positional options are parsed by their position in the list of arguments
## are are not assigned by name by the user.
## If [param op_name] is an Array of Strings, all elements of the array
## will be aliases for the same argument and will be treated as such during
## parsing.
## [param default] is the default value the option will be set to if it is not
## explicitly set during parsing.
## [param desc] is a human readable text description of the option.
## If the option is successfully added, the Option object will be returned.
## If the option is not successfully added (e.g. a name collision with another
## option occurs), an error message will be printed and [code]null[/code]
## will be returned.
func add_positional_required(op_name, default, desc: String) -> Option:
var op = add_positional(op_name, default, desc)
if(op != null):
op.required = true
return op
## Headings are used to separate logical groups of command line options
## when printing out options from the help menu.
## Headings are printed out between option descriptions in the order
## that [method add_heading] was called.
func add_heading(display_text: String) -> void:
options.add_heading(display_text)
## Gets the value assigned to an option after parsing.
## [param name] can be the name of the option or an alias of it.
## [param name] specifies the option whose value you wish to query.
## If the option exists, the value assigned to it during parsing is returned.
## Otherwise, an error message is printed and [code]null[/code] is returned.
func get_value(name: String):
var found_param: Option = options.get_by_name(name)
if(found_param != null):
return found_param.value
else:
push_error("COULD NOT FIND OPTION " + name)
return null
## Gets the value assigned to an option after parsing,
## returning null if the option was not assigned instead of its default value.
## [param name] specifies the option whose value you wish to query.
## This can be useful when providing an order of precedence to your values.
## For example if
## [codeblock]
## default value < config file < command line
## [/codeblock]
## then you do not want to get the default value for a command line option or
## it will overwrite the value in a config file.
func get_value_or_null(name: String):
var found_param: Option = options.get_by_name(name)
if(found_param != null and found_param.has_been_set()):
return found_param.value
else:
return null
## Returns the help text for all defined options.
func get_help() -> String:
var sep := '---------------------------------------------------------'
var text := str(sep, "\n", banner, "\n\n")
text += "Usage\n-----------\n"
text += " " + options.get_usage_text() + "\n\n"
text += "\nOptions\n-----------\n"
text += options.get_help_text()
text += str(sep, "\n")
return text
## Prints out the help text for all defined options.
func print_help() -> void:
print(get_help())
## Parses a string for all options that have been set in this optparse.
## if [param cli_args] is passed as a String, then it is parsed.
## Otherwise if [param cli_args] is null,
## aruments passed to the Godot engine at startup are parsed.
## See the explanation at the top of addons/gut/cli/optparse.gd to understand
## which arguments this will have access to.
func parse(cli_args=null) -> void:
parsed_args = cli_args
if(parsed_args == null):
parsed_args = OS.get_cmdline_args()
parsed_args.append_array(OS.get_cmdline_user_args())
unused = _parse_command_line_arguments(parsed_args)
_populate_values_dictionary()
## Get all options that were required and were not set during parsing.
## The return value is an Array of Options.
func get_missing_required_options() -> Array:
return options.get_missing_required_options()
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://c8m4fojwln6bq

View File

@@ -0,0 +1,208 @@
# ------------------------------------------------------------------------------
# This holds all the meta information for a test script. It contains the
# name of the inner class and an array of CollectedTests. This does not parse
# anything, it just holds the data about parsed scripts and tests. The
# TestCollector is responsible for populating this object.
#
# This class also facilitates all the exporting and importing of tests.
# ------------------------------------------------------------------------------
var CollectedTest = GutUtils.CollectedTest
var _lgr = null
# One entry per test found in the script. Added externally by TestCollector
var tests = []
# One entry for before_all and after_all (maybe add before_each and after_each).
# These are added by Gut when running before_all and after_all for the script.
var setup_teardown_tests = []
var inner_class_name:StringName
var path:String
# Set externally by test_collector after it can verify that the script was
# actually loaded. This could probably be changed to just hold the GutTest
# script that was loaded, cutting down on complexity elsewhere.
var is_loaded = false
# Set by Gut when it decides that a script should be skipped.
# Right now this is whenever the script has the variable skip_script declared.
# the value of skip_script is put into skip_reason.
var was_skipped = false
var skip_reason = ''
var was_run = false
var name = '' :
get: return path
set(val):pass
func _init(logger=null):
_lgr = logger
func get_new():
var inst = load_script().new()
inst.collected_script = self
return inst
func load_script():
var to_return = load(path)
if(inner_class_name != null and inner_class_name != ''):
# If we wanted to do inner classes in inner classses
# then this would have to become some kind of loop or recursive
# call to go all the way down the chain or this class would
# have to change to hold onto the loaded class instead of
# just path information.
to_return = to_return.get(inner_class_name)
return to_return
# script.gd.InnerClass
func get_filename_and_inner():
var to_return = get_filename()
if(inner_class_name != ''):
to_return += '.' + String(inner_class_name)
return to_return
# res://foo/bar.gd.FooBar
func get_full_name():
var to_return = path
if(inner_class_name != ''):
to_return += '.' + String(inner_class_name)
return to_return
func get_filename():
return path.get_file()
func has_inner_class():
return inner_class_name != ''
# Note: although this no longer needs to export the inner_class names since
# they are pulled from metadata now, it is easier to leave that in
# so we don't have to cut the export down to unique script names.
func export_to(config_file, section):
config_file.set_value(section, 'path', path)
config_file.set_value(section, 'inner_class', inner_class_name)
var names = []
for i in range(tests.size()):
names.append(tests[i].name)
config_file.set_value(section, 'tests', names)
func _remap_path(source_path):
var to_return = source_path
if(!FileAccess.file_exists(source_path)):
_lgr.debug('Checking for remap for: ' + source_path)
var remap_path = source_path.get_basename() + '.gd.remap'
if(FileAccess.file_exists(remap_path)):
var cf = ConfigFile.new()
cf.load(remap_path)
to_return = cf.get_value('remap', 'path')
else:
_lgr.warn('Could not find remap file ' + remap_path)
return to_return
func import_from(config_file, section):
path = config_file.get_value(section, 'path')
path = _remap_path(path)
# Null is an acceptable value, but you can't pass null as a default to
# get_value since it thinks you didn't send a default...then it spits
# out red text. This works around that.
var inner_name = config_file.get_value(section, 'inner_class', 'Placeholder')
if(inner_name != 'Placeholder'):
inner_class_name = inner_name
else: # just being explicit
inner_class_name = StringName("")
func get_test_named(test_name):
return GutUtils.search_array(tests, 'name', test_name)
func get_ran_test_count():
var count = 0
for t in tests:
if(t.was_run):
count += 1
return count
func get_assert_count():
var count = 0
for t in tests:
count += t.pass_texts.size()
count += t.fail_texts.size()
for t in setup_teardown_tests:
count += t.pass_texts.size()
count += t.fail_texts.size()
return count
func get_pass_count():
var count = 0
for t in tests:
count += t.pass_texts.size()
for t in setup_teardown_tests:
count += t.pass_texts.size()
return count
func get_fail_count():
var count = 0
for t in tests:
count += t.fail_texts.size()
for t in setup_teardown_tests:
count += t.fail_texts.size()
return count
func get_pending_count():
var count = 0
for t in tests:
count += t.pending_texts.size()
return count
func get_passing_test_count():
var count = 0
for t in tests:
if(t.is_passing()):
count += 1
return count
func get_failing_test_count():
var count = 0
for t in tests:
if(t.is_failing()):
count += 1
return count
func get_risky_count():
var count = 0
if(was_skipped):
count = 1
else:
for t in tests:
if(t.is_risky()):
count += 1
return count
func to_s():
var to_return = path
if(inner_class_name != null):
to_return += str('.', inner_class_name)
to_return += "\n"
for i in range(tests.size()):
to_return += str(' ', tests[i].to_s())
return to_return

View File

@@ -0,0 +1 @@
uid://bjjcnr1oqvag6

View File

@@ -0,0 +1,120 @@
# ------------------------------------------------------------------------------
# Used to keep track of info about each test ran.
# ------------------------------------------------------------------------------
# the name of the function
var name = ""
# flag to know if the name has been printed yet. Used by the logger.
var has_printed_name = false
# the number of arguments the method has
var arg_count = 0
# the time it took to execute the test in seconds
var time_taken : float = 0
# The number of asserts in the test. Converted to a property for backwards
# compatibility. This now reflects the text sizes instead of being a value
# that can be altered externally.
var assert_count = 0 :
get: return pass_texts.size() + fail_texts.size()
set(val): pass
# Converted to propety for backwards compatibility. This now cannot be set
# externally
var pending = false :
get: return is_pending()
set(val): pass
# the line number when the test fails
var line_number = -1
# Set internally by Gut using whatever reason Gut wants to use to set this.
# Gut will skip these marked true and the test will be listed as risky.
var should_skip = false # -- Currently not used by GUT don't believe ^
var pass_texts = []
var fail_texts = []
var pending_texts = []
var orphans = 0
var was_run = false
var collected_script : WeakRef = null
func did_pass():
return is_passing()
func add_fail(fail_text):
fail_texts.append(fail_text)
func add_pending(pending_text):
pending_texts.append(pending_text)
func add_pass(passing_text):
pass_texts.append(passing_text)
# must have passed an assert and not have any other status to be passing
func is_passing():
return pass_texts.size() > 0 and fail_texts.size() == 0 and pending_texts.size() == 0
# failing takes precedence over everything else, so any failures makes the
# test a failure.
func is_failing():
return fail_texts.size() > 0
# test is only pending if pending was called and the test is not failing.
func is_pending():
return pending_texts.size() > 0 and fail_texts.size() == 0
func is_risky():
return should_skip or (was_run and !did_something())
func did_something():
return is_passing() or is_failing() or is_pending()
func get_status_text():
var to_return = GutUtils.TEST_STATUSES.NO_ASSERTS
if(should_skip):
to_return = GutUtils.TEST_STATUSES.SKIPPED
elif(!was_run):
to_return = GutUtils.TEST_STATUSES.NOT_RUN
elif(pending_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.PENDING
elif(fail_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.FAILED
elif(pass_texts.size() > 0):
to_return = GutUtils.TEST_STATUSES.PASSED
return to_return
# Deprecated
func get_status():
return get_status_text()
func to_s():
var pad = ' '
var to_return = str(name, "[", get_status_text(), "]\n")
for i in range(fail_texts.size()):
to_return += str(pad, 'Fail: ', fail_texts[i])
for i in range(pending_texts.size()):
to_return += str(pad, 'Pending: ', pending_texts[i], "\n")
for i in range(pass_texts.size()):
to_return += str(pad, 'Pass: ', pass_texts[i], "\n")
return to_return

View File

@@ -0,0 +1 @@
uid://cl854f1m26a2a

125
addons/gut/comparator.gd Normal file
View File

@@ -0,0 +1,125 @@
var _strutils = GutUtils.Strutils.new()
var _max_length = 100
var _should_compare_int_to_float = true
const MISSING = '|__missing__gut__compare__value__|'
func _cannot_compare_text(v1, v2):
return str('Cannot compare ', _strutils.types[typeof(v1)], ' with ',
_strutils.types[typeof(v2)], '.')
func _make_missing_string(text):
return '<missing ' + text + '>'
func _create_missing_result(v1, v2, text):
var to_return = null
var v1_str = format_value(v1)
var v2_str = format_value(v2)
if(typeof(v1) == TYPE_STRING and v1 == MISSING):
v1_str = _make_missing_string(text)
to_return = GutUtils.CompareResult.new()
elif(typeof(v2) == TYPE_STRING and v2 == MISSING):
v2_str = _make_missing_string(text)
to_return = GutUtils.CompareResult.new()
if(to_return != null):
to_return.summary = str(v1_str, ' != ', v2_str)
to_return.are_equal = false
return to_return
func simple(v1, v2, missing_string=''):
var missing_result = _create_missing_result(v1, v2, missing_string)
if(missing_result != null):
return missing_result
var result = GutUtils.CompareResult.new()
var cmp_str = null
var extra = ''
var tv1 = typeof(v1)
var tv2 = typeof(v2)
# print(tv1, '::', tv2, ' ', _strutils.types[tv1], '::', _strutils.types[tv2])
if(_should_compare_int_to_float and [TYPE_INT, TYPE_FLOAT].has(tv1) and [TYPE_INT, TYPE_FLOAT].has(tv2)):
result.are_equal = v1 == v2
elif([TYPE_STRING, TYPE_STRING_NAME].has(tv1) and [TYPE_STRING, TYPE_STRING_NAME].has(tv2)):
result.are_equal = v1 == v2
elif(GutUtils.are_datatypes_same(v1, v2)):
result.are_equal = v1 == v2
if(typeof(v1) == TYPE_DICTIONARY or typeof(v1) == TYPE_ARRAY):
var sub_result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
result.summary = sub_result.get_short_summary()
if(!sub_result.are_equal):
extra = ".\n" + sub_result.get_short_summary()
else:
cmp_str = '!='
result.are_equal = false
extra = str('. ', _cannot_compare_text(v1, v2))
cmp_str = get_compare_symbol(result.are_equal)
result.summary = str(format_value(v1), ' ', cmp_str, ' ', format_value(v2), extra)
return result
func shallow(v1, v2):
var result = null
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
else:
result = simple(v1, v2)
else:
result = simple(v1, v2)
return result
func deep(v1, v2):
var result = null
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) in [TYPE_ARRAY, TYPE_DICTIONARY]):
result = GutUtils.DiffTool.new(v1, v2, GutUtils.DIFF.DEEP)
else:
result = simple(v1, v2)
else:
result = simple(v1, v2)
return result
func format_value(val, max_val_length=_max_length):
return _strutils.truncate_string(_strutils.type2str(val), max_val_length)
func compare(v1, v2, diff_type=GutUtils.DIFF.SIMPLE):
var result = null
if(diff_type == GutUtils.DIFF.SIMPLE):
result = simple(v1, v2)
elif(diff_type == GutUtils.DIFF.DEEP):
result = deep(v1, v2)
return result
func get_should_compare_int_to_float():
return _should_compare_int_to_float
func set_should_compare_int_to_float(should_compare_int_float):
_should_compare_int_to_float = should_compare_int_float
func get_compare_symbol(is_equal):
if(is_equal):
return '=='
else:
return '!='

View File

@@ -0,0 +1 @@
uid://bohry7fhscy7y

View File

@@ -0,0 +1,70 @@
var _are_equal = false
var are_equal = false :
get:
return get_are_equal()
set(val):
set_are_equal(val)
var _summary = null
var summary = null :
get:
return get_summary()
set(val):
set_summary(val)
var _max_differences = 30
var max_differences = 30 :
get:
return get_max_differences()
set(val):
set_max_differences(val)
var _differences = {}
var differences :
get:
return get_differences()
set(val):
set_differences(val)
func _block_set(which, val):
push_error(str('cannot set ', which, ', value [', val, '] ignored.'))
func _to_string():
return str(get_summary()) # could be null, gotta str it.
func get_are_equal():
return _are_equal
func set_are_equal(r_eq):
_are_equal = r_eq
func get_summary():
return _summary
func set_summary(smry):
_summary = smry
func get_total_count():
pass
func get_different_count():
pass
func get_short_summary():
return summary
func get_max_differences():
return _max_differences
func set_max_differences(max_diff):
_max_differences = max_diff
func get_differences():
return _differences
func set_differences(diffs):
_block_set('differences', diffs)
func get_brackets():
return null

View File

@@ -0,0 +1 @@
uid://cow1xqmqqvn4e

View File

@@ -0,0 +1,63 @@
var _strutils = GutUtils.Strutils.new()
const INDENT = ' '
var _max_to_display = 30
const ABSOLUTE_MAX_DISPLAYED = 10000
const UNLIMITED = -1
func _single_diff(diff, depth=0):
var to_return = ""
var brackets = diff.get_brackets()
if(brackets != null and !diff.are_equal):
to_return = ''
to_return += str(brackets.open, "\n",
_strutils.indent_text(differences_to_s(diff.differences, depth), depth+1, INDENT), "\n",
brackets.close)
else:
to_return = str(diff)
return to_return
func make_it(diff):
var to_return = ''
if(diff.are_equal):
to_return = diff.summary
else:
if(_max_to_display == ABSOLUTE_MAX_DISPLAYED):
to_return = str(diff.get_value_1(), ' != ', diff.get_value_2())
else:
to_return = diff.get_short_summary()
to_return += str("\n", _strutils.indent_text(_single_diff(diff, 0), 1, ' '))
return to_return
func differences_to_s(differences, depth=0):
var to_return = ''
var keys = differences.keys()
keys.sort()
var limit = min(_max_to_display, differences.size())
for i in range(limit):
var key = keys[i]
to_return += str(key, ": ", _single_diff(differences[key], depth))
if(i != limit -1):
to_return += "\n"
if(differences.size() > _max_to_display):
to_return += str("\n\n... ", differences.size() - _max_to_display, " more.")
return to_return
func get_max_to_display():
return _max_to_display
func set_max_to_display(max_to_display):
_max_to_display = max_to_display
if(_max_to_display == UNLIMITED):
_max_to_display = ABSOLUTE_MAX_DISPLAYED

View File

@@ -0,0 +1 @@
uid://ch2km05phxacd

156
addons/gut/diff_tool.gd Normal file
View File

@@ -0,0 +1,156 @@
extends 'res://addons/gut/compare_result.gd'
const INDENT = ' '
enum {
DEEP,
SIMPLE
}
var _strutils = GutUtils.Strutils.new()
var _compare = GutUtils.Comparator.new()
var _value_1 = null
var _value_2 = null
var _total_count = 0
var _diff_type = null
var _brackets = null
var _valid = true
var _desc_things = 'somethings'
# -------- comapre_result.gd "interface" ---------------------
func set_are_equal(val):
_block_set('are_equal', val)
func get_are_equal():
if(!_valid):
return null
else:
return differences.size() == 0
func set_summary(val):
_block_set('summary', val)
func get_summary():
return summarize()
func get_different_count():
return differences.size()
func get_total_count():
return _total_count
func get_short_summary():
var text = str(_strutils.truncate_string(str(_value_1), 50),
' ', _compare.get_compare_symbol(are_equal), ' ',
_strutils.truncate_string(str(_value_2), 50))
if(!are_equal):
text += str(' ', get_different_count(), ' of ', get_total_count(),
' ', _desc_things, ' do not match.')
return text
func get_brackets():
return _brackets
# -------- comapre_result.gd "interface" ---------------------
func _invalidate():
_valid = false
differences = null
func _init(v1,v2,diff_type=DEEP):
_value_1 = v1
_value_2 = v2
_diff_type = diff_type
_compare.set_should_compare_int_to_float(false)
_find_differences(_value_1, _value_2)
func _find_differences(v1, v2):
if(GutUtils.are_datatypes_same(v1, v2)):
if(typeof(v1) == TYPE_ARRAY):
_brackets = {'open':'[', 'close':']'}
_desc_things = 'indexes'
_diff_array(v1, v2)
elif(typeof(v2) == TYPE_DICTIONARY):
_brackets = {'open':'{', 'close':'}'}
_desc_things = 'keys'
_diff_dictionary(v1, v2)
else:
_invalidate()
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
else:
_invalidate()
GutUtils.get_logger().error('Only Arrays and Dictionaries are supported.')
func _diff_array(a1, a2):
_total_count = max(a1.size(), a2.size())
for i in range(a1.size()):
var result = null
if(i < a2.size()):
if(_diff_type == DEEP):
result = _compare.deep(a1[i], a2[i])
else:
result = _compare.simple(a1[i], a2[i])
else:
result = _compare.simple(a1[i], _compare.MISSING, 'index')
if(!result.are_equal):
differences[i] = result
if(a1.size() < a2.size()):
for i in range(a1.size(), a2.size()):
differences[i] = _compare.simple(_compare.MISSING, a2[i], 'index')
func _diff_dictionary(d1, d2):
var d1_keys = d1.keys()
var d2_keys = d2.keys()
# Process all the keys in d1
_total_count += d1_keys.size()
for key in d1_keys:
if(!d2.has(key)):
differences[key] = _compare.simple(d1[key], _compare.MISSING, 'key')
else:
d2_keys.remove_at(d2_keys.find(key))
var result = null
if(_diff_type == DEEP):
result = _compare.deep(d1[key], d2[key])
else:
result = _compare.simple(d1[key], d2[key])
if(!result.are_equal):
differences[key] = result
# Process all the keys in d2 that didn't exist in d1
_total_count += d2_keys.size()
for i in range(d2_keys.size()):
differences[d2_keys[i]] = _compare.simple(_compare.MISSING, d2[d2_keys[i]], 'key')
func summarize():
var summary = ''
if(are_equal):
summary = get_short_summary()
else:
var formatter = load('res://addons/gut/diff_formatter.gd').new()
formatter.set_max_to_display(max_differences)
summary = formatter.make_it(self)
return summary
func get_diff_type():
return _diff_type
func get_value_1():
return _value_1
func get_value_2():
return _value_2

View File

@@ -0,0 +1 @@
uid://beoxokvl1hjs8

View File

@@ -0,0 +1,9 @@
{func_decleration}
if(__gutdbl == null):
return
__gutdbl.spy_on('{method_name}', {param_array})
if(__gutdbl.is_stubbed_to_call_super('{method_name}', {param_array})):
return {super_call}
else:
return await __gutdbl.handle_other_stubs('{method_name}', {param_array})

View File

@@ -0,0 +1,4 @@
{func_decleration}:
super({super_params})
__gutdbl.spy_on('{method_name}', {param_array})

View File

@@ -0,0 +1,37 @@
# ##############################################################################
# Gut Doubled Script
# ##############################################################################
{extends}
{constants}
{properties}
# ------------------------------------------------------------------------------
# GUT stuff
# ------------------------------------------------------------------------------
var __gutdbl_values = {
thepath = '{path}',
subpath = '{subpath}',
stubber = {stubber_id},
spy = {spy_id},
gut = {gut_id},
from_singleton = '{singleton_name}',
is_partial = {is_partial},
doubled_methods = {doubled_methods},
}
var __gutdbl = load('res://addons/gut/double_tools.gd').new(self)
# Here so other things can check for a method to know if this is a double.
func __gutdbl_check_method__():
pass
# Cleanup called by GUT after tests have finished. Important for RefCounted
# objects. Nodes are freed, and won't have this method called on them.
func __gutdbl_done():
__gutdbl = null
__gutdbl_values.clear()
# ------------------------------------------------------------------------------
# Doubled Methods
# ------------------------------------------------------------------------------

View File

@@ -0,0 +1,70 @@
var thepath = ''
var subpath = ''
var from_singleton = null
var is_partial = null
var double_ref : WeakRef = null
var stubber_ref : WeakRef = null
var spy_ref : WeakRef = null
var gut_ref : WeakRef = null
const NO_DEFAULT_VALUE = '!__gut__no__default__value__!'
func _init(double = null):
if(double != null):
var values = double.__gutdbl_values
double_ref = weakref(double)
thepath = values.thepath
subpath = values.subpath
stubber_ref = weakref_from_id(values.stubber)
spy_ref = weakref_from_id(values.spy)
gut_ref = weakref_from_id(values.gut)
from_singleton = values.from_singleton
is_partial = values.is_partial
if(gut_ref.get_ref() != null):
gut_ref.get_ref().get_autofree().add_free(double_ref.get_ref())
func _get_stubbed_method_to_call(method_name, called_with):
var method = stubber_ref.get_ref().get_call_this(double_ref.get_ref(), method_name, called_with)
if(method != null):
method = method.bindv(called_with)
return method
return method
func weakref_from_id(inst_id):
if(inst_id == -1):
return weakref(null)
else:
return weakref(instance_from_id(inst_id))
func is_stubbed_to_call_super(method_name, called_with):
if(stubber_ref.get_ref() != null):
return stubber_ref.get_ref().should_call_super(double_ref.get_ref(), method_name, called_with)
else:
return false
func handle_other_stubs(method_name, called_with):
if(stubber_ref.get_ref() == null):
return
var method = _get_stubbed_method_to_call(method_name, called_with)
if(method != null):
return await method.call()
else:
return stubber_ref.get_ref().get_return(double_ref.get_ref(), method_name, called_with)
func spy_on(method_name, called_with):
if(spy_ref.get_ref() != null):
spy_ref.get_ref().add_call(double_ref.get_ref(), method_name, called_with)
func default_val(method_name, p_index):
if(stubber_ref.get_ref() == null):
return null
else:
return stubber_ref.get_ref().get_default_value(double_ref.get_ref(), method_name, p_index)

View File

@@ -0,0 +1 @@
uid://tr4khoco1hef

312
addons/gut/doubler.gd Normal file
View File

@@ -0,0 +1,312 @@
extends RefCounted
var _base_script_text = GutUtils.get_file_as_text('res://addons/gut/double_templates/script_template.txt')
var _script_collector = GutUtils.ScriptCollector.new()
# used by tests for debugging purposes.
var print_source = false
var inner_class_registry = GutUtils.InnerClassRegistry.new()
# ###############
# Properties
# ###############
var _stubber = GutUtils.Stubber.new()
func get_stubber():
return _stubber
func set_stubber(stubber):
_stubber = stubber
var _lgr = GutUtils.get_logger()
func get_logger():
return _lgr
func set_logger(logger):
_lgr = logger
_method_maker.set_logger(logger)
var _spy = null
func get_spy():
return _spy
func set_spy(spy):
_spy = spy
var _gut = null
func get_gut():
return _gut
func set_gut(gut):
_gut = gut
var _strategy = null
func get_strategy():
return _strategy
func set_strategy(strategy):
if(GutUtils.DOUBLE_STRATEGY.values().has(strategy)):
_strategy = strategy
else:
_lgr.error(str('doubler.gd: invalid double strategy ', strategy))
var _method_maker = GutUtils.MethodMaker.new()
func get_method_maker():
return _method_maker
var _ignored_methods = GutUtils.OneToMany.new()
func get_ignored_methods():
return _ignored_methods
# ###############
# Private
# ###############
func _init(strategy=GutUtils.DOUBLE_STRATEGY.SCRIPT_ONLY):
set_logger(GutUtils.get_logger())
_strategy = strategy
func _get_indented_line(indents, text):
var to_return = ''
for _i in range(indents):
to_return += "\t"
return str(to_return, text, "\n")
func _stub_to_call_super(parsed, method_name):
if(!parsed.get_method(method_name).is_eligible_for_doubling()):
return
var params = GutUtils.StubParams.new(parsed.script_path, method_name, parsed.subpath)
params.to_call_super()
_stubber.add_stub(params)
func _get_base_script_text(parsed, override_path, partial, included_methods):
var path = parsed.script_path
if(override_path != null):
path = override_path
var stubber_id = -1
if(_stubber != null):
stubber_id = _stubber.get_instance_id()
var spy_id = -1
if(_spy != null):
spy_id = _spy.get_instance_id()
var gut_id = -1
if(_gut != null):
gut_id = _gut.get_instance_id()
var extends_text = parsed.get_extends_text()
var values = {
# Top sections
"extends":extends_text,
"constants":'',#obj_info.get_constants_text(),
"properties":'',#obj_info.get_properties_text(),
# metadata values
"path":path,
"subpath":GutUtils.nvl(parsed.subpath, ''),
"stubber_id":stubber_id,
"spy_id":spy_id,
"gut_id":gut_id,
"singleton_name":'',#GutUtils.nvl(obj_info.get_singleton_name(), ''),
"is_partial":partial,
"doubled_methods":included_methods,
}
return _base_script_text.format(values)
func _is_method_eligible_for_doubling(parsed_script, parsed_method):
return !parsed_method.is_accessor() and \
parsed_method.is_eligible_for_doubling() and \
!_ignored_methods.has(parsed_script.resource, parsed_method.meta.name)
# Disable the native_method_override setting so that doubles do not generate
# errors or warnings when doubling with INCLUDE_NATIVE or when a method has
# been added because of param_count stub.
func _create_script_no_warnings(src):
var prev_native_override_value = null
var native_method_override = 'debug/gdscript/warnings/native_method_override'
prev_native_override_value = ProjectSettings.get_setting(native_method_override)
ProjectSettings.set_setting(native_method_override, 0)
var DblClass = GutUtils.create_script_from_source(src)
ProjectSettings.set_setting(native_method_override, prev_native_override_value)
return DblClass
func _create_double(parsed, strategy, override_path, partial):
var dbl_src = ""
var included_methods = []
for method in parsed.get_local_methods():
if(_is_method_eligible_for_doubling(parsed, method)):
included_methods.append(method.meta.name)
dbl_src += _get_func_text(method.meta)
if(strategy == GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE):
for method in parsed.get_super_methods():
if(_is_method_eligible_for_doubling(parsed, method)):
included_methods.append(method.meta.name)
_stub_to_call_super(parsed, method.meta.name)
dbl_src += _get_func_text(method.meta)
var base_script = _get_base_script_text(parsed, override_path, partial, included_methods)
dbl_src = base_script + "\n\n" + dbl_src
if(print_source):
var to_print :String = GutUtils.add_line_numbers(dbl_src)
to_print = to_print.rstrip("\n")
_lgr.log(str(to_print))
var DblClass = _create_script_no_warnings(dbl_src)
if(_stubber != null):
_stub_method_default_values(DblClass, parsed, strategy)
if(print_source):
_lgr.log(str(" path | ", DblClass.resource_path, "\n"))
return DblClass
func _stub_method_default_values(which, parsed, strategy):
for method in parsed.get_local_methods():
if(method.is_eligible_for_doubling() and !_ignored_methods.has(parsed.resource, method.meta.name)):
_stubber.stub_defaults_from_meta(parsed.script_path, method.meta)
func _double_scene_and_script(scene, strategy, partial):
var dbl_bundle = scene._bundled.duplicate(true)
var script_obj = GutUtils.get_scene_script_object(scene)
# I'm not sure if the script object for the root node of a packed scene is
# always the first entry in "variants" so this tries to find it.
var script_index = dbl_bundle["variants"].find(script_obj)
var script_dbl = null
if(script_obj != null):
if(partial):
script_dbl = _partial_double(script_obj, strategy, scene.get_path())
else:
script_dbl = _double(script_obj, strategy, scene.get_path())
if(script_index != -1):
dbl_bundle["variants"][script_index] = script_dbl
var doubled_scene = PackedScene.new()
doubled_scene._set_bundled_scene(dbl_bundle)
return doubled_scene
func _get_inst_id_ref_str(inst):
var ref_str = 'null'
if(inst):
ref_str = str('instance_from_id(', inst.get_instance_id(),')')
return ref_str
func _get_func_text(method_hash):
return _method_maker.get_function_text(method_hash) + "\n"
func _parse_script(obj):
var parsed = null
if(GutUtils.is_inner_class(obj)):
if(inner_class_registry.has(obj)):
parsed = _script_collector.parse(inner_class_registry.get_base_resource(obj), obj)
else:
_lgr.error('Doubling Inner Classes requires you register them first. Call register_inner_classes passing the script that contains the inner class.')
else:
parsed = _script_collector.parse(obj)
return parsed
# Override path is used with scenes.
func _double(obj, strategy, override_path=null):
var parsed = _parse_script(obj)
if(parsed != null):
return _create_double(parsed, strategy, override_path, false)
func _partial_double(obj, strategy, override_path=null):
var parsed = _parse_script(obj)
if(parsed != null):
return _create_double(parsed, strategy, override_path, true)
# -------------------------
# Public
# -------------------------
# double a script/object
func double(obj, strategy=_strategy):
return _double(obj, strategy)
func partial_double(obj, strategy=_strategy):
return _partial_double(obj, strategy)
# double a scene
func double_scene(scene, strategy=_strategy):
return _double_scene_and_script(scene, strategy, false)
func partial_double_scene(scene, strategy=_strategy):
return _double_scene_and_script(scene, strategy, true)
func double_gdnative(which):
return _double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
func partial_double_gdnative(which):
return _partial_double(which, GutUtils.DOUBLE_STRATEGY.INCLUDE_NATIVE)
func double_inner(parent, inner, strategy=_strategy):
var parsed = _script_collector.parse(parent, inner)
return _create_double(parsed, strategy, null, false)
func partial_double_inner(parent, inner, strategy=_strategy):
var parsed = _script_collector.parse(parent, inner)
return _create_double(parsed, strategy, null, true)
func add_ignored_method(obj, method_name):
_ignored_methods.add(obj, method_name)
# ##############################################################################
#(G)odot (U)nit (T)est class
#
# ##############################################################################
# The MIT License (MIT)
# =====================
#
# Copyright (c) 2025 Tom "Butch" Wesley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ##############################################################################

View File

@@ -0,0 +1 @@
uid://cpy013l0wqwmg

View File

@@ -0,0 +1,33 @@
@tool
var default_script_name_no_extension = 'gut_dynamic_script'
var default_script_resource_path = 'res://addons/gut/not_a_real_file/'
var default_script_extension = "gd"
var _created_script_count = 0
# Creates a loaded script from the passed in source. This loaded script is
# returned unless there is an error. When an error occcurs the error number
# is returned instead.
func create_script_from_source(source, override_path=null):
_created_script_count += 1
var r_path = str(default_script_resource_path,
default_script_name_no_extension, '_', _created_script_count, ".",
default_script_extension)
if(override_path != null):
r_path = override_path
var DynamicScript = GDScript.new()
DynamicScript.source_code = source.dedent()
# The resource_path must be unique or Godot thinks it is trying
# to load something it has already loaded and generates an error like
# ERROR: Another resource is loaded from path 'workaround for godot
# issue #65263' (possible cyclic resource inclusion).
DynamicScript.resource_path = r_path
var result = DynamicScript.reload()
if(result != OK):
DynamicScript = result
return DynamicScript

View File

@@ -0,0 +1 @@
uid://cnbjsrik0p5uf

View File

@@ -0,0 +1,207 @@
@tool
extends Node
# ##############################################################################
#
# Watches script editors and emits a signal whenever the method, inner class,
# or script changes based on cursor position and other stuff.
#
# Basically, whenever this thing's signal is emitted, then the RunAtCursor
# buttons should be updated to match the data passed to the signal.
# ##############################################################################
# In the editor, whenever a script is opened you get these new things that
# hang off of EditorInterface.get_script_editor()
# * ScriptEditorBase
# * CodeEdit
# ##############################################################################
var _last_info : Dictionary = {}
var _last_line = -1
# This is the control that holds all the individual editors.
var _current_script_editor : ScriptEditor = null
# Reference to the GDScript for the last script we were notified about.
var _current_script = null
var _current_script_is_test_script = false
var _current_editor_base : ScriptEditorBase = null
var _current_editor : CodeEdit = null
# Quick lookup of editors based on the current script.
var _editors_for_scripts : Dictionary= {}
# In order to keep the data that comes back from the emitted signal way more
# usable, we have to know what GUT looks for for an inner-test-class prefix.
# If we didn't do this, then this thing would have to return all the inner
# classes and then we'd have to determine if we were in an inner-test-class
# outside of here by traversing all the classes returned. It makes this thing
# less generic and know too much, but this is probably already too generic as
# it is.
var inner_class_prefix = "Test"
var method_prefix = "test_"
var script_prefix = "test_"
var script_suffix = ".gd"
# Based on cursor and open editors, this will be emitted. You do what you
# want with it.
signal it_changed(change_data)
func _ready():
# This will not change, and should not change, over the course of a session.
_current_script_editor = EditorInterface.get_script_editor()
_current_script_editor.editor_script_changed.connect(_on_editor_script_changed)
_current_script_editor.script_close.connect(_on_editor_script_close)
func _handle_caret_location(which):
var current_line = which.get_caret_line(0) + 1
if(_last_line != current_line):
_last_line = current_line
if(_current_script_is_test_script):
var new_info = _make_info(which, _current_script, _current_script_is_test_script)
if(_last_info != new_info):
_last_info = new_info
it_changed.emit(_last_info.duplicate())
func _get_func_name_from_line(text):
text = text.strip_edges()
var left = text.split("(")[0]
var func_name = left.split(" ")[1]
return func_name
func _get_class_name_from_line(text):
text = text.strip_edges()
var right = text.split(" ")[1]
var the_name = right.rstrip(":")
return the_name
func _make_info(editor, script, test_script_flag):
if(editor == null):
return
var info = {
script = script,
inner_class = null,
method = null,
is_test_script = test_script_flag
}
var start_line = editor.get_caret_line()
var line = start_line
var done_func = false
var done_inner = false
while(line > 0 and (!done_func or !done_inner)):
if(editor.can_fold_line(line)):
var text = editor.get_line(line)
var strip_text = text.strip_edges(true, false) # only left
if(!done_func and strip_text.begins_with("func ")):
info.method = _get_func_name_from_line(text)
done_func = true
# If the func line is left justified then there won't be any
# inner classes above it.
if(editor.get_indent_level(line) == 0):
done_inner = true
if(!done_inner and strip_text.begins_with("class")):
var inner_name = _get_class_name_from_line(text)
# See note about inner_class_prefix, this knows too much, but
# if it was to know less it would insanely more difficult
# everywhere.
if(inner_name.begins_with(inner_class_prefix)):
info.inner_class = inner_name
done_inner = true
done_func = true
line -= 1
# print('parsed lines: ', start_line - line, '(', info.inner_class, ':', info.method, ')')
return info
# -------------
# Events
# -------------
# Fired whenever the script changes. This does not fire if you select something
# other than a script from the tree. So if you click a help file and then
# back to the same file, then this will fire for the same script
#
# This can fire multiple times for the same script when a script is opened.
func _on_editor_script_changed(script):
_last_line = -1
_current_script = script
_current_editor_base = _current_script_editor.get_current_editor()
if(_current_editor_base.get_base_editor() is CodeEdit):
_current_editor = _current_editor_base.get_base_editor()
if(!_current_editor.caret_changed.is_connected(_on_caret_changed)):
_current_editor.caret_changed.connect(_on_caret_changed.bind(_current_editor))
else:
_current_editor = null
_editors_for_scripts[script] = _current_editor
_current_script_is_test_script = is_test_script(_current_script)
_handle_caret_location(_current_editor)
func _on_editor_script_close(script):
var script_editor = _editors_for_scripts.get(script, null)
if(script_editor != null):
if(script_editor.caret_changed.is_connected(_on_caret_changed)):
script_editor.caret_changed.disconnect(_on_caret_changed)
_editors_for_scripts.erase(script)
func _on_caret_changed(which):
# Sometimes this is fired for editors that are not the current. I could
# make this fire by saving a file in an external editor. I was unable to
# get useful data out when it wasn't the current editor so I'm only doing
# anything when it is the current editor.
if(which == _current_editor):
_handle_caret_location(which)
func _could_be_test_script(script):
return script.resource_path.get_file().begins_with(script_prefix) and \
script.resource_path.get_file().ends_with(script_suffix)
# -------------
# Public
# -------------
var _scripts_that_have_been_warned_about = []
var _we_have_warned_enough = false
var _max_warnings = 5
func is_test_script(script):
var base = script.get_base_script()
if(base == null and script.get_script_method_list().size() == 0 and _could_be_test_script(script)):
if(OS.is_stdout_verbose() or (!_scripts_that_have_been_warned_about.has(script.resource_path) and !_we_have_warned_enough)):
_scripts_that_have_been_warned_about.append(script.resource_path)
push_warning(str('[GUT] Treating ', script.resource_path, " as test script: ",
"GUT was not able to retrieve information about this script. If this is ",
"a new script you can ignore this warning. Otherwise, this may ",
"have to do with having VSCode open. Restarting Godot sometimes helps. See ",
"https://github.com/bitwes/Gut/issues/754"))
if(!OS.is_stdout_verbose() and _scripts_that_have_been_warned_about.size() >= _max_warnings):
print("[GUT] Disabling warning.")
_we_have_warned_enough = true
# We can't know if this is a test script. It's more usable if we
# assume this is a test script.
return true
else:
while(base and base.resource_path != 'res://addons/gut/test.gd'):
base = base.get_base_script()
return base != null
func get_info():
return _last_info.duplicate()
func log_values():
print("---------------------------------------------------------------")
print("script ", _current_script)
print("script_editor ", _current_script_editor)
print("editor_base ", _current_editor_base)
print("editor ", _current_editor)

View File

@@ -0,0 +1 @@
uid://c110s7a32x4su

193
addons/gut/error_tracker.gd Normal file
View File

@@ -0,0 +1,193 @@
extends Logger
class_name GutErrorTracker
# ------------------------------------------------------------------------------
# Static methods wrap around add/remove logger to make disabling the logger
# easier and to help avoid misusing add/remove in tests. If GUT needs to
# add/remove a logger then this is how it should do it.
# ------------------------------------------------------------------------------
static var registered_loggers := {}
static var register_loggers = true
static func register_logger(which):
if(register_loggers and !registered_loggers.has(which)):
OS.add_logger(which)
registered_loggers[which] = get_stack()
static func deregister_logger(which):
if(registered_loggers.has(which)):
OS.remove_logger(which)
registered_loggers.erase(which)
# ------------------------------------------------------------------------------
# GutErrorTracker
# ------------------------------------------------------------------------------
var _current_test_id = GutUtils.NO_TEST
var _mutex = Mutex.new()
var errors = GutUtils.OneToMany.new()
var treat_gut_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var treat_engine_errors_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var treat_push_error_as : GutUtils.TREAT_AS = GutUtils.TREAT_AS.FAILURE
var disabled = false
# ----------------
#region Private
# ----------------
func _get_stack_data(current_test_name):
var test_entry = {}
var stackTrace = get_stack()
if(stackTrace!=null):
var index = 0
while(index < stackTrace.size() and test_entry == {}):
var line = stackTrace[index]
var function = line.get("function")
if function == current_test_name:
test_entry = stackTrace[index]
else:
index += 1
for i in range(index):
stackTrace.remove_at(0)
return {
"test_entry" = test_entry,
"full_stack" = stackTrace
}
func _is_error_failable(error : GutTrackedError):
var is_it = false
if(error.handled == false):
if(error.is_gut_error()):
is_it = treat_gut_errors_as == GutUtils.TREAT_AS.FAILURE
elif(error.is_push_error()):
is_it = treat_push_error_as == GutUtils.TREAT_AS.FAILURE
elif(error.is_engine_error()):
is_it = treat_engine_errors_as == GutUtils.TREAT_AS.FAILURE
return is_it
# ----------------
#endregion
#region Godot's Logger Overrides
# ----------------
# Godot's Logger virtual method for errors
func _log_error(function: String, file: String, line: int,
code: String, rationale: String, editor_notify: bool,
error_type: int, script_backtraces: Array[ScriptBacktrace]) -> void:
add_error(function, file, line,
code, rationale, editor_notify,
error_type, script_backtraces)
# Godot's Logger virtual method for any output?
# func _log_message(message: String, error: bool) -> void:
# pass
# ----------------
#endregion
#region Public
# ----------------
func start_test(test_id):
_current_test_id = test_id
func end_test():
_current_test_id = GutUtils.NO_TEST
func did_test_error(test_id=_current_test_id):
return errors.size(test_id) > 0
func get_current_test_errors():
return errors.items.get(_current_test_id, [])
# This should look through all the errors for a test and see if a failure
# should happen based off of flags.
func should_test_fail_from_errors(test_id = _current_test_id):
var to_return = false
if(errors.items.has(test_id)):
var errs = errors.items[test_id]
var index = 0
while(index < errs.size() and !to_return):
var error = errs[index]
to_return = _is_error_failable(error)
index += 1
return to_return
func get_errors_for_test(test_id=_current_test_id):
var to_return = []
if(errors.items.has(test_id)):
to_return = errors.items[test_id].duplicate()
return to_return
# Returns emtpy string or text for errors that occurred during the test that
# should cause failure based on this class' flags.
func get_fail_text_for_errors(test_id=_current_test_id) -> String:
var error_texts = []
if(errors.items.has(test_id)):
for error in errors.items[test_id]:
if(_is_error_failable(error)):
error_texts.append(str('<', error.get_error_type_name(), '>', error.code))
var to_return = ""
for i in error_texts.size():
if(to_return != ""):
to_return += "\n"
to_return += str("[", i + 1, "] ", error_texts[i])
return to_return
func add_gut_error(text) -> GutTrackedError:
if(_current_test_id != GutUtils.NO_TEST):
var data = _get_stack_data(_current_test_id)
if(data.test_entry != {}):
return add_error(_current_test_id, data.test_entry.source, data.test_entry.line,
text, '', false,
GutUtils.GUT_ERROR_TYPE, data.full_stack)
return add_error(_current_test_id, "unknown", -1,
text, '', false,
GutUtils.GUT_ERROR_TYPE, get_stack())
func add_error(function: String, file: String, line: int,
code: String, rationale: String, editor_notify: bool,
error_type: int, script_backtraces: Array) -> GutTrackedError:
if(disabled):
return
_mutex.lock()
var err := GutTrackedError.new()
err.backtrace = script_backtraces
err.code = code
err.rationale = rationale
err.error_type = error_type
err.editor_notify = editor_notify
err.file = file
err.function = function
err.line = line
errors.add(_current_test_id, err)
_mutex.unlock()
return err

View File

@@ -0,0 +1 @@
uid://35kxgqotjmlu

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://c8axnpxc0nrk4"
path="res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-Bold.ttf"
dest_files=["res://.godot/imported/AnonymousPro-Bold.ttf-9d8fef4d357af5b52cd60afbe608aa49.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://msst1l2s2s"
path="res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-BoldItalic.ttf"
dest_files=["res://.godot/imported/AnonymousPro-BoldItalic.ttf-4274bf704d3d6b9cd32c4f0754d8c83d.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://hf5rdg67jcwc"
path="res://.godot/imported/AnonymousPro-Italic.ttf-9989590b02137b799e13d570de2a42c1.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-Italic.ttf"
dest_files=["res://.godot/imported/AnonymousPro-Italic.ttf-9989590b02137b799e13d570de2a42c1.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://c6c7gnx36opr0"
path="res://.godot/imported/AnonymousPro-Regular.ttf-856c843fd6f89964d2ca8d8ff1724f13.fontdata"
[deps]
source_file="res://addons/gut/fonts/AnonymousPro-Regular.ttf"
dest_files=["res://.godot/imported/AnonymousPro-Regular.ttf-856c843fd6f89964d2ca8d8ff1724f13.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bhjgpy1dovmyq"
path="res://.godot/imported/CourierPrime-Bold.ttf-1f003c66d63ebed70964e7756f4fa235.fontdata"
[deps]
source_file="res://addons/gut/fonts/CourierPrime-Bold.ttf"
dest_files=["res://.godot/imported/CourierPrime-Bold.ttf-1f003c66d63ebed70964e7756f4fa235.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://n6mxiov5sbgc"
path="res://.godot/imported/CourierPrime-BoldItalic.ttf-65ebcc61dd5e1dfa8f96313da4ad7019.fontdata"
[deps]
source_file="res://addons/gut/fonts/CourierPrime-BoldItalic.ttf"
dest_files=["res://.godot/imported/CourierPrime-BoldItalic.ttf-65ebcc61dd5e1dfa8f96313da4ad7019.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://mcht266g817e"
path="res://.godot/imported/CourierPrime-Italic.ttf-baa9156a73770735a0f72fb20b907112.fontdata"
[deps]
source_file="res://addons/gut/fonts/CourierPrime-Italic.ttf"
dest_files=["res://.godot/imported/CourierPrime-Italic.ttf-baa9156a73770735a0f72fb20b907112.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bnh0lslf4yh87"
path="res://.godot/imported/CourierPrime-Regular.ttf-3babe7e4a7a588dfc9a84c14b4f1fe23.fontdata"
[deps]
source_file="res://addons/gut/fonts/CourierPrime-Regular.ttf"
dest_files=["res://.godot/imported/CourierPrime-Regular.ttf-3babe7e4a7a588dfc9a84c14b4f1fe23.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cmiuntu71oyl3"
path="res://.godot/imported/LobsterTwo-Bold.ttf-7c7f734103b58a32491a4788186f3dcb.fontdata"
[deps]
source_file="res://addons/gut/fonts/LobsterTwo-Bold.ttf"
dest_files=["res://.godot/imported/LobsterTwo-Bold.ttf-7c7f734103b58a32491a4788186f3dcb.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://bll38n2ct6qme"
path="res://.godot/imported/LobsterTwo-BoldItalic.ttf-227406a33e84448e6aa974176016de19.fontdata"
[deps]
source_file="res://addons/gut/fonts/LobsterTwo-BoldItalic.ttf"
dest_files=["res://.godot/imported/LobsterTwo-BoldItalic.ttf-227406a33e84448e6aa974176016de19.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://dis65h8wxc3f2"
path="res://.godot/imported/LobsterTwo-Italic.ttf-f93abf6c25390c85ad5fb6c4ee75159e.fontdata"
[deps]
source_file="res://addons/gut/fonts/LobsterTwo-Italic.ttf"
dest_files=["res://.godot/imported/LobsterTwo-Italic.ttf-f93abf6c25390c85ad5fb6c4ee75159e.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=1
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Some files were not shown because too many files have changed in this diff Show More