commit 8cca00d0da83642a10d4dc6640df703f49de2412 Author: wxm <18854896936@163.com> Date: Mon Jul 6 22:55:14 2026 -0700 Initial project sync diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a74481 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ specific ignores +.godot/ +/android/ +/build/windows/Rthythm_archor_game.exe +.DS_Store +*.import.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb9ff1e --- /dev/null +++ b/README.md @@ -0,0 +1,656 @@ +# Rthythm_archor_game + +`Rthythm_archor_game` 是一个使用 Godot 4.6 开发的 2D 节奏横板动作游戏原型。项目核心来自《Anchor》策划方案:玩家不是用传统方向键自由移动,而是在音乐拍点窗口内输入动作,通过攻击、蓄力、技能、位移、浮空、击退、时间锚点和 Past / Future 双相位来推进战斗。 + +当前版本已经完成可运行的第一关战斗闭环:菜单与难度选择、第一关小怪流程、Boss 房门控、Boss 战入场、HUD、节奏条、时间锚点、攻击 Buff、玩家与敌人动作、Boss 行为、双层音乐、判定门控与输入锁定(反狂按)、Windows 打包链路、自动化回归测试都已接入。 + +## 快速开始 + +### 环境 + +- 引擎:Godot 4.6.x +- 主场景:`res://scenes/main/main.tscn` +- 项目配置:`project.godot` +- 默认音乐:`res://assets/audio/ev_past1.mp3` + +在 Godot 中打开本目录后,运行主场景即可进入游戏流程。 + +以下命令中的 `` 代表 Godot 4.6.3 可执行文件路径。Windows 下请使用 +`Godot_v4.6.3-stable_win64_console.exe`(console 包装器才能看到 stdout 输出; +它按自身文件名寻找同目录的 GUI 版,两个文件必须按原名放在同一目录)。 + +命令行运行单个测试示例: + +```bash + --headless --path . --script tests/test_level1_spawn_flow.gd +``` + +重新导入 / 检查项目资源: + +```bash + --headless --path . --import +``` + +## 基本操作 + +| 输入 | Godot action | 语义 | +| ----- | -------------------------- | ---------------------------------- | +| A | `combo_a` | 左向攻击;长按后释放为左向蓄力剑雨 | +| D | `combo_d` | 右向攻击;长按后释放为右向蓄力剑雨 | +| W | `combo_w` | 上挑、浮空、空中动作入口 | +| S | `combo_s` | 下砸、防御或特殊蓄力入口 | +| Space | `combo_space` | 特殊键,内部符号为 `SP` | +| E | runtime `toggle_move_list` | 显示 / 隐藏招式表 | +| Esc | `ui_cancel` | 游戏中暂停;暂停菜单内返回上一层 | + +注意: + +- A / D / W / S / Space 不直接自由移动角色。每次输入先进入节奏判定,再决定是否执行动作。 +- **判定门控(2026-07-05 new1 定案,反狂按)**:每个节奏点只能被一次有效输入消耗。同一节奏点被消耗后的第 1 次重复按键按双击容错静默忽略(视同没按、无惩罚);第 2 次重复、或落在 BAD 窗之外的空按,判 MISS——不出招、连击清零、四拍槽写 `Ø`,并触发 **0.5 秒输入锁定**。锁定期间的按键完全无效:不判定、不留痕、也不刷新锁定时长。门控只作用于实时判定的输入(测试 / AI 的预置判定绕过)。实现见 `RhythmManager.gate_judged_input`(`input_lockout_seconds = 0.5`、`consumed_repeat_grace = 1`)。 +- 输入锁定若横跨时间锚点拍,该锚点必破(锁定期无法产生维持锚点的判定 → 超时 → 相位切换)。这是有意设计:失误的代价随连击档位升高而变重。 +- W + A / D 会触发带明显横向位移的空中抛物线式动作表现。 +- S 蓄力后按 Space 释放的斩波按玩家当前朝向发射:朝左即向左,不再自动瞄准最近敌人。 +- A / D 蓄力释放的剑雨落点与玩家可见脚底地面线对齐(角色锚点 y=560、可见脚底线 y=520)。 +- A / D 蓄力已进入 armed / charging 状态时按下 Space 会中断蓄力并按四拍槽派生解算(四拍槽仍保有 `[A]`,故 `[A][SP]` 解算为可穿过 Boss 的冲刺斩);S 蓄力不受此规则影响。 +- 右上角没有常驻暂停按钮,暂停只通过 Esc 触发。 + +## 当前主流程 + +```text +Title / DifficultySelect +-> Gameplay_FrontArea +-> 第一关小怪流程 +-> 全部小怪清场 +-> Boss 房路径解封 +-> 玩家进入 Boss 区域 +-> Boss HUD 立即显示,Boss AI 激活 +-> Gameplay_BossRoom +-> Victory / Defeat / Result +``` + +难度选择由 `GameFlowManager` 管理,菜单贴图位于 `assets/ui/menu/`。难度页面点击后会保留选中态,并通过确认按钮进入游戏;返回、暂停、音量、退出确认、结算页面也在同一流程管理器中。 + +标题侧表现: + +- 标题、难度选择、退出确认界面持续播放节奏音乐(`ev_past1.mp3`,走 Music 总线,由 `GameFlowManager` 的常驻播放器承载,不受 SceneTree 暂停影响)。 +- 标题背景在「过去」与「现在」两张场景图之间循环交叉淡入淡出。 +- 点击开始游戏后标题音乐停止,游戏内音乐从第 0 拍重新播放(`RhythmManager.start()` + `AudioLayerController.restart_layers()`)。 + +难度影响: + +| 难度 | 判定窗口(Perfect / Good / Bad 秒) | 小怪血量 | +| ---- | ----------------------------------- | -------- | +| 简单 | 0.090 / 0.160 / 0.260 | ×1(650)| +| 普通 | 0.060 / 0.115 / 0.215 | ×3(1950)| +| 困难 | 0.030 / 0.070 / 0.170 | ×5(3250)| + +判定窗口定义在 `GameSettings.DIFFICULTY_WINDOWS`,经 `apply_judgement_windows` 写入 `RhythmManager`。 + +小怪血量倍率定义在 `GameSettings.DIFFICULTY_MINION_HEALTH_MULTIPLIER`,由 `minion.gd` 在 `_ready` 时读取;Boss 血量不受该倍率影响。 + +## 核心玩法循环 + +当前战斗循环如下: + +```text +RhythmManager 推进音乐时间 +-> ChartRunner 触发谱面事件与预告 +-> 玩家在节奏窗口输入 A / D / W / S / Space +-> InputComponent 创建 InputIntent +-> ActionController 调用 RhythmManager 判定 +-> 判定门控 gate_judged_input:锁定期 / 已消耗节奏点的按键在此被拦下 + (ok = 消耗该节奏点继续;ignored = 视同没按;miss = 记 MISS 并锁定 0.5s) +-> ComboWindow 记录最近四拍输入 +-> ActionResolver 根据四拍槽与角色状态选择 ActionData +-> ActionExecutor 执行动作、消耗或回复能量、打开命中窗口 +-> CombatManager / Resolver 结算伤害、击退、打断、防御、受击 +-> TimeAnchorSystem 根据判定事实结算时间锚点 +-> UI 刷新节奏、判定、combo、Buff、血量、能量、蓄力和四拍槽 +``` + +A / D 蓄力释放与 S + Space 二段施放两条蓄力路径同样经过判定门控。 + +时间锚点不会新增输入按键。同一次玩家输入同时服务于动作系统和锚点系统:动作系统决定是否出招,锚点系统决定是否维持当前时间相位。 + +## 第一关关卡实现 + +实现入口: + +- `scenes/stage/stage.tscn` +- `scenes/stage/stage.gd` +- `scenes/stage/level_director.gd` +- `scenes/stage/boss_room_gate.gd` +- `scenes/enemies/minion_behavior.gd` + +第一关沿用参考工程的角色、Boss、背景、HUD 和相机相对大小,只调整关卡流程、出怪方式、门控和位置关系。玩家、小怪、Boss 的地面锚点统一在 `y = 560`。 + +### 关键坐标 + +| 对象 | 坐标 / 范围 | 说明 | +| ---------------------- | --------------: | ----------------------------------------------- | +| 竞技场左边界 | `x = 1120` | 小怪巡逻 / 后撤左边界 | +| 左侧增援刷新点 | `x = 1420` | 左侧入场起点 | +| 玩家出生点 | `(1180, 560)` | 2026-07-05 new2:地图偏左出生,开场向右推进遇敌 | +| 战斗中心 | `x = 2047` | 增援入场目标基准 | +| 阶段一近战怪 | `(2200, 560)` | 开局预置;玩家从左侧出生点推进约 1000px 后遭遇 | +| 右侧增援刷新点 | `x = 2240` | 右侧入场起点(与小怪右侧安全线同值) | +| 小怪右侧安全线 | `x = 2240` | Boss 空气墙左侧 220px;所有小怪都限制在此线左侧 | +| Boss 空气墙 / 触发参考 | `gate_x = 2460` | 小怪不会越过该区域 | +| Boss 初始锚点 | `(2520, 560)` | Boss 战前 `combat_enabled = false` | +| 相机初始位置 | `(1180, 395)` | 跟随玩家,`camera_follow_offset = (0, -165)` | + +以上均为 `stage.tscn` 的实际覆盖值;`level_director.gd` / `boss_room_gate.gd` 脚本里的 export 默认值会被场景覆盖,以 tscn 为准。 + +`LevelDirector` 会根据 `BossRoomGate.gate_x` 和 `boss_gate_minion_clearance = 220` 自动计算前场最大活动范围。动态刷新的远程怪、近战怪,以及编辑器内预置的小怪,都会统一套用这条安全边界,避免刷到空气墙右侧或相机边缘。 + +### 出怪阶段 + +第一关总计 6 只小怪。 + +| 阶段 | 触发条件 | 内容 | +| ------ | ----------------- | ---------------------------------------------------- | +| 阶段一 | 开局 | 预置近战怪 × 1 | +| 阶段二 | 击败第一只近战怪 | 玩家附近生成远程怪 × 1,生成点可感知但不贴脸 | +| 阶段三 | 击败阶段二远程怪 | 按 2 秒间隔生成 4 只增援:近战L、远程R、近战R、远程L | +| 清场 | 击败全部 6 只小怪 | 解封 Boss 房路径并广播 `front_area_cleared` | + +阶段三的“右侧”增援也不会刷到空气墙右侧;运行时会被夹到前场安全线以内。 + +### Boss 房门控 + +`BossRoomGate` 当前规则: + +- 开局 `sealed = true`,阻挡玩家进入 Boss 区域。 +- 小怪全灭后 `LevelDirector` 调用 `unseal()`,空气墙碰撞关闭,视觉保持透明。 +- 玩家跨过门线(`x >= gate_x`)的瞬间 `lock()` 立即触发:Boss 解锁、面板显示,不需要走到 Boss 面前。 +- `lock()` 只触发一次 Boss 战入场逻辑。 +- 进入 Boss 区域后空气墙不会重新显示,也不会重新启用碰撞,会完全消失。 +- Boss 入场时立即激活 Boss AI,并主动刷新 Boss HUD 绑定。 +- Boss 面板只在 `Gameplay_BossRoom` 显示;前场阶段隐藏,即使 Boss 已被绑定也不会提前显示。 + +相机在 Boss 区域未解锁时会限制右侧视野,避免提前看到 Boss 区域。 + +## 玩家系统 + +实现入口: + +- `scenes/characters/player.tscn` +- `scenes/characters/player.gd` +- `scenes/components/` +- `resources/actions/` +- `resources/player_action_bindings.tres` + +玩家由组件组合而成: + +- `HealthComponent`:生命 +- `EnergyComponent`:能量 +- `ChargeComponent`:蓄力 +- `InputComponent`:输入转 intent +- `ComboWindow`:四拍槽 +- `ActionController`:动作生命周期(含判定门控接入) +- `ActionResolver`:四拍槽 + 角色状态 → ActionData 解算 +- `ActionExecutor`:动作效果执行 +- `MotionExecutor`:动作位移 +- `MovementMotor`:浮空、落地、击退等运动处理 +- `StateMachine`:地面 / 动作 / 防御 / 生死状态轴 +- `DamageEmitter` / `DamageReceiver`:攻击与受击区域 +- `EffectContainer`:Buff / Debuff 容器 +- `StreakCounter`:连续成功动作计数 +- `AttackBuffComponent`:时间锚点攻击 Buff(含回血与受伤掉层) +- `BurstComponent`:爆发动作门槛 +- `FrameCollisionDriver`:帧动画命中框驱动 +- `ContactFxSpawner`:命中接触特效 +- `OverheadChargeSegments`:头顶蓄力段显示 +- `Visual/AttackBuffVisual`、`Visual/FxOverlay` + `Visual/FxAnimationPlayer`:Buff 层数与 FX 贴片表现 + +动作由 `ActionData` 资源驱动,数据包含输入模式、能量、伤害倍率、水平 / 竖直位移、击退、命中类型、Startup / Active / Recovery 拍数、可取消时机、可执行状态、标签、动画名等。 + +当前已接入: + +- 普通左右攻击 +- 上挑与空中动作 +- 下砸 +- 防御 +- Dash / 冲刺斩 +- A / D 长按蓄力剑雨 +- S + SP 二段式蓄力 +- 四拍槽派生动作 +- 攻击命中特效与蓄力释放世界 FX +- 强势 Buff 视觉效果,层数越多表现越明显 + +玩家死亡动画会保持地面线一致;死亡动画结束后尸体会消失。 + +## 小怪系统 + +实现入口: + +- `scenes/enemies/minion.tscn` +- `scenes/enemies/minion.gd` +- `scenes/enemies/minion_behavior.gd` +- `resources/actions/enemies/` +- `resources/time_phase/profile_past_strong_enemy.tres` +- `resources/time_phase/profile_future_strong_enemy.tres` + +小怪分近战与远程两类,并有 Past / Future 两种形态。 + +基础攻击力按职责区分(2026-07-05 new3 定案):**近战 50、远程 25**。由 `LevelDirector` 按 `MinionBehavior.role` 统一写入 `DamageEmitter.damage`(预置怪与动态刷新怪都覆盖),最终伤害仍乘相位强弱倍率。 + +### 近战怪 + +- Past 形态:`jin_zhan_3`,强势 +- Future 形态:`jin_zhan_1`,弱势 +- 会巡逻、警戒、追击、按拍攻击 +- 强势相位 2 拍攻击一次,弱势相位 4 拍攻击一次 +- 距离参数(new3):警戒 360、追踪停步 120、出手距离 120、攻击判定前缘约 140(动作 `range = 130`)、巡逻半径 110 +- 强势相位命中会打断并击退玩家约 86px(`knockback_mult_x = 2.4`);弱势相位命中只掉血 +- 朝向按目标位置正确翻转,不再固定朝右 / 朝左 + +### 远程怪 + +- Past 形态:`yuan_cheng_1`,小女孩,弱势 +- Future 形态:`yuan_cheng_2`,女巫,强势 +- 不巡逻,固定站位射击(站位停步 340、警戒 540) +- **最大开火距离 450,压在相机可视半宽(约 460px)以内——刚进画面即可开火,但绝不从屏幕外攻击玩家** +- 近身后撤:玩家进入危险距离 220 持续 0.6 秒 → 向远离玩家方向退开 220px(贴墙无退路时反向跳过玩家身位);后撤结束按节拍停顿 1 拍再恢复攻击;3 秒冷却 +- 受击脱离(2026-07-05 新增):2 秒链窗内连续受击 4 次 → 0.4 秒闪烁无敌 + 跳跃形式高速脱离(3.2× 速度);3 秒冷却防连续触发。无敌通过 `FrameCollisionDriver.damage_receiver_enabled` 实现(直接改 Area2D 会被驱动器每帧重申覆盖) +- 小女孩和女巫的视觉高度已对齐,两个形态都水平射击,projectile 发射高度一致(`projectile_height = 86`) +- 小女孩素材原生朝右(`native_facing = RIGHT`),镜像翻转以此为准,攻击朝向始终对准玩家 + +小怪形态切换时会保持脚下锚点稳定,避免动画移动过程中 y 轴突变造成视觉瞬移。 + +## Boss 系统 + +实现入口: + +- `scenes/enemies/boss.tscn` +- `scenes/enemies/boss.gd` +- `scenes/enemies/boss_behavior_tree.gd` +- `resources/actions/enemies/` + +Boss 当前支持: + +- 近战连段 +- 远程射击 +- 二连发射击 +- Dash / retreat +- 行为树决策 +- 谱面事件优先级 +- 受击链逃脱(1.2 秒链窗内连续受击 4 次 → 取消当前动作、`boss_retreat_dash` 逃脱;4 秒冷却;训练桩状态不逃脱) +- Past / Future 招式池差异 +- **普攻恒霸体(2026-07-05 new3 定案)**:对不耗能量的攻击(`base_cost <= 0`,即玩家普通攻击)伤害照常结算,但不被打断、不被击退;只有耗能技能能压制 Boss。实现为 `boss.gd` 的 `shrugs_off_hit` + `CombatResolver` 受击方钩子。项目**没有**韧性 / 削韧系统——策划以霸体规则替代了 new3 §6.5 的韧性方案 +- 远程(Future)相位被近身 ≤240px 时优先 `boss_retreat_dash` 拉开距离(后撤本身无伤害、8 拍冷却、后撤完接远程攻击) +- 相位切换后停顿 1 拍再决策(以事件总线最后拍号为基准) +- Boss 战前静默,进入 Boss 区域后才激活 +- Boss 血条、名称和头像 HUD + +行为树距离阈值(2026-07-05 调整后):`melee_distance = 180`、`ranged_distance = 360`、`dash_distance = 460`(超过则考虑突进,4 拍冷却)、远程相位后撤触发 `retreat_trigger_distance = 240`;决策间隔 2 拍(Future 相位 ×0.5)。 + +Boss 入场规则由 `BossRoomGate` 与 `GameFlowManager` 共同处理:玩家进入 Boss 区域后,Boss 从训练桩状态切换为可战斗状态,Boss 面板立即出现。 + +## 时间相位与时间锚点 + +实现入口: + +- `autoload/time_phase_manager.gd` +- `autoload/time_anchor_system.gd` +- `resources/time_anchor_frequency_default.tres` +- `resources/time_phase/` +- `scenes/components/time_phase_adapter.gd` +- `scenes/audio/audio_layer_controller.gd` +- `scenes/ui/rhythm_track.gd` + +世界有两个时间相位: + +```text +past +future +``` + +`TimePhaseManager` 是相位唯一写入者。相位改变后通过 `EventBus.time_phase_changed` 广播,Stage、UI、音乐、敌人强弱、Buff 系统分别响应。 + +时间锚点规则: + +- 时间锚点是特殊节奏拍,不是额外按键。 +- 锚点拍内 `perfect / good / bad` 都能维持当前相位(锚点带 `min_grade` 字段可提高门槛,当前谱面均用默认 `bad`,即三档都维持)。 +- 锚点超时或 Miss 会切换 Past / Future。空按触发的 0.5 秒输入锁定若横跨锚点拍,该锚点必破。 +- `perfect / good` 成功锚定会增加 1 层攻击 Buff。 +- `bad` 只保持相位,不增加攻击 Buff。 +- 当前攻击 Buff 上限为 7 层;每层对 `damage_mult` 加算 +0.5(每层 +50% 伤害,线性叠加,7 层满层 ×4.5)。effect 资源为 `resources/effects/time_phase/effect_time_anchor_attack_buff.tres`。 +- 每次实际相位切换时,Buff 层数减少 2,最低为 0。 +- 锚点成功会按判定质量回血:Perfect 30、Good 20、Bad 10;生命低于 50% 时回血翻倍。回血按 4 拍窗口限一次——同一窗口内第二次锚点成功只加 Buff 不回血(高连击档位下每 4 拍最多 4 个锚点,只有第一个触发回血)。 +- 受伤掉层与霸体:玩家被攻击进入受伤状态时掉落一半 Buff 层数(向下取整),每掉 1 层获得 0.5 秒霸体(SuperArmor:伤害照吃、不被打断、无击退);霸体期间不会再次进入受伤状态,因此不会连锁掉层。实现见 `AttackBuffComponent` + `resources/effects/effect_hurt_super_armor.tres`。 + +连击数会影响时间锚点频率: + +| 连击数 | 每 4 拍锚点数 | +| -----: | ------------: | +| 0 - 7 | 1 | +| 8 - 15 | 2 | +| 16+ | 4 | + +节奏 UI 当前做过可读性强化: + +- 特殊节奏点放大,时间锚点比普通节奏点更明显。 +- 节奏点命中中心后的 flash / hit 特效放大且保留更久。 +- 锚点成功、断裂会使用不同颜色反馈。 +- 节奏点从两侧向中心的移动直接由 `RhythmManager` 的歌曲时间驱动(`get_current_beat_progress`),全程连续丝滑,到达中心的时刻与拍点严格重合。 + +## UI 与 HUD + +实现入口: + +- `scenes/ui/main_ui.tscn` +- `scenes/ui/main_ui.gd` +- `scenes/ui/rhythm_track.tscn` +- `scenes/ui/rhythm_track.gd` +- `scenes/ui/combo_window_hud.tscn` +- `scenes/ui/combo_window_hud.gd` +- `assets/ui/` + +当前 HUD 包含: + +- 玩家状态栏:头像面板、HP、EN、蓄力条、蓄力等级 +- Boss 状态栏:Boss 头像、名称、血条,只在 Boss 战显示 +- 中央节奏条:左右节奏点向中心收束 +- 时间锚点:特殊节奏点纹理与放大显示 +- 判定艺术字:Perfect / Good / Bad / Miss +- combo 计数 +- ATK Buff 读数 +- 7 格 Buff 点亮条 +- 四拍槽 +- E 键招式表 +- 调试面板,默认隐藏 + +HUD 由主场景绑定玩家和 Boss: + +```text +Main + -> Stage + -> ActorsContainer + -> Player + -> Boss + -> UILayer + -> UI +``` + +Boss 面板显示条件是: + +```text +flow state == Gameplay_BossRoom +and boss_actor is valid +``` + +因此前场阶段不会提前显示 Boss 血条;进入 Boss 区域时 `BossRoomGate` 会主动绑定 Boss,确保 HUD 立即可见。 + +## 音频 + +实现入口: + +- `autoload/sfx_manager.gd` +- `scenes/audio/audio_layer_controller.gd` +- `assets/audio/` + +当前音频系统包含: + +- Past / Future 双音乐层同步播放 +- 相位切换时交叉淡入淡出 +- 节奏时钟不因切相位重置 +- 标题侧节奏音乐(`GameFlowManager` 常驻播放器,Music 总线) +- 开局重置:`_reset_run_state()` 调 `RhythmManager.start()` 归零时钟后,立即调 `AudioLayerController.restart_layers()` 把双音轨拉回第 0 拍,不等漂移校正 +- UI、攻击、命中、锚点等事件 SFX 入口 + +当前 Future 层仍使用占位处理,可继续替换为正式 Future 音乐资源。 + +## 谱面与事件 + +实现入口: + +- `resources/beat_chart.gd` +- `resources/chart_track.gd` +- `resources/chart_event.gd` +- `resources/charts/` +- `scenes/chart/chart_runner.gd` + +`BeatChart` 由多个 `ChartTrack` 组成,每个 track 内包含若干 `ChartEvent`。当前事件支持: + +- `enemy_action` +- `show_accent_marker` +- `camera_pulse` +- `time_anchor` +- `force_time_phase` + +`ChartRunner` 负责按音乐时间触发事件、提前广播 upcoming 事件给 UI、根据 `time_phase_mask` 过滤当前相位不可触发事件,并将敌人动作事件路由到目标 Actor。 + +当前谱面资源包括: + +- `resources/charts/stage7_boss_opening.tres` +- `resources/charts/stage9_boss_duel.tres` + +## 战斗结算 + +实现入口: + +- `autoload/combat_manager.gd` +- `scripts/resolvers/stat_resolver.gd` +- `scripts/resolvers/combat_resolver.gd` +- `scripts/resolvers/defense_resolver.gd` +- `scenes/components/damage_emitter.gd` +- `scenes/components/damage_receiver.gd` +- `scenes/components/effect_container.gd` + +战斗结算采用 resolver 管线: + +- `StatResolver` 计算伤害、消耗、回复、动作位移。 +- `CombatResolver` 处理命中结果、击退和命中上下文。 +- `DefenseResolver` 处理防御、格挡、减伤、打断规则。 +- `CombatManager` 负责一次命中事实的缓存、生命扣除、击退、打断、效果事件和 `hit_confirmed` 广播。 + +强弱与打断规则: + +- 只有强势状态小怪能打断玩家。 +- 弱势小怪攻击只结算伤害,不打断、不击退、不取消玩家动作。 +- Boss 恒定视为强势,Boss 攻击可以打断玩家(玩家霸体动作除外)。 +- 强弱状态在动作开始时快照,命中前切相位不会 retroactively 改变本次攻击。 +- 受击方霸体钩子(2026-07-05 新增):`CombatResolver` 结算时调用受击方的 `shrugs_off_hit(action)`,返回 `true` 则本次命中的打断与击退一并取消、伤害照常。目前只有 Boss 实现该钩子(普攻恒霸体、耗能技能可压制)。 +- 击退跟随伤害判定通道:击退判定倍率走 damage 通道(perfect 1.0 / good 0.85 / bad 0.7),且仅在攻击方拥有打断权时结算——弱势攻击与被霸体消化的攻击击退为零;受击方处于 Invincible / SuperArmor 防御态时同样归零。 +- 所有耗能技能都带击退 / 击飞(dash_slash 1.5、combo_finisher 2.0、ground_smash 2.2 + 上挑、zhan_bo 1.0/1.3/1.6、jian_yu 0.8);玩家弹丸由 `ActorsContainer` 注入基准击退 `(120, 304)`。 + +## 目录结构 + +| 路径 | 说明 | +| ---------------------------- | ------------------------------------------------------------ | +| `addons/godot_mcp/` | Godot MCP 插件 | +| `assets/audio/` | 音乐与 SFX | +| `assets/art/ground/` | 地面、远景、中景背景 | +| `assets/art/characters/` | 玩家、Boss、小怪素材 | +| `assets/art/effects/` | 通用特效图集 | +| `assets/ui/menu/` | 标题、难度、暂停、确认等菜单贴图 | +| `assets/ui/panels/` | 玩家与 Boss 状态栏面板、Boss 头像 | +| `assets/ui/rhythm/` | 节奏条、锚点、节奏点、中心纹理 | +| `assets/ui/buff/` | Buff 点亮 / 未点亮图标 | +| `assets/ui/buff_effect/` | 玩家攻击 Buff 特效帧 | +| `assets/ui/combo/` | combo 数字素材 | +| `assets/ui/judgements/` | Perfect / Good / Bad / Miss 判定艺术字素材 | +| `assets/ui/strong_buff/` | 强势状态视觉特效 | +| `autoload/` | 9 个游戏单例:事件总线、节奏、难度设置、统计、战斗、相位、锚点、音效、流程 | +| `build/windows/` | Windows 打包产物(`Rthythm_archor_game.exe`,单文件内嵌 pck) | +| `docs/` | 策划、架构、第一关设计和决策文档 | +| `resources/actions/` | 玩家动作数据 | +| `resources/actions/enemies/` | 敌人与 Boss 动作数据 | +| `resources/charts/` | 谱面资源 | +| `resources/effects/` | Buff / Debuff / 相位效果 | +| `resources/time_phase/` | Past / Future 强弱 profile | +| `scenes/audio/` | 双音乐层控制 | +| `scenes/characters/` | 玩家与角色基类 | +| `scenes/chart/` | 谱面运行器 | +| `scenes/combat/` | projectile、一次性 FX、动作解析 | +| `scenes/components/` | 可复用 Actor 组件 | +| `scenes/enemies/` | 小怪、Boss、行为树与 AI | +| `scenes/ground/` | 地面与背景场景 | +| `scenes/main/` | 主场景入口 | +| `scenes/stage/` | 关卡、相机、门控、出怪导演、projectile 生成 | +| `scenes/ui/` | HUD、节奏条、能量条、四拍槽 | +| `scripts/resolvers/` | 战斗、数值、防御、动作规则 resolver | +| `tests/` | SceneTree 自动化测试(66 个) | +| `tools/` | 内容导出、谱面验证和模拟工具 | +| `export_presets.cfg` | "Windows Desktop" 导出预设(x86_64 / embed_pck 单 exe) | + +## 自动化测试 + +项目包含 66 个 Godot SceneTree 测试,覆盖: + +- 架构迁移契约 +- 输入 intent 与节奏判定 payload +- 判定门控与输入锁定(节奏点一次性消耗、双击容错、MISS + 0.5s 锁定) +- 动作控制、四拍槽、蓄力、能量 +- 战斗 resolver、Effect、强弱打断规则 +- new3 怪物 / Boss 行为规则(Boss 霸体、按职责基础攻击、远程怪受击脱离) +- 玩家场景、动画节奏、死亡表现、剑雨落点 +- 小怪节拍、朝向、远程射击、高度、行为状态机 +- 第一关出怪流程与 Boss 房门控 +- Boss 行为、Boss HUD、Boss projectile +- 菜单、难度选择、暂停、GameFlow +- 时间相位、时间锚点、攻击 Buff +- HUD 布局、节奏条、E 键招式表、UI 动画回归 + +常用回归命令(`` 见「快速开始」): + +```bash + --headless --path . --script tests/test_input_gate.gd + --headless --path . --script tests/test_new3_enemy_rules.gd + --headless --path . --script tests/test_level1_spawn_flow.gd + --headless --path . --script tests/test_boss_room_gate.gd + --headless --path . --script tests/test_boss_room_entry_hud_and_wall.gd + --headless --path . --script tests/test_minion_behavior_states.gd + --headless --path . --script tests/test_minion_cadence_and_facing.gd + --headless --path . --script tests/test_player2_fx_and_witch_projectile.gd + --headless --path . --script tests/test_rhythm_feedback_and_move_list.gd + --headless --path . --script tests/test_phase9_boss_health_ui.gd + --headless --path . --script tests/test_title_music_and_background.gd + --headless --path . --script tests/test_difficulty_minion_health.gd + --headless --path . --script tests/test_hurt_buff_drop_and_super_armor.gd + --headless --path . --script tests/test_blade_rain_alignment.gd +``` + +也可以串行跑一组关键回归: + +```bash +GODOT= +for test_file in \ + tests/test_input_gate.gd \ + tests/test_new3_enemy_rules.gd \ + tests/test_game_flow.gd \ + tests/test_level1_spawn_flow.gd \ + tests/test_boss_room_gate.gd \ + tests/test_boss_room_entry_hud_and_wall.gd \ + tests/test_minion_behavior_states.gd \ + tests/test_level1_melee_contact.gd \ + tests/test_boss_integration.gd \ + tests/test_minion_phase_system.gd \ + tests/test_main_ui_camera_canvas.gd \ + tests/test_phase9_boss_health_ui.gd \ + tests/test_rhythm_ui_layout.gd \ + tests/test_x1_visual_baseline.gd \ + tests/test_player_combo_input.gd \ + tests/test_combo_hud.gd \ + tests/test_blade_rain_alignment.gd +do + "$GODOT" --headless --path . --script "$test_file" || exit $? +done +``` + +已知存量失败:`test_stage4_player_scene`(期望旧版 2 倍 Visual 缩放的历史基线,与当前实现无关,排查回归时可忽略)。 + +部分测试在加载资源时可能打印 Godot 的 UID fallback 或 MCP port warning;如果最终输出 `PASS ...` 且退出码为 0,则测试通过。 + +## 设计文档 + +| 文档 | 说明 | +| ---------------------- | -------------------------------------- | +| `docs/AnchorV1.0.md` | 核心策划方案 | +| `docs/Level1Design.md` | 第一关关卡与小怪行为设计 | +| `docs/new1.md` | 策划需求:节奏输入有效范围(判定门控) | +| `docs/new2.md` | 策划需求:标题固定与出生点左移 | +| `docs/new3.md` | 策划需求:怪物与 Boss 行为设定 | +| `docs/decisions.md` | 迁移、调试、玩法手感和技术决策日志 | + +注意:`new1.md` / `new3.md` 的原文与最终实现有出入(例如 new1 的"零代价无效输入"被改为"MISS + 锁定"、new3 的韧性系统被霸体规则替代、720 射程被否决)——最终口径以 `docs/decisions.md` 顶部的 "2026-07-05 new1/new2/new3 需求落地" 条目为准。 + +## 打包与发布 + +项目已具备完整的 Windows 打包链路: + +- 导出预设:`export_presets.cfg`(已入库)——预设名 `"Windows Desktop"`,x86_64,`embed_pck = true`(单 exe 内嵌资源包)。 +- 产物路径:`build/windows/Rthythm_archor_game.exe`(约 143MB)。 +- 前提:Godot 4.6.3-stable 导出模板需安装在 `%APPDATA%\Godot\export_templates\4.6.3.stable\`(目录名必须精确匹配引擎版本号去掉 `.official.` 后缀;升级引擎版本时需换用对应版本的模板包)。 + +重新打包只需两条命令: + +```bash + --headless --path . --import + --headless --path . --export-release "Windows Desktop" "build/windows/Rthythm_archor_game.exe" +``` + +打包注意事项:导出包内 `DirAccess` 列出的资源名带 `.remap` / `.import` 后缀,运行时扫描 `res://` 的代码必须去后缀还原原名再 `load()` 并去重(`action_resolver.gd` 等三处已按此模式修复);新增运行时扫描代码时务必遵守,更稳的做法是预加载资源列表。 + +## Godot MCP + +项目包含 Godot MCP 插件: + +- `addons/godot_mcp/` +- `project.godot` 中已启用插件 +- Autoload 注册了 `MCPScreenshot`、`MCPInputService`、`MCPGameInspector` + +这允许外部工具对 Godot 运行场景进行截图、输入模拟、节点检查和自动化测试。命令行 headless import 时如果已有其他项目占用 MCP 端口,可能出现 project mismatch warning;通常不影响导入或测试结果。 + +## 当前实现状态 + +已实现: + +- 标题、难度选择、暂停、确认、胜负和结算流程 +- 第一关 6 小怪流程与 Boss 房解锁 +- 玩家从地图偏左出生(x=1180),开场向右推进遇敌(2026-07-05 new2) +- 判定门控与输入锁定:节奏点一次性消耗、双击容错、空按 MISS + 0.5 秒锁定(2026-07-05 new1 定案) +- 小怪全部限制在 Boss 空气墙左侧安全区 +- Boss 区域入场后 Boss HUD 立即显示 +- Boss 区域空气墙进入后完全消失,不再显示或阻挡 +- 玩家节奏输入、四拍槽、动作执行 +- 能量、蓄力、技能释放与剑雨落点对齐 +- 玩家 projectile 与敌人 projectile +- 小女孩 / 女巫高度对齐,远程水平射击 +- 小怪节拍攻击、朝向、强弱打断规则 +- new3 怪物 / Boss 行为体系:近战基础攻击 50 / 远程 25、距离参数重调、远程怪受击 4 次闪烁无敌跳跃脱离、射程压到视距内、Boss 普攻恒霸体 + 耗能技能压制、Boss 远程相位近身后撤、相位切换停顿(2026-07-05) +- Boss 行为树与谱面驱动动作 +- 命中、伤害、击退、打断、防御和生命结算 +- Effect / Buff / Debuff 管线 +- Past / Future 时间相位 +- 时间锚点调度、结算和放大视觉反馈 +- 攻击 Buff 7 层显示、伤害加成和玩家特效增强 +- 双音乐层同步切换 +- 世界色调与径向相位切换 wipe +- HUD:节奏条、combo、ATK、Buff、判定艺术字、状态栏、Boss 栏、四拍槽、E 键招式表 +- Windows 打包链路(export_presets.cfg + 单 exe 内嵌 pck,打包版已做端到端验证) +- 自动化测试覆盖关键系统(66 个测试) + +仍可继续打磨: + +- 替换正式 Future 音乐层 +- 继续扩展 Boss 相位差异和条件锚点 +- 增加更多正式谱面与战斗段落 +- 继续调优 HUD 美术尺寸、透明度和可读性 +- 扩展小怪组合与关卡后续段落 +- 播测校准 2026-07-05 数值:近战小怪强势单跳约 38、Boss 近战翻倍后的压力、高连击档位下单次失误(断连击 + 锁定 + 锚点必破)的代价 +- 进战斗场景瞬间批量启停碰撞体会刷非致命的 "flushing queries" 物理警告,可改 deferred 消除 + +## 开发约定 + +- `EventBus` 是系统间事实广播中心,避免跨系统硬引用。 +- `TimePhaseManager` 是时间相位唯一写入者。 +- `TimeAnchorSystem` 只消费判定事实,不参与输入链路裁决。 +- `GameFlowManager` 管理菜单、暂停、难度、胜负和 BossRoom 状态。 +- 动作数值优先写入 `ActionData` 资源,不硬编码在角色脚本中。 +- Buff / Debuff 优先走 `EffectContainer` 和 effect 资源。 +- 战斗数值通过 resolver 计算,减少场景脚本间耦合。 +- UI 尽量订阅事实信号,不主动推导战斗状态。 +- 关卡坐标优先通过 `Stage` / `LevelDirector` 导出参数表达,并补测试锁定。 +- 新功能或 bugfix 应补充对应 `tests/test_*.gd` 回归测试。 \ No newline at end of file diff --git a/addons/godot_mcp/command_router.gd b/addons/godot_mcp/command_router.gd new file mode 100644 index 0000000..65d6268 --- /dev/null +++ b/addons/godot_mcp/command_router.gd @@ -0,0 +1,120 @@ +@tool +extends Node + +var editor_plugin: EditorPlugin + +var _command_handlers: Dictionary = {} # method_name -> Callable +var _disabled_tools: Dictionary = {} # method_name -> true + +const TOOL_CONFIG_PATH := "user://mcp_tool_config.cfg" + + +func _ready() -> void: + _load_tool_config() + _register_commands() + + +func _register_commands() -> void: + var command_classes := [ + preload("res://addons/godot_mcp/commands/project_commands.gd"), + preload("res://addons/godot_mcp/commands/scene_commands.gd"), + preload("res://addons/godot_mcp/commands/node_commands.gd"), + preload("res://addons/godot_mcp/commands/script_commands.gd"), + preload("res://addons/godot_mcp/commands/editor_commands.gd"), + preload("res://addons/godot_mcp/commands/input_commands.gd"), + preload("res://addons/godot_mcp/commands/runtime_commands.gd"), + preload("res://addons/godot_mcp/commands/animation_commands.gd"), + preload("res://addons/godot_mcp/commands/tilemap_commands.gd"), + preload("res://addons/godot_mcp/commands/theme_commands.gd"), + preload("res://addons/godot_mcp/commands/profiling_commands.gd"), + preload("res://addons/godot_mcp/commands/batch_commands.gd"), + preload("res://addons/godot_mcp/commands/shader_commands.gd"), + preload("res://addons/godot_mcp/commands/export_commands.gd"), + preload("res://addons/godot_mcp/commands/resource_commands.gd"), + preload("res://addons/godot_mcp/commands/input_map_commands.gd"), + preload("res://addons/godot_mcp/commands/scene_3d_commands.gd"), + preload("res://addons/godot_mcp/commands/physics_commands.gd"), + preload("res://addons/godot_mcp/commands/analysis_commands.gd"), + preload("res://addons/godot_mcp/commands/animation_tree_commands.gd"), + preload("res://addons/godot_mcp/commands/audio_commands.gd"), + preload("res://addons/godot_mcp/commands/navigation_commands.gd"), + preload("res://addons/godot_mcp/commands/particle_commands.gd"), + preload("res://addons/godot_mcp/commands/test_commands.gd"), + preload("res://addons/godot_mcp/commands/android_commands.gd"), + ] + + for cmd_class in command_classes: + var cmd: Node = cmd_class.new() + cmd.editor_plugin = editor_plugin + add_child(cmd) + var methods: Dictionary = cmd.get_commands() + for method_name: String in methods: + _command_handlers[method_name] = methods[method_name] + + print("[MCP] Registered %d commands" % _command_handlers.size()) + + +func execute(method: String, params: Dictionary) -> Dictionary: + if not _command_handlers.has(method): + return { + "error": { + "code": -32601, + "message": "Method not found: %s" % method, + "data": {"available_methods": _command_handlers.keys()} + } + } + + if _disabled_tools.has(method): + return { + "error": { + "code": -32603, + "message": "Tool '%s' is disabled in MCP Server settings" % method + } + } + + var handler: Callable = _command_handlers[method] + var result: Dictionary = await handler.call(params) + return result + + +func get_available_methods() -> Array: + return _command_handlers.keys() + + +func is_tool_disabled(method: String) -> bool: + return _disabled_tools.has(method) + + +func set_tool_disabled(method: String, disabled: bool) -> void: + if disabled: + _disabled_tools[method] = true + else: + _disabled_tools.erase(method) + _save_tool_config() + + +func set_all_tools_disabled(disabled: bool) -> void: + if disabled: + for method: String in _command_handlers: + _disabled_tools[method] = true + else: + _disabled_tools.clear() + _save_tool_config() + + +func _load_tool_config() -> void: + var cfg := ConfigFile.new() + if cfg.load(TOOL_CONFIG_PATH) != OK: + return + if not cfg.has_section("disabled_tools"): + return + for method: String in cfg.get_section_keys("disabled_tools"): + if cfg.get_value("disabled_tools", method, false): + _disabled_tools[method] = true + + +func _save_tool_config() -> void: + var cfg := ConfigFile.new() + for method: String in _disabled_tools: + cfg.set_value("disabled_tools", method, true) + cfg.save(TOOL_CONFIG_PATH) diff --git a/addons/godot_mcp/command_router.gd.uid b/addons/godot_mcp/command_router.gd.uid new file mode 100644 index 0000000..287d35f --- /dev/null +++ b/addons/godot_mcp/command_router.gd.uid @@ -0,0 +1 @@ +uid://csmce0ikcxvhv diff --git a/addons/godot_mcp/commands/analysis_commands.gd b/addons/godot_mcp/commands/analysis_commands.gd new file mode 100644 index 0000000..f767a8e --- /dev/null +++ b/addons/godot_mcp/commands/analysis_commands.gd @@ -0,0 +1,518 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "find_unused_resources": _find_unused_resources, + "analyze_signal_flow": _analyze_signal_flow, + "analyze_scene_complexity": _analyze_scene_complexity, + "find_script_references": _find_script_references, + "detect_circular_dependencies": _detect_circular_dependencies, + "get_project_statistics": _get_project_statistics, + } + + +# ============================================================================= +# find_unused_resources +# ============================================================================= + +## Scan project for resources not referenced by any .tscn, .gd, or .tres file. +func _find_unused_resources(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + # Step 1: Collect all resource files + var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg", + "wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material", + "theme", "stylebox", "font", "anim"] + var all_resources: Array = [] + _collect_files_by_ext(path, resource_extensions, all_resources, include_addons) + + # Step 2: Collect all referencing files (.tscn, .gd, .tres) + var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] + var ref_files: Array = [] + _collect_files_by_ext(path, ref_extensions, ref_files, include_addons) + + # Step 3: Build a set of all referenced paths + var referenced: Dictionary = {} # path -> true + for ref_file in ref_files: + var content := _read_file_text(ref_file as String) + if content.is_empty(): + continue + # Find res:// paths in file content + var idx := 0 + while idx < content.length(): + var found := content.find("res://", idx) + if found == -1: + break + # Extract the path (up to quote, space, or end of line) + var end := found + 6 + while end < content.length(): + var c := content[end] + if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}': + break + end += 1 + var ref_path := content.substr(found, end - found) + referenced[ref_path] = true + idx = end + + # Step 4: Find unreferenced resources + var unused: Array = [] + for res_path in all_resources: + var p: String = res_path + if not referenced.has(p): + # Also check without uid:// prefix variants — some references use uid + unused.append(p) + + return success({ + "unused_resources": unused, + "unused_count": unused.size(), + "total_resources_scanned": all_resources.size(), + "total_files_checked": ref_files.size(), + }) + + +# ============================================================================= +# analyze_signal_flow +# ============================================================================= + +## Map all signal connections in the currently edited scene. +func _analyze_signal_flow(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var nodes_data: Array = [] + _collect_signal_data(root, root, nodes_data) + + return success({ + "scene": root.scene_file_path, + "nodes": nodes_data, + "total_nodes": nodes_data.size(), + }) + + +func _collect_signal_data(node: Node, root: Node, out: Array) -> void: + var node_path := str(root.get_path_to(node)) + var signals_emitted: Array = [] + var signals_connected_to: Array = [] + + # Get all signals this node defines + for sig in node.get_signal_list(): + var sig_name: String = sig["name"] + var connections := node.get_signal_connection_list(sig_name) + if connections.size() > 0: + var targets: Array = [] + for conn in connections: + var callable: Callable = conn["callable"] + var target_node: Node = callable.get_object() as Node + var target_path := "" + if target_node != null: + target_path = str(root.get_path_to(target_node)) + targets.append({ + "target_node": target_path, + "method": callable.get_method(), + }) + # Also record on the target side + signals_connected_to.append({ + "from_node": node_path, + "signal": sig_name, + "method": callable.get_method(), + }) + signals_emitted.append({ + "signal": sig_name, + "targets": targets, + }) + + # Only include nodes that have signal activity + if signals_emitted.size() > 0 or signals_connected_to.size() > 0: + out.append({ + "name": node.name, + "path": node_path, + "type": node.get_class(), + "signals_emitted": signals_emitted, + "signals_connected_to": signals_connected_to, + }) + + for child in node.get_children(): + _collect_signal_data(child, root, out) + + +# ============================================================================= +# analyze_scene_complexity +# ============================================================================= + +## Analyze a scene's complexity: node count, depth, types, scripts, potential issues. +func _analyze_scene_complexity(params: Dictionary) -> Dictionary: + var scene_path: String = optional_string(params, "path", "") + + var root: Node = null + if scene_path.is_empty(): + root = get_edited_root() + if root == null: + return error_no_scene() + scene_path = root.scene_file_path + else: + if not ResourceLoader.exists(scene_path): + return error_not_found("Scene '%s'" % scene_path) + var packed := ResourceLoader.load(scene_path) as PackedScene + if packed == null: + return error_internal("Failed to load scene: %s" % scene_path) + root = packed.instantiate() + + var total_nodes := 0 + var max_depth := 0 + var types: Dictionary = {} # class_name -> count + var scripts_attached: Array = [] + var resources_used: Dictionary = {} # resource path -> count + var issues: Array = [] + + _analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used) + + # Count totals from recursive walk + total_nodes = _count_nodes_recursive(root) + max_depth = _get_max_depth(root, 0) + + # Detect potential issues + if total_nodes > 1000: + issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes}) + elif total_nodes > 500: + issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes}) + + if max_depth > 15: + issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth}) + elif max_depth > 10: + issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth}) + + # If we instantiated the scene ourselves, free it + if not scene_path.is_empty() and root != get_edited_root(): + root.queue_free() + + return success({ + "scene_path": scene_path, + "total_nodes": total_nodes, + "max_depth": max_depth, + "nodes_by_type": types, + "scripts_attached": scripts_attached, + "unique_resource_count": resources_used.size(), + "issues": issues, + }) + + +func _analyze_node(node: Node, root: Node, depth: int, + total_nodes: int, max_depth: int, + types: Dictionary, scripts: Array, resources: Dictionary) -> void: + var type_name := node.get_class() + types[type_name] = types.get(type_name, 0) + 1 + + if node.get_script() != null: + var script: Script = node.get_script() + var script_path := script.resource_path + if not script_path.is_empty(): + scripts.append({ + "node": str(root.get_path_to(node)), + "script": script_path, + }) + + for child in node.get_children(): + _analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources) + + +func _count_nodes_recursive(node: Node) -> int: + var count := 1 + for child in node.get_children(): + count += _count_nodes_recursive(child) + return count + + +func _get_max_depth(node: Node, current_depth: int) -> int: + var max_d := current_depth + for child in node.get_children(): + var child_depth := _get_max_depth(child, current_depth + 1) + if child_depth > max_d: + max_d = child_depth + return max_d + + +# ============================================================================= +# find_script_references +# ============================================================================= + +## Find all places where a given script, class_name, or resource path is used. +func _find_script_references(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"] + var search_files: Array = [] + _collect_files_by_ext(path, search_extensions, search_files, include_addons) + + var references: Array = [] + for file_path in search_files: + var fp: String = file_path + var content := _read_file_text(fp) + if content.is_empty(): + continue + + var lines := content.split("\n") + var line_num := 0 + for line in lines: + line_num += 1 + var l: String = line + if l.contains(query): + references.append({ + "file": fp, + "line": line_num, + "content": l.strip_edges(), + }) + + return success({ + "query": query, + "references": references, + "reference_count": references.size(), + "files_searched": search_files.size(), + }) + + +# ============================================================================= +# detect_circular_dependencies +# ============================================================================= + +## Check for circular scene dependencies (.tscn files referencing each other). +func _detect_circular_dependencies(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + # Step 1: Collect all .tscn files + var tscn_files: Array = [] + _collect_files_by_ext(path, ["tscn"], tscn_files, include_addons) + + # Step 2: Build dependency graph: scene_path -> [referenced_scene_paths] + var dep_graph: Dictionary = {} # String -> Array[String] + for tscn_path in tscn_files: + var tp: String = tscn_path + var content := _read_file_text(tp) + if content.is_empty(): + continue + + var deps: Array = [] + for line in content.split("\n"): + var l: String = line + # Match [ext_resource ... path="res://..." ...] lines that reference .tscn + if l.begins_with("[ext_resource") and ".tscn" in l: + var path_start := l.find('path="') + if path_start == -1: + continue + path_start += 6 # len('path="') + var path_end := l.find('"', path_start) + if path_end == -1: + continue + var ref_path := l.substr(path_start, path_end - path_start) + if ref_path.ends_with(".tscn"): + deps.append(ref_path) + dep_graph[tp] = deps + + # Step 3: Detect cycles using DFS + var cycles: Array = [] + var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited" + for scene in dep_graph: + visited[scene] = "unvisited" + + for scene in dep_graph: + if visited[scene] == "unvisited": + var path_stack: Array = [] + _dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles) + + return success({ + "scenes_checked": tscn_files.size(), + "circular_dependencies": cycles, + "has_circular": cycles.size() > 0, + "dependency_graph": dep_graph, + }) + + +func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary, + path_stack: Array, cycles: Array) -> void: + visited[node] = "visiting" + path_stack.append(node) + + if graph.has(node): + var deps: Array = graph[node] + for dep in deps: + var d: String = dep + if not visited.has(d): + # Scene referenced but not in our graph (might not exist or outside scope) + continue + if visited[d] == "visiting": + # Found a cycle — extract it from the stack + var cycle_start := path_stack.find(d) + var cycle: Array = path_stack.slice(cycle_start) + cycle.append(d) # Close the cycle + cycles.append(cycle) + elif visited[d] == "unvisited": + _dfs_detect_cycle(d, graph, visited, path_stack, cycles) + + path_stack.pop_back() + visited[node] = "visited" + + +# ============================================================================= +# get_project_statistics +# ============================================================================= + +## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins. +func _get_project_statistics(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var include_addons: bool = optional_bool(params, "include_addons", false) + + var file_counts: Dictionary = {} # extension -> count + var total_script_lines := 0 + var scene_count := 0 + var resource_count := 0 + var total_files := 0 + + _collect_statistics(path, include_addons, file_counts) + + # Extract internal counters and remove them from the visible dict + total_script_lines = int(file_counts.get("_total_script_lines", 0)) + scene_count = int(file_counts.get("_scene_count", 0)) + resource_count = int(file_counts.get("_resource_count", 0)) + total_files = int(file_counts.get("_total_files", 0)) + file_counts.erase("_total_script_lines") + file_counts.erase("_scene_count") + file_counts.erase("_resource_count") + file_counts.erase("_total_files") + + # Autoloads + var autoloads: Dictionary = {} + for prop in ProjectSettings.get_property_list(): + var prop_name: String = prop["name"] + if prop_name.begins_with("autoload/"): + autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name)) + + # Enabled plugins + var plugins: Array = [] + var plugin_cfg_path := "res://addons" + var enabled_plugins: PackedStringArray = ProjectSettings.get_setting( + "editor_plugins/enabled", PackedStringArray() + ) + var plugin_dir := DirAccess.open(plugin_cfg_path) + if plugin_dir != null: + plugin_dir.list_dir_begin() + var dir_name := plugin_dir.get_next() + while not dir_name.is_empty(): + if plugin_dir.current_is_dir() and not dir_name.begins_with("."): + var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg") + if FileAccess.file_exists(cfg_path): + var plugin_path := "res://addons/%s/plugin.cfg" % dir_name + plugins.append({ + "name": dir_name, + "enabled": plugin_path in enabled_plugins, + }) + dir_name = plugin_dir.get_next() + plugin_dir.list_dir_end() + + return success({ + "file_counts_by_extension": file_counts, + "total_files": total_files, + "total_script_lines": total_script_lines, + "scene_count": scene_count, + "resource_count": resource_count, + "autoloads": autoloads, + "plugins": plugins, + }) + + +func _collect_statistics(path: String, include_addons: bool, + file_counts: Dictionary) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if file_name == "addons" and not include_addons: + file_name = dir.get_next() + continue + _collect_statistics(full_path, include_addons, file_counts) + else: + var ext := file_name.get_extension().to_lower() + file_counts[ext] = file_counts.get(ext, 0) + 1 + + if ext == "gd": + var content := _read_file_text(full_path) + var line_count := content.count("\n") + 1 if not content.is_empty() else 0 + # We can't modify int params, so we store in the dict + file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count + + if ext == "tscn": + file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1 + + if ext in ["tres", "material", "theme", "stylebox", "font"]: + file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1 + + file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1 + + file_name = dir.get_next() + + dir.list_dir_end() + + +# ============================================================================= +# Shared helpers +# ============================================================================= + +## Recursively collect files matching given extensions. +func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if file_name == "addons" and not include_addons: + file_name = dir.get_next() + continue + _collect_files_by_ext(full_path, extensions, out, include_addons) + else: + var ext := file_name.get_extension().to_lower() + if ext in extensions: + out.append(full_path) + + file_name = dir.get_next() + + dir.list_dir_end() + + +## Read a file's text content. Returns empty string on failure. +func _read_file_text(file_path: String) -> String: + var file := FileAccess.open(file_path, FileAccess.READ) + if file == null: + return "" + var content := file.get_as_text() + file.close() + return content diff --git a/addons/godot_mcp/commands/analysis_commands.gd.uid b/addons/godot_mcp/commands/analysis_commands.gd.uid new file mode 100644 index 0000000..7773ab9 --- /dev/null +++ b/addons/godot_mcp/commands/analysis_commands.gd.uid @@ -0,0 +1 @@ +uid://do5j22vn55a4h diff --git a/addons/godot_mcp/commands/android_commands.gd b/addons/godot_mcp/commands/android_commands.gd new file mode 100644 index 0000000..898d645 --- /dev/null +++ b/addons/godot_mcp/commands/android_commands.gd @@ -0,0 +1,192 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_android_devices": _list_android_devices, + "get_android_preset_info": _get_android_preset_info, + "deploy_to_android": _deploy_to_android, + } + + +## Resolve adb path from editor settings or PATH fallback. +func _resolve_adb_path() -> String: + var editor_settings := get_editor().get_editor_settings() + # Godot exposes this under export/android/adb (may be stored as an absolute path). + var configured: String = "" + if editor_settings.has_setting("export/android/adb"): + configured = str(editor_settings.get_setting("export/android/adb")) + if not configured.is_empty() and FileAccess.file_exists(configured): + return configured + # Fallback: assume adb is on PATH. OS.execute will resolve it at call time. + return "adb" + + +func _run(cmd: String, args: PackedStringArray) -> Dictionary: + var output: Array = [] + var exit_code := OS.execute(cmd, args, output, true) + var stdout := "" + if not output.is_empty(): + stdout = str(output[0]) + return {"exit_code": exit_code, "stdout": stdout} + + +## List devices visible to adb. +func _list_android_devices(_params: Dictionary) -> Dictionary: + var adb := _resolve_adb_path() + var result := _run(adb, PackedStringArray(["devices", "-l"])) + if result["exit_code"] != 0: + return error(-32000, "adb failed (exit %d). Install Android platform-tools or set Editor Settings > Export > Android > Adb." % result["exit_code"], {"adb_path": adb, "output": result["stdout"]}) + + # Parse `adb devices -l` output: + # List of devices attached + # R58M12345 device usb:3-1 product:foo model:Pixel_5 device:redfin + var devices: Array = [] + var lines: PackedStringArray = str(result["stdout"]).split("\n") + for raw_line in lines: + var line: String = raw_line.strip_edges() + if line.is_empty() or line.begins_with("List of devices") or line.begins_with("* daemon"): + continue + var parts: PackedStringArray = line.split(" ", false) + if parts.size() < 2: + continue + var dev: Dictionary = {"serial": parts[0], "state": parts[1]} + for i in range(2, parts.size()): + var kv: String = parts[i] + var eq: int = kv.find(":") + if eq > 0: + dev[kv.substr(0, eq)] = kv.substr(eq + 1) + devices.append(dev) + + return success({"devices": devices, "count": devices.size(), "adb_path": adb}) + + +## Find an Android preset in export_presets.cfg. Returns the preset dict or null. +func _find_android_preset(preset_name: String, preset_index: int) -> Dictionary: + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return {} + var cfg := ConfigFile.new() + if cfg.load(presets_path) != OK: + return {} + + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + var platform := str(cfg.get_value(section, "platform", "")) + var name := str(cfg.get_value(section, "name", "")) + var matches := false + if not preset_name.is_empty(): + matches = (name == preset_name) + elif preset_index >= 0: + matches = (idx == preset_index) + else: + # No filter: pick the first Android preset. + matches = (platform == "Android") + if matches: + var options_section := "preset.%d.options" % idx + var package_name := "" + if cfg.has_section(options_section): + package_name = str(cfg.get_value(options_section, "package/unique_name", "")) + return { + "index": idx, + "name": name, + "platform": platform, + "runnable": bool(cfg.get_value(section, "runnable", false)), + "export_path": str(cfg.get_value(section, "export_path", "")), + "package_name": package_name, + } + idx += 1 + return {} + + +## Read Android preset metadata (package name, export path, etc.) +func _get_android_preset_info(params: Dictionary) -> Dictionary: + var preset_name: String = optional_string(params, "preset_name", "") + var preset_index: int = optional_int(params, "preset_index", -1) + var preset := _find_android_preset(preset_name, preset_index) + if preset.is_empty(): + return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.") + if preset["platform"] != "Android": + return error(-32000, "Preset '%s' is not an Android preset (platform=%s)" % [preset["name"], preset["platform"]]) + return success(preset) + + +## Export APK, install it on a device, then optionally launch the main activity. +func _deploy_to_android(params: Dictionary) -> Dictionary: + var preset_name: String = optional_string(params, "preset_name", "") + var preset_index: int = optional_int(params, "preset_index", -1) + var device_serial: String = optional_string(params, "device_serial", "") + var debug: bool = optional_bool(params, "debug", true) + var launch: bool = optional_bool(params, "launch", true) + var skip_export: bool = optional_bool(params, "skip_export", false) + + var preset := _find_android_preset(preset_name, preset_index) + if preset.is_empty(): + return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.") + if preset["platform"] != "Android": + return error(-32000, "Preset '%s' is not an Android preset" % preset["name"]) + + var export_path_res: String = preset["export_path"] + if export_path_res.is_empty(): + return error(-32000, "Export path not configured for preset '%s'" % preset["name"]) + var export_path_abs: String = ProjectSettings.globalize_path(export_path_res) if export_path_res.begins_with("res://") else export_path_res + + var steps: Array = [] + + # Step 1: Export APK via Godot CLI (unless caller already has an APK). + if not skip_export: + var godot_bin := OS.get_executable_path() + var project_dir := ProjectSettings.globalize_path("res://") + var export_flag := "--export-debug" if debug else "--export-release" + var export_args := PackedStringArray(["--headless", "--path", project_dir, export_flag, preset["name"], export_path_abs]) + var export_result := _run(godot_bin, export_args) + steps.append({"step": "export", "command": godot_bin, "args": export_args, "exit_code": export_result["exit_code"]}) + if export_result["exit_code"] != 0: + return error(-32000, "Godot export failed (exit %d). See stdout." % export_result["exit_code"], {"steps": steps, "stdout": export_result["stdout"]}) + + if not FileAccess.file_exists(export_path_abs): + return error(-32000, "APK not found at %s after export" % export_path_abs, {"steps": steps}) + + # Step 2: adb install -r + var adb := _resolve_adb_path() + var install_args := PackedStringArray() + if not device_serial.is_empty(): + install_args.append("-s") + install_args.append(device_serial) + install_args.append("install") + install_args.append("-r") + install_args.append(export_path_abs) + var install_result := _run(adb, install_args) + steps.append({"step": "install", "command": adb, "args": install_args, "exit_code": install_result["exit_code"], "stdout": install_result["stdout"]}) + if install_result["exit_code"] != 0: + return error(-32000, "adb install failed (exit %d)" % install_result["exit_code"], {"steps": steps}) + + # Step 3: adb shell am start (optional) + if launch: + var package_name: String = preset["package_name"] + if package_name.is_empty(): + steps.append({"step": "launch", "skipped": true, "reason": "package_name not found in preset"}) + else: + var launch_args := PackedStringArray() + if not device_serial.is_empty(): + launch_args.append("-s") + launch_args.append(device_serial) + launch_args.append("shell") + launch_args.append("monkey") + launch_args.append("-p") + launch_args.append(package_name) + launch_args.append("-c") + launch_args.append("android.intent.category.LAUNCHER") + launch_args.append("1") + var launch_result := _run(adb, launch_args) + steps.append({"step": "launch", "command": adb, "args": launch_args, "exit_code": launch_result["exit_code"], "stdout": launch_result["stdout"]}) + + return success({ + "preset": preset["name"], + "apk_path": export_path_abs, + "device": device_serial if not device_serial.is_empty() else "(default)", + "package_name": preset["package_name"], + "steps": steps, + }) diff --git a/addons/godot_mcp/commands/android_commands.gd.uid b/addons/godot_mcp/commands/android_commands.gd.uid new file mode 100644 index 0000000..aedc0d9 --- /dev/null +++ b/addons/godot_mcp/commands/android_commands.gd.uid @@ -0,0 +1 @@ +uid://di2ucc53dq8bx diff --git a/addons/godot_mcp/commands/animation_commands.gd b/addons/godot_mcp/commands/animation_commands.gd new file mode 100644 index 0000000..245fe22 --- /dev/null +++ b/addons/godot_mcp/commands/animation_commands.gd @@ -0,0 +1,237 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_animations": _list_animations, + "create_animation": _create_animation, + "add_animation_track": _add_animation_track, + "set_animation_keyframe": _set_animation_keyframe, + "get_animation_info": _get_animation_info, + "remove_animation": _remove_animation, + } + + +func _find_animation_player(node_path: String) -> AnimationPlayer: + var node := find_node_by_path(node_path) + if node is AnimationPlayer: + return node as AnimationPlayer + return null + + +func _list_animations(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var animations: Array = [] + for anim_name in player.get_animation_list(): + var anim := player.get_animation(anim_name) + animations.append({ + "name": anim_name, + "length": anim.length, + "loop_mode": anim.loop_mode, + "track_count": anim.get_track_count(), + }) + + return success({"node_path": node_path, "animations": animations, "count": animations.size()}) + + +func _create_animation(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var length: float = float(params.get("length", 1.0)) + var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong + + var anim := Animation.new() + anim.length = length + anim.loop_mode = loop_mode + + var lib := player.get_animation_library("") + if lib == null: + lib = AnimationLibrary.new() + player.add_animation_library("", lib) + + lib.add_animation(anim_name, anim) + + return success({"name": anim_name, "length": length, "created": true}) + + +func _add_animation_track(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var result3 := require_string(params, "track_path") + if result3[1] != null: + return result3[1] + var track_path: String = result3[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var track_type_str: String = optional_string(params, "track_type", "value") + var track_type: int + match track_type_str: + "value": track_type = Animation.TYPE_VALUE + "position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too + "rotation_2d": track_type = Animation.TYPE_ROTATION_3D + "scale_2d": track_type = Animation.TYPE_SCALE_3D + "method": track_type = Animation.TYPE_METHOD + "bezier": track_type = Animation.TYPE_BEZIER + "blend_shape": track_type = Animation.TYPE_BLEND_SHAPE + _: track_type = Animation.TYPE_VALUE + + var track_idx := anim.add_track(track_type) + anim.track_set_path(track_idx, NodePath(track_path)) + + var update_mode_str: String = optional_string(params, "update_mode", "") + if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE: + match update_mode_str: + "continuous": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_CONTINUOUS) + "discrete": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_DISCRETE) + "capture": anim.value_track_set_update_mode(track_idx, Animation.UPDATE_CAPTURE) + + return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str}) + + +func _set_animation_keyframe(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var track_index: int = int(params.get("track_index", 0)) + if track_index < 0 or track_index >= anim.get_track_count(): + return error_invalid_params("Invalid track_index: %d" % track_index) + + var time: float = float(params.get("time", 0.0)) + var value = params.get("value") + + # Parse value string for common types + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var key_idx := anim.track_insert_key(track_index, time, value) + + var easing: float = float(params.get("easing", 1.0)) + if easing != 1.0: + anim.track_set_key_transition(track_index, key_idx, easing) + + return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)}) + + +func _get_animation_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "animation") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var anim := player.get_animation(anim_name) + if anim == null: + return error_not_found("Animation '%s'" % anim_name) + + var tracks: Array = [] + for i in anim.get_track_count(): + var track_info := { + "index": i, + "path": str(anim.track_get_path(i)), + "type": anim.track_get_type(i), + "key_count": anim.track_get_key_count(i), + } + var keys: Array = [] + for k in anim.track_get_key_count(i): + keys.append({ + "time": anim.track_get_key_time(i, k), + "value": str(anim.track_get_key_value(i, k)), + "easing": anim.track_get_key_transition(i, k), + }) + track_info["keys"] = keys + tracks.append(track_info) + + return success({ + "name": anim_name, + "length": anim.length, + "loop_mode": anim.loop_mode, + "step": anim.step, + "tracks": tracks, + }) + + +func _remove_animation(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var anim_name: String = result2[0] + + var player := _find_animation_player(node_path) + if player == null: + return error_not_found("AnimationPlayer at '%s'" % node_path) + + var lib := player.get_animation_library("") + if lib == null or not lib.has_animation(anim_name): + return error_not_found("Animation '%s'" % anim_name) + + lib.remove_animation(anim_name) + return success({"name": anim_name, "removed": true}) diff --git a/addons/godot_mcp/commands/animation_commands.gd.uid b/addons/godot_mcp/commands/animation_commands.gd.uid new file mode 100644 index 0000000..27570d1 --- /dev/null +++ b/addons/godot_mcp/commands/animation_commands.gd.uid @@ -0,0 +1 @@ +uid://ng11e1gsaj73 diff --git a/addons/godot_mcp/commands/animation_tree_commands.gd b/addons/godot_mcp/commands/animation_tree_commands.gd new file mode 100644 index 0000000..a9889ac --- /dev/null +++ b/addons/godot_mcp/commands/animation_tree_commands.gd @@ -0,0 +1,563 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_animation_tree": _create_animation_tree, + "get_animation_tree_structure": _get_animation_tree_structure, + "add_state_machine_state": _add_state_machine_state, + "remove_state_machine_state": _remove_state_machine_state, + "add_state_machine_transition": _add_state_machine_transition, + "remove_state_machine_transition": _remove_state_machine_transition, + "set_blend_tree_node": _set_blend_tree_node, + "set_tree_parameter": _set_tree_parameter, + } + + +## Find AnimationTree on a node or return null +func _find_animation_tree(node_path: String) -> AnimationTree: + var node := find_node_by_path(node_path) + if node is AnimationTree: + return node as AnimationTree + return null + + +## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState") +## Returns [state_machine, error_or_null] +func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array: + var root := tree.tree_root + if not root is AnimationNodeStateMachine: + return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")] + + if sm_path.is_empty() or sm_path == ".": + return [root as AnimationNodeStateMachine, null] + + var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine + var parts := sm_path.split("/") + for part in parts: + if not current.has_node(StringName(part)): + return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])] + var child := current.get_node(StringName(part)) + if not child is AnimationNodeStateMachine: + return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)] + current = child as AnimationNodeStateMachine + return [current, null] + + +## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine, +## or a slash-separated path. The last segment is the BlendTree node name. +## Returns [blend_tree, error_or_null] +func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array: + var result := _resolve_state_machine(tree, sm_path) + if result[1] != null: + return result + + var sm: AnimationNodeStateMachine = result[0] + if not sm.has_node(StringName(bt_name)): + return [null, error_not_found("BlendTree node '%s'" % bt_name)] + + var node := sm.get_node(StringName(bt_name)) + if not node is AnimationNodeBlendTree: + return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)] + + return [node as AnimationNodeBlendTree, null] + + +func _create_animation_tree(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(node_path) + if parent == null: + return error_not_found("Node at '%s'" % node_path) + + var anim_player_path: String = optional_string(params, "anim_player", "") + var tree_name: String = optional_string(params, "name", "AnimationTree") + + # Create the AnimationTree + var tree := AnimationTree.new() + tree.name = tree_name + + # Set root to AnimationNodeStateMachine + var state_machine := AnimationNodeStateMachine.new() + tree.tree_root = state_machine + + # Link to AnimationPlayer if provided + if not anim_player_path.is_empty(): + tree.anim_player = NodePath(anim_player_path) + + parent.add_child(tree, true) + tree.owner = root + + return success({ + "name": tree.name, + "node_path": str(root.get_path_to(tree)), + "root_type": "AnimationNodeStateMachine", + "anim_player": anim_player_path, + "created": true, + }) + + +func _get_animation_tree_structure(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var root := tree.tree_root + if root == null: + return success({"node_path": node_path, "root": null}) + + var structure := _read_node_structure(root) + structure["active"] = tree.active + structure["anim_player"] = str(tree.anim_player) + structure["node_path"] = node_path + + return success(structure) + + +func _read_node_structure(node: AnimationNode) -> Dictionary: + if node is AnimationNodeStateMachine: + return _read_state_machine_structure(node as AnimationNodeStateMachine) + elif node is AnimationNodeBlendTree: + return _read_blend_tree_structure(node as AnimationNodeBlendTree) + elif node is AnimationNodeAnimation: + var anim_node := node as AnimationNodeAnimation + return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)} + else: + return {"type": node.get_class()} + + +func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary: + var states: Array = [] + # Iterate through graph nodes via get_node_name + # AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes + var node_list := _get_sm_node_names(sm) + for state_name in node_list: + var child := sm.get_node(StringName(state_name)) + var state_info := { + "name": state_name, + "position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y}, + } + state_info.merge(_read_node_structure(child)) + states.append(state_info) + + var transitions: Array = [] + for i in sm.get_transition_count(): + var from_node := sm.get_transition_from(i) + var to_node := sm.get_transition_to(i) + var trans := sm.get_transition(i) + var trans_info := { + "from": str(from_node), + "to": str(to_node), + "switch_mode": trans.switch_mode, + "advance_mode": trans.advance_mode, + } + if not trans.advance_expression.is_empty(): + trans_info["advance_expression"] = trans.advance_expression + if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO: + trans_info["auto"] = true + transitions.append(trans_info) + + return { + "type": "AnimationNodeStateMachine", + "states": states, + "transitions": transitions, + } + + +func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array: + # Use the internal _get_child_nodes or iterate known patterns + # AnimationNodeStateMachine doesn't expose a simple list method, + # but we can use get_graph_offset and iterate via has_node with common checks. + # Actually in Godot 4.x we can get the node list by checking property list + # or using the script resource approach. The most reliable is iterating through + # the resource properties. + var names: Array = [] + var prop_list := sm.get_property_list() + for prop in prop_list: + var pname: String = prop["name"] + # State machine stores nodes as "states//node" + if pname.begins_with("states/") and pname.ends_with("/node"): + var state_name := pname.get_slice("/", 1) + if state_name != "Start" and state_name != "End": + names.append(state_name) + return names + + +func _read_blend_tree_structure(bt: AnimationNodeBlendTree) -> Dictionary: + var nodes_info: Array = [] + var prop_list := bt.get_property_list() + var node_names: Array = [] + for prop in prop_list: + var pname: String = prop["name"] + if pname.begins_with("nodes/") and pname.ends_with("/node"): + var n := pname.get_slice("/", 1) + if n != "output": + node_names.append(n) + + for n_name in node_names: + var child: AnimationNode = bt.get_node(StringName(n_name)) + var node_info := { + "name": n_name, + "type": child.get_class(), + "position": {"x": bt.get_node_position(StringName(n_name)).x, "y": bt.get_node_position(StringName(n_name)).y}, + } + if child is AnimationNodeAnimation: + node_info["animation"] = str((child as AnimationNodeAnimation).animation) + nodes_info.append(node_info) + + # Read connections + var connections: Array = [] + # BlendTree connections are stored as "node_connections" in properties + # We can read them from the resource property list + for prop in prop_list: + var pname: String = prop["name"] + if pname.begins_with("nodes/") and pname.ends_with("/node"): + continue + if pname.begins_with("nodes/") and pname.ends_with("/position"): + continue + # Connection format: "node_connection///" + # Actually connections are stored differently - let's skip for now + + return { + "type": "AnimationNodeBlendTree", + "nodes": nodes_info, + } + + +func _add_state_machine_state(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "state_name") + if result2[1] != null: + return result2[1] + var state_name: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + if sm.has_node(StringName(state_name)): + return error_invalid_params("State '%s' already exists" % state_name) + + var state_type: String = optional_string(params, "state_type", "animation") + var position_x: float = float(params.get("position_x", 0.0)) + var position_y: float = float(params.get("position_y", 0.0)) + var position := Vector2(position_x, position_y) + + var node: AnimationNode + match state_type: + "animation": + var anim_node := AnimationNodeAnimation.new() + var anim_name: String = optional_string(params, "animation", "") + if not anim_name.is_empty(): + anim_node.animation = StringName(anim_name) + node = anim_node + "blend_tree": + node = AnimationNodeBlendTree.new() + "state_machine": + node = AnimationNodeStateMachine.new() + _: + return error_invalid_params("Unknown state_type: '%s'. Use 'animation', 'blend_tree', or 'state_machine'" % state_type) + + sm.add_node(StringName(state_name), node, position) + + return success({ + "state_name": state_name, + "state_type": state_type, + "position": {"x": position_x, "y": position_y}, + "added": true, + }) + + +func _remove_state_machine_state(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "state_name") + if result2[1] != null: + return result2[1] + var state_name: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + if not sm.has_node(StringName(state_name)): + return error_not_found("State '%s'" % state_name) + + sm.remove_node(StringName(state_name)) + + return success({"state_name": state_name, "removed": true}) + + +func _add_state_machine_transition(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "from_state") + if result2[1] != null: + return result2[1] + var from_state: String = result2[0] + + var result3 := require_string(params, "to_state") + if result3[1] != null: + return result3[1] + var to_state: String = result3[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + # Validate states exist (Start and End are special built-in nodes) + if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)): + return error_not_found("State '%s'" % from_state) + if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)): + return error_not_found("State '%s'" % to_state) + + var transition := AnimationNodeStateMachineTransition.new() + + # switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2 + var switch_mode_str: String = optional_string(params, "switch_mode", "immediate") + match switch_mode_str: + "at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END + "immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE + "sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly + _: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE + + # advance_mode: DISABLED=0, ENABLED=1, AUTO=2 + var advance_mode_str: String = optional_string(params, "advance_mode", "enabled") + match advance_mode_str: + "disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED + "enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED + "auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO + _: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED + + # advance_expression + var expression: String = optional_string(params, "advance_expression", "") + if not expression.is_empty(): + transition.advance_expression = expression + + # xfade_time + if params.has("xfade_time"): + transition.xfade_time = float(params["xfade_time"]) + + sm.add_transition(StringName(from_state), StringName(to_state), transition) + + return success({ + "from": from_state, + "to": to_state, + "switch_mode": switch_mode_str, + "advance_mode": advance_mode_str, + "advance_expression": expression, + "added": true, + }) + + +func _remove_state_machine_transition(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "from_state") + if result2[1] != null: + return result2[1] + var from_state: String = result2[0] + + var result3 := require_string(params, "to_state") + if result3[1] != null: + return result3[1] + var to_state: String = result3[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var sm_result := _resolve_state_machine(tree, sm_path) + if sm_result[1] != null: + return sm_result[1] + var sm: AnimationNodeStateMachine = sm_result[0] + + # Check if transition exists + var found := false + for i in sm.get_transition_count(): + if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state: + found = true + break + + if not found: + return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state]) + + sm.remove_transition(StringName(from_state), StringName(to_state)) + + return success({"from": from_state, "to": to_state, "removed": true}) + + +func _set_blend_tree_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "blend_tree_state") + if result2[1] != null: + return result2[1] + var bt_state: String = result2[0] + + var result3 := require_string(params, "bt_node_name") + if result3[1] != null: + return result3[1] + var bt_node_name: String = result3[0] + + var result4 := require_string(params, "bt_node_type") + if result4[1] != null: + return result4[1] + var bt_node_type: String = result4[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + var sm_path: String = optional_string(params, "state_machine_path", "") + var bt_result := _resolve_blend_tree(tree, sm_path, bt_state) + if bt_result[1] != null: + return bt_result[1] + var bt: AnimationNodeBlendTree = bt_result[0] + + var position_x: float = float(params.get("position_x", 0.0)) + var position_y: float = float(params.get("position_y", 0.0)) + var position := Vector2(position_x, position_y) + + # Remove existing node if replacing + if bt.has_node(StringName(bt_node_name)): + bt.remove_node(StringName(bt_node_name)) + + var node: AnimationNode + match bt_node_type: + "Animation": + var anim_node := AnimationNodeAnimation.new() + var anim_name: String = optional_string(params, "animation", "") + if not anim_name.is_empty(): + anim_node.animation = StringName(anim_name) + node = anim_node + "Add2": + node = AnimationNodeAdd2.new() + "Blend2": + node = AnimationNodeBlend2.new() + "Add3": + node = AnimationNodeAdd3.new() + "Blend3": + node = AnimationNodeBlend3.new() + "TimeScale": + node = AnimationNodeTimeScale.new() + "TimeSeek": + node = AnimationNodeTimeSeek.new() + "Transition": + node = AnimationNodeTransition.new() + "OneShot": + node = AnimationNodeOneShot.new() + "Sub2": + node = AnimationNodeSub2.new() + _: + return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type) + + bt.add_node(StringName(bt_node_name), node, position) + + # Connect to another node if specified + var connect_to: String = optional_string(params, "connect_to", "") + var connect_port: int = optional_int(params, "connect_port", 0) + if not connect_to.is_empty(): + bt.connect_node(StringName(connect_to), connect_port, StringName(bt_node_name)) + + return success({ + "blend_tree_state": bt_state, + "bt_node_name": bt_node_name, + "bt_node_type": bt_node_type, + "position": {"x": position_x, "y": position_y}, + "connected_to": connect_to if not connect_to.is_empty() else null, + "added": true, + }) + + +func _set_tree_parameter(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "parameter") + if result2[1] != null: + return result2[1] + var parameter: String = result2[0] + + var tree := _find_animation_tree(node_path) + if tree == null: + return error_not_found("AnimationTree at '%s'" % node_path) + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + var value = params["value"] + + # Prefix with "parameters/" if not already + if not parameter.begins_with("parameters/"): + parameter = "parameters/" + parameter + + # Parse string values for common types + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + tree.set(parameter, value) + + # Read back to confirm + var actual = tree.get(parameter) + + return success({ + "parameter": parameter, + "value": str(actual), + "set": true, + }) diff --git a/addons/godot_mcp/commands/animation_tree_commands.gd.uid b/addons/godot_mcp/commands/animation_tree_commands.gd.uid new file mode 100644 index 0000000..c51fda6 --- /dev/null +++ b/addons/godot_mcp/commands/animation_tree_commands.gd.uid @@ -0,0 +1 @@ +uid://dnyewiui5yq5h diff --git a/addons/godot_mcp/commands/audio_commands.gd b/addons/godot_mcp/commands/audio_commands.gd new file mode 100644 index 0000000..2af6e4e --- /dev/null +++ b/addons/godot_mcp/commands/audio_commands.gd @@ -0,0 +1,429 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_audio_bus_layout": _get_audio_bus_layout, + "add_audio_bus": _add_audio_bus, + "set_audio_bus": _set_audio_bus, + "add_audio_bus_effect": _add_audio_bus_effect, + "add_audio_player": _add_audio_player, + "get_audio_info": _get_audio_info, + } + + +func _get_audio_bus_layout(_params: Dictionary) -> Dictionary: + var buses: Array[Dictionary] = [] + for i in range(AudioServer.bus_count): + var bus_data := { + "index": i, + "name": AudioServer.get_bus_name(i), + "volume_db": AudioServer.get_bus_volume_db(i), + "solo": AudioServer.is_bus_solo(i), + "mute": AudioServer.is_bus_mute(i), + "bypass_effects": AudioServer.is_bus_bypassing_effects(i), + "send": AudioServer.get_bus_send(i), + "effects": [], + } + var effects: Array[Dictionary] = [] + for j in range(AudioServer.get_bus_effect_count(i)): + var effect := AudioServer.get_bus_effect(i, j) + var effect_data := { + "index": j, + "type": effect.get_class(), + "enabled": AudioServer.is_bus_effect_enabled(i, j), + } + # Include effect-specific parameters + effect_data["params"] = _get_effect_params(effect) + effects.append(effect_data) + bus_data["effects"] = effects + buses.append(bus_data) + return success({"bus_count": AudioServer.bus_count, "buses": buses}) + + +func _get_effect_params(effect: AudioEffect) -> Dictionary: + var params := {} + if effect is AudioEffectReverb: + var rev := effect as AudioEffectReverb + params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread} + elif effect is AudioEffectDelay: + var d := effect as AudioEffectDelay + params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db} + elif effect is AudioEffectCompressor: + var c := effect as AudioEffectCompressor + params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain} + elif effect is AudioEffectLimiter: + var l := effect as AudioEffectLimiter + params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio} + elif effect is AudioEffectDistortion: + var dist := effect as AudioEffectDistortion + params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive} + elif effect is AudioEffectChorus: + var ch := effect as AudioEffectChorus + params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet} + elif effect is AudioEffectPhaser: + var ph := effect as AudioEffectPhaser + params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth} + elif effect is AudioEffectFilter: + # Covers LowPassFilter, HighPassFilter, BandPassFilter, etc. + var f := effect as AudioEffectFilter + params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db} + elif effect is AudioEffectAmplify: + var a := effect as AudioEffectAmplify + params = {"volume_db": a.volume_db} + return params + + +func _add_audio_bus(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + # Check if bus name already exists + for i in range(AudioServer.bus_count): + if AudioServer.get_bus_name(i) == bus_name: + return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i]) + + var at_position: int = optional_int(params, "at_position", -1) + AudioServer.add_bus(at_position) + + var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position + AudioServer.set_bus_name(idx, bus_name) + + if params.has("volume_db"): + AudioServer.set_bus_volume_db(idx, float(params["volume_db"])) + + var send: String = optional_string(params, "send", "") + if not send.is_empty(): + AudioServer.set_bus_send(idx, send) + + if params.has("solo"): + AudioServer.set_bus_solo(idx, bool(params["solo"])) + + if params.has("mute"): + AudioServer.set_bus_mute(idx, bool(params["mute"])) + + return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count}) + + +func _set_audio_bus(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + var idx := AudioServer.get_bus_index(bus_name) + if idx < 0: + return error_not_found("Audio bus '%s'" % bus_name) + + var changes := 0 + + if params.has("volume_db"): + AudioServer.set_bus_volume_db(idx, float(params["volume_db"])) + changes += 1 + + if params.has("solo"): + AudioServer.set_bus_solo(idx, bool(params["solo"])) + changes += 1 + + if params.has("mute"): + AudioServer.set_bus_mute(idx, bool(params["mute"])) + changes += 1 + + if params.has("bypass_effects"): + AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"])) + changes += 1 + + var send: String = optional_string(params, "send", "") + if not send.is_empty(): + AudioServer.set_bus_send(idx, send) + changes += 1 + + if params.has("rename"): + var new_name: String = str(params["rename"]) + AudioServer.set_bus_name(idx, new_name) + bus_name = new_name + changes += 1 + + return success({"name": bus_name, "index": idx, "changes": changes}) + + +func _add_audio_bus_effect(params: Dictionary) -> Dictionary: + var result := require_string(params, "bus") + if result[1] != null: + return result[1] + var bus_name: String = result[0] + + var result2 := require_string(params, "effect_type") + if result2[1] != null: + return result2[1] + var effect_type: String = result2[0] + + var bus_idx := AudioServer.get_bus_index(bus_name) + if bus_idx < 0: + return error_not_found("Audio bus '%s'" % bus_name) + + var effect: AudioEffect = null + var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {} + + match effect_type.to_lower(): + "reverb": + var e := AudioEffectReverb.new() + if effect_params.has("room_size"): + e.room_size = float(effect_params["room_size"]) + if effect_params.has("damping"): + e.damping = float(effect_params["damping"]) + if effect_params.has("wet"): + e.wet = float(effect_params["wet"]) + if effect_params.has("dry"): + e.dry = float(effect_params["dry"]) + if effect_params.has("spread"): + e.spread = float(effect_params["spread"]) + effect = e + "chorus": + var e := AudioEffectChorus.new() + if effect_params.has("voice_count"): + e.voice_count = int(effect_params["voice_count"]) + if effect_params.has("dry"): + e.dry = float(effect_params["dry"]) + if effect_params.has("wet"): + e.wet = float(effect_params["wet"]) + effect = e + "delay": + var e := AudioEffectDelay.new() + if effect_params.has("tap1_active"): + e.tap1_active = bool(effect_params["tap1_active"]) + if effect_params.has("tap1_delay_ms"): + e.tap1_delay_ms = float(effect_params["tap1_delay_ms"]) + if effect_params.has("tap1_level_db"): + e.tap1_level_db = float(effect_params["tap1_level_db"]) + if effect_params.has("tap2_active"): + e.tap2_active = bool(effect_params["tap2_active"]) + if effect_params.has("tap2_delay_ms"): + e.tap2_delay_ms = float(effect_params["tap2_delay_ms"]) + if effect_params.has("tap2_level_db"): + e.tap2_level_db = float(effect_params["tap2_level_db"]) + effect = e + "compressor": + var e := AudioEffectCompressor.new() + if effect_params.has("threshold"): + e.threshold = float(effect_params["threshold"]) + if effect_params.has("ratio"): + e.ratio = float(effect_params["ratio"]) + if effect_params.has("attack_us"): + e.attack_us = float(effect_params["attack_us"]) + if effect_params.has("release_ms"): + e.release_ms = float(effect_params["release_ms"]) + if effect_params.has("gain"): + e.gain = float(effect_params["gain"]) + if effect_params.has("mix"): + e.mix = float(effect_params["mix"]) + effect = e + "limiter": + var e := AudioEffectLimiter.new() + if effect_params.has("ceiling_db"): + e.ceiling_db = float(effect_params["ceiling_db"]) + if effect_params.has("threshold_db"): + e.threshold_db = float(effect_params["threshold_db"]) + if effect_params.has("soft_clip_db"): + e.soft_clip_db = float(effect_params["soft_clip_db"]) + if effect_params.has("soft_clip_ratio"): + e.soft_clip_ratio = float(effect_params["soft_clip_ratio"]) + effect = e + "phaser": + var e := AudioEffectPhaser.new() + if effect_params.has("range_min_hz"): + e.range_min_hz = float(effect_params["range_min_hz"]) + if effect_params.has("range_max_hz"): + e.range_max_hz = float(effect_params["range_max_hz"]) + if effect_params.has("rate_hz"): + e.rate_hz = float(effect_params["rate_hz"]) + if effect_params.has("feedback"): + e.feedback = float(effect_params["feedback"]) + if effect_params.has("depth"): + e.depth = float(effect_params["depth"]) + effect = e + "distortion": + var e := AudioEffectDistortion.new() + if effect_params.has("mode"): + e.mode = int(effect_params["mode"]) + if effect_params.has("pre_gain"): + e.pre_gain = float(effect_params["pre_gain"]) + if effect_params.has("post_gain"): + e.post_gain = float(effect_params["post_gain"]) + if effect_params.has("keep_hf_hz"): + e.keep_hf_hz = float(effect_params["keep_hf_hz"]) + if effect_params.has("drive"): + e.drive = float(effect_params["drive"]) + effect = e + "lowpassfilter", "lowpass": + var e := AudioEffectLowPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "highpassfilter", "highpass": + var e := AudioEffectHighPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "bandpassfilter", "bandpass": + var e := AudioEffectBandPassFilter.new() + if effect_params.has("cutoff_hz"): + e.cutoff_hz = float(effect_params["cutoff_hz"]) + if effect_params.has("resonance"): + e.resonance = float(effect_params["resonance"]) + effect = e + "amplify": + var e := AudioEffectAmplify.new() + if effect_params.has("volume_db"): + e.volume_db = float(effect_params["volume_db"]) + effect = e + "eq": + var e := AudioEffectEQ.new() + effect = e + _: + return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type) + + var at_position: int = optional_int(params, "at_position", -1) + AudioServer.add_bus_effect(bus_idx, effect, at_position) + + var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position + return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx}) + + +func _add_audio_player(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var player_name: String = result2[0] + + var player_type: String = optional_string(params, "type", "AudioStreamPlayer") + var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"] + if player_type not in valid_types: + return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)]) + + var parent := find_node_by_path(node_path) + if parent == null: + return error_not_found("Node at '%s'" % node_path) + + var player: Node = null + match player_type: + "AudioStreamPlayer": + player = AudioStreamPlayer.new() + "AudioStreamPlayer2D": + player = AudioStreamPlayer2D.new() + "AudioStreamPlayer3D": + player = AudioStreamPlayer3D.new() + + player.name = player_name + + # Set stream if provided + var stream_path: String = optional_string(params, "stream", "") + if not stream_path.is_empty(): + if ResourceLoader.exists(stream_path): + var stream = ResourceLoader.load(stream_path) + if stream is AudioStream: + player.set("stream", stream) + else: + player.queue_free() + return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path) + else: + player.queue_free() + return error_not_found("Audio stream at '%s'" % stream_path) + + # Common properties + if params.has("volume_db"): + player.set("volume_db", float(params["volume_db"])) + + var bus: String = optional_string(params, "bus", "") + if not bus.is_empty(): + player.set("bus", bus) + + if params.has("autoplay"): + player.set("autoplay", bool(params["autoplay"])) + + # 2D-specific properties + if player is AudioStreamPlayer2D: + if params.has("max_distance"): + (player as AudioStreamPlayer2D).max_distance = float(params["max_distance"]) + if params.has("attenuation"): + (player as AudioStreamPlayer2D).attenuation = float(params["attenuation"]) + + # 3D-specific properties + if player is AudioStreamPlayer3D: + if params.has("max_distance"): + (player as AudioStreamPlayer3D).max_distance = float(params["max_distance"]) + if params.has("attenuation_model"): + (player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) + if params.has("unit_size"): + (player as AudioStreamPlayer3D).unit_size = float(params["unit_size"]) + + parent.add_child(player) + player.owner = get_edited_root() + + return success({ + "name": player_name, + "type": player_type, + "parent": node_path, + "stream": stream_path, + "bus": player.get("bus"), + "volume_db": player.get("volume_db"), + "autoplay": player.get("autoplay"), + }) + + +func _get_audio_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var players: Array[Dictionary] = [] + _collect_audio_players(node, players) + + return success({"node_path": node_path, "audio_player_count": players.size(), "players": players}) + + +func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void: + if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D: + var info := { + "name": node.name, + "path": str(get_edited_root().get_path_to(node)), + "type": node.get_class(), + "volume_db": node.get("volume_db"), + "bus": node.get("bus"), + "autoplay": node.get("autoplay"), + "playing": node.get("playing"), + "stream": "", + } + var stream = node.get("stream") + if stream != null and stream is AudioStream: + info["stream"] = stream.resource_path + + if node is AudioStreamPlayer2D: + info["max_distance"] = (node as AudioStreamPlayer2D).max_distance + info["attenuation"] = (node as AudioStreamPlayer2D).attenuation + elif node is AudioStreamPlayer3D: + info["max_distance"] = (node as AudioStreamPlayer3D).max_distance + info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model + info["unit_size"] = (node as AudioStreamPlayer3D).unit_size + + result.append(info) + + for child in node.get_children(): + _collect_audio_players(child, result) diff --git a/addons/godot_mcp/commands/audio_commands.gd.uid b/addons/godot_mcp/commands/audio_commands.gd.uid new file mode 100644 index 0000000..6824564 --- /dev/null +++ b/addons/godot_mcp/commands/audio_commands.gd.uid @@ -0,0 +1 @@ +uid://o7myqm3vv5dp diff --git a/addons/godot_mcp/commands/base_command.gd b/addons/godot_mcp/commands/base_command.gd new file mode 100644 index 0000000..e2f2fef --- /dev/null +++ b/addons/godot_mcp/commands/base_command.gd @@ -0,0 +1,133 @@ +@tool +extends Node + +var editor_plugin: EditorPlugin + + +## Override in subclasses: return {"method_name": Callable} +func get_commands() -> Dictionary: + return {} + + +## Helper: return a success result +func success(data: Dictionary = {}) -> Dictionary: + return {"result": data} + + +## Helper: return an error +func error(code: int, message: String, data: Dictionary = {}) -> Dictionary: + var err := {"code": code, "message": message} + if not data.is_empty(): + err["data"] = data + return {"error": err} + + +## Error codes +func error_not_found(what: String, suggestion: String = "") -> Dictionary: + var data := {} + if suggestion: + data["suggestion"] = suggestion + return error(-32001, "%s not found" % what, data) + + +func error_invalid_params(message: String) -> Dictionary: + return error(-32602, message) + + +func error_no_scene() -> Dictionary: + return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"}) + + +func error_internal(message: String) -> Dictionary: + return error(-32603, "Internal error: %s" % message) + + +## Get required string param +func require_string(params: Dictionary, key: String) -> Array: + if not params.has(key) or not params[key] is String or (params[key] as String).is_empty(): + return [null, error_invalid_params("Missing required parameter: %s" % key)] + return [params[key] as String, null] + + +## Get optional string param with default +func optional_string(params: Dictionary, key: String, default: String = "") -> String: + if params.has(key) and params[key] is String: + return params[key] as String + return default + + +## Get optional bool param with default +func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool: + if params.has(key) and params[key] is bool: + return params[key] as bool + return default + + +## Get optional int param with default +func optional_int(params: Dictionary, key: String, default: int = 0) -> int: + if params.has(key): + return int(params[key]) + return default + + +## Get the game process's user data directory. +## OS.get_user_data_dir() is cached at editor startup and won't reflect +## project name changes made to project.godot while the editor is running. +## The game process reads the name from disk, so we must do the same. +func get_game_user_dir() -> String: + var cached_dir := OS.get_user_data_dir() + var cfg := ConfigFile.new() + var err := cfg.load(ProjectSettings.globalize_path("res://project.godot")) + if err != OK: + return cached_dir + # When use_custom_user_dir=true, editor and game share the same dir + # (OS.get_user_data_dir() already resolves to the custom path). + if cfg.get_value("application", "config/use_custom_user_dir", false): + return cached_dir + var disk_name = cfg.get_value("application", "config/name", "") + if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty(): + return cached_dir + # Sanitize exactly like Godot does when computing the default user dir + # (core/config/project_settings.cpp ProjectSettings::_init). + var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_") + if sanitized.is_empty(): + return cached_dir + var base_dir := cached_dir.get_base_dir() + var game_dir := base_dir.path_join(sanitized) + # Ensure the directory exists (game may not have created it yet) + if not DirAccess.dir_exists_absolute(game_dir): + DirAccess.make_dir_recursive_absolute(game_dir) + return game_dir + + +## Get EditorInterface +func get_editor() -> EditorInterface: + return editor_plugin.get_editor_interface() + + +## Get the edited scene root +func get_edited_root() -> Node: + return get_editor().get_edited_scene_root() + + +## Get UndoRedo +func get_undo_redo() -> EditorUndoRedoManager: + return editor_plugin.get_undo_redo() + + +## Find node by path in edited scene +func find_node_by_path(node_path: String) -> Node: + var root := get_edited_root() + if root == null: + return null + if node_path == "." or node_path == root.name: + return root + # Try relative from root + if root.has_node(node_path): + return root.get_node(node_path) + # Try with root name prefix stripped + if node_path.begins_with(root.name + "/"): + var rel := node_path.substr(root.name.length() + 1) + if root.has_node(rel): + return root.get_node(rel) + return null diff --git a/addons/godot_mcp/commands/base_command.gd.uid b/addons/godot_mcp/commands/base_command.gd.uid new file mode 100644 index 0000000..13c8ed5 --- /dev/null +++ b/addons/godot_mcp/commands/base_command.gd.uid @@ -0,0 +1 @@ +uid://bohowt8xbeyke diff --git a/addons/godot_mcp/commands/batch_commands.gd b/addons/godot_mcp/commands/batch_commands.gd new file mode 100644 index 0000000..8f03287 --- /dev/null +++ b/addons/godot_mcp/commands/batch_commands.gd @@ -0,0 +1,383 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "find_nodes_by_type": _find_nodes_by_type, + "find_signal_connections": _find_signal_connections, + "batch_set_property": _batch_set_property, + "batch_add_nodes": _batch_add_nodes, + "find_node_references": _find_node_references, + "get_scene_dependencies": _get_scene_dependencies, + "cross_scene_set_property": _cross_scene_set_property, + } + + +func _find_nodes_by_type(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var recursive: bool = optional_bool(params, "recursive", true) + var matches: Array = [] + _search_by_type(root, type_name, recursive, matches) + + return success({"type": type_name, "matches": matches, "count": matches.size()}) + + +func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + var root := get_edited_root() + matches.append({ + "name": node.name, + "path": str(root.get_path_to(node)), + "type": node.get_class(), + }) + if recursive: + for child in node.get_children(): + _search_by_type(child, type_name, recursive, matches) + + +func _find_signal_connections(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var signal_filter: String = optional_string(params, "signal_name", "") + var node_filter: String = optional_string(params, "node_path", "") + + var connections: Array = [] + _collect_signals(root, root, signal_filter, node_filter, connections) + + return success({"connections": connections, "count": connections.size()}) + + +func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void: + var node_path := str(root.get_path_to(node)) + + if node_filter.is_empty() or node_path.contains(node_filter): + for sig_info in node.get_signal_list(): + var sig_name: String = sig_info["name"] + if not signal_filter.is_empty() and not sig_name.contains(signal_filter): + continue + for conn in node.get_signal_connection_list(sig_name): + connections.append({ + "source": node_path, + "signal": sig_name, + "target": str(root.get_path_to(conn["callable"].get_object())), + "method": conn["callable"].get_method(), + }) + + for child in node.get_children(): + _collect_signals(child, root, signal_filter, node_filter, connections) + + +func _batch_set_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value = params["value"] + + # Parse value string + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var affected: Array = [] + _batch_set_recursive(root, root, type_name, property, value, affected) + + return success({"property": property, "affected": affected, "count": affected.size()}) + + +func _batch_set_recursive(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + if property in node: + node.set(property, value) + affected.append(str(root.get_path_to(node))) + for child in node.get_children(): + _batch_set_recursive(child, root, type_name, property, value, affected) + + +func _batch_add_nodes(params: Dictionary) -> Dictionary: + if not params.has("nodes") or not params["nodes"] is Array: + return error_invalid_params("Missing required parameter: nodes (Array)") + + var nodes_data: Array = params["nodes"] + if nodes_data.is_empty(): + return error_invalid_params("nodes array is empty") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var created: Array = [] + var errors: Array = [] + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Batch add %d nodes" % nodes_data.size()) + + for i: int in nodes_data.size(): + var entry: Dictionary = nodes_data[i] + + if not entry.has("type") or not entry["type"] is String: + errors.append({"index": i, "error": "Missing or invalid 'type'"}) + continue + + var type: String = entry["type"] + if not ClassDB.class_exists(type): + errors.append({"index": i, "error": "Unknown node type: %s" % type}) + continue + + var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "." + var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else "" + var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {} + + var parent := find_node_by_path(parent_path) + if parent == null: + errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path}) + continue + + var node: Node = ClassDB.instantiate(type) + if not node_name.is_empty(): + node.name = node_name + + for prop_name: String in properties: + var prop_exists := false + for prop in node.get_property_list(): + if prop["name"] == prop_name: + prop_exists = true + break + if prop_exists: + var current: Variant = node.get(prop_name) + var target_type := typeof(current) + node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type)) + + # Add directly so subsequent nodes can reference this as parent + parent.add_child(node) + node.set_owner(root) + + # Register undo/redo (do methods re-add on redo, undo removes) + undo_redo.add_do_method(parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(parent, "remove_child", node) + + created.append({ + "index": i, + "type": type, + "name": str(node.name), + "parent": parent_path, + "node_path": str(root.get_path_to(node)), + }) + + # Commit without re-executing do methods (nodes already added) + undo_redo.commit_action(false) + + var result := {"created": created, "count": created.size()} + if not errors.is_empty(): + result["errors"] = errors + return success(result) + + +func _find_node_references(params: Dictionary) -> Dictionary: + var result := require_string(params, "pattern") + if result[1] != null: + return result[1] + var pattern: String = result[0] + + # Search through all .tscn and .gd files for references + var matches: Array = [] + _search_files_for_pattern("res://", pattern, matches, 100) + + return success({"pattern": pattern, "matches": matches, "count": matches.size()}) + + +func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + _search_files_for_pattern(full_path, pattern, matches, max_results) + elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]: + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + if content.contains(pattern): + # Find line numbers + var lines := content.split("\n") + var line_matches: Array = [] + for i in lines.size(): + if lines[i].contains(pattern): + line_matches.append(i + 1) + if line_matches.size() >= 5: + break + matches.append({ + "file": full_path, + "lines": line_matches, + }) + + file_name = dir.get_next() + dir.list_dir_end() + + +func _cross_scene_set_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type_name: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value = params["value"] + + # Parse value string + if value is String: + var expr := Expression.new() + if expr.parse(value) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + var path_filter: String = optional_string(params, "path_filter", "res://") + var exclude_addons: bool = optional_bool(params, "exclude_addons", true) + + var scenes_affected: Array = [] + var total_nodes: int = 0 + var scene_files: Array = [] + _collect_scene_files(path_filter, scene_files, exclude_addons) + + for scene_path: String in scene_files: + var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene + if packed == null: + continue + var instance: Node = packed.instantiate() + if instance == null: + continue + + var affected_nodes: Array = [] + _cross_scene_set_recursive(instance, instance, type_name, property, value, affected_nodes) + + if not affected_nodes.is_empty(): + # Pack and save + var new_packed := PackedScene.new() + new_packed.pack(instance) + ResourceSaver.save(new_packed, scene_path) + scenes_affected.append({ + "scene": scene_path, + "nodes": affected_nodes, + "count": affected_nodes.size(), + }) + total_nodes += affected_nodes.size() + + instance.free() + + # Rescan filesystem so editor picks up changes + if not scenes_affected.is_empty(): + get_editor().get_resource_filesystem().scan() + + return success({ + "type": type_name, + "property": property, + "scenes_affected": scenes_affected, + "total_scenes": scenes_affected.size(), + "total_nodes": total_nodes, + }) + + +func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + dir.list_dir_begin() + var file_name := dir.get_next() + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + var full_path := path.path_join(file_name) + if dir.current_is_dir(): + if exclude_addons and file_name == "addons": + file_name = dir.get_next() + continue + _collect_scene_files(full_path, files, exclude_addons) + elif file_name.get_extension() == "tscn": + files.append(full_path) + file_name = dir.get_next() + dir.list_dir_end() + + +func _cross_scene_set_recursive(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array) -> void: + if node.is_class(type_name) or node.get_class() == type_name: + if property in node: + node.set(property, value) + affected.append(str(root.get_path_to(node))) + for child in node.get_children(): + _cross_scene_set_recursive(child, root, type_name, property, value, affected) + + +func _get_scene_dependencies(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("File '%s'" % path) + + var deps := ResourceLoader.get_dependencies(path) + var dependencies: Array = [] + for dep: String in deps: + # Format: "path::type" + var parts := dep.split("::") + dependencies.append({ + "path": parts[0] if parts.size() > 0 else dep, + "type": parts[2] if parts.size() > 2 else "", + }) + + return success({"path": path, "dependencies": dependencies, "count": dependencies.size()}) diff --git a/addons/godot_mcp/commands/batch_commands.gd.uid b/addons/godot_mcp/commands/batch_commands.gd.uid new file mode 100644 index 0000000..e5e7100 --- /dev/null +++ b/addons/godot_mcp/commands/batch_commands.gd.uid @@ -0,0 +1 @@ +uid://67nvcoxon6p5 diff --git a/addons/godot_mcp/commands/editor_commands.gd b/addons/godot_mcp/commands/editor_commands.gd new file mode 100644 index 0000000..737ef5f --- /dev/null +++ b/addons/godot_mcp/commands/editor_commands.gd @@ -0,0 +1,644 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_editor_errors": _get_editor_errors, + "get_output_log": _get_output_log, + "get_editor_screenshot": _get_editor_screenshot, + "get_game_screenshot": _get_game_screenshot, + "execute_editor_script": _execute_editor_script, + "clear_output": _clear_output, + "reload_plugin": _reload_plugin, + "reload_project": _reload_project, + "get_signals": _get_signals, + "compare_screenshots": _compare_screenshots, + "set_auto_dismiss": _set_auto_dismiss, + "get_editor_camera": _get_editor_camera, + "set_editor_camera": _set_editor_camera, + } + + +func _get_editor_errors(params: Dictionary) -> Dictionary: + var errors: Array = [] + var max_lines: int = optional_int(params, "max_lines", 50) + var base: Control = get_editor().get_base_control() + + # 1. Read from the editor's Output panel (EditorLog RichTextLabel) + # This captures runtime errors, warnings, and print output + var editor_log: Node = base.find_child("Output", true, false) + if editor_log: + var rtl: RichTextLabel = _find_rtl(editor_log) + if rtl: + var content: String = rtl.get_parsed_text() + var lines: PackedStringArray = content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + for i in range(start, lines.size()): + var line: String = lines[i] + if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"): + errors.append(line.strip_edges()) + + # 2. Check the script editor for compile errors (red background lines) + # These don't appear in the Output panel + var script_errors: Array = [] + var script_editor: ScriptEditor = EditorInterface.get_script_editor() + if script_editor: + var current_script: Script = script_editor.get_current_script() + var ce: CodeEdit = _find_code_edit(script_editor) + if ce and current_script: + var script_path: String = current_script.resource_path + for i in range(ce.get_line_count()): + var bg: Color = ce.get_line_background_color(i) + if bg.r > 0.8 and bg.a > 0: # Red-ish background = error + var line_text: String = ce.get_line(i).strip_edges() + script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text]) + + # 3. Read from script editor error/warning panels (GDScript analyzer messages) + # Each open script editor has a VSplitContainer with two RichTextLabels: + # child[1] = warnings panel, child[2] = errors panel + var analyzer_errors: Array = [] + if script_editor: + var open_editors: Array = script_editor.get_open_script_editors() + var open_scripts: Array = script_editor.get_open_scripts() + for ei in range(open_editors.size()): + var editor_node: Node = open_editors[ei] + var script_path: String = "" + if ei < open_scripts.size() and open_scripts[ei] != null: + script_path = (open_scripts[ei] as Resource).resource_path + var vsplit: VSplitContainer = null + for c in editor_node.get_children(): + if c is VSplitContainer: + vsplit = c as VSplitContainer + break + if vsplit == null: + continue + var children: Array = vsplit.get_children() + # child[1] = warnings panel (RichTextLabel) + if children.size() > 1 and children[1] is RichTextLabel: + var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges() + if not text.is_empty(): + for line in text.split("\n"): + var stripped: String = line.strip_edges() + if stripped.is_empty() or stripped == "[Ignore]": + continue + # Remove leading "[Ignore]" prefix from warning lines + stripped = stripped.trim_prefix("[Ignore]") + var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: " + analyzer_errors.append(prefix + stripped) + # child[2] = errors panel (RichTextLabel) + if children.size() > 2 and children[2] is RichTextLabel: + var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges() + if not text.is_empty(): + for line in text.split("\n"): + var stripped: String = line.strip_edges() + if stripped.is_empty(): + continue + var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: " + analyzer_errors.append(prefix + stripped) + + # 4. Read from the debugger Errors tab (runtime errors/warnings) + # Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree + var debugger_errors: Array = [] + var base2: Control = get_editor().get_base_control() + if base2: + var queue: Array[Node] = [base2] + while not queue.is_empty(): + var node := queue.pop_front() + if node.get_class() == "ScriptEditorDebugger": + # Find TabContainer inside the debugger + for child in node.get_children(): + if child is TabContainer: + var tab_container := child as TabContainer + for tab_idx in range(tab_container.get_tab_count()): + var tab_control: Control = tab_container.get_tab_control(tab_idx) + if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"): + # Find Tree inside the Errors tab + for vchild in tab_control.get_children(): + if vchild is Tree: + var tree := vchild as Tree + var root_item: TreeItem = tree.get_root() + if root_item: + var item: TreeItem = root_item.get_first_child() + while item: + var col0: String = item.get_text(0).strip_edges() + var col1: String = item.get_text(1).strip_edges() + if not col0.is_empty() or not col1.is_empty(): + var msg: String = col0 + if not col1.is_empty(): + msg += " " + col1 if not msg.is_empty() else col1 + debugger_errors.append("DEBUGGER: " + msg) + # Also check child items (expanded error details) + var sub: TreeItem = item.get_first_child() + while sub: + var sub0: String = sub.get_text(0).strip_edges() + var sub1: String = sub.get_text(1).strip_edges() + if not sub0.is_empty() or not sub1.is_empty(): + var sub_msg: String = sub0 + if not sub1.is_empty(): + sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1 + debugger_errors.append("DEBUGGER: " + sub_msg) + sub = sub.get_next() + item = item.get_next() + break # Found Errors tab, stop searching tabs + break # Found TabContainer, stop searching debugger children + break # Found ScriptEditorDebugger, stop BFS + for child in node.get_children(): + queue.append(child) + + # Fallback: read from log file if Output panel not accessible + if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0: + var log_path := "user://logs/godot.log" + if FileAccess.file_exists(log_path): + var file := FileAccess.open(log_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + for i in range(start, lines.size()): + var line: String = lines[i] + if line.contains("ERROR") or line.contains("SCRIPT ERROR"): + errors.append(line.strip_edges()) + + errors.append_array(script_errors) + errors.append_array(analyzer_errors) + errors.append_array(debugger_errors) + return success({"errors": errors, "count": errors.size()}) + + +func _get_output_log(params: Dictionary) -> Dictionary: + var max_lines: int = optional_int(params, "max_lines", 100) + var filter: String = optional_string(params, "filter", "") + var base: Control = get_editor().get_base_control() + + var editor_log: Node = base.find_child("Output", true, false) + if editor_log == null: + # Fallback: read from log file + var log_path := "user://logs/godot.log" + if not FileAccess.file_exists(log_path): + return error_internal("Output panel not found and no log file available") + var file := FileAccess.open(log_path, FileAccess.READ) + if file == null: + return error_internal("Cannot read log file") + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + var start: int = maxi(0, lines.size() - max_lines) + var output_lines: Array = [] + for i in range(start, lines.size()): + var line: String = lines[i] + if filter.is_empty() or line.contains(filter): + output_lines.append(line) + return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"}) + + var rtl: RichTextLabel = _find_rtl(editor_log) + if rtl == null: + return error_internal("Could not find RichTextLabel in Output panel") + + var content: String = rtl.get_parsed_text() + var all_lines: PackedStringArray = content.split("\n") + var start: int = maxi(0, all_lines.size() - max_lines) + var output_lines: Array = [] + for i in range(start, all_lines.size()): + var line: String = all_lines[i] + if filter.is_empty() or line.contains(filter): + output_lines.append(line) + + return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"}) + + +func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit: + if depth > 8: + return null + if node is CodeEdit: + return node as CodeEdit + for child in node.get_children(): + var found: CodeEdit = _find_code_edit(child, depth + 1) + if found: + return found + return null + + +func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel: + if depth > 6: + return null + if node is RichTextLabel: + return node as RichTextLabel + for child in node.get_children(): + var found: RichTextLabel = _find_rtl(child, depth + 1) + if found: + return found + return null + + +func _get_editor_screenshot(params: Dictionary) -> Dictionary: + # Capture the editor's main viewport - no await to avoid timeout + var base_control: Control = get_editor().get_base_control() + if base_control == null: + return error_internal("Could not access editor base control") + + var viewport: Viewport = base_control.get_viewport() + if viewport == null: + return error_internal("Could not access editor viewport") + + var texture: ViewportTexture = viewport.get_texture() + if texture == null: + return error_internal("Could not get viewport texture") + + var image: Image = texture.get_image() + if image == null: + return error_internal("Could not get image from viewport") + + var save_path: String = params.get("save_path", "") + if save_path != "": + var abs_path := _resolve_save_path(save_path) + var err := image.save_png(abs_path) + if err != OK: + return error_internal("Failed to save screenshot: %s" % error_string(err)) + return success({ + "saved_path": save_path, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + +func _get_game_screenshot(params: Dictionary) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + # Communicate with the game process via file system + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_screenshot_request" + var screenshot_path := user_dir + "/mcp_screenshot.png" + + # Clean up any stale screenshot file + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) + + # Create the request file to signal the game process + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create screenshot request file") + req.close() + + # Poll for the screenshot file (max 3 seconds, 0.1s interval) + var attempts := 30 + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(screenshot_path): + break + attempts -= 1 + + if not FileAccess.file_exists(screenshot_path): + # Clean up request file if it still exists + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Screenshot timed out", { + "suggestion": "Ensure the game is running and MCPScreenshot autoload is active", + }) + + # Load the PNG file + var image := Image.new() + var err := image.load(screenshot_path) + if err != OK: + DirAccess.remove_absolute(screenshot_path) + return error_internal("Failed to load screenshot: %s" % error_string(err)) + + # Clean up temp file + DirAccess.remove_absolute(screenshot_path) + + var save_path_param: String = params.get("save_path", "") + if save_path_param != "": + var abs_path := _resolve_save_path(save_path_param) + var save_err := image.save_png(abs_path) + if save_err != OK: + return error_internal("Failed to save screenshot: %s" % error_string(save_err)) + return success({ + "saved_path": save_path_param, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + }) + + +func _resolve_save_path(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + return path + + +func _execute_editor_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "code") + if result[1] != null: + return result[1] + var code: String = result[0] + + # Wrap user code in a @tool script + var wrapped_code := """@tool +extends Node + +var _mcp_output: Array = [] + +func _mcp_print(value: Variant) -> void: + _mcp_output.append(str(value)) + +func run() -> Variant: + # User code begins +%s + # User code ends + return _mcp_output +""" % _indent_code(code) + + # Create a temporary script + var script := GDScript.new() + script.source_code = wrapped_code + var err := script.reload() + + if err != OK: + return error(-32002, "Script compilation failed", { + "error": error_string(err), + "code": wrapped_code, + }) + + # Create temp node and execute + var temp_node := Node.new() + temp_node.set_script(script) + add_child(temp_node) + + var output: Variant = null + + # Execute with error handling + if temp_node.has_method("run"): + output = temp_node.run() + + var mcp_output: Array = [] + var raw_output: Variant = temp_node.get("_mcp_output") + if raw_output is Array: + mcp_output = raw_output + + # Cleanup + temp_node.queue_free() + + return success({ + "output": mcp_output, + "return_value": str(output) if output != null else null, + }) + + +func _indent_code(code: String) -> String: + var lines := code.split("\n") + var indented: PackedStringArray = [] + for line in lines: + indented.append("\t" + line) + return "\n".join(indented) + + +func _clear_output(params: Dictionary) -> Dictionary: + print("\n".repeat(50)) + return success({"cleared": true}) + + +func _reload_plugin(params: Dictionary) -> Dictionary: + # Disable and re-enable this plugin to reload all scripts + var plugin_name := "godot_mcp" + var ei := get_editor() + + # Send success BEFORE reloading (connection will briefly drop) + # Use call_deferred so the response is sent first + _deferred_reload_plugin.call_deferred(ei, plugin_name) + return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."}) + + +func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void: + ei.set_plugin_enabled(plugin_name, false) + ei.set_plugin_enabled(plugin_name, true) + print("[MCP] Plugin reloaded") + + +func _reload_project(params: Dictionary) -> Dictionary: + # Rescan filesystem and reload changed scripts + var ei := get_editor() + ei.get_resource_filesystem().scan() + + return success({"reloaded": true, "message": "Filesystem rescanned."}) + + +func _get_signals(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path) + + var signals: Array = [] + for sig in node.get_signal_list(): + var sig_info: Dictionary = { + "name": sig["name"], + "args": [], + } + for arg in sig["args"]: + sig_info["args"].append({"name": arg["name"], "type": arg["type"]}) + + # Get connections for this signal + var connections: Array = [] + for conn in node.get_signal_connection_list(sig["name"]): + connections.append({ + "target": str(root.get_path_to(conn["callable"].get_object())), + "method": conn["callable"].get_method(), + }) + sig_info["connections"] = connections + signals.append(sig_info) + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "signals": signals, + "count": signals.size(), + }) + + +func _load_image_from_param(value: String, label: String) -> Array: + ## Returns [Image, null] on success or [null, error_dict] on failure. + ## Accepts a file path (res://, user://) or raw base64 PNG data. + var img := Image.new() + if value.begins_with("res://") or value.begins_with("user://"): + var err := img.load(value) + if err != OK: + return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])] + return [img, null] + # Treat as base64 PNG + var buf := Marshalls.base64_to_raw(value) + var err := img.load_png_from_buffer(buf) + if err != OK: + return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])] + return [img, null] + + +func _compare_screenshots(params: Dictionary) -> Dictionary: + var result := require_string(params, "image_a") + if result[1] != null: + return result[1] + var image_a_value: String = result[0] + + var result2 := require_string(params, "image_b") + if result2[1] != null: + return result2[1] + var image_b_value: String = result2[0] + + var threshold: int = optional_int(params, "threshold", 10) + + # Load images (from path or base64) + var load_a := _load_image_from_param(image_a_value, "image_a") + if load_a[1] != null: + return load_a[1] + var img_a: Image = load_a[0] + + var load_b := _load_image_from_param(image_b_value, "image_b") + if load_b[1] != null: + return load_b[1] + var img_b: Image = load_b[0] + + if img_a.get_size() != img_b.get_size(): + return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())]) + + var width := img_a.get_width() + var height := img_a.get_height() + var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8) + + var changed_pixels: int = 0 + var total_pixels: int = width * height + + for y in height: + for x in width: + var ca: Color = img_a.get_pixel(x, y) + var cb: Color = img_b.get_pixel(x, y) + var dr := absi(int(ca.r8) - int(cb.r8)) + var dg := absi(int(ca.g8) - int(cb.g8)) + var db := absi(int(ca.b8) - int(cb.b8)) + var max_diff := maxi(dr, maxi(dg, db)) + if max_diff > threshold: + changed_pixels += 1 + # Red highlight for changed pixels + diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0))) + else: + # Dim version of original + diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0)) + + var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0 + var identical: bool = changed_pixels == 0 + + # Encode diff image + var diff_png := diff_image.save_png_to_buffer() + var diff_base64 := Marshalls.raw_to_base64(diff_png) + + return success({ + "identical": identical, + "changed_pixels": changed_pixels, + "total_pixels": total_pixels, + "diff_percentage": snappedf(diff_percentage, 0.01), + "threshold": threshold, + "width": width, + "height": height, + "diff_image_base64": diff_base64, + }) + + +func _get_editor_camera(_params: Dictionary) -> Dictionary: + var vp3d := EditorInterface.get_editor_viewport_3d() + var cam := vp3d.get_camera_3d() if vp3d else null + if not cam: + return error(-32000, "No 3D editor camera found", { + "suggestion": "Make sure a 3D scene is open in the editor", + }) + var pos := cam.global_position + var rot := cam.rotation_degrees + return success({ + "position": {"x": pos.x, "y": pos.y, "z": pos.z}, + "rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z}, + "fov": cam.fov, + "near": cam.near, + "far": cam.far, + }) + + +func _set_editor_camera(params: Dictionary) -> Dictionary: + var vp3d := EditorInterface.get_editor_viewport_3d() + var cam := vp3d.get_camera_3d() if vp3d else null + if not cam: + return error(-32000, "No 3D editor camera found", { + "suggestion": "Make sure a 3D scene is open in the editor", + }) + + # Set position + if params.has("position"): + var p: Dictionary = params["position"] + cam.global_position = Vector3( + float(p.get("x", cam.global_position.x)), + float(p.get("y", cam.global_position.y)), + float(p.get("z", cam.global_position.z)), + ) + + # Set rotation + if params.has("rotation_degrees"): + var r: Dictionary = params["rotation_degrees"] + cam.rotation_degrees = Vector3( + float(r.get("x", cam.rotation_degrees.x)), + float(r.get("y", cam.rotation_degrees.y)), + float(r.get("z", cam.rotation_degrees.z)), + ) + + # Look at target (overrides rotation if set) + if params.has("look_at"): + var t: Dictionary = params["look_at"] + cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0)))) + + # Set FOV + if params.has("fov"): + cam.fov = float(params["fov"]) + + var pos := cam.global_position + var rot := cam.rotation_degrees + return success({ + "position": {"x": pos.x, "y": pos.y, "z": pos.z}, + "rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z}, + "fov": cam.fov, + }) + + +func _set_auto_dismiss(params: Dictionary) -> Dictionary: + var enabled: bool = params.get("enabled", true) + editor_plugin.auto_dismiss_dialogs = enabled + return success({ + "auto_dismiss": enabled, + "message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"), + }) diff --git a/addons/godot_mcp/commands/editor_commands.gd.uid b/addons/godot_mcp/commands/editor_commands.gd.uid new file mode 100644 index 0000000..ae2baa2 --- /dev/null +++ b/addons/godot_mcp/commands/editor_commands.gd.uid @@ -0,0 +1 @@ +uid://bxs5156xdtne diff --git a/addons/godot_mcp/commands/export_commands.gd b/addons/godot_mcp/commands/export_commands.gd new file mode 100644 index 0000000..64164ed --- /dev/null +++ b/addons/godot_mcp/commands/export_commands.gd @@ -0,0 +1,117 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_export_presets": _list_export_presets, + "export_project": _export_project, + "get_export_info": _get_export_info, + } + + +func _list_export_presets(params: Dictionary) -> Dictionary: + # Read export_presets.cfg + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return success({"presets": [], "count": 0, "message": "No export_presets.cfg found"}) + + var cfg := ConfigFile.new() + var err := cfg.load(presets_path) + if err != OK: + return error_internal("Failed to read export_presets.cfg: %s" % error_string(err)) + + var presets: Array = [] + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + presets.append({ + "index": idx, + "name": cfg.get_value(section, "name", ""), + "platform": cfg.get_value(section, "platform", ""), + "runnable": cfg.get_value(section, "runnable", false), + "export_path": cfg.get_value(section, "export_path", ""), + }) + idx += 1 + + return success({"presets": presets, "count": presets.size()}) + + +func _export_project(params: Dictionary) -> Dictionary: + var preset_index: int = optional_int(params, "preset_index", -1) + var preset_name: String = optional_string(params, "preset_name", "") + var debug: bool = optional_bool(params, "debug", true) + + # Find preset + var presets_path := "res://export_presets.cfg" + if not FileAccess.file_exists(presets_path): + return error(-32000, "No export_presets.cfg found. Configure exports in Project > Export first.") + + var cfg := ConfigFile.new() + var err := cfg.load(presets_path) + if err != OK: + return error_internal("Failed to read export_presets.cfg") + + # Find by name or index + var target_section := "" + var target_name := "" + var target_path := "" + + if not preset_name.is_empty(): + var idx := 0 + while cfg.has_section("preset.%d" % idx): + var section := "preset.%d" % idx + if cfg.get_value(section, "name", "") == preset_name: + target_section = section + target_name = preset_name + target_path = cfg.get_value(section, "export_path", "") + break + idx += 1 + elif preset_index >= 0: + var section := "preset.%d" % preset_index + if cfg.has_section(section): + target_section = section + target_name = cfg.get_value(section, "name", "") + target_path = cfg.get_value(section, "export_path", "") + + if target_section.is_empty(): + return error_not_found("Export preset") + + if target_path.is_empty(): + return error(-32000, "Export path not configured for preset '%s'" % target_name) + + # Use EditorExportPlatform via command line + # We can't directly call export from the plugin, so we return the command to run + var godot_path := OS.get_executable_path() + var project_path := ProjectSettings.globalize_path("res://") + var export_path := ProjectSettings.globalize_path(target_path) if target_path.begins_with("res://") else target_path + + var flag := "--export-debug" if debug else "--export-release" + var command := '"%s" --headless --path "%s" %s "%s"' % [godot_path, project_path, flag, target_name] + + return success({ + "preset": target_name, + "export_path": export_path, + "debug": debug, + "command": command, + "message": "Run the command above to export. Direct export from editor plugin is not supported in Godot 4.", + }) + + +func _get_export_info(params: Dictionary) -> Dictionary: + # General export-related project info + var info := {} + + # Check if export_presets.cfg exists + info["has_export_presets"] = FileAccess.file_exists("res://export_presets.cfg") + + # Get Godot executable path (useful for command-line exports) + info["godot_executable"] = OS.get_executable_path() + info["project_path"] = ProjectSettings.globalize_path("res://") + + # Check for common export templates + var templates_path := OS.get_data_dir().path_join("export_templates") + info["templates_dir"] = templates_path + info["templates_installed"] = DirAccess.dir_exists_absolute(templates_path) + + return success(info) diff --git a/addons/godot_mcp/commands/export_commands.gd.uid b/addons/godot_mcp/commands/export_commands.gd.uid new file mode 100644 index 0000000..4dbf0f7 --- /dev/null +++ b/addons/godot_mcp/commands/export_commands.gd.uid @@ -0,0 +1 @@ +uid://dqo2ldb6gok1f diff --git a/addons/godot_mcp/commands/input_commands.gd b/addons/godot_mcp/commands/input_commands.gd new file mode 100644 index 0000000..8dbcacd --- /dev/null +++ b/addons/godot_mcp/commands/input_commands.gd @@ -0,0 +1,162 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const COMMANDS_PATH := "user://mcp_input_commands" + + +func get_commands() -> Dictionary: + return { + "simulate_key": _simulate_key, + "simulate_mouse_click": _simulate_mouse_click, + "simulate_mouse_move": _simulate_mouse_move, + "simulate_action": _simulate_action, + "simulate_sequence": _simulate_sequence, + } + + +func _simulate_key(params: Dictionary) -> Dictionary: + var result := require_string(params, "keycode") + if result[1] != null: + return result[1] + var keycode: String = result[0] + + var pressed: bool = optional_bool(params, "pressed", true) + var shift: bool = optional_bool(params, "shift", false) + var ctrl: bool = optional_bool(params, "ctrl", false) + var alt: bool = optional_bool(params, "alt", false) + + var event := { + "type": "key", + "keycode": keycode, + "pressed": pressed, + "shift": shift, + "ctrl": ctrl, + "alt": alt, + } + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_mouse_click(params: Dictionary) -> Dictionary: + var button: int = optional_int(params, "button", 1) # MOUSE_BUTTON_LEFT + var pressed: bool = optional_bool(params, "pressed", true) + var double_click: bool = optional_bool(params, "double_click", false) + var auto_release: bool = optional_bool(params, "auto_release", true) + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + + var press_event := { + "type": "mouse_button", + "button": button, + "pressed": pressed, + "double_click": double_click, + "position": {"x": x, "y": y}, + } + + # Auto-release: send press + release in sequence so UI buttons actually fire + if pressed and auto_release: + var release_event := press_event.duplicate() + release_event["pressed"] = false + var sequence_data := { + "sequence_events": [press_event, release_event], + "frame_delay": 1, + } + var json := JSON.stringify(sequence_data) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + file.store_string(json) + file.close() + return success({"sent": true, "event": press_event, "auto_release": true}) + + _write_commands([press_event]) + return success({"sent": true, "event": press_event}) + + +func _simulate_mouse_move(params: Dictionary) -> Dictionary: + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var rel_x: float = float(params.get("relative_x", 0)) + var rel_y: float = float(params.get("relative_y", 0)) + var button_mask: int = optional_int(params, "button_mask", 0) + var unhandled_explicit: bool = params.has("unhandled") + var unhandled: bool = optional_bool(params, "unhandled", false) + + var event := { + "type": "mouse_motion", + "position": {"x": x, "y": y}, + "relative": {"x": rel_x, "y": rel_y}, + "button_mask": button_mask, + } + # Auto-enable unhandled for drag motions (camera-pan use case) ONLY when + # the caller did NOT explicitly pass an "unhandled" key. If they passed + # one — true or false — honor it. This lets UI drag-and-drop tests opt + # back into normal GUI dispatch by passing unhandled: false explicitly. + if unhandled_explicit: + event["unhandled"] = unhandled + elif button_mask > 0: + event["unhandled"] = true + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_action(params: Dictionary) -> Dictionary: + var result := require_string(params, "action") + if result[1] != null: + return result[1] + var action_name: String = result[0] + + var pressed: bool = optional_bool(params, "pressed", true) + var strength: float = float(params.get("strength", 1.0)) + + var event := { + "type": "action", + "action": action_name, + "pressed": pressed, + "strength": strength, + } + _write_commands([event]) + return success({"sent": true, "event": event}) + + +func _simulate_sequence(params: Dictionary) -> Dictionary: + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("Missing required parameter: events (Array)") + + var events: Array = params["events"] + if events.is_empty(): + return error_invalid_params("Events array is empty") + + var frame_delay: int = optional_int(params, "frame_delay", 1) + + for event_data: Dictionary in events: + if not event_data.has("type") or (event_data["type"] as String).is_empty(): + return error_invalid_params("Invalid event in sequence: %s" % str(event_data)) + + if frame_delay <= 0: + # All events in one frame - write as plain array + _write_commands(events) + else: + # Sequence with frame delay - game side handles timing + var sequence_data := { + "sequence_events": events, + "frame_delay": frame_delay, + } + var json := JSON.stringify(sequence_data) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + file.store_string(json) + file.close() + + return success({"sent": true, "event_count": events.size(), "frame_delay": frame_delay}) + + +func _write_commands(events: Array) -> void: + var json := JSON.stringify(events) + var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE) + if file == null: + push_error("[MCP Input] Failed to write commands: %s" % error_string(FileAccess.get_open_error())) + return + file.store_string(json) + file.close() diff --git a/addons/godot_mcp/commands/input_commands.gd.uid b/addons/godot_mcp/commands/input_commands.gd.uid new file mode 100644 index 0000000..b7287ef --- /dev/null +++ b/addons/godot_mcp/commands/input_commands.gd.uid @@ -0,0 +1 @@ +uid://c7n50xbscvtl diff --git a/addons/godot_mcp/commands/input_map_commands.gd b/addons/godot_mcp/commands/input_map_commands.gd new file mode 100644 index 0000000..6798ffc --- /dev/null +++ b/addons/godot_mcp/commands/input_map_commands.gd @@ -0,0 +1,151 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_input_actions": _get_input_actions, + "set_input_action": _set_input_action, + } + + +func _get_input_actions(params: Dictionary) -> Dictionary: + var filter: String = optional_string(params, "filter", "") + var include_builtin: bool = optional_bool(params, "include_builtin", false) + + var actions: Dictionary = {} + for action: StringName in InputMap.get_actions(): + var action_str := str(action) + # Skip built-in UI actions unless requested + if not include_builtin and action_str.begins_with("ui_"): + continue + # Apply filter + if not filter.is_empty() and not action_str.contains(filter): + continue + + var events: Array = [] + for event: InputEvent in InputMap.action_get_events(action): + events.append(_serialize_event(event)) + + actions[action_str] = { + "deadzone": InputMap.action_get_deadzone(action), + "events": events, + } + + return success({"actions": actions, "count": actions.size()}) + + +func _set_input_action(params: Dictionary) -> Dictionary: + var result := require_string(params, "action") + if result[1] != null: + return result[1] + var action_name: String = result[0] + + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("'events' array is required") + var event_defs: Array = params["events"] + + var deadzone: float = float(params.get("deadzone", 0.5)) + + # Build the events array + var events: Array[InputEvent] = [] + for event_def in event_defs: + if not event_def is Dictionary: + continue + var event := _parse_event(event_def) + if event != null: + events.append(event) + + # Save to ProjectSettings + var setting_value := { + "deadzone": deadzone, + "events": events, + } + + ProjectSettings.set_setting("input/" + action_name, setting_value) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + # Also update the runtime InputMap + if not InputMap.has_action(action_name): + InputMap.add_action(action_name, deadzone) + else: + InputMap.action_set_deadzone(action_name, deadzone) + InputMap.action_erase_events(action_name) + for event in events: + InputMap.action_add_event(action_name, event) + + return success({ + "action": action_name, + "deadzone": deadzone, + "events_count": events.size(), + "saved": true, + }) + + +func _serialize_event(event: InputEvent) -> Dictionary: + if event is InputEventKey: + var key_event: InputEventKey = event + var info := { + "type": "key", + "keycode": OS.get_keycode_string(key_event.keycode) if key_event.keycode != KEY_NONE else "", + "physical_keycode": OS.get_keycode_string(key_event.physical_keycode) if key_event.physical_keycode != KEY_NONE else "", + } + if key_event.ctrl_pressed: info["ctrl"] = true + if key_event.shift_pressed: info["shift"] = true + if key_event.alt_pressed: info["alt"] = true + if key_event.meta_pressed: info["meta"] = true + return info + elif event is InputEventMouseButton: + var mb_event: InputEventMouseButton = event + return { + "type": "mouse_button", + "button_index": mb_event.button_index, + } + elif event is InputEventJoypadButton: + var jb_event: InputEventJoypadButton = event + return { + "type": "joypad_button", + "button_index": jb_event.button_index, + } + elif event is InputEventJoypadMotion: + var jm_event: InputEventJoypadMotion = event + return { + "type": "joypad_motion", + "axis": jm_event.axis, + "axis_value": jm_event.axis_value, + } + return {"type": event.get_class()} + + +func _parse_event(def: Dictionary) -> InputEvent: + var type: String = def.get("type", "") + match type: + "key": + var event := InputEventKey.new() + var keycode_str: String = def.get("keycode", "") + if not keycode_str.is_empty(): + event.keycode = OS.find_keycode_from_string(keycode_str) + var phys_str: String = def.get("physical_keycode", "") + if not phys_str.is_empty(): + event.physical_keycode = OS.find_keycode_from_string(phys_str) + event.ctrl_pressed = def.get("ctrl", false) + event.shift_pressed = def.get("shift", false) + event.alt_pressed = def.get("alt", false) + event.meta_pressed = def.get("meta", false) + return event + "mouse_button": + var event := InputEventMouseButton.new() + event.button_index = int(def.get("button_index", 1)) + return event + "joypad_button": + var event := InputEventJoypadButton.new() + event.button_index = int(def.get("button_index", 0)) + return event + "joypad_motion": + var event := InputEventJoypadMotion.new() + event.axis = int(def.get("axis", 0)) + event.axis_value = float(def.get("axis_value", 1.0)) + return event + return null diff --git a/addons/godot_mcp/commands/input_map_commands.gd.uid b/addons/godot_mcp/commands/input_map_commands.gd.uid new file mode 100644 index 0000000..5aecbd6 --- /dev/null +++ b/addons/godot_mcp/commands/input_map_commands.gd.uid @@ -0,0 +1 @@ +uid://ce6ppbync0eke diff --git a/addons/godot_mcp/commands/navigation_commands.gd b/addons/godot_mcp/commands/navigation_commands.gd new file mode 100644 index 0000000..e688898 --- /dev/null +++ b/addons/godot_mcp/commands/navigation_commands.gd @@ -0,0 +1,464 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "setup_navigation_region": _setup_navigation_region, + "bake_navigation_mesh": _bake_navigation_mesh, + "setup_navigation_agent": _setup_navigation_agent, + "set_navigation_layers": _set_navigation_layers, + "get_navigation_info": _get_navigation_info, + } + + +func _is_3d_context(node: Node) -> bool: + if node is Node3D: + return true + if node is Node2D: + return false + # Walk up to detect context + var parent := node.get_parent() + while parent != null: + if parent is Node3D: + return true + if parent is Node2D: + return false + parent = parent.get_parent() + return false + + +func _setup_navigation_region(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var force_mode: String = optional_string(params, "mode", "auto") + var is_3d: bool + match force_mode: + "2d": is_3d = false + "3d": is_3d = true + _: is_3d = _is_3d_context(node) + + if is_3d: + var region := NavigationRegion3D.new() + region.name = optional_string(params, "name", "NavigationRegion3D") + + var nav_mesh := NavigationMesh.new() + nav_mesh.agent_radius = float(params.get("agent_radius", 0.5)) + nav_mesh.agent_height = float(params.get("agent_height", 1.5)) + nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25)) + nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0)) + nav_mesh.cell_size = float(params.get("cell_size", 0.25)) + nav_mesh.cell_height = float(params.get("cell_height", 0.25)) + region.navigation_mesh = nav_mesh + + if params.has("navigation_layers"): + region.navigation_layers = int(params["navigation_layers"]) + + node.add_child(region, true) + region.owner = get_edited_root() + + return success({ + "node_path": str(region.get_path()), + "type": "NavigationRegion3D", + "agent_radius": nav_mesh.agent_radius, + "agent_height": nav_mesh.agent_height, + "cell_size": nav_mesh.cell_size, + "created": true, + }) + else: + var region := NavigationRegion2D.new() + region.name = optional_string(params, "name", "NavigationRegion2D") + + var nav_poly := NavigationPolygon.new() + + # Set parsed geometry source if available + if params.has("source_geometry_mode"): + var mode_str: String = str(params["source_geometry_mode"]) + match mode_str: + "root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN + "groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN + "groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT + + if params.has("cell_size"): + nav_poly.cell_size = float(params["cell_size"]) + + if params.has("agent_radius"): + nav_poly.agent_radius = float(params["agent_radius"]) + + region.navigation_polygon = nav_poly + + if params.has("navigation_layers"): + region.navigation_layers = int(params["navigation_layers"]) + + node.add_child(region, true) + region.owner = get_edited_root() + + return success({ + "node_path": str(region.get_path()), + "type": "NavigationRegion2D", + "cell_size": nav_poly.cell_size, + "created": true, + }) + + +func _bake_navigation_mesh(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + if node is NavigationRegion3D: + var region: NavigationRegion3D = node as NavigationRegion3D + if region.navigation_mesh == null: + return error_invalid_params("NavigationRegion3D has no NavigationMesh resource") + region.bake_navigation_mesh() + return success({ + "node_path": node_path, + "type": "NavigationRegion3D", + "baked": true, + }) + + elif node is NavigationRegion2D: + var region: NavigationRegion2D = node as NavigationRegion2D + if region.navigation_polygon == null: + var nav_poly := NavigationPolygon.new() + region.navigation_polygon = nav_poly + + # Set outline vertices from params + if params.has("outline"): + var outline_data: Array = params["outline"] + var outline := PackedVector2Array() + for point in outline_data: + if point is Array and point.size() >= 2: + outline.append(Vector2(float(point[0]), float(point[1]))) + elif point is Dictionary: + outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0)))) + + if outline.size() >= 3: + # Clear existing outlines + while region.navigation_polygon.get_outline_count() > 0: + region.navigation_polygon.remove_outline(0) + region.navigation_polygon.add_outline(outline) + region.navigation_polygon.make_polygons_from_outlines() + return success({ + "node_path": node_path, + "type": "NavigationRegion2D", + "outline_vertices": outline.size(), + "baked": true, + }) + else: + return error_invalid_params("Outline must have at least 3 vertices") + else: + # Try baking from source geometry + region.bake_navigation_polygon() + return success({ + "node_path": node_path, + "type": "NavigationRegion2D", + "baked": true, + }) + + return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path) + + +func _setup_navigation_agent(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var force_mode: String = optional_string(params, "mode", "auto") + var is_3d: bool + match force_mode: + "2d": is_3d = false + "3d": is_3d = true + _: is_3d = _is_3d_context(node) + + var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D") + + if is_3d: + var agent := NavigationAgent3D.new() + agent.name = agent_name + + if params.has("path_desired_distance"): + agent.path_desired_distance = float(params["path_desired_distance"]) + if params.has("target_desired_distance"): + agent.target_desired_distance = float(params["target_desired_distance"]) + if params.has("radius"): + agent.radius = float(params["radius"]) + if params.has("neighbor_distance"): + agent.neighbor_distance = float(params["neighbor_distance"]) + if params.has("max_neighbors"): + agent.max_neighbors = int(params["max_neighbors"]) + if params.has("max_speed"): + agent.max_speed = float(params["max_speed"]) + if params.has("avoidance_enabled"): + agent.avoidance_enabled = bool(params["avoidance_enabled"]) + if params.has("navigation_layers"): + agent.navigation_layers = int(params["navigation_layers"]) + + node.add_child(agent, true) + agent.owner = get_edited_root() + + return success({ + "node_path": str(agent.get_path()), + "type": "NavigationAgent3D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + "created": true, + }) + else: + var agent := NavigationAgent2D.new() + agent.name = agent_name + + if params.has("path_desired_distance"): + agent.path_desired_distance = float(params["path_desired_distance"]) + if params.has("target_desired_distance"): + agent.target_desired_distance = float(params["target_desired_distance"]) + if params.has("radius"): + agent.radius = float(params["radius"]) + if params.has("neighbor_distance"): + agent.neighbor_distance = float(params["neighbor_distance"]) + if params.has("max_neighbors"): + agent.max_neighbors = int(params["max_neighbors"]) + if params.has("max_speed"): + agent.max_speed = float(params["max_speed"]) + if params.has("avoidance_enabled"): + agent.avoidance_enabled = bool(params["avoidance_enabled"]) + if params.has("navigation_layers"): + agent.navigation_layers = int(params["navigation_layers"]) + + node.add_child(agent, true) + agent.owner = get_edited_root() + + return success({ + "node_path": str(agent.get_path()), + "type": "NavigationAgent2D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + "created": true, + }) + + +func _set_navigation_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + # Support setting by bitmask value + if params.has("layers"): + var layers_val: int = int(params["layers"]) + if node is NavigationRegion2D: + (node as NavigationRegion2D).navigation_layers = layers_val + elif node is NavigationRegion3D: + (node as NavigationRegion3D).navigation_layers = layers_val + elif node is NavigationAgent2D: + (node as NavigationAgent2D).navigation_layers = layers_val + elif node is NavigationAgent3D: + (node as NavigationAgent3D).navigation_layers = layers_val + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": layers_val, + "updated": true, + }) + + # Support setting individual layer bits by number + if params.has("layer_bits"): + var bits: Array = params["layer_bits"] + var current_layers: int = 0 + + # Calculate bitmask from layer numbers (1-based) + for bit in bits: + var layer_num: int = int(bit) + if layer_num >= 1 and layer_num <= 32: + current_layers |= (1 << (layer_num - 1)) + + if node is NavigationRegion2D: + (node as NavigationRegion2D).navigation_layers = current_layers + elif node is NavigationRegion3D: + (node as NavigationRegion3D).navigation_layers = current_layers + elif node is NavigationAgent2D: + (node as NavigationAgent2D).navigation_layers = current_layers + elif node is NavigationAgent3D: + (node as NavigationAgent3D).navigation_layers = current_layers + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": current_layers, + "layer_bits": bits, + "updated": true, + }) + + # Support named layers from ProjectSettings + if params.has("layer_names"): + var names: Array = params["layer_names"] + var current_layers: int = 0 + var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D + var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_" + + for i in range(1, 33): + var setting_key: String = prefix + str(i) + if ProjectSettings.has_setting(setting_key): + var layer_name: String = str(ProjectSettings.get_setting(setting_key)) + if layer_name in names: + current_layers |= (1 << (i - 1)) + + if node is NavigationRegion2D: + (node as NavigationRegion2D).navigation_layers = current_layers + elif node is NavigationRegion3D: + (node as NavigationRegion3D).navigation_layers = current_layers + elif node is NavigationAgent2D: + (node as NavigationAgent2D).navigation_layers = current_layers + elif node is NavigationAgent3D: + (node as NavigationAgent3D).navigation_layers = current_layers + else: + return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path) + + return success({ + "node_path": node_path, + "navigation_layers": current_layers, + "layer_names": names, + "updated": true, + }) + + return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)") + + +func _get_navigation_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var regions: Array = [] + var agents: Array = [] + + _collect_navigation_nodes(node, regions, agents) + + # Collect named layers from ProjectSettings + var layer_names_2d: Dictionary = {} + var layer_names_3d: Dictionary = {} + for i in range(1, 33): + var key_2d: String = "layer_names/2d_navigation/layer_" + str(i) + var key_3d: String = "layer_names/3d_navigation/layer_" + str(i) + if ProjectSettings.has_setting(key_2d): + var name_2d: String = str(ProjectSettings.get_setting(key_2d)) + if not name_2d.is_empty(): + layer_names_2d[i] = name_2d + if ProjectSettings.has_setting(key_3d): + var name_3d: String = str(ProjectSettings.get_setting(key_3d)) + if not name_3d.is_empty(): + layer_names_3d[i] = name_3d + + return success({ + "node_path": node_path, + "regions": regions, + "agents": agents, + "region_count": regions.size(), + "agent_count": agents.size(), + "layer_names_2d": layer_names_2d, + "layer_names_3d": layer_names_3d, + }) + + +func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void: + if node is NavigationRegion2D: + var region: NavigationRegion2D = node as NavigationRegion2D + var region_info := { + "path": str(region.get_path()), + "type": "NavigationRegion2D", + "enabled": region.enabled, + "navigation_layers": region.navigation_layers, + "has_polygon": region.navigation_polygon != null, + } + if region.navigation_polygon != null: + var nav_poly: NavigationPolygon = region.navigation_polygon + region_info["outline_count"] = nav_poly.get_outline_count() + region_info["polygon_count"] = nav_poly.get_polygon_count() + region_info["cell_size"] = nav_poly.cell_size + region_info["agent_radius"] = nav_poly.agent_radius + regions.append(region_info) + + elif node is NavigationRegion3D: + var region: NavigationRegion3D = node as NavigationRegion3D + var region_info := { + "path": str(region.get_path()), + "type": "NavigationRegion3D", + "enabled": region.enabled, + "navigation_layers": region.navigation_layers, + "has_mesh": region.navigation_mesh != null, + } + if region.navigation_mesh != null: + var nav_mesh: NavigationMesh = region.navigation_mesh + region_info["agent_radius"] = nav_mesh.agent_radius + region_info["agent_height"] = nav_mesh.agent_height + region_info["agent_max_climb"] = nav_mesh.agent_max_climb + region_info["agent_max_slope"] = nav_mesh.agent_max_slope + region_info["cell_size"] = nav_mesh.cell_size + region_info["cell_height"] = nav_mesh.cell_height + regions.append(region_info) + + if node is NavigationAgent2D: + var agent: NavigationAgent2D = node as NavigationAgent2D + agents.append({ + "path": str(agent.get_path()), + "type": "NavigationAgent2D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "path_desired_distance": agent.path_desired_distance, + "target_desired_distance": agent.target_desired_distance, + "neighbor_distance": agent.neighbor_distance, + "max_neighbors": agent.max_neighbors, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + }) + + elif node is NavigationAgent3D: + var agent: NavigationAgent3D = node as NavigationAgent3D + agents.append({ + "path": str(agent.get_path()), + "type": "NavigationAgent3D", + "radius": agent.radius, + "max_speed": agent.max_speed, + "path_desired_distance": agent.path_desired_distance, + "target_desired_distance": agent.target_desired_distance, + "neighbor_distance": agent.neighbor_distance, + "max_neighbors": agent.max_neighbors, + "avoidance_enabled": agent.avoidance_enabled, + "navigation_layers": agent.navigation_layers, + }) + + for child in node.get_children(): + _collect_navigation_nodes(child, regions, agents) diff --git a/addons/godot_mcp/commands/navigation_commands.gd.uid b/addons/godot_mcp/commands/navigation_commands.gd.uid new file mode 100644 index 0000000..5d7cd76 --- /dev/null +++ b/addons/godot_mcp/commands/navigation_commands.gd.uid @@ -0,0 +1 @@ +uid://cy2x6yf3qm20e diff --git a/addons/godot_mcp/commands/node_commands.gd b/addons/godot_mcp/commands/node_commands.gd new file mode 100644 index 0000000..b386d6a --- /dev/null +++ b/addons/godot_mcp/commands/node_commands.gd @@ -0,0 +1,687 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "add_node": _add_node, + "delete_node": _delete_node, + "duplicate_node": _duplicate_node, + "move_node": _move_node, + "update_property": _update_property, + "get_node_properties": _get_node_properties, + "add_resource": _add_resource, + "set_anchor_preset": _set_anchor_preset, + "rename_node": _rename_node, + "connect_signal": _connect_signal, + "disconnect_signal": _disconnect_signal, + "get_node_groups": _get_node_groups, + "set_node_groups": _set_node_groups, + "find_nodes_in_group": _find_nodes_in_group, + } + + +func _find_script_by_class_name(class_name_str: String) -> Script: + # Search project files for a script with matching class_name + var global_classes: Array = ProjectSettings.get_global_class_list() + for entry: Dictionary in global_classes: + if entry.get("class", "") == class_name_str: + var path: String = entry.get("path", "") + if not path.is_empty(): + return load(path) as Script + return null + + +func _add_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "type") + if result[1] != null: + return result[1] + var type: String = result[0] + + var parent_path: String = optional_string(params, "parent_path", ".") + var node_name: String = optional_string(params, "name", "") + var properties: Dictionary = params.get("properties", {}) + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes") + + var node: Node + var custom_script: Script = null + + if ClassDB.class_exists(type): + node = ClassDB.instantiate(type) + else: + # Try to find a script with matching class_name + custom_script = _find_script_by_class_name(type) + if custom_script == null: + return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type) + var base_type: String = custom_script.get_instance_base_type() + if not ClassDB.class_exists(base_type): + return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type]) + node = ClassDB.instantiate(base_type) + node.set_script(custom_script) + if not node_name.is_empty(): + node.name = node_name + + # Apply properties + for prop_name: String in properties: + var prop_exists := false + for prop in node.get_property_list(): + if prop["name"] == prop_name: + prop_exists = true + break + if prop_exists: + var current: Variant = node.get(prop_name) + var target_type := typeof(current) + node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type)) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add %s" % type) + undo_redo.add_do_method(parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(parent, "remove_child", node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "type": type, + "name": str(node.name), + }) + + +func _delete_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if node == root: + return error_invalid_params("Cannot delete the root node") + + var parent := node.get_parent() + var node_name := str(node.name) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Delete %s" % node_name) + undo_redo.add_do_method(parent, "remove_child", node) + undo_redo.add_undo_method(parent, "add_child", node) + undo_redo.add_undo_method(node, "set_owner", root) + undo_redo.add_undo_reference(node) + undo_redo.commit_action() + + return success({"deleted": node_name}) + + +func _duplicate_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var new_name: String = optional_string(params, "name", "") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if new_name.is_empty(): + new_name = str(node.name) + "_copy" + + var dup := node.duplicate() + dup.name = new_name + var parent := node.get_parent() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Duplicate %s" % node.name) + undo_redo.add_do_method(parent, "add_child", dup) + undo_redo.add_do_method(dup, "set_owner", root) + undo_redo.add_do_reference(dup) + undo_redo.add_undo_method(parent, "remove_child", dup) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(dup, root) + + return success({ + "original": str(root.get_path_to(node)), + "duplicate": str(root.get_path_to(dup)), + "name": str(dup.name), + }) + + +func _move_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "new_parent_path") + if result2[1] != null: + return result2[1] + var new_parent_path: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if node == root: + return error_invalid_params("Cannot move the root node") + + var new_parent := find_node_by_path(new_parent_path) + if new_parent == null: + return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes") + + # Check we're not moving a node into its own subtree + if new_parent == node or node.is_ancestor_of(new_parent): + return error_invalid_params("Cannot move a node into its own subtree") + + var old_parent := node.get_parent() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Move %s" % node.name) + undo_redo.add_do_method(old_parent, "remove_child", node) + undo_redo.add_do_method(new_parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_undo_method(new_parent, "remove_child", node) + undo_redo.add_undo_method(old_parent, "add_child", node) + undo_redo.add_undo_method(node, "set_owner", root) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(node, root) + + return success({ + "node": str(node.name), + "old_parent": str(root.get_path_to(old_parent)), + "new_parent": str(root.get_path_to(new_parent)), + "new_path": str(root.get_path_to(node)), + }) + + +func _update_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + var value: Variant = params["value"] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Check property exists + if not property in node: + var available: Array = [] + for prop in node.get_property_list(): + if prop["usage"] & PROPERTY_USAGE_EDITOR: + available.append(prop["name"]) + return error_not_found("Property '%s' on %s" % [property, node.get_class()], + "Available: %s" % str(available.slice(0, 20))) + + var old_value: Variant = node.get(property) + var target_type := typeof(old_value) + var parsed_value: Variant = PropertyParser.parse_value(value, target_type) + + # Handle @export node references (e.g. @export var hud: HUD) + # typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set, + # neither resolves a string path to a node — check the property hint instead + if value is String: + for prop in node.get_property_list(): + if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE: + var target_node: Node = node.get_node_or_null(NodePath(value)) + if target_node == null: + target_node = root.get_node_or_null(NodePath(value)) + if target_node == null: + return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property) + parsed_value = target_node + break + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set %s.%s" % [node.name, property]) + undo_redo.add_do_property(node, property, parsed_value) + undo_redo.add_undo_property(node, property, old_value) + undo_redo.commit_action() + + return success({ + "node": str(root.get_path_to(node)), + "property": property, + "old_value": PropertyParser.serialize_value(old_value), + "new_value": PropertyParser.serialize_value(node.get(property)), + }) + + +func _get_node_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var category: String = optional_string(params, "category", "") + var props := NodeUtils.get_node_properties_dict(node) + + # Filter by category if specified + if not category.is_empty(): + var filtered: Dictionary = {} + for key: String in props: + if key.begins_with(category): + filtered[key] = props[key] + props = filtered + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "properties": props, + }) + + +func _add_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "property") + if result2[1] != null: + return result2[1] + var property: String = result2[0] + + var result3 := require_string(params, "resource_type") + if result3[1] != null: + return result3[1] + var resource_type: String = result3[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not ClassDB.class_exists(resource_type): + return error_invalid_params("Unknown resource type: %s" % resource_type) + + if not ClassDB.is_parent_class(resource_type, "Resource"): + return error_invalid_params("'%s' is not a Resource type" % resource_type) + + var resource: Resource = ClassDB.instantiate(resource_type) + if resource == null: + return error_internal("Failed to create resource: %s" % resource_type) + + # Apply resource properties if provided + var resource_props: Dictionary = params.get("resource_properties", {}) + for prop_name: String in resource_props: + if prop_name in resource: + var current := resource.get(prop_name) + resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current))) + + var old_value: Variant = node.get(property) if property in node else null + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name]) + undo_redo.add_do_property(node, property, resource) + undo_redo.add_undo_property(node, property, old_value) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "property": property, + "resource_type": resource_type, + }) + + +func _set_anchor_preset(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "preset") + if result2[1] != null: + return result2[1] + var preset_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not node is Control: + return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()]) + + var control: Control = node + var presets := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, + } + + if not presets.has(preset_name): + return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()]) + + var keep_offsets: bool = optional_bool(params, "keep_offsets", false) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set anchor preset on %s" % node.name) + + # Store old values + var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom] + var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom] + + control.set_anchors_and_offsets_preset(presets[preset_name], + Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE) + + undo_redo.add_do_property(control, "anchor_left", control.anchor_left) + undo_redo.add_do_property(control, "anchor_top", control.anchor_top) + undo_redo.add_do_property(control, "anchor_right", control.anchor_right) + undo_redo.add_do_property(control, "anchor_bottom", control.anchor_bottom) + undo_redo.add_do_property(control, "offset_left", control.offset_left) + undo_redo.add_do_property(control, "offset_top", control.offset_top) + undo_redo.add_do_property(control, "offset_right", control.offset_right) + undo_redo.add_do_property(control, "offset_bottom", control.offset_bottom) + + undo_redo.add_undo_property(control, "anchor_left", old_anchors[0]) + undo_redo.add_undo_property(control, "anchor_top", old_anchors[1]) + undo_redo.add_undo_property(control, "anchor_right", old_anchors[2]) + undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3]) + undo_redo.add_undo_property(control, "offset_left", old_offsets[0]) + undo_redo.add_undo_property(control, "offset_top", old_offsets[1]) + undo_redo.add_undo_property(control, "offset_right", old_offsets[2]) + undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3]) + + undo_redo.commit_action() + + return success({"node_path": str(root.get_path_to(control)), "preset": preset_name}) + + +func _rename_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "new_name") + if result2[1] != null: + return result2[1] + var new_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var old_name: String = node.name + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name]) + undo_redo.add_do_property(node, "name", new_name) + undo_redo.add_undo_property(node, "name", old_name) + undo_redo.commit_action() + + return success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))}) + + +func _connect_signal(params: Dictionary) -> Dictionary: + var result := require_string(params, "source_path") + if result[1] != null: + return result[1] + var source_path: String = result[0] + + var result2 := require_string(params, "signal_name") + if result2[1] != null: + return result2[1] + var signal_name: String = result2[0] + + var result3 := require_string(params, "target_path") + if result3[1] != null: + return result3[1] + var target_path: String = result3[0] + + var result4 := require_string(params, "method_name") + if result4[1] != null: + return result4[1] + var method_name: String = result4[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var source := find_node_by_path(source_path) + if source == null: + return error_not_found("Source node '%s'" % source_path) + + var target := find_node_by_path(target_path) + if target == null: + return error_not_found("Target node '%s'" % target_path) + + if not source.has_signal(signal_name): + return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()]) + + if source.is_connected(signal_name, Callable(target, method_name)): + return success({"already_connected": true, "signal": signal_name}) + + source.connect(signal_name, Callable(target, method_name)) + + return success({ + "source": str(root.get_path_to(source)), + "signal": signal_name, + "target": str(root.get_path_to(target)), + "method": method_name, + "connected": true, + }) + + +func _disconnect_signal(params: Dictionary) -> Dictionary: + var result := require_string(params, "source_path") + if result[1] != null: + return result[1] + var source_path: String = result[0] + + var result2 := require_string(params, "signal_name") + if result2[1] != null: + return result2[1] + var signal_name: String = result2[0] + + var result3 := require_string(params, "target_path") + if result3[1] != null: + return result3[1] + var target_path: String = result3[0] + + var result4 := require_string(params, "method_name") + if result4[1] != null: + return result4[1] + var method_name: String = result4[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var source := find_node_by_path(source_path) + if source == null: + return error_not_found("Source node '%s'" % source_path) + + var target := find_node_by_path(target_path) + if target == null: + return error_not_found("Target node '%s'" % target_path) + + if not source.is_connected(signal_name, Callable(target, method_name)): + return success({"was_connected": false}) + + source.disconnect(signal_name, Callable(target, method_name)) + + return success({ + "source": str(root.get_path_to(source)), + "signal": signal_name, + "target": str(root.get_path_to(target)), + "method": method_name, + "disconnected": true, + }) + + +func _get_node_groups(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var groups: Array = [] + for group: StringName in node.get_groups(): + var g := str(group) + # Filter out internal groups (start with _) + if not g.begins_with("_"): + groups.append(g) + + return success({ + "node_path": str(root.get_path_to(node)), + "groups": groups, + "count": groups.size(), + }) + + +func _set_node_groups(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + if not params.has("groups") or not params["groups"] is Array: + return error_invalid_params("'groups' array is required") + var desired_groups: Array = params["groups"] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Get current non-internal groups + var current_groups: Array = [] + for group: StringName in node.get_groups(): + var g := str(group) + if not g.begins_with("_"): + current_groups.append(g) + + var added: Array = [] + var removed: Array = [] + + # Remove groups not in desired + for group: String in current_groups: + if group not in desired_groups: + node.remove_from_group(group) + removed.append(group) + + # Add groups not in current + for group in desired_groups: + var g: String = str(group) + if g not in current_groups: + node.add_to_group(g, true) + added.append(g) + + return success({ + "node_path": str(root.get_path_to(node)), + "groups": desired_groups, + "added": added, + "removed": removed, + }) + + +func _find_nodes_in_group(params: Dictionary) -> Dictionary: + var result := require_string(params, "group") + if result[1] != null: + return result[1] + var group_name: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var matches: Array = [] + _find_in_group_recursive(root, root, group_name, matches) + + return success({ + "group": group_name, + "nodes": matches, + "count": matches.size(), + }) + + +func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void: + if node.is_in_group(group_name): + matches.append({ + "name": node.name, + "path": str(root.get_path_to(node)), + "type": node.get_class(), + }) + for child in node.get_children(): + _find_in_group_recursive(child, root, group_name, matches) diff --git a/addons/godot_mcp/commands/node_commands.gd.uid b/addons/godot_mcp/commands/node_commands.gd.uid new file mode 100644 index 0000000..89cb4e9 --- /dev/null +++ b/addons/godot_mcp/commands/node_commands.gd.uid @@ -0,0 +1 @@ +uid://l06yo5kv0pd1 diff --git a/addons/godot_mcp/commands/particle_commands.gd b/addons/godot_mcp/commands/particle_commands.gd new file mode 100644 index 0000000..872502d --- /dev/null +++ b/addons/godot_mcp/commands/particle_commands.gd @@ -0,0 +1,582 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_particles": _create_particles, + "set_particle_material": _set_particle_material, + "set_particle_color_gradient": _set_particle_color_gradient, + "apply_particle_preset": _apply_particle_preset, + "get_particle_info": _get_particle_info, + } + + +func _get_particles_node(node_path: String) -> GPUParticles2D: + # Returns any GPUParticles2D or GPUParticles3D (both share similar API) + var node := find_node_by_path(node_path) + if node is GPUParticles2D: + return node as GPUParticles2D + return null + + +func _get_particles_node_any(node_path: String) -> Node: + var node := find_node_by_path(node_path) + if node is GPUParticles2D or node is GPUParticles3D: + return node + return null + + +func _parse_color(color_str: String) -> Color: + # Support hex "#RRGGBB", "#RRGGBBAA", or named colors + if color_str.begins_with("#"): + return Color.html(color_str) + # Try named color + match color_str.to_lower(): + "red": return Color.RED + "green": return Color.GREEN + "blue": return Color.BLUE + "white": return Color.WHITE + "black": return Color.BLACK + "yellow": return Color.YELLOW + "orange": return Color(1.0, 0.5, 0.0) + "gray", "grey": return Color.GRAY + "cyan": return Color.CYAN + "magenta": return Color.MAGENTA + "transparent": return Color(0, 0, 0, 0) + # Try Expression parser for Color(r,g,b,a) + var expr := Expression.new() + if expr.parse(color_str) == OK: + var parsed = expr.execute() + if parsed is Color: + return parsed + return Color.WHITE + + +func _create_particles(params: Dictionary) -> Dictionary: + var result := require_string(params, "parent_path") + if result[1] != null: + return result[1] + var parent_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Node at '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "Particles") + var is_3d: bool = optional_bool(params, "is_3d", false) + var amount: int = optional_int(params, "amount", 16) + var lifetime: float = float(params.get("lifetime", 1.0)) + var one_shot: bool = optional_bool(params, "one_shot", false) + var explosiveness: float = float(params.get("explosiveness", 0.0)) + var randomness: float = float(params.get("randomness", 0.0)) + var emitting: bool = optional_bool(params, "emitting", true) + + var particles_node: Node + if is_3d: + var p := GPUParticles3D.new() + p.name = node_name + p.amount = amount + p.lifetime = lifetime + p.one_shot = one_shot + p.explosiveness = explosiveness + p.randomness = randomness + p.emitting = emitting + var mat := ParticleProcessMaterial.new() + p.process_material = mat + particles_node = p + else: + var p := GPUParticles2D.new() + p.name = node_name + p.amount = amount + p.lifetime = lifetime + p.one_shot = one_shot + p.explosiveness = explosiveness + p.randomness = randomness + p.emitting = emitting + var mat := ParticleProcessMaterial.new() + p.process_material = mat + particles_node = p + + parent.add_child(particles_node, true) + particles_node.owner = root + + return success({ + "name": particles_node.name, + "parent": parent_path, + "is_3d": is_3d, + "amount": amount, + "lifetime": lifetime, + "one_shot": one_shot, + "created": true, + }) + + +func _set_particle_material(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var mat: ParticleProcessMaterial = node.get("process_material") + if mat == null: + mat = ParticleProcessMaterial.new() + node.set("process_material", mat) + + var changes: Array = [] + + # Direction + if params.has("direction"): + var dir = params["direction"] + if dir is Dictionary: + mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0))) + changes.append("direction") + elif dir is String: + var expr := Expression.new() + if expr.parse(dir) == OK: + var parsed = expr.execute() + if parsed is Vector3: + mat.direction = parsed + changes.append("direction") + + # Spread + if params.has("spread"): + mat.spread = float(params["spread"]) + changes.append("spread") + + # Initial velocity + if params.has("initial_velocity_min"): + mat.initial_velocity_min = float(params["initial_velocity_min"]) + changes.append("initial_velocity_min") + if params.has("initial_velocity_max"): + mat.initial_velocity_max = float(params["initial_velocity_max"]) + changes.append("initial_velocity_max") + + # Gravity + if params.has("gravity"): + var grav = params["gravity"] + if grav is Dictionary: + mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0))) + changes.append("gravity") + elif grav is String: + var expr := Expression.new() + if expr.parse(grav) == OK: + var parsed = expr.execute() + if parsed is Vector3: + mat.gravity = parsed + changes.append("gravity") + + # Scale + if params.has("scale_min"): + mat.scale_min = float(params["scale_min"]) + changes.append("scale_min") + if params.has("scale_max"): + mat.scale_max = float(params["scale_max"]) + changes.append("scale_max") + + # Color + if params.has("color"): + mat.color = _parse_color(str(params["color"])) + changes.append("color") + + # Emission shape + if params.has("emission_shape"): + var shape_str: String = str(params["emission_shape"]).to_lower() + match shape_str: + "point": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT + "sphere": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE + if params.has("emission_sphere_radius"): + mat.emission_sphere_radius = float(params["emission_sphere_radius"]) + "sphere_surface": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE + if params.has("emission_sphere_radius"): + mat.emission_sphere_radius = float(params["emission_sphere_radius"]) + "box": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + if params.has("emission_box_extents"): + var ext = params["emission_box_extents"] + if ext is Dictionary: + mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1))) + "ring": + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING + if params.has("emission_ring_radius"): + mat.emission_ring_radius = float(params["emission_ring_radius"]) + if params.has("emission_ring_inner_radius"): + mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"]) + if params.has("emission_ring_height"): + mat.emission_ring_height = float(params["emission_ring_height"]) + changes.append("emission_shape") + + # Angular velocity + if params.has("angular_velocity_min"): + mat.angular_velocity_min = float(params["angular_velocity_min"]) + changes.append("angular_velocity_min") + if params.has("angular_velocity_max"): + mat.angular_velocity_max = float(params["angular_velocity_max"]) + changes.append("angular_velocity_max") + + # Orbit velocity + if params.has("orbit_velocity_min"): + mat.orbit_velocity_min = float(params["orbit_velocity_min"]) + changes.append("orbit_velocity_min") + if params.has("orbit_velocity_max"): + mat.orbit_velocity_max = float(params["orbit_velocity_max"]) + changes.append("orbit_velocity_max") + + # Damping + if params.has("damping_min"): + mat.damping_min = float(params["damping_min"]) + changes.append("damping_min") + if params.has("damping_max"): + mat.damping_max = float(params["damping_max"]) + changes.append("damping_max") + + # Attractor interaction + if params.has("attractor_interaction_enabled"): + mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"]) + changes.append("attractor_interaction_enabled") + + return success({"node_path": node_path, "changes": changes}) + + +func _set_particle_color_gradient(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var mat: ParticleProcessMaterial = node.get("process_material") + if mat == null: + mat = ParticleProcessMaterial.new() + node.set("process_material", mat) + + if not params.has("stops") or not params["stops"] is Array: + return error_invalid_params("Missing required parameter: stops (array of {offset, color})") + + var stops: Array = params["stops"] + if stops.is_empty(): + return error_invalid_params("stops array must not be empty") + + var gradient := Gradient.new() + # Remove default points + while gradient.get_point_count() > 0: + gradient.remove_point(0) + + for stop in stops: + if stop is Dictionary: + var offset: float = float(stop.get("offset", 0.0)) + var color: Color = _parse_color(str(stop.get("color", "#ffffff"))) + gradient.add_point(offset, color) + + var grad_tex := GradientTexture1D.new() + grad_tex.gradient = gradient + mat.color_ramp = grad_tex + + return success({"node_path": node_path, "stops_count": gradient.get_point_count()}) + + +func _apply_particle_preset(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "preset") + if result2[1] != null: + return result2[1] + var preset: String = result2[0].to_lower() + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var mat := ParticleProcessMaterial.new() + var is_2d: bool = node is GPUParticles2D + + # Default gravity for 2D (Y-down) vs 3D (Y-down) + var gravity_down := Vector3(0, 98 if is_2d else 9.8, 0) + var gravity_up := Vector3(0, -98 if is_2d else -9.8, 0) + var gravity_none := Vector3.ZERO + + match preset: + "explosion": + node.set("amount", 32) + node.set("lifetime", 0.6) + node.set("one_shot", true) + node.set("explosiveness", 1.0) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 100.0 if is_2d else 5.0 + mat.initial_velocity_max = 200.0 if is_2d else 10.0 + mat.gravity = gravity_down * 0.5 + mat.damping_min = 2.0 + mat.damping_max = 4.0 + mat.scale_min = 0.5 + mat.scale_max = 1.5 + mat.color = Color(1.0, 0.6, 0.1) + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color.WHITE}, + {"offset": 0.3, "color": Color(1.0, 0.8, 0.2)}, + {"offset": 0.7, "color": Color(1.0, 0.3, 0.0)}, + {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, + ]) + + "fire": + node.set("amount", 24) + node.set("lifetime", 1.2) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 15.0 + mat.initial_velocity_min = 30.0 if is_2d else 1.5 + mat.initial_velocity_max = 60.0 if is_2d else 3.0 + mat.gravity = gravity_none + mat.scale_min = 0.8 + mat.scale_max = 1.5 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(1.0, 1.0, 0.5)}, + {"offset": 0.3, "color": Color(1.0, 0.6, 0.0)}, + {"offset": 0.7, "color": Color(0.8, 0.2, 0.0)}, + {"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)}, + ]) + + "smoke": + node.set("amount", 16) + node.set("lifetime", 3.0) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 25.0 + mat.initial_velocity_min = 10.0 if is_2d else 0.5 + mat.initial_velocity_max = 25.0 if is_2d else 1.2 + mat.gravity = gravity_none + mat.scale_min = 1.5 + mat.scale_max = 3.0 + mat.damping_min = 1.0 + mat.damping_max = 2.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)}, + {"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)}, + {"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)}, + ]) + + "sparks": + node.set("amount", 48) + node.set("lifetime", 0.4) + node.set("one_shot", true) + node.set("explosiveness", 0.95) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 200.0 if is_2d else 8.0 + mat.initial_velocity_max = 400.0 if is_2d else 16.0 + mat.gravity = gravity_down + mat.scale_min = 0.1 + mat.scale_max = 0.3 + mat.damping_min = 1.0 + mat.damping_max = 3.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(1.0, 1.0, 0.8)}, + {"offset": 0.5, "color": Color(1.0, 0.7, 0.2)}, + {"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)}, + ]) + + "rain": + node.set("amount", 64) + node.set("lifetime", 0.8) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) + mat.spread = 5.0 + mat.initial_velocity_min = 300.0 if is_2d else 12.0 + mat.initial_velocity_max = 400.0 if is_2d else 16.0 + mat.gravity = gravity_down + mat.scale_min = 0.1 + mat.scale_max = 0.2 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) + mat.color = Color(0.6, 0.7, 1.0, 0.7) + + "snow": + node.set("amount", 48) + node.set("lifetime", 4.0) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0) + mat.spread = 20.0 + mat.initial_velocity_min = 20.0 if is_2d else 0.8 + mat.initial_velocity_max = 40.0 if is_2d else 1.5 + mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0) + mat.scale_min = 0.3 + mat.scale_max = 0.8 + mat.angular_velocity_min = -45.0 + mat.angular_velocity_max = 45.0 + mat.damping_min = 0.5 + mat.damping_max = 1.5 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5) + mat.color = Color(1.0, 1.0, 1.0, 0.9) + + "magic": + node.set("amount", 24) + node.set("lifetime", 2.0) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 20.0 if is_2d else 1.0 + mat.initial_velocity_max = 50.0 if is_2d else 2.5 + mat.gravity = gravity_none + mat.orbit_velocity_min = 0.5 + mat.orbit_velocity_max = 1.5 + mat.scale_min = 0.3 + mat.scale_max = 0.8 + mat.damping_min = 1.0 + mat.damping_max = 2.0 + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.3, 0.5, 1.0)}, + {"offset": 0.25, "color": Color(1.0, 0.3, 0.8)}, + {"offset": 0.5, "color": Color(0.3, 1.0, 0.5)}, + {"offset": 0.75, "color": Color(1.0, 0.8, 0.2)}, + {"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)}, + ]) + + "dust": + node.set("amount", 12) + node.set("lifetime", 5.0) + node.set("one_shot", false) + node.set("explosiveness", 0.0) + mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0) + mat.spread = 180.0 + mat.initial_velocity_min = 3.0 if is_2d else 0.1 + mat.initial_velocity_max = 8.0 if is_2d else 0.3 + mat.gravity = gravity_none + mat.scale_min = 0.2 + mat.scale_max = 0.5 + mat.damping_min = 0.5 + mat.damping_max = 1.0 + mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX + mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3) + _apply_gradient(mat, [ + {"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, + {"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)}, + {"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)}, + {"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)}, + ]) + + _: + return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset) + + node.set("process_material", mat) + + return success({"node_path": node_path, "preset": preset, "applied": true}) + + +func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void: + var gradient := Gradient.new() + # Remove default points before adding custom stops + for i in range(gradient.get_point_count() - 1, -1, -1): + gradient.remove_point(i) + for stop in stops: + gradient.add_point(stop["offset"], stop["color"]) + var grad_tex := GradientTexture1D.new() + grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode + grad_tex.gradient = gradient + # Defer color_ramp assignment to avoid editor crash during rendering + mat.set_deferred("color_ramp", grad_tex) + + +func _get_particle_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := _get_particles_node_any(node_path) + if node == null: + return error_not_found("GPUParticles2D/3D at '%s'" % node_path) + + var info: Dictionary = { + "node_path": node_path, + "type": node.get_class(), + "amount": node.get("amount"), + "lifetime": node.get("lifetime"), + "one_shot": node.get("one_shot"), + "explosiveness": node.get("explosiveness"), + "randomness": node.get("randomness"), + "emitting": node.get("emitting"), + } + + var mat: ParticleProcessMaterial = node.get("process_material") + if mat != null and mat is ParticleProcessMaterial: + var mat_info: Dictionary = { + "direction": str(mat.direction), + "spread": mat.spread, + "initial_velocity_min": mat.initial_velocity_min, + "initial_velocity_max": mat.initial_velocity_max, + "gravity": str(mat.gravity), + "scale_min": mat.scale_min, + "scale_max": mat.scale_max, + "color": str(mat.color), + "angular_velocity_min": mat.angular_velocity_min, + "angular_velocity_max": mat.angular_velocity_max, + "orbit_velocity_min": mat.orbit_velocity_min, + "orbit_velocity_max": mat.orbit_velocity_max, + "damping_min": mat.damping_min, + "damping_max": mat.damping_max, + "attractor_interaction_enabled": mat.attractor_interaction_enabled, + } + + # Emission shape + var shape_name: String + match mat.emission_shape: + ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point" + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere" + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface" + ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box" + ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring" + _: shape_name = "unknown(%d)" % mat.emission_shape + + mat_info["emission_shape"] = shape_name + + match mat.emission_shape: + ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: + mat_info["emission_sphere_radius"] = mat.emission_sphere_radius + ParticleProcessMaterial.EMISSION_SHAPE_BOX: + mat_info["emission_box_extents"] = str(mat.emission_box_extents) + ParticleProcessMaterial.EMISSION_SHAPE_RING: + mat_info["emission_ring_radius"] = mat.emission_ring_radius + mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius + mat_info["emission_ring_height"] = mat.emission_ring_height + + # Color gradient + if mat.color_ramp != null and mat.color_ramp is GradientTexture1D: + var grad_tex: GradientTexture1D = mat.color_ramp + if grad_tex.gradient != null: + var gradient_stops: Array = [] + var grad: Gradient = grad_tex.gradient + for i in grad.get_point_count(): + gradient_stops.append({ + "offset": grad.get_offset(i), + "color": str(grad.get_color(i)), + }) + mat_info["color_ramp"] = gradient_stops + + info["material"] = mat_info + else: + info["material"] = null + + return success(info) diff --git a/addons/godot_mcp/commands/particle_commands.gd.uid b/addons/godot_mcp/commands/particle_commands.gd.uid new file mode 100644 index 0000000..3a24aa1 --- /dev/null +++ b/addons/godot_mcp/commands/particle_commands.gd.uid @@ -0,0 +1 @@ +uid://brhvd8o3eo2av diff --git a/addons/godot_mcp/commands/physics_commands.gd b/addons/godot_mcp/commands/physics_commands.gd new file mode 100644 index 0000000..615b1ff --- /dev/null +++ b/addons/godot_mcp/commands/physics_commands.gd @@ -0,0 +1,757 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "setup_collision": _setup_collision, + "set_physics_layers": _set_physics_layers, + "get_physics_layers": _get_physics_layers, + "add_raycast": _add_raycast, + "setup_physics_body": _setup_physics_body, + "get_collision_info": _get_collision_info, + } + + +## Determine if a node (or its ancestors) lives in a 2D or 3D context. +## Returns "2d", "3d", or "" if undetermined. +func _detect_dimension(node: Node) -> String: + if node is Node2D or node is Control: + return "2d" + if node is Node3D: + return "3d" + # Walk up the tree + var parent := node.get_parent() + while parent != null: + if parent is Node2D or parent is Control: + return "2d" + if parent is Node3D: + return "3d" + parent = parent.get_parent() + return "" + + +func _setup_collision(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "shape") + if result2[1] != null: + return result2[1] + var shape_name: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var dim := _detect_dimension(node) + if dim.is_empty(): + # Allow explicit override + dim = optional_string(params, "dimension", "2d") + + # Validate parent can have collision children + var valid_parents_2d := ["PhysicsBody2D", "Area2D", "StaticBody2D", "CharacterBody2D", "RigidBody2D", "AnimatableBody2D"] + var valid_parents_3d := ["PhysicsBody3D", "Area3D", "StaticBody3D", "CharacterBody3D", "RigidBody3D", "AnimatableBody3D"] + + var is_valid_parent := false + if dim == "2d": + for vp: String in valid_parents_2d: + if node.is_class(vp): + is_valid_parent = true + break + else: + for vp: String in valid_parents_3d: + if node.is_class(vp): + is_valid_parent = true + break + + if not is_valid_parent: + return error_invalid_params("Node '%s' (%s) is not a physics body or area. CollisionShape should be added to a PhysicsBody or Area node." % [node_path, node.get_class()]) + + # Create shape resource + var shape: Resource = null + var child_name := "CollisionShape" + + if dim == "2d": + match shape_name: + "rectangle", "rect": + shape = RectangleShape2D.new() + var w: float = float(params.get("width", 32.0)) + var h: float = float(params.get("height", 32.0)) + shape.size = Vector2(w, h) + "circle": + shape = CircleShape2D.new() + shape.radius = float(params.get("radius", 16.0)) + "capsule": + shape = CapsuleShape2D.new() + shape.radius = float(params.get("radius", 16.0)) + shape.height = float(params.get("height", 40.0)) + "segment": + shape = SegmentShape2D.new() + shape.a = Vector2(float(params.get("ax", 0.0)), float(params.get("ay", 0.0))) + shape.b = Vector2(float(params.get("bx", 32.0)), float(params.get("by", 0.0))) + "custom": + # ConvexPolygonShape2D — expects "points" as array of [x,y] pairs + shape = ConvexPolygonShape2D.new() + var points_data: Array = params.get("points", []) + var pool: PackedVector2Array = PackedVector2Array() + for p: Variant in points_data: + if p is Array and p.size() >= 2: + pool.append(Vector2(float(p[0]), float(p[1]))) + if pool.size() >= 3: + shape.points = pool + _: + return error_invalid_params("Unknown 2D shape: '%s'. Available: rectangle, circle, capsule, segment, custom" % shape_name) + + var collision_node := CollisionShape2D.new() + collision_node.shape = shape + collision_node.name = child_name + + var disabled: bool = optional_bool(params, "disabled", false) + collision_node.disabled = disabled + var one_way: bool = optional_bool(params, "one_way_collision", false) + collision_node.one_way_collision = one_way + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add CollisionShape2D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", collision_node) + undo_redo.add_do_method(collision_node, "set_owner", root) + undo_redo.add_do_reference(collision_node) + undo_redo.add_undo_method(node, "remove_child", collision_node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(collision_node)), + "shape_type": shape.get_class(), + "dimension": "2D", + }) + + else: + # 3D shapes + match shape_name: + "box", "rectangle", "rect": + shape = BoxShape3D.new() + var sx: float = float(params.get("width", 1.0)) + var sy: float = float(params.get("height", 1.0)) + var sz: float = float(params.get("depth", 1.0)) + shape.size = Vector3(sx, sy, sz) + "sphere", "circle": + shape = SphereShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + "capsule": + shape = CapsuleShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + shape.height = float(params.get("height", 2.0)) + "cylinder": + shape = CylinderShape3D.new() + shape.radius = float(params.get("radius", 0.5)) + shape.height = float(params.get("height", 2.0)) + "convex", "custom": + shape = ConvexPolygonShape3D.new() + var points_data: Array = params.get("points", []) + var pool: PackedVector3Array = PackedVector3Array() + for p: Variant in points_data: + if p is Array and p.size() >= 3: + pool.append(Vector3(float(p[0]), float(p[1]), float(p[2]))) + if pool.size() >= 4: + shape.points = pool + _: + return error_invalid_params("Unknown 3D shape: '%s'. Available: box, sphere, capsule, cylinder, convex" % shape_name) + + var collision_node := CollisionShape3D.new() + collision_node.shape = shape + collision_node.name = child_name + + var disabled: bool = optional_bool(params, "disabled", false) + collision_node.disabled = disabled + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add CollisionShape3D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", collision_node) + undo_redo.add_do_method(collision_node, "set_owner", root) + undo_redo.add_do_reference(collision_node) + undo_redo.add_undo_method(node, "remove_child", collision_node) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(collision_node)), + "shape_type": shape.get_class(), + "dimension": "3D", + }) + + +func _get_layer_name(dim: String, layer_index: int) -> String: + var setting_key := "layer_names/%s_physics/layer_%d" % [dim, layer_index] + if ProjectSettings.has_setting(setting_key): + var name_val: Variant = ProjectSettings.get_setting(setting_key) + if name_val is String and not (name_val as String).is_empty(): + return name_val as String + return "" + + +func _layer_bitmask_to_info(bitmask: int, dim: String) -> Array: + var layers: Array = [] + for i in range(1, 33): + if bitmask & (1 << (i - 1)): + var layer_name := _get_layer_name(dim, i) + var entry: Dictionary = {"layer": i} + if not layer_name.is_empty(): + entry["name"] = layer_name + layers.append(entry) + return layers + + +func _set_physics_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + # Check node has collision_layer/collision_mask properties + if not "collision_layer" in node: + return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()]) + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set physics layers on %s" % node.name) + + var changes: Dictionary = {} + + if params.has("collision_layer"): + var old_layer: int = node.get("collision_layer") + var new_layer: int = _parse_layer_value(params["collision_layer"]) + undo_redo.add_do_property(node, "collision_layer", new_layer) + undo_redo.add_undo_property(node, "collision_layer", old_layer) + changes["collision_layer"] = new_layer + + if params.has("collision_mask"): + var old_mask: int = node.get("collision_mask") + var new_mask: int = _parse_layer_value(params["collision_mask"]) + undo_redo.add_do_property(node, "collision_mask", new_mask) + undo_redo.add_undo_property(node, "collision_mask", old_mask) + changes["collision_mask"] = new_mask + + if changes.is_empty(): + return error_invalid_params("Must provide collision_layer and/or collision_mask") + + undo_redo.commit_action() + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + + var result_data: Dictionary = { + "node_path": str(root.get_path_to(node)), + } + if changes.has("collision_layer"): + result_data["collision_layer"] = changes["collision_layer"] + result_data["collision_layer_info"] = _layer_bitmask_to_info(changes["collision_layer"], dim) + if changes.has("collision_mask"): + result_data["collision_mask"] = changes["collision_mask"] + result_data["collision_mask_info"] = _layer_bitmask_to_info(changes["collision_mask"], dim) + + return success(result_data) + + +## Parse layer value: can be an int bitmask, or an array of layer numbers [1, 3, 5] +func _parse_layer_value(value: Variant) -> int: + if value is int or value is float: + return int(value) + if value is Array: + var bitmask: int = 0 + for layer_num: Variant in value: + var n: int = int(layer_num) + if n >= 1 and n <= 32: + bitmask |= (1 << (n - 1)) + return bitmask + # Try parsing as int + return int(value) + + +func _get_physics_layers(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not "collision_layer" in node: + return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()]) + + var layer: int = node.get("collision_layer") + var mask: int = node.get("collision_mask") + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "collision_layer": layer, + "collision_layer_info": _layer_bitmask_to_info(layer, dim), + "collision_mask": mask, + "collision_mask_info": _layer_bitmask_to_info(mask, dim), + }) + + +func _add_raycast(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = optional_string(params, "dimension", "2d") + + var ray_name: String = optional_string(params, "name", "RayCast") + var enabled: bool = optional_bool(params, "enabled", true) + var collision_mask: int = optional_int(params, "collision_mask", 1) + var collide_with_areas: bool = optional_bool(params, "collide_with_areas", false) + var collide_with_bodies: bool = optional_bool(params, "collide_with_bodies", true) + var hit_from_inside: bool = optional_bool(params, "hit_from_inside", false) + + var undo_redo := get_undo_redo() + + if dim == "2d": + var ray := RayCast2D.new() + ray.name = ray_name + ray.enabled = enabled + ray.collision_mask = collision_mask + ray.collide_with_areas = collide_with_areas + ray.collide_with_bodies = collide_with_bodies + ray.hit_from_inside = hit_from_inside + + var tx: float = float(params.get("target_x", 0.0)) + var ty: float = float(params.get("target_y", 50.0)) + ray.target_position = Vector2(tx, ty) + + undo_redo.create_action("MCP: Add RayCast2D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", ray) + undo_redo.add_do_method(ray, "set_owner", root) + undo_redo.add_do_reference(ray) + undo_redo.add_undo_method(node, "remove_child", ray) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(ray)), + "type": "RayCast2D", + "target_position": "Vector2(%s, %s)" % [tx, ty], + "collision_mask": collision_mask, + }) + + else: + var ray := RayCast3D.new() + ray.name = ray_name + ray.enabled = enabled + ray.collision_mask = collision_mask + ray.collide_with_areas = collide_with_areas + ray.collide_with_bodies = collide_with_bodies + ray.hit_from_inside = hit_from_inside + + var tx: float = float(params.get("target_x", 0.0)) + var ty: float = float(params.get("target_y", -1.0)) + var tz: float = float(params.get("target_z", 0.0)) + ray.target_position = Vector3(tx, ty, tz) + + undo_redo.create_action("MCP: Add RayCast3D to %s" % node.name) + undo_redo.add_do_method(node, "add_child", ray) + undo_redo.add_do_method(ray, "set_owner", root) + undo_redo.add_do_reference(ray) + undo_redo.add_undo_method(node, "remove_child", ray) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(ray)), + "type": "RayCast3D", + "target_position": "Vector3(%s, %s, %s)" % [tx, ty, tz], + "collision_mask": collision_mask, + }) + + +func _setup_physics_body(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Setup physics body %s" % node.name) + + var applied: Dictionary = {} + + if node is CharacterBody2D or node is CharacterBody3D: + # CharacterBody properties + if params.has("floor_stop_on_slope"): + var old_val: bool = node.floor_stop_on_slope + var new_val: bool = bool(params["floor_stop_on_slope"]) + undo_redo.add_do_property(node, "floor_stop_on_slope", new_val) + undo_redo.add_undo_property(node, "floor_stop_on_slope", old_val) + applied["floor_stop_on_slope"] = new_val + + if params.has("floor_max_angle"): + var old_val: float = node.floor_max_angle + var new_val: float = float(params["floor_max_angle"]) + undo_redo.add_do_property(node, "floor_max_angle", new_val) + undo_redo.add_undo_property(node, "floor_max_angle", old_val) + applied["floor_max_angle"] = new_val + + if params.has("floor_snap_length"): + var old_val: float = node.floor_snap_length + var new_val: float = float(params["floor_snap_length"]) + undo_redo.add_do_property(node, "floor_snap_length", new_val) + undo_redo.add_undo_property(node, "floor_snap_length", old_val) + applied["floor_snap_length"] = new_val + + if params.has("wall_min_slide_angle"): + var old_val: float = node.wall_min_slide_angle + var new_val: float = float(params["wall_min_slide_angle"]) + undo_redo.add_do_property(node, "wall_min_slide_angle", new_val) + undo_redo.add_undo_property(node, "wall_min_slide_angle", old_val) + applied["wall_min_slide_angle"] = new_val + + if params.has("motion_mode"): + var mode_str: String = str(params["motion_mode"]) + var mode_val: int = 0 + if node is CharacterBody2D: + match mode_str.to_lower(): + "grounded": + mode_val = CharacterBody2D.MOTION_MODE_GROUNDED + "floating": + mode_val = CharacterBody2D.MOTION_MODE_FLOATING + _: + mode_val = int(params["motion_mode"]) + else: + match mode_str.to_lower(): + "grounded": + mode_val = CharacterBody3D.MOTION_MODE_GROUNDED + "floating": + mode_val = CharacterBody3D.MOTION_MODE_FLOATING + _: + mode_val = int(params["motion_mode"]) + var old_val: int = node.motion_mode + undo_redo.add_do_property(node, "motion_mode", mode_val) + undo_redo.add_undo_property(node, "motion_mode", old_val) + applied["motion_mode"] = mode_str + + if params.has("max_slides"): + var old_val: int = node.max_slides + var new_val: int = int(params["max_slides"]) + undo_redo.add_do_property(node, "max_slides", new_val) + undo_redo.add_undo_property(node, "max_slides", old_val) + applied["max_slides"] = new_val + + if params.has("slide_on_ceiling"): + var old_val: bool = node.slide_on_ceiling + var new_val: bool = bool(params["slide_on_ceiling"]) + undo_redo.add_do_property(node, "slide_on_ceiling", new_val) + undo_redo.add_undo_property(node, "slide_on_ceiling", old_val) + applied["slide_on_ceiling"] = new_val + + elif node is RigidBody2D or node is RigidBody3D: + # RigidBody properties + if params.has("mass"): + var old_val: float = node.mass + var new_val: float = float(params["mass"]) + undo_redo.add_do_property(node, "mass", new_val) + undo_redo.add_undo_property(node, "mass", old_val) + applied["mass"] = new_val + + if params.has("gravity_scale"): + var old_val: float = node.gravity_scale + var new_val: float = float(params["gravity_scale"]) + undo_redo.add_do_property(node, "gravity_scale", new_val) + undo_redo.add_undo_property(node, "gravity_scale", old_val) + applied["gravity_scale"] = new_val + + if params.has("linear_damp"): + var old_val: float = node.linear_damp + var new_val: float = float(params["linear_damp"]) + undo_redo.add_do_property(node, "linear_damp", new_val) + undo_redo.add_undo_property(node, "linear_damp", old_val) + applied["linear_damp"] = new_val + + if params.has("angular_damp"): + var old_val: float = node.angular_damp + var new_val: float = float(params["angular_damp"]) + undo_redo.add_do_property(node, "angular_damp", new_val) + undo_redo.add_undo_property(node, "angular_damp", old_val) + applied["angular_damp"] = new_val + + if params.has("freeze"): + var old_val: bool = node.freeze + var new_val: bool = bool(params["freeze"]) + undo_redo.add_do_property(node, "freeze", new_val) + undo_redo.add_undo_property(node, "freeze", old_val) + applied["freeze"] = new_val + + if params.has("freeze_mode"): + var mode_str: String = str(params["freeze_mode"]) + var mode_val: int = 0 + if node is RigidBody2D: + match mode_str.to_lower(): + "static": + mode_val = RigidBody2D.FREEZE_MODE_STATIC + "kinematic": + mode_val = RigidBody2D.FREEZE_MODE_KINEMATIC + _: + mode_val = int(params["freeze_mode"]) + else: + match mode_str.to_lower(): + "static": + mode_val = RigidBody3D.FREEZE_MODE_STATIC + "kinematic": + mode_val = RigidBody3D.FREEZE_MODE_KINEMATIC + _: + mode_val = int(params["freeze_mode"]) + var old_val: int = node.freeze_mode + undo_redo.add_do_property(node, "freeze_mode", mode_val) + undo_redo.add_undo_property(node, "freeze_mode", old_val) + applied["freeze_mode"] = mode_str + + if params.has("continuous_cd"): + if node is RigidBody2D: + var ccd_str: String = str(params["continuous_cd"]) + var ccd_val: int = 0 + match ccd_str.to_lower(): + "disabled": + ccd_val = RigidBody2D.CCD_MODE_DISABLED + "cast_ray": + ccd_val = RigidBody2D.CCD_MODE_CAST_RAY + "cast_shape": + ccd_val = RigidBody2D.CCD_MODE_CAST_SHAPE + _: + ccd_val = int(params["continuous_cd"]) + var old_val: int = node.continuous_cd + undo_redo.add_do_property(node, "continuous_cd", ccd_val) + undo_redo.add_undo_property(node, "continuous_cd", old_val) + applied["continuous_cd"] = ccd_str + else: + var old_val: bool = node.continuous_cd + var new_val: bool = bool(params["continuous_cd"]) + undo_redo.add_do_property(node, "continuous_cd", new_val) + undo_redo.add_undo_property(node, "continuous_cd", old_val) + applied["continuous_cd"] = new_val + + if params.has("contact_monitor"): + var old_val: bool = node.contact_monitor + var new_val: bool = bool(params["contact_monitor"]) + undo_redo.add_do_property(node, "contact_monitor", new_val) + undo_redo.add_undo_property(node, "contact_monitor", old_val) + applied["contact_monitor"] = new_val + + if params.has("max_contacts_reported"): + var old_val: int = node.max_contacts_reported + var new_val: int = int(params["max_contacts_reported"]) + undo_redo.add_do_property(node, "max_contacts_reported", new_val) + undo_redo.add_undo_property(node, "max_contacts_reported", old_val) + applied["max_contacts_reported"] = new_val + + elif node is StaticBody2D or node is StaticBody3D or node is AnimatableBody2D or node is AnimatableBody3D: + # StaticBody / AnimatableBody shared properties + if params.has("physics_material_override"): + # We just note it — use add_resource for complex resource assignment + return error_invalid_params("Use add_resource to set physics_material_override") + else: + return error_invalid_params("Node '%s' (%s) is not a recognized physics body type. Supported: CharacterBody2D/3D, RigidBody2D/3D, StaticBody2D/3D, AnimatableBody2D/3D" % [node_path, node.get_class()]) + + if applied.is_empty(): + undo_redo.commit_action() + return error_invalid_params("No valid properties provided for %s" % node.get_class()) + + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + "applied": applied, + }) + + +func _get_collision_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + var include_children: bool = optional_bool(params, "include_children", true) + + var info: Dictionary = { + "node_path": str(root.get_path_to(node)), + "type": node.get_class(), + } + + # Collect physics body properties + if "collision_layer" in node: + var dim := _detect_dimension(node) + if dim.is_empty(): + dim = "2d" + info["collision_layer"] = node.get("collision_layer") + info["collision_layer_info"] = _layer_bitmask_to_info(int(node.get("collision_layer")), dim) + info["collision_mask"] = node.get("collision_mask") + info["collision_mask_info"] = _layer_bitmask_to_info(int(node.get("collision_mask")), dim) + + # Collect body-specific properties + if node is CharacterBody2D or node is CharacterBody3D: + info["body_settings"] = { + "motion_mode": node.motion_mode, + "floor_stop_on_slope": node.floor_stop_on_slope, + "floor_max_angle": node.floor_max_angle, + "floor_snap_length": node.floor_snap_length, + "wall_min_slide_angle": node.wall_min_slide_angle, + "max_slides": node.max_slides, + "slide_on_ceiling": node.slide_on_ceiling, + } + elif node is RigidBody2D or node is RigidBody3D: + info["body_settings"] = { + "mass": node.mass, + "gravity_scale": node.gravity_scale, + "linear_damp": node.linear_damp, + "angular_damp": node.angular_damp, + "freeze": node.freeze, + "freeze_mode": node.freeze_mode, + "contact_monitor": node.contact_monitor, + "max_contacts_reported": node.max_contacts_reported, + } + + # Collect collision shapes + var shapes: Array = [] + var raycasts: Array = [] + var nodes_to_check: Array = [node] + + if include_children: + var queue: Array = [node] + while queue.size() > 0: + var current: Node = queue.pop_front() + for child_idx in current.get_child_count(): + var child := current.get_child(child_idx) + nodes_to_check.append(child) + queue.append(child) + + for check_node: Node in nodes_to_check: + if check_node is CollisionShape2D: + var shape_info: Dictionary = { + "node_path": str(root.get_path_to(check_node)), + "disabled": check_node.disabled, + "one_way_collision": check_node.one_way_collision, + } + if check_node.shape != null: + shape_info["shape_type"] = check_node.shape.get_class() + if check_node.shape is RectangleShape2D: + shape_info["size"] = "Vector2(%s, %s)" % [check_node.shape.size.x, check_node.shape.size.y] + elif check_node.shape is CircleShape2D: + shape_info["radius"] = check_node.shape.radius + elif check_node.shape is CapsuleShape2D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + shapes.append(shape_info) + + elif check_node is CollisionShape3D: + var shape_info: Dictionary = { + "node_path": str(root.get_path_to(check_node)), + "disabled": check_node.disabled, + } + if check_node.shape != null: + shape_info["shape_type"] = check_node.shape.get_class() + if check_node.shape is BoxShape3D: + shape_info["size"] = "Vector3(%s, %s, %s)" % [check_node.shape.size.x, check_node.shape.size.y, check_node.shape.size.z] + elif check_node.shape is SphereShape3D: + shape_info["radius"] = check_node.shape.radius + elif check_node.shape is CapsuleShape3D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + elif check_node.shape is CylinderShape3D: + shape_info["radius"] = check_node.shape.radius + shape_info["height"] = check_node.shape.height + shapes.append(shape_info) + + elif check_node is CollisionPolygon2D: + shapes.append({ + "node_path": str(root.get_path_to(check_node)), + "shape_type": "CollisionPolygon2D", + "disabled": check_node.disabled, + "one_way_collision": check_node.one_way_collision, + "polygon_points": check_node.polygon.size(), + }) + + elif check_node is CollisionPolygon3D: + shapes.append({ + "node_path": str(root.get_path_to(check_node)), + "shape_type": "CollisionPolygon3D", + "disabled": check_node.disabled, + "polygon_points": check_node.polygon.size(), + }) + + elif check_node is RayCast2D: + raycasts.append({ + "node_path": str(root.get_path_to(check_node)), + "type": "RayCast2D", + "enabled": check_node.enabled, + "target_position": "Vector2(%s, %s)" % [check_node.target_position.x, check_node.target_position.y], + "collision_mask": check_node.collision_mask, + "collide_with_areas": check_node.collide_with_areas, + "collide_with_bodies": check_node.collide_with_bodies, + }) + + elif check_node is RayCast3D: + raycasts.append({ + "node_path": str(root.get_path_to(check_node)), + "type": "RayCast3D", + "enabled": check_node.enabled, + "target_position": "Vector3(%s, %s, %s)" % [check_node.target_position.x, check_node.target_position.y, check_node.target_position.z], + "collision_mask": check_node.collision_mask, + "collide_with_areas": check_node.collide_with_areas, + "collide_with_bodies": check_node.collide_with_bodies, + }) + + info["collision_shapes"] = shapes + info["raycasts"] = raycasts + + return success(info) diff --git a/addons/godot_mcp/commands/physics_commands.gd.uid b/addons/godot_mcp/commands/physics_commands.gd.uid new file mode 100644 index 0000000..2e340e3 --- /dev/null +++ b/addons/godot_mcp/commands/physics_commands.gd.uid @@ -0,0 +1 @@ +uid://d01frowxu8aoy diff --git a/addons/godot_mcp/commands/profiling_commands.gd b/addons/godot_mcp/commands/profiling_commands.gd new file mode 100644 index 0000000..cdfae40 --- /dev/null +++ b/addons/godot_mcp/commands/profiling_commands.gd @@ -0,0 +1,69 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_performance_monitors": _get_performance_monitors, + "get_editor_performance": _get_editor_performance, + } + + +func _get_performance_monitors(params: Dictionary) -> Dictionary: + # Return all available performance monitors + var monitors := {} + monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS) + monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0 + monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0 + monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0 + + monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC) + monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX) + + monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT) + monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT) + monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT) + monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT) + + monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME) + monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME) + monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME) + monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) + + monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS) + monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS) + monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT) + + monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS) + monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS) + monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT) + + monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS) + monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT) + monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT) + + # Filter by category if requested + var category: String = optional_string(params, "category", "") + if not category.is_empty(): + var filtered := {} + for key: String in monitors: + if key.begins_with(category): + filtered[key] = monitors[key] + return success({"monitors": filtered, "category": category}) + + return success({"monitors": monitors}) + + +func _get_editor_performance(params: Dictionary) -> Dictionary: + # Quick summary for common use + var summary := { + "fps": Performance.get_monitor(Performance.TIME_FPS), + "frame_time_msec": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), + "objects_in_frame": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME), + "node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT), + "orphan_nodes": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT), + "memory_static_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / (1024.0 * 1024.0), + "video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0), + } + return success(summary) diff --git a/addons/godot_mcp/commands/profiling_commands.gd.uid b/addons/godot_mcp/commands/profiling_commands.gd.uid new file mode 100644 index 0000000..e0bb30c --- /dev/null +++ b/addons/godot_mcp/commands/profiling_commands.gd.uid @@ -0,0 +1 @@ +uid://1k2vr51iv0ur diff --git a/addons/godot_mcp/commands/project_commands.gd b/addons/godot_mcp/commands/project_commands.gd new file mode 100644 index 0000000..d9ce4d3 --- /dev/null +++ b/addons/godot_mcp/commands/project_commands.gd @@ -0,0 +1,390 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "get_project_info": _get_project_info, + "get_filesystem_tree": _get_filesystem_tree, + "search_files": _search_files, + "search_in_files": _search_in_files, + "get_project_settings": _get_project_settings, + "set_project_setting": _set_project_setting, + "uid_to_project_path": _uid_to_project_path, + "project_path_to_uid": _project_path_to_uid, + "add_autoload": _add_autoload, + "remove_autoload": _remove_autoload, + } + + +func _get_project_info(params: Dictionary) -> Dictionary: + var info := {} + info["project_name"] = ProjectSettings.get_setting("application/config/name", "") + info["godot_version"] = Engine.get_version_info() + info["project_path"] = ProjectSettings.globalize_path("res://") + info["main_scene"] = ProjectSettings.get_setting("application/run/main_scene", "") + + # Viewport settings + info["viewport_width"] = ProjectSettings.get_setting("display/window/size/viewport_width", 0) + info["viewport_height"] = ProjectSettings.get_setting("display/window/size/viewport_height", 0) + info["window_width"] = ProjectSettings.get_setting("display/window/size/window_width_override", 0) + info["window_height"] = ProjectSettings.get_setting("display/window/size/window_height_override", 0) + + # Rendering + info["renderer"] = ProjectSettings.get_setting("rendering/renderer/rendering_method", "") + + # Autoloads + var autoloads := {} + for prop in ProjectSettings.get_property_list(): + var name: String = prop["name"] + if name.begins_with("autoload/"): + autoloads[name.substr(9)] = ProjectSettings.get_setting(name) + info["autoloads"] = autoloads + + return success(info) + + +func _get_filesystem_tree(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var filter: String = optional_string(params, "filter", "") # e.g. "*.gd", "*.tscn" + var max_depth: int = optional_int(params, "max_depth", 10) + + var tree := _scan_directory(path, filter, max_depth, 0) + return success({"tree": tree}) + + +func _scan_directory(path: String, filter: String, max_depth: int, depth: int) -> Dictionary: + var result := {"name": path.get_file(), "path": path, "type": "directory"} + + if depth >= max_depth: + return result + + var dir := DirAccess.open(path) + if dir == null: + return result + + var children: Array = [] + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + children.append(_scan_directory(full_path, filter, max_depth, depth + 1)) + else: + if filter.is_empty() or file_name.match(filter): + children.append({ + "name": file_name, + "path": full_path, + "type": "file", + }) + + file_name = dir.get_next() + + dir.list_dir_end() + + if not children.is_empty(): + result["children"] = children + + return result + + +func _search_files(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var file_type: String = optional_string(params, "file_type", "") # e.g. "gd", "tscn" + var max_results: int = optional_int(params, "max_results", 50) + + var matches: Array = [] + _search_recursive(path, query, file_type, matches, max_results) + + return success({"matches": matches, "count": matches.size()}) + + +func _search_recursive(path: String, query: String, file_type: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + _search_recursive(full_path, query, file_type, matches, max_results) + else: + # Check file type filter + if not file_type.is_empty() and file_name.get_extension() != file_type: + file_name = dir.get_next() + continue + + # Fuzzy match: check if query is contained in filename (case insensitive) + if file_name.to_lower().contains(query.to_lower()): + matches.append(full_path) + # Also check glob pattern + elif file_name.match(query): + matches.append(full_path) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _get_project_settings(params: Dictionary) -> Dictionary: + var section: String = optional_string(params, "section", "") + var key: String = optional_string(params, "key", "") + + # If specific key requested + if not key.is_empty(): + if ProjectSettings.has_setting(key): + var value = ProjectSettings.get_setting(key) + return success({"key": key, "value": str(value), "type": typeof(value)}) + else: + return error_not_found("Setting '%s'" % key) + + # If section requested, return all settings in that section + var settings := {} + for prop in ProjectSettings.get_property_list(): + var name: String = prop["name"] + if section.is_empty() or name.begins_with(section): + settings[name] = str(ProjectSettings.get_setting(name)) + + return success({"settings": settings, "count": settings.size()}) + + +func _set_project_setting(params: Dictionary) -> Dictionary: + var result := require_string(params, "key") + if result[1] != null: + return result[1] + var key: String = result[0] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + var value = params["value"] + + # Type conversion for common patterns + if value is String: + var s: String = value + # Try to parse typed values from string + if s.begins_with("Vector2("): + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed is Vector2: + value = parsed + elif s == "true": + value = true + elif s == "false": + value = false + elif s.is_valid_int(): + value = s.to_int() + elif s.is_valid_float(): + value = s.to_float() + + ProjectSettings.set_setting(key, value) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "key": key, + "value": str(ProjectSettings.get_setting(key)), + "saved": true, + }) + + +func _uid_to_project_path(params: Dictionary) -> Dictionary: + var result := require_string(params, "uid") + if result[1] != null: + return result[1] + var uid_str: String = result[0] + + # Use ResourceUID to convert + var uid := ResourceUID.text_to_id(uid_str) + if uid == ResourceUID.INVALID_ID: + return error_invalid_params("Invalid UID format: %s" % uid_str) + + if not ResourceUID.has_id(uid): + return error_not_found("UID '%s'" % uid_str) + + var path := ResourceUID.get_id_path(uid) + return success({"uid": uid_str, "path": path}) + + +func _project_path_to_uid(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not ResourceLoader.exists(path): + return error_not_found("Resource at '%s'" % path) + + var uid := ResourceLoader.get_resource_uid(path) + if uid == ResourceUID.INVALID_ID: + return error(-32001, "No UID assigned to '%s'" % path) + + var uid_str := ResourceUID.id_to_text(uid) + return success({"path": path, "uid": uid_str}) + + +const _TEXT_EXTENSIONS: PackedStringArray = ["gd", "tscn", "tres", "cfg", "godot", "gdshader", "md", "txt", "json"] + +func _search_in_files(params: Dictionary) -> Dictionary: + var result := require_string(params, "query") + if result[1] != null: + return result[1] + var query: String = result[0] + + var path: String = optional_string(params, "path", "res://") + var max_results: int = optional_int(params, "max_results", 50) + var use_regex: bool = optional_bool(params, "regex", false) + var file_type: String = optional_string(params, "file_type", "") + + var regex: RegEx = null + if use_regex: + regex = RegEx.new() + var err := regex.compile(query) + if err != OK: + return error_invalid_params("Invalid regex pattern: %s" % error_string(err)) + + var matches: Array = [] + _search_in_files_recursive(path, query, regex, file_type, matches, max_results) + + return success({"matches": matches, "count": matches.size(), "query": query}) + + +func _search_in_files_recursive(path: String, query: String, regex: RegEx, file_type: String, matches: Array, max_results: int) -> void: + if matches.size() >= max_results: + return + + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty() and matches.size() < max_results: + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + # Skip addons and .godot directories + if file_name != "addons" and file_name != ".godot": + _search_in_files_recursive(full_path, query, regex, file_type, matches, max_results) + else: + var ext := file_name.get_extension() + # Filter by file type if specified, otherwise use text extensions + if not file_type.is_empty(): + if ext != file_type: + file_name = dir.get_next() + continue + elif ext not in _TEXT_EXTENSIONS: + file_name = dir.get_next() + continue + + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + for i in range(lines.size()): + if matches.size() >= max_results: + break + var line: String = lines[i] + var matched := false + if regex != null: + matched = regex.search(line) != null + else: + matched = line.contains(query) + if matched: + matches.append({ + "file": full_path, + "line": i + 1, + "text": line.strip_edges(), + }) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _add_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var autoload_name: String = result[0] + + var result2 := require_string(params, "path") + if result2[1] != null: + return result2[1] + var autoload_path: String = result2[0] + + if not FileAccess.file_exists(autoload_path): + return error_not_found("File '%s'" % autoload_path) + + # Check if already exists + var setting_key := "autoload/" + autoload_name + if ProjectSettings.has_setting(setting_key): + return error(-32000, "Autoload '%s' already exists" % autoload_name, { + "current_value": str(ProjectSettings.get_setting(setting_key)), + "suggestion": "Use remove_autoload first to replace it", + }) + + # Autoload format: "*res://path.gd" (the * prefix means it's a singleton) + ProjectSettings.set_setting(setting_key, "*" + autoload_path) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "name": autoload_name, + "path": autoload_path, + "added": true, + }) + + +func _remove_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + var autoload_name: String = result[0] + + var setting_key := "autoload/" + autoload_name + if not ProjectSettings.has_setting(setting_key): + return error_not_found("Autoload '%s'" % autoload_name) + + var old_value: String = str(ProjectSettings.get_setting(setting_key)) + ProjectSettings.clear(setting_key) + var err := ProjectSettings.save() + if err != OK: + return error_internal("Failed to save project settings: %s" % error_string(err)) + + return success({ + "name": autoload_name, + "old_path": old_value, + "removed": true, + }) diff --git a/addons/godot_mcp/commands/project_commands.gd.uid b/addons/godot_mcp/commands/project_commands.gd.uid new file mode 100644 index 0000000..7a0c89d --- /dev/null +++ b/addons/godot_mcp/commands/project_commands.gd.uid @@ -0,0 +1 @@ +uid://dasry43axv6qm diff --git a/addons/godot_mcp/commands/resource_commands.gd b/addons/godot_mcp/commands/resource_commands.gd new file mode 100644 index 0000000..893cc85 --- /dev/null +++ b/addons/godot_mcp/commands/resource_commands.gd @@ -0,0 +1,189 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "read_resource": _read_resource, + "edit_resource": _edit_resource, + "create_resource": _create_resource, + "get_resource_preview": _get_resource_preview, + } + + +func _read_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + var props: Dictionary = {} + for prop_info in resource.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path": + continue + props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name)) + + return success({ + "path": path, + "type": resource.get_class(), + "resource_name": resource.resource_name, + "properties": props, + }) + + +func _edit_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not params.has("properties") or not params["properties"] is Dictionary: + return error_invalid_params("'properties' dictionary is required") + var new_props: Dictionary = params["properties"] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + var changed: Dictionary = {} + for prop_name: String in new_props: + if not prop_name in resource: + continue + var old_value: Variant = resource.get(prop_name) + var target_type := typeof(old_value) + var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type) + resource.set(prop_name, new_value) + changed[prop_name] = { + "old": PropertyParser.serialize_value(old_value), + "new": PropertyParser.serialize_value(resource.get(prop_name)), + } + + if changed.is_empty(): + return success({"path": path, "changed": {}, "message": "No properties were changed"}) + + var err := ResourceSaver.save(resource, path) + if err != OK: + return error_internal("Failed to save resource: %s" % error_string(err)) + + return success({ + "path": path, + "type": resource.get_class(), + "changed": changed, + }) + + +func _create_resource(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var result2 := require_string(params, "type") + if result2[1] != null: + return result2[1] + var resource_type: String = result2[0] + + if not ClassDB.class_exists(resource_type): + return error_invalid_params("Unknown resource type: %s" % resource_type) + if not ClassDB.is_parent_class(resource_type, "Resource"): + return error_invalid_params("'%s' is not a Resource type" % resource_type) + + var overwrite: bool = optional_bool(params, "overwrite", false) + if FileAccess.file_exists(path) and not overwrite: + return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"}) + + var resource: Resource = ClassDB.instantiate(resource_type) + if resource == null: + return error_internal("Failed to instantiate: %s" % resource_type) + + # Apply properties + var properties: Dictionary = params.get("properties", {}) + for prop_name: String in properties: + if prop_name in resource: + var current := resource.get(prop_name) + resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current))) + + var err := ResourceSaver.save(resource, path) + if err != OK: + return error_internal("Failed to save resource: %s" % error_string(err)) + + # Rescan filesystem + get_editor().get_resource_filesystem().scan() + + return success({ + "path": path, + "type": resource_type, + "properties_set": properties.keys(), + }) + + +func _get_resource_preview(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Resource '%s'" % path) + + var max_size: int = optional_int(params, "max_size", 256) + var image: Image = null + + # Try loading as image file directly + var ext := path.get_extension().to_lower() + if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]: + image = Image.new() + var err := image.load(path) + if err != OK: + return error_internal("Failed to load image: %s" % error_string(err)) + else: + # Try loading as resource and extracting image + var resource: Resource = ResourceLoader.load(path) + if resource == null: + return error_internal("Failed to load resource: %s" % path) + + if resource is Texture2D: + image = (resource as Texture2D).get_image() + elif resource is Image: + image = resource as Image + else: + return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class()) + + if image == null: + return error_internal("Could not extract image from resource") + + # Resize if needed + if image.get_width() > max_size or image.get_height() > max_size: + var scale_x := float(max_size) / float(image.get_width()) + var scale_y := float(max_size) / float(image.get_height()) + var scale := minf(scale_x, scale_y) + var new_w := int(image.get_width() * scale) + var new_h := int(image.get_height() * scale) + image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS) + + var png_buffer := image.save_png_to_buffer() + var base64 := Marshalls.raw_to_base64(png_buffer) + + return success({ + "image_base64": base64, + "width": image.get_width(), + "height": image.get_height(), + "format": "png", + "path": path, + }) diff --git a/addons/godot_mcp/commands/resource_commands.gd.uid b/addons/godot_mcp/commands/resource_commands.gd.uid new file mode 100644 index 0000000..aaf0c1a --- /dev/null +++ b/addons/godot_mcp/commands/resource_commands.gd.uid @@ -0,0 +1 @@ +uid://nb466gyf17iu diff --git a/addons/godot_mcp/commands/runtime_commands.gd b/addons/godot_mcp/commands/runtime_commands.gd new file mode 100644 index 0000000..95661ff --- /dev/null +++ b/addons/godot_mcp/commands/runtime_commands.gd @@ -0,0 +1,398 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +## Editor-side commands for runtime game inspection. +## Communicates with MCPGameInspector autoload via file-based IPC. + + +func get_commands() -> Dictionary: + return { + "get_game_scene_tree": _get_game_scene_tree, + "get_game_node_properties": _get_game_node_properties, + "set_game_node_property": _set_game_node_property, + "capture_frames": _capture_frames, + "monitor_properties": _monitor_properties, + "execute_game_script": _execute_game_script, + "start_recording": _start_recording, + "stop_recording": _stop_recording, + "replay_recording": _replay_recording, + "find_nodes_by_script": _find_nodes_by_script, + "get_autoload": _get_autoload, + "batch_get_properties": _batch_get_properties, + "find_ui_elements": _find_ui_elements, + "click_button_by_text": _click_button_by_text, + "wait_for_node": _wait_for_node, + "find_nearby_nodes": _find_nearby_nodes, + "navigate_to": _navigate_to, + "move_to": _move_to, + "watch_signals": _watch_signals, + } + + +func _get_game_scene_tree(params: Dictionary) -> Dictionary: + var max_depth: int = optional_int(params, "max_depth", -1) + var cmd_params := {"max_depth": max_depth} + + var script_filter: String = optional_string(params, "script_filter") + if not script_filter.is_empty(): + cmd_params["script_filter"] = script_filter + + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + + var named_only: bool = optional_bool(params, "named_only", false) + if named_only: + cmd_params["named_only"] = true + + return await _send_game_command("get_scene_tree", cmd_params) + + +func _get_game_node_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var cmd_params := {"node_path": result[0]} + # Optional property filter + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("get_node_properties", cmd_params) + + +func _set_game_node_property(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var prop_result := require_string(params, "property") + if prop_result[1] != null: + return prop_result[1] + + if not params.has("value"): + return error_invalid_params("Missing required parameter: value") + + return await _send_game_command("set_node_property", { + "node_path": result[0], + "property": prop_result[0], + "value": params["value"], + }) + + +func _execute_game_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "code") + if result[1] != null: + return result[1] + + return await _send_game_command("execute_script", { + "code": result[0], + }, 10.0) + + +func _capture_frames(params: Dictionary) -> Dictionary: + var count: int = optional_int(params, "count", 5) + var frame_interval: int = optional_int(params, "frame_interval", 10) + var half_resolution: bool = optional_bool(params, "half_resolution", true) + + # Dynamic timeout: allow enough time for frame capture + # At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead + var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0 + var timeout := minf(estimated_seconds, 25.0) + + return await _send_game_command("capture_frames", { + "count": count, + "frame_interval": frame_interval, + "half_resolution": half_resolution, + }, timeout) + + +func _monitor_properties(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + if not params.has("properties") or not params["properties"] is Array: + return error_invalid_params("'properties' array is required") + + var frame_count: int = optional_int(params, "frame_count", 60) + var frame_interval: int = optional_int(params, "frame_interval", 1) + + # Dynamic timeout + var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0 + var timeout := minf(estimated_seconds, 25.0) + + return await _send_game_command("monitor_properties", { + "node_path": result[0], + "properties": params["properties"], + "frame_count": frame_count, + "frame_interval": frame_interval, + }, timeout) + + +func _start_recording(params: Dictionary) -> Dictionary: + return await _send_game_command("start_recording", {}) + + +func _stop_recording(params: Dictionary) -> Dictionary: + return await _send_game_command("stop_recording", {}, 5.0) + + +func _replay_recording(params: Dictionary) -> Dictionary: + if not params.has("events") or not params["events"] is Array: + return error_invalid_params("'events' array is required") + var speed: float = float(params.get("speed", 1.0)) + + # Calculate timeout based on event duration + var max_time_ms: int = 0 + for event_data: Dictionary in params["events"]: + var t: int = int(event_data.get("time_ms", 0)) + if t > max_time_ms: + max_time_ms = t + var timeout := (max_time_ms / 1000.0 / speed) + 5.0 + + return await _send_game_command("replay_recording", { + "events": params["events"], + "speed": speed, + }, minf(timeout, 120.0)) + + +func _find_nodes_by_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "script") + if result[1] != null: + return result[1] + + var cmd_params := {"script": result[0]} + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("find_nodes_by_script", cmd_params) + + +func _get_autoload(params: Dictionary) -> Dictionary: + var result := require_string(params, "name") + if result[1] != null: + return result[1] + + var cmd_params := {"name": result[0]} + if params.has("properties") and params["properties"] is Array: + cmd_params["properties"] = params["properties"] + + return await _send_game_command("get_autoload", cmd_params) + + +func _batch_get_properties(params: Dictionary) -> Dictionary: + if not params.has("nodes") or not params["nodes"] is Array: + return error_invalid_params("'nodes' array is required") + + return await _send_game_command("batch_get_properties", { + "nodes": params["nodes"], + }) + + +func _find_ui_elements(params: Dictionary) -> Dictionary: + var cmd_params := {} + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + return await _send_game_command("find_ui_elements", cmd_params) + + +func _click_button_by_text(params: Dictionary) -> Dictionary: + var result := require_string(params, "text") + if result[1] != null: + return result[1] + + var cmd_params := {"text": result[0]} + var partial: bool = optional_bool(params, "partial", true) + cmd_params["partial"] = partial + + return await _send_game_command("click_button_by_text", cmd_params) + + +func _wait_for_node(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + + var timeout: float = float(params.get("timeout", 5.0)) + var poll_frames: int = optional_int(params, "poll_frames", 5) + + return await _send_game_command("wait_for_node", { + "node_path": result[0], + "timeout": timeout, + "poll_frames": poll_frames, + }, timeout + 2.0) + + +func _find_nearby_nodes(params: Dictionary) -> Dictionary: + if not params.has("position"): + return error_invalid_params("Missing required parameter: position") + + var cmd_params: Dictionary = {"position": params["position"]} + if params.has("radius"): + cmd_params["radius"] = float(params["radius"]) + var type_filter: String = optional_string(params, "type_filter") + if not type_filter.is_empty(): + cmd_params["type_filter"] = type_filter + var group_filter: String = optional_string(params, "group_filter") + if not group_filter.is_empty(): + cmd_params["group_filter"] = group_filter + if params.has("max_results"): + cmd_params["max_results"] = int(params["max_results"]) + + return await _send_game_command("find_nearby_nodes", cmd_params) + + +func _navigate_to(params: Dictionary) -> Dictionary: + if not params.has("target"): + return error_invalid_params("Missing required parameter: target") + + var cmd_params: Dictionary = {"target": params["target"]} + var player_path: String = optional_string(params, "player_path") + if not player_path.is_empty(): + cmd_params["player_path"] = player_path + var camera_path: String = optional_string(params, "camera_path") + if not camera_path.is_empty(): + cmd_params["camera_path"] = camera_path + if params.has("move_speed"): + cmd_params["move_speed"] = float(params["move_speed"]) + + return await _send_game_command("navigate_to", cmd_params) + + +func _move_to(params: Dictionary) -> Dictionary: + if not params.has("target"): + return error_invalid_params("Missing required parameter: target") + + var cmd_params: Dictionary = {"target": params["target"]} + var player_path: String = optional_string(params, "player_path") + if not player_path.is_empty(): + cmd_params["player_path"] = player_path + var camera_path: String = optional_string(params, "camera_path") + if not camera_path.is_empty(): + cmd_params["camera_path"] = camera_path + if params.has("arrival_radius"): + cmd_params["arrival_radius"] = float(params["arrival_radius"]) + if params.has("timeout"): + cmd_params["timeout"] = float(params["timeout"]) + if params.has("run"): + cmd_params["run"] = bool(params["run"]) + if params.has("look_at_target"): + cmd_params["look_at_target"] = bool(params["look_at_target"]) + + # Dynamic timeout: game-side timeout + overhead for IPC polling + var game_timeout: float = float(params.get("timeout", 15.0)) + var ipc_timeout: float = game_timeout + 5.0 + + return await _send_game_command("move_to", cmd_params, ipc_timeout) + + +func _watch_signals(params: Dictionary) -> Dictionary: + if not params.has("node_paths") or not params["node_paths"] is Array: + return error_invalid_params("Missing required parameter: node_paths (Array)") + + var cmd_params: Dictionary = {"node_paths": params["node_paths"]} + if params.has("signal_filter") and params["signal_filter"] is Array: + cmd_params["signal_filter"] = params["signal_filter"] + var duration_ms: int = optional_int(params, "duration_ms", 5000) + cmd_params["duration_ms"] = duration_ms + + # Dynamic timeout: duration + overhead + var timeout_sec: float = (duration_ms / 1000.0) + 5.0 + + return await _send_game_command("watch_signals", cmd_params, timeout_sec) + + +# ── IPC Helper ──────────────────────────────────────────────────────────────── + +func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + + # Clean stale response + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) + + # Write request + var request_data := JSON.stringify({"command": command, "params": params}) + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create game request file") + req.store_string(request_data) + req.close() + + # Poll for response + var attempts := int(timeout_sec / 0.1) + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + # Check if game is still running + if not ei.is_playing_scene(): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game stopped during command execution") + attempts -= 1 + + if not FileAccess.file_exists(response_path): + # Try to auto-resume the debugger (runtime error may have paused the game) + if ei.is_playing_scene(): + _try_debugger_continue() + # Give the game a chance to recover and write a response + for _retry in 20: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + + if not FileAccess.file_exists(response_path): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game command timed out after %.1fs" % timeout_sec, { + "suggestion": "Ensure the game is running and MCPGameInspector autoload is active", + }) + + # Read response + var file := FileAccess.open(response_path, FileAccess.READ) + if file == null: + return error_internal("Could not read game response file") + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(response_path) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + return error_internal("Invalid response JSON from game") + + if parsed.has("error"): + return error(-32000, str(parsed["error"])) + + return success(parsed) + + +## Press the debugger "Continue" button to resume a paused game process. +func _try_debugger_continue() -> void: + var base := EditorInterface.get_base_control() + if base == null: + return + var queue: Array[Node] = [base] + while not queue.is_empty(): + var node := queue.pop_front() + if node.get_class() == "ScriptEditorDebugger": + var inner: Array[Node] = [node] + while not inner.is_empty(): + var n := inner.pop_front() + if n is Button and n.tooltip_text == "Continue": + n.emit_signal("pressed") + push_warning("[MCP] Auto-resumed debugger after runtime error") + return + for c in n.get_children(): + inner.append(c) + return + for child in node.get_children(): + queue.append(child) diff --git a/addons/godot_mcp/commands/runtime_commands.gd.uid b/addons/godot_mcp/commands/runtime_commands.gd.uid new file mode 100644 index 0000000..631c62a --- /dev/null +++ b/addons/godot_mcp/commands/runtime_commands.gd.uid @@ -0,0 +1 @@ +uid://b5x2k2g6nfn1p diff --git a/addons/godot_mcp/commands/scene_3d_commands.gd b/addons/godot_mcp/commands/scene_3d_commands.gd new file mode 100644 index 0000000..5798b86 --- /dev/null +++ b/addons/godot_mcp/commands/scene_3d_commands.gd @@ -0,0 +1,680 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") + + +func get_commands() -> Dictionary: + return { + "add_mesh_instance": _add_mesh_instance, + "setup_lighting": _setup_lighting, + "set_material_3d": _set_material_3d, + "setup_environment": _setup_environment, + "setup_camera_3d": _setup_camera_3d, + "add_gridmap": _add_gridmap, + } + + +## ─── Helpers ─────────────────────────────────────────────────────────────── + +func _optional_float(params: Dictionary, key: String, default: float) -> float: + if params.has(key): + return float(params[key]) + return default + + +func _parse_color_param(params: Dictionary, key: String, default: Color) -> Color: + if not params.has(key): + return default + var val: Variant = params[key] + if val is String: + return PropertyParser.parse_value(val, TYPE_COLOR) + if val is Dictionary: + return Color( + float(val.get("r", default.r)), + float(val.get("g", default.g)), + float(val.get("b", default.b)), + float(val.get("a", default.a)) + ) + return default + + +func _parse_vector3_param(params: Dictionary, key: String, default: Vector3) -> Vector3: + if not params.has(key): + return default + var val: Variant = params[key] + if val is String: + return PropertyParser.parse_value(val, TYPE_VECTOR3) + if val is Dictionary: + return Vector3( + float(val.get("x", default.x)), + float(val.get("y", default.y)), + float(val.get("z", default.z)) + ) + if val is Array and val.size() >= 3: + return Vector3(float(val[0]), float(val[1]), float(val[2])) + return default + + +func _add_child_with_undo(node: Node, parent: Node, root: Node, action_name: String) -> void: + var undo_redo := get_undo_redo() + undo_redo.create_action(action_name) + undo_redo.add_do_method(parent, "add_child", node) + undo_redo.add_do_method(node, "set_owner", root) + undo_redo.add_do_reference(node) + undo_redo.add_undo_method(parent, "remove_child", node) + undo_redo.commit_action() + + +## ─── 1. add_mesh_instance ────────────────────────────────────────────────── + +func _add_mesh_instance(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "MeshInstance3D") + var mesh_type: String = optional_string(params, "mesh_type", "") + var mesh_file: String = optional_string(params, "mesh_file", "") + + if mesh_type.is_empty() and mesh_file.is_empty(): + return error_invalid_params("Either 'mesh_type' or 'mesh_file' is required") + + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = node_name + + if not mesh_file.is_empty(): + # Load .glb / .gltf / .obj + if not ResourceLoader.exists(mesh_file): + mesh_instance.queue_free() + return error_not_found("Mesh file '%s'" % mesh_file, "Provide a valid res:// path to .glb, .gltf, or .obj") + var loaded: Resource = load(mesh_file) + if loaded is Mesh: + mesh_instance.mesh = loaded as Mesh + elif loaded is PackedScene: + # For .glb/.gltf we instantiate and steal the first MeshInstance3D's mesh + var scene_instance: Node = (loaded as PackedScene).instantiate() + var found_mesh: Mesh = null + var search_nodes: Array[Node] = [scene_instance] + while not search_nodes.is_empty(): + var n: Node = search_nodes.pop_front() + if n is MeshInstance3D and (n as MeshInstance3D).mesh != null: + found_mesh = (n as MeshInstance3D).mesh + break + for child in n.get_children(): + search_nodes.append(child) + scene_instance.queue_free() + if found_mesh == null: + mesh_instance.queue_free() + return error_invalid_params("No mesh found in '%s'" % mesh_file) + mesh_instance.mesh = found_mesh + else: + mesh_instance.queue_free() + return error_invalid_params("'%s' is not a Mesh or PackedScene" % mesh_file) + else: + # Primitive mesh + var mesh_classes := { + "BoxMesh": BoxMesh, + "SphereMesh": SphereMesh, + "CylinderMesh": CylinderMesh, + "CapsuleMesh": CapsuleMesh, + "PlaneMesh": PlaneMesh, + "PrismMesh": PrismMesh, + "TorusMesh": TorusMesh, + "QuadMesh": QuadMesh, + } + if not mesh_classes.has(mesh_type): + mesh_instance.queue_free() + return error_invalid_params("Unknown mesh_type '%s'. Available: %s" % [mesh_type, mesh_classes.keys()]) + var mesh_res: Mesh = mesh_classes[mesh_type].new() + # Apply mesh properties if provided + var mesh_properties: Dictionary = params.get("mesh_properties", {}) + for prop_name: String in mesh_properties: + if prop_name in mesh_res: + var current: Variant = mesh_res.get(prop_name) + mesh_res.set(prop_name, PropertyParser.parse_value(mesh_properties[prop_name], typeof(current))) + mesh_instance.mesh = mesh_res + + # Transform + var position := _parse_vector3_param(params, "position", Vector3.ZERO) + var rotation_deg := _parse_vector3_param(params, "rotation", Vector3.ZERO) + var scale_vec := _parse_vector3_param(params, "scale", Vector3.ONE) + + mesh_instance.position = position + mesh_instance.rotation_degrees = rotation_deg + mesh_instance.scale = scale_vec + + _add_child_with_undo(mesh_instance, parent, root, "MCP: Add MeshInstance3D") + + return success({ + "node_path": str(root.get_path_to(mesh_instance)), + "name": str(mesh_instance.name), + "mesh_type": mesh_type if mesh_file.is_empty() else mesh_file, + }) + + +## ─── 2. setup_lighting ──────────────────────────────────────────────────── + +func _setup_lighting(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var light_type: String = optional_string(params, "light_type", "") + var preset: String = optional_string(params, "preset", "") + var node_name: String = optional_string(params, "name", "") + + # Preset configurations + if not preset.is_empty(): + match preset: + "sun": + light_type = "DirectionalLight3D" + if node_name.is_empty(): + node_name = "SunLight" + "indoor": + light_type = "OmniLight3D" + if node_name.is_empty(): + node_name = "IndoorLight" + "dramatic": + light_type = "SpotLight3D" + if node_name.is_empty(): + node_name = "DramaticLight" + _: + return error_invalid_params("Unknown preset '%s'. Available: sun, indoor, dramatic" % preset) + + if light_type.is_empty(): + return error_invalid_params("Either 'light_type' or 'preset' is required") + + var light: Light3D + match light_type: + "DirectionalLight3D": + light = DirectionalLight3D.new() + "OmniLight3D": + light = OmniLight3D.new() + "SpotLight3D": + light = SpotLight3D.new() + _: + return error_invalid_params("Unknown light_type '%s'. Available: DirectionalLight3D, OmniLight3D, SpotLight3D" % light_type) + + if node_name.is_empty(): + node_name = light_type + light.name = node_name + + # Common properties + light.light_color = _parse_color_param(params, "color", Color.WHITE) + light.light_energy = _optional_float(params, "energy", 1.0) + light.shadow_enabled = optional_bool(params, "shadows", false) + + # Type-specific properties + if light is OmniLight3D: + var omni: OmniLight3D = light as OmniLight3D + omni.omni_range = _optional_float(params, "range", 5.0) + omni.omni_attenuation = _optional_float(params, "attenuation", 1.0) + elif light is SpotLight3D: + var spot: SpotLight3D = light as SpotLight3D + spot.spot_range = _optional_float(params, "range", 5.0) + spot.spot_attenuation = _optional_float(params, "attenuation", 1.0) + spot.spot_angle = _optional_float(params, "spot_angle", 45.0) + spot.spot_angle_attenuation = _optional_float(params, "spot_angle_attenuation", 1.0) + + # Apply preset defaults after type creation + if not preset.is_empty(): + match preset: + "sun": + light.light_energy = _optional_float(params, "energy", 1.0) + light.shadow_enabled = optional_bool(params, "shadows", true) + light.rotation_degrees = _parse_vector3_param(params, "rotation", Vector3(-45, -30, 0)) + "indoor": + light.light_energy = _optional_float(params, "energy", 0.8) + light.light_color = _parse_color_param(params, "color", Color(1.0, 0.95, 0.85)) + if light is OmniLight3D: + (light as OmniLight3D).omni_range = _optional_float(params, "range", 8.0) + "dramatic": + light.light_energy = _optional_float(params, "energy", 2.0) + light.shadow_enabled = optional_bool(params, "shadows", true) + if light is SpotLight3D: + (light as SpotLight3D).spot_angle = _optional_float(params, "spot_angle", 25.0) + (light as SpotLight3D).spot_range = _optional_float(params, "range", 10.0) + + # Position / rotation + light.position = _parse_vector3_param(params, "position", Vector3.ZERO) + if params.has("rotation"): + light.rotation_degrees = _parse_vector3_param(params, "rotation", light.rotation_degrees) + + _add_child_with_undo(light, parent, root, "MCP: Add %s" % light_type) + + return success({ + "node_path": str(root.get_path_to(light)), + "name": str(light.name), + "light_type": light_type, + "preset": preset, + }) + + +## ─── 3. set_material_3d ─────────────────────────────────────────────────── + +func _set_material_3d(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path) + + if not node is MeshInstance3D: + return error_invalid_params("Node '%s' is not a MeshInstance3D (is %s)" % [node_path, node.get_class()]) + + var mesh_inst: MeshInstance3D = node as MeshInstance3D + var surface_index: int = optional_int(params, "surface_index", 0) + + var mat := StandardMaterial3D.new() + + # Albedo + mat.albedo_color = _parse_color_param(params, "albedo_color", Color.WHITE) + if params.has("albedo_texture"): + var tex_path: String = params["albedo_texture"] + if ResourceLoader.exists(tex_path): + mat.albedo_texture = load(tex_path) as Texture2D + + # PBR + mat.metallic = _optional_float(params, "metallic", 0.0) + mat.roughness = _optional_float(params, "roughness", 1.0) + if params.has("metallic_texture"): + var tex_path: String = params["metallic_texture"] + if ResourceLoader.exists(tex_path): + mat.metallic_texture = load(tex_path) as Texture2D + if params.has("roughness_texture"): + var tex_path: String = params["roughness_texture"] + if ResourceLoader.exists(tex_path): + mat.roughness_texture = load(tex_path) as Texture2D + if params.has("normal_texture"): + mat.normal_enabled = true + var tex_path: String = params["normal_texture"] + if ResourceLoader.exists(tex_path): + mat.normal_texture = load(tex_path) as Texture2D + + # Emission + if params.has("emission") or params.has("emission_color"): + mat.emission_enabled = true + mat.emission = _parse_color_param(params, "emission", _parse_color_param(params, "emission_color", Color.BLACK)) + mat.emission_energy_multiplier = _optional_float(params, "emission_energy", 1.0) + if params.has("emission_texture"): + mat.emission_enabled = true + var tex_path: String = params["emission_texture"] + if ResourceLoader.exists(tex_path): + mat.emission_texture = load(tex_path) as Texture2D + + # Transparency + if params.has("transparency"): + var transparency_val: String = str(params["transparency"]) + match transparency_val.to_upper(): + "DISABLED", "0": + mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED + "ALPHA", "1": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + "ALPHA_SCISSOR", "2": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + "ALPHA_HASH", "3": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH + "ALPHA_DEPTH_PRE_PASS", "4": + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS + + # Cull mode + if params.has("cull_mode"): + var cull_val: String = str(params["cull_mode"]) + match cull_val.to_upper(): + "BACK", "0": + mat.cull_mode = BaseMaterial3D.CULL_BACK + "FRONT", "1": + mat.cull_mode = BaseMaterial3D.CULL_FRONT + "DISABLED", "2": + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + # Apply + var old_mat: Material = mesh_inst.get_surface_override_material(surface_index) + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Set material on %s" % mesh_inst.name) + undo_redo.add_do_method(mesh_inst, "set_surface_override_material", surface_index, mat) + undo_redo.add_undo_method(mesh_inst, "set_surface_override_material", surface_index, old_mat) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(mesh_inst)), + "surface_index": surface_index, + "albedo_color": str(mat.albedo_color), + "metallic": mat.metallic, + "roughness": mat.roughness, + }) + + +## ─── 4. setup_environment ───────────────────────────────────────────────── + +func _setup_environment(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "WorldEnvironment") + + # Check if a WorldEnvironment already exists at the target + var node_path: String = optional_string(params, "node_path", "") + var world_env: WorldEnvironment = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is WorldEnvironment: + world_env = existing as WorldEnvironment + is_existing = true + + if world_env == null: + world_env = WorldEnvironment.new() + world_env.name = node_name + + var env: Environment = world_env.environment + if env == null: + env = Environment.new() + + # Background / Sky + var bg_mode: String = optional_string(params, "background_mode", "sky") + match bg_mode.to_lower(): + "sky": + env.background_mode = Environment.BG_SKY + "color": + env.background_mode = Environment.BG_COLOR + env.background_color = _parse_color_param(params, "background_color", Color(0.3, 0.3, 0.3)) + "canvas": + env.background_mode = Environment.BG_CANVAS + "clear_color": + env.background_mode = Environment.BG_CLEAR_COLOR + + # Procedural sky + if params.has("sky") and params["sky"] is Dictionary: + var sky_params: Dictionary = params["sky"] + var sky_mat := ProceduralSkyMaterial.new() + sky_mat.sky_top_color = _parse_color_param(sky_params, "sky_top_color", Color(0.385, 0.454, 0.55)) + sky_mat.sky_horizon_color = _parse_color_param(sky_params, "sky_horizon_color", Color(0.646, 0.654, 0.67)) + sky_mat.ground_bottom_color = _parse_color_param(sky_params, "ground_bottom_color", Color(0.2, 0.169, 0.133)) + sky_mat.ground_horizon_color = _parse_color_param(sky_params, "ground_horizon_color", Color(0.646, 0.654, 0.67)) + sky_mat.sun_angle_max = _optional_float(sky_params, "sun_angle_max", 30.0) if sky_params.has("sun_angle_max") else 30.0 + sky_mat.sky_curve = _optional_float(sky_params, "sky_curve", 0.15) if sky_params.has("sky_curve") else 0.15 + + var sky := Sky.new() + sky.sky_material = sky_mat + env.sky = sky + env.background_mode = Environment.BG_SKY + + # Ambient light + if params.has("ambient_light_color"): + env.ambient_light_color = _parse_color_param(params, "ambient_light_color", Color.WHITE) + env.ambient_light_energy = _optional_float(params, "ambient_light_energy", 1.0) if params.has("ambient_light_energy") else env.ambient_light_energy + if params.has("ambient_light_source"): + var src: String = str(params["ambient_light_source"]) + match src.to_upper(): + "BACKGROUND", "0": + env.ambient_light_source = Environment.AMBIENT_SOURCE_BG + "DISABLED", "1": + env.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED + "COLOR", "2": + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + "SKY", "3": + env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + + # Tonemap + if params.has("tonemap_mode"): + var tm: String = str(params["tonemap_mode"]) + match tm.to_upper(): + "LINEAR", "0": + env.tonemap_mode = Environment.TONE_MAPPER_LINEAR + "REINHARDT", "1": + env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT + "FILMIC", "2": + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + "ACES", "3": + env.tonemap_mode = Environment.TONE_MAPPER_ACES + "AGX", "4": + env.tonemap_mode = 4 # Environment.TONE_MAPPER_AGX (Godot 4.4+) + if params.has("tonemap_exposure"): + env.tonemap_exposure = _optional_float(params, "tonemap_exposure", 1.0) + if params.has("tonemap_white"): + env.tonemap_white = _optional_float(params, "tonemap_white", 1.0) + + # Fog + if params.has("fog_enabled"): + env.fog_enabled = optional_bool(params, "fog_enabled", false) + if env.fog_enabled or params.has("fog_light_color"): + env.fog_light_color = _parse_color_param(params, "fog_light_color", Color(0.518, 0.553, 0.608)) + env.fog_density = _optional_float(params, "fog_density", 0.01) if params.has("fog_density") else env.fog_density + env.fog_light_energy = _optional_float(params, "fog_light_energy", 1.0) if params.has("fog_light_energy") else env.fog_light_energy + + # Glow + if params.has("glow_enabled"): + env.glow_enabled = optional_bool(params, "glow_enabled", false) + if env.glow_enabled: + env.glow_intensity = _optional_float(params, "glow_intensity", 0.8) if params.has("glow_intensity") else env.glow_intensity + env.glow_strength = _optional_float(params, "glow_strength", 1.0) if params.has("glow_strength") else env.glow_strength + env.glow_bloom = _optional_float(params, "glow_bloom", 0.0) if params.has("glow_bloom") else env.glow_bloom + + # SSAO + if params.has("ssao_enabled"): + env.ssao_enabled = optional_bool(params, "ssao_enabled", false) + if env.ssao_enabled: + env.ssao_radius = _optional_float(params, "ssao_radius", 1.0) if params.has("ssao_radius") else env.ssao_radius + env.ssao_intensity = _optional_float(params, "ssao_intensity", 2.0) if params.has("ssao_intensity") else env.ssao_intensity + + # SSR + if params.has("ssr_enabled"): + env.ssr_enabled = optional_bool(params, "ssr_enabled", false) + if env.ssr_enabled: + env.ssr_max_steps = optional_int(params, "ssr_max_steps", 64) if params.has("ssr_max_steps") else env.ssr_max_steps + env.ssr_fade_in = _optional_float(params, "ssr_fade_in", 0.15) if params.has("ssr_fade_in") else env.ssr_fade_in + env.ssr_fade_out = _optional_float(params, "ssr_fade_out", 2.0) if params.has("ssr_fade_out") else env.ssr_fade_out + + # SDFGI + if params.has("sdfgi_enabled"): + env.sdfgi_enabled = optional_bool(params, "sdfgi_enabled", false) + + world_env.environment = env + + if not is_existing: + _add_child_with_undo(world_env, parent, root, "MCP: Add WorldEnvironment") + + var features: Array = [] + if env.fog_enabled: features.append("fog") + if env.glow_enabled: features.append("glow") + if env.ssao_enabled: features.append("ssao") + if env.ssr_enabled: features.append("ssr") + if env.sdfgi_enabled: features.append("sdfgi") + + return success({ + "node_path": str(root.get_path_to(world_env)), + "name": str(world_env.name), + "background_mode": bg_mode, + "features": features, + "is_existing": is_existing, + }) + + +## ─── 5. setup_camera_3d ────────────────────────────────────────────────── + +func _setup_camera_3d(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + # Check if we're configuring an existing camera + var node_path: String = optional_string(params, "node_path", "") + var camera: Camera3D = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is Camera3D: + camera = existing as Camera3D + is_existing = true + elif existing != null: + return error_invalid_params("Node '%s' is not a Camera3D (is %s)" % [node_path, existing.get_class()]) + + if camera == null: + camera = Camera3D.new() + camera.name = optional_string(params, "name", "Camera3D") + + # Projection + var projection_str: String = optional_string(params, "projection", "") + if not projection_str.is_empty(): + match projection_str.to_lower(): + "perspective", "0": + camera.projection = Camera3D.PROJECTION_PERSPECTIVE + "orthogonal", "orthographic", "1": + camera.projection = Camera3D.PROJECTION_ORTHOGONAL + "frustum", "2": + camera.projection = Camera3D.PROJECTION_FRUSTUM + + # Properties + if params.has("fov"): + camera.fov = _optional_float(params, "fov", 75.0) + if params.has("size"): + camera.size = _optional_float(params, "size", 1.0) + if params.has("near"): + camera.near = _optional_float(params, "near", 0.05) + if params.has("far"): + camera.far = _optional_float(params, "far", 4000.0) + if params.has("cull_mask"): + camera.cull_mask = optional_int(params, "cull_mask", 1048575) + + # Make current + camera.current = optional_bool(params, "current", false) + + # Transform + camera.position = _parse_vector3_param(params, "position", camera.position if is_existing else Vector3(0, 1, 3)) + if params.has("rotation"): + camera.rotation_degrees = _parse_vector3_param(params, "rotation", camera.rotation_degrees) + if params.has("look_at"): + var target := _parse_vector3_param(params, "look_at", Vector3.ZERO) + # We need to set position first, then use look_at + camera.look_at(target) + + # Environment override + if params.has("environment_path"): + var env_path: String = params["environment_path"] + if ResourceLoader.exists(env_path): + var env_res: Resource = load(env_path) + if env_res is Environment: + camera.environment = env_res as Environment + + if not is_existing: + _add_child_with_undo(camera, parent, root, "MCP: Add Camera3D") + + return success({ + "node_path": str(root.get_path_to(camera)), + "name": str(camera.name), + "projection": "perspective" if camera.projection == Camera3D.PROJECTION_PERSPECTIVE else "orthogonal", + "fov": camera.fov, + "position": str(camera.position), + "is_existing": is_existing, + }) + + +## ─── 6. add_gridmap ────────────────────────────────────────────────────── + +func _add_gridmap(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var parent_path: String = optional_string(params, "parent_path", ".") + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path) + + var node_name: String = optional_string(params, "name", "GridMap") + + # Check for existing GridMap to configure + var node_path: String = optional_string(params, "node_path", "") + var gridmap: GridMap = null + var is_existing := false + + if not node_path.is_empty(): + var existing := find_node_by_path(node_path) + if existing != null and existing is GridMap: + gridmap = existing as GridMap + is_existing = true + elif existing != null: + return error_invalid_params("Node '%s' is not a GridMap (is %s)" % [node_path, existing.get_class()]) + + if gridmap == null: + gridmap = GridMap.new() + gridmap.name = node_name + + # Mesh library + if params.has("mesh_library_path"): + var lib_path: String = params["mesh_library_path"] + if not ResourceLoader.exists(lib_path): + if not is_existing: + gridmap.queue_free() + return error_not_found("MeshLibrary '%s'" % lib_path, "Provide a valid res:// path to a .meshlib or .tres file") + var lib: Resource = load(lib_path) + if lib is MeshLibrary: + gridmap.mesh_library = lib as MeshLibrary + else: + if not is_existing: + gridmap.queue_free() + return error_invalid_params("'%s' is not a MeshLibrary" % lib_path) + + # Cell size + if params.has("cell_size"): + gridmap.cell_size = _parse_vector3_param(params, "cell_size", Vector3(2, 2, 2)) + + # Position + gridmap.position = _parse_vector3_param(params, "position", gridmap.position if is_existing else Vector3.ZERO) + + if not is_existing: + _add_child_with_undo(gridmap, parent, root, "MCP: Add GridMap") + + # Set cells + var cells: Array = params.get("cells", []) + var cells_set: int = 0 + for cell in cells: + if cell is Dictionary: + var x: int = int(cell.get("x", 0)) + var y: int = int(cell.get("y", 0)) + var z: int = int(cell.get("z", 0)) + var item: int = int(cell.get("item", 0)) + var orientation: int = int(cell.get("orientation", 0)) + gridmap.set_cell_item(Vector3i(x, y, z), item, orientation) + cells_set += 1 + + return success({ + "node_path": str(root.get_path_to(gridmap)), + "name": str(gridmap.name), + "cells_set": cells_set, + "is_existing": is_existing, + "has_mesh_library": gridmap.mesh_library != null, + }) diff --git a/addons/godot_mcp/commands/scene_3d_commands.gd.uid b/addons/godot_mcp/commands/scene_3d_commands.gd.uid new file mode 100644 index 0000000..5342105 --- /dev/null +++ b/addons/godot_mcp/commands/scene_3d_commands.gd.uid @@ -0,0 +1 @@ +uid://c0genhd54bvpt diff --git a/addons/godot_mcp/commands/scene_commands.gd b/addons/godot_mcp/commands/scene_commands.gd new file mode 100644 index 0000000..24b53a2 --- /dev/null +++ b/addons/godot_mcp/commands/scene_commands.gd @@ -0,0 +1,310 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd") +const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + + +func get_commands() -> Dictionary: + return { + "get_scene_tree": _get_scene_tree, + "get_scene_file_content": _get_scene_file_content, + "create_scene": _create_scene, + "open_scene": _open_scene, + "delete_scene": _delete_scene, + "add_scene_instance": _add_scene_instance, + "play_scene": _play_scene, + "stop_scene": _stop_scene, + "save_scene": _save_scene, + "get_scene_exports": _get_scene_exports, + } + + +func _get_scene_tree(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var max_depth: int = optional_int(params, "max_depth", -1) + var tree := NodeUtils.get_node_tree(root, max_depth) + return success({"scene_path": root.scene_file_path, "tree": tree}) + + +func _get_scene_file_content(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read file: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + return success({"path": path, "content": content, "size": content.length()}) + + +func _create_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var root_type: String = optional_string(params, "root_type", "Node2D") + var root_name: String = optional_string(params, "root_name", "") + + # Validate root type exists + if not ClassDB.class_exists(root_type): + return error_invalid_params("Unknown node type: %s" % root_type) + + # Create the scene + var root: Node = ClassDB.instantiate(root_type) + if root_name.is_empty(): + root_name = path.get_file().get_basename() + root.name = root_name + + var scene := PackedScene.new() + var err := scene.pack(root) + root.queue_free() + + if err != OK: + return error_internal("Failed to pack scene: %s" % error_string(err)) + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + err = ResourceSaver.save(scene, path) + if err != OK: + return error_internal("Failed to save scene: %s" % error_string(err)) + + # Refresh filesystem + get_editor().get_resource_filesystem().scan() + + return success({"path": path, "root_type": root_type, "root_name": root_name}) + + +func _open_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + get_editor().open_scene_from_path(path) + return success({"path": path, "opened": true}) + + +func _delete_scene(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var err := DirAccess.remove_absolute(path) + if err != OK: + return error_internal("Failed to delete scene: %s" % error_string(err)) + + # Also remove .import file if exists + var import_path := path + ".import" + if FileAccess.file_exists(import_path): + DirAccess.remove_absolute(import_path) + + get_editor().get_resource_filesystem().scan() + return success({"path": path, "deleted": true}) + + +func _add_scene_instance(params: Dictionary) -> Dictionary: + var result := require_string(params, "scene_path") + if result[1] != null: + return result[1] + var scene_path: String = result[0] + + var parent_path: String = optional_string(params, "parent_path", ".") + var instance_name: String = optional_string(params, "name", "") + + var root := get_edited_root() + if root == null: + return error_no_scene() + + if not FileAccess.file_exists(scene_path): + return error_not_found("Scene file '%s'" % scene_path) + + var parent := find_node_by_path(parent_path) + if parent == null: + return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes") + + var packed: PackedScene = load(scene_path) + if packed == null: + return error_internal("Failed to load scene: %s" % scene_path) + + var instance := packed.instantiate() + if not instance_name.is_empty(): + instance.name = instance_name + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Add scene instance") + undo_redo.add_do_method(parent, "add_child", instance) + undo_redo.add_do_method(instance, "set_owner", root) + undo_redo.add_do_reference(instance) + undo_redo.add_undo_method(parent, "remove_child", instance) + undo_redo.commit_action() + + NodeUtils.set_owner_recursive(instance, root) + + return success({ + "node_path": str(root.get_path_to(instance)), + "scene_path": scene_path, + "name": instance.name, + }) + + +func _play_scene(params: Dictionary) -> Dictionary: + var mode: String = optional_string(params, "mode", "main") # "main", "current", or path + var ei := get_editor() + + match mode: + "main": + ei.play_main_scene() + "current": + ei.play_current_scene() + _: + # Treat as scene path + if not FileAccess.file_exists(mode): + return error_not_found("Scene file '%s'" % mode) + ei.play_custom_scene(mode) + + return success({"playing": true, "mode": mode}) + + +func _stop_scene(params: Dictionary) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return success({"stopped": false, "message": "No scene is currently playing"}) + + ei.stop_playing_scene() + + # Clean up temp files + _cleanup_screenshot_files() + _cleanup_input_files() + _cleanup_inspector_files() + + return success({"stopped": true}) + + +func _save_scene(params: Dictionary) -> Dictionary: + var root := get_edited_root() + if root == null: + return error_no_scene() + + var path: String = optional_string(params, "path", "") + if path.is_empty(): + path = root.scene_file_path + + if path.is_empty(): + return error_invalid_params("No save path specified and scene has no existing path") + + var scene := PackedScene.new() + var err := scene.pack(root) + if err != OK: + return error_internal("Failed to pack scene: %s" % error_string(err)) + + err = ResourceSaver.save(scene, path) + if err != OK: + return error_internal("Failed to save scene: %s" % error_string(err)) + + return success({"path": path, "saved": true}) + + +func _get_scene_exports(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Scene file '%s'" % path) + + var packed: PackedScene = load(path) + if packed == null: + return error_internal("Failed to load scene: %s" % path) + + var instance: Node = packed.instantiate() + if instance == null: + return error_internal("Failed to instantiate scene: %s" % path) + + var nodes_data: Array = [] + _collect_exports_recursive(instance, instance, nodes_data) + + instance.queue_free() + + return success({ + "path": path, + "nodes": nodes_data, + "count": nodes_data.size(), + }) + + +func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void: + var script: Script = node.get_script() + if script != null: + var exports: Dictionary = {} + for prop_info in script.get_script_property_list(): + var usage: int = prop_info["usage"] + if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE): + var prop_name: String = prop_info["name"] + exports[prop_name] = { + "value": PropertyParser.serialize_value(node.get(prop_name)), + "type": prop_info["type"], + "hint": prop_info.get("hint", 0), + "hint_string": prop_info.get("hint_string", ""), + } + if not exports.is_empty(): + var node_path := "." if node == root else str(root.get_path_to(node)) + nodes_data.append({ + "node_path": node_path, + "node_name": node.name, + "node_type": node.get_class(), + "script_path": script.resource_path, + "exports": exports, + }) + + for child in node.get_children(): + _collect_exports_recursive(child, root, nodes_data) + + +func _cleanup_screenshot_files() -> void: + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_screenshot_request" + var screenshot_path := user_dir + "/mcp_screenshot.png" + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) + + +func _cleanup_input_files() -> void: + var user_dir := get_game_user_dir() + var commands_path := user_dir + "/mcp_input_commands" + if FileAccess.file_exists(commands_path): + DirAccess.remove_absolute(commands_path) + + +func _cleanup_inspector_files() -> void: + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) diff --git a/addons/godot_mcp/commands/scene_commands.gd.uid b/addons/godot_mcp/commands/scene_commands.gd.uid new file mode 100644 index 0000000..96b1774 --- /dev/null +++ b/addons/godot_mcp/commands/scene_commands.gd.uid @@ -0,0 +1 @@ +uid://cfej2ub6lorfy diff --git a/addons/godot_mcp/commands/script_commands.gd b/addons/godot_mcp/commands/script_commands.gd new file mode 100644 index 0000000..4ef117e --- /dev/null +++ b/addons/godot_mcp/commands/script_commands.gd @@ -0,0 +1,310 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "list_scripts": _list_scripts, + "read_script": _read_script, + "create_script": _create_script, + "edit_script": _edit_script, + "attach_script": _attach_script, + "get_open_scripts": _get_open_scripts, + "validate_script": _validate_script, + } + + +func _list_scripts(params: Dictionary) -> Dictionary: + var path: String = optional_string(params, "path", "res://") + var recursive: bool = optional_bool(params, "recursive", true) + + var scripts: Array = [] + _find_scripts(path, recursive, scripts) + + return success({"scripts": scripts, "count": scripts.size()}) + + +func _find_scripts(path: String, recursive: bool, scripts: Array) -> void: + var dir := DirAccess.open(path) + if dir == null: + return + + dir.list_dir_begin() + var file_name := dir.get_next() + + while not file_name.is_empty(): + if file_name.begins_with("."): + file_name = dir.get_next() + continue + + var full_path := path.path_join(file_name) + + if dir.current_is_dir(): + if recursive: + _find_scripts(full_path, recursive, scripts) + elif file_name.get_extension() in ["gd", "cs", "gdshader"]: + var info := {"path": full_path, "type": file_name.get_extension()} + # Get basic file info + var file := FileAccess.open(full_path, FileAccess.READ) + if file: + info["size"] = file.get_length() + # Read first line for class/extends info + var first_line := file.get_line().strip_edges() + if first_line.begins_with("class_name "): + info["class_name"] = first_line.substr(11).strip_edges() + elif first_line.begins_with("extends "): + info["extends"] = first_line.substr(8).strip_edges() + file.close() + scripts.append(info) + + file_name = dir.get_next() + + dir.list_dir_end() + + +func _read_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + var line_count := content.count("\n") + 1 + file.close() + + return success({ + "path": path, + "content": content, + "line_count": line_count, + "size": content.length(), + }) + + +func _create_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var content: String = optional_string(params, "content", "") + var base_class: String = optional_string(params, "extends", "Node") + var class_name_str: String = optional_string(params, "class_name", "") + + # Generate template if no content provided + if content.is_empty(): + var lines: PackedStringArray = [] + if not class_name_str.is_empty(): + lines.append("class_name %s" % class_name_str) + lines.append("extends %s" % base_class) + lines.append("") + lines.append("") + lines.append("func _ready() -> void:") + lines.append("\tpass") + lines.append("") + content = "\n".join(lines) + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + get_editor().get_resource_filesystem().scan() + + # Pre-load so the script is available immediately + if ResourceLoader.exists(path): + var script = load(path) + if script is Script: + script.reload(true) + + return success({"path": path, "created": true}) + + +func _edit_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + # Read current content + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + + var old_content := content + var changes_made := 0 + + # Support search-and-replace + if params.has("replacements") and params["replacements"] is Array: + var replacements: Array = params["replacements"] + for replacement in replacements: + if replacement is Dictionary: + var search: String = replacement.get("search", "") + var replace: String = replacement.get("replace", "") + if not search.is_empty(): + var use_regex: bool = replacement.get("regex", false) + if use_regex: + var regex := RegEx.new() + var err := regex.compile(search) + if err == OK: + var new_content := regex.sub(content, replace, true) + if new_content != content: + content = new_content + changes_made += 1 + else: + if content.contains(search): + content = content.replace(search, replace) + changes_made += 1 + + # Support full content replacement + elif params.has("content"): + content = str(params["content"]) + changes_made = 1 + + # Support insert at line + elif params.has("insert_at_line") and params.has("text"): + var line_num: int = int(params["insert_at_line"]) + var text: String = str(params["text"]) + var lines := content.split("\n") + line_num = clampi(line_num, 0, lines.size()) + lines.insert(line_num, text) + content = "\n".join(lines) + changes_made = 1 + + if changes_made == 0: + return success({"path": path, "changes_made": 0, "message": "No changes applied"}) + + # Write back + file = FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + # Reload the script resource so the editor picks up changes immediately + _reload_script(path) + + return success({"path": path, "changes_made": changes_made}) + + +## Force-reload a script so the editor reflects disk changes immediately. +func _reload_script(path: String) -> void: + # First, trigger a filesystem scan so Godot knows the file changed + get_editor().get_resource_filesystem().scan() + + # If the script is already loaded in memory, reload it + if ResourceLoader.exists(path): + var script = load(path) + if script is Script: + script.reload(true) + + # If the script is open in the script editor, the reload above updates it. + # But we also need to notify the editor to refresh its error indicators. + get_editor().get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED) + + +func _attach_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "script_path") + if result2[1] != null: + return result2[1] + var script_path: String = result2[0] + + var root := get_edited_root() + if root == null: + return error_no_scene() + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes") + + if not FileAccess.file_exists(script_path): + return error_not_found("Script '%s'" % script_path) + + var script: Script = load(script_path) + if script == null: + return error_internal("Failed to load script: %s" % script_path) + + var old_script: Variant = node.get_script() + + var undo_redo := get_undo_redo() + undo_redo.create_action("MCP: Attach script to %s" % node.name) + undo_redo.add_do_method(node, "set_script", script) + undo_redo.add_undo_method(node, "set_script", old_script) + undo_redo.commit_action() + + return success({ + "node_path": str(root.get_path_to(node)), + "script_path": script_path, + "attached": true, + }) + + +func _validate_script(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Script '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error())) + + var source_code := file.get_as_text() + file.close() + + var script := GDScript.new() + script.source_code = source_code + var err := script.reload() + + if err == OK: + return success({"path": path, "valid": true, "message": "Script compiles successfully"}) + + return success({ + "path": path, + "valid": false, + "error_code": err, + "error_string": error_string(err), + "message": "Compilation failed. Use get_output_log or get_editor_errors for details.", + }) + + +func _get_open_scripts(params: Dictionary) -> Dictionary: + var script_editor := get_editor().get_script_editor() + var open_scripts: Array = [] + + for script_base in script_editor.get_open_scripts(): + var info := { + "path": script_base.resource_path, + "type": script_base.get_class(), + } + open_scripts.append(info) + + return success({"scripts": open_scripts, "count": open_scripts.size()}) diff --git a/addons/godot_mcp/commands/script_commands.gd.uid b/addons/godot_mcp/commands/script_commands.gd.uid new file mode 100644 index 0000000..3aec16f --- /dev/null +++ b/addons/godot_mcp/commands/script_commands.gd.uid @@ -0,0 +1 @@ +uid://luyrk86h6aii diff --git a/addons/godot_mcp/commands/shader_commands.gd b/addons/godot_mcp/commands/shader_commands.gd new file mode 100644 index 0000000..d6b0baf --- /dev/null +++ b/addons/godot_mcp/commands/shader_commands.gd @@ -0,0 +1,226 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_shader": _create_shader, + "read_shader": _read_shader, + "edit_shader": _edit_shader, + "assign_shader_material": _assign_shader_material, + "set_shader_param": _set_shader_param, + "get_shader_params": _get_shader_params, + } + + +func _create_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var content: String = optional_string(params, "content", "") + var shader_type: String = optional_string(params, "shader_type", "spatial") + + if content.is_empty(): + match shader_type: + "spatial": + content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n" + "canvas_item": + content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n" + "particles": + content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n" + "sky": + content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n" + + # Ensure directory exists + var dir_path := path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir_path): + DirAccess.make_dir_recursive_absolute(dir_path) + + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error())) + + file.store_string(content) + file.close() + + get_editor().get_resource_filesystem().scan() + + return success({"path": path, "shader_type": shader_type, "created": true}) + + +func _read_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Shader '%s'" % path) + + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error())) + + var content := file.get_as_text() + file.close() + + return success({"path": path, "content": content, "size": content.length()}) + + +func _edit_shader(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + if not FileAccess.file_exists(path): + return error_not_found("Shader '%s'" % path) + + var changes_made := 0 + + if params.has("content"): + # Full replacement + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return error_internal("Cannot write shader") + file.store_string(str(params["content"])) + file.close() + changes_made = 1 + elif params.has("replacements") and params["replacements"] is Array: + # Read current + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return error_internal("Cannot read shader") + var content := file.get_as_text() + file.close() + + for replacement in params["replacements"]: + if replacement is Dictionary: + var search: String = replacement.get("search", "") + var replace: String = replacement.get("replace", "") + if not search.is_empty() and content.contains(search): + content = content.replace(search, replace) + changes_made += 1 + + file = FileAccess.open(path, FileAccess.WRITE) + if file: + file.store_string(content) + file.close() + + if changes_made > 0: + # Reload shader resource + get_editor().get_resource_filesystem().scan() + if ResourceLoader.exists(path): + var shader = load(path) + if shader is Shader: + shader.reload_from_file() + + return success({"path": path, "changes_made": changes_made}) + + +func _assign_shader_material(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "shader_path") + if result2[1] != null: + return result2[1] + var shader_path: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + if not ResourceLoader.exists(shader_path): + return error_not_found("Shader '%s'" % shader_path) + + var shader: Shader = load(shader_path) + if shader == null: + return error_internal("Failed to load shader") + + var material := ShaderMaterial.new() + material.shader = shader + + if node is CanvasItem: + (node as CanvasItem).material = material + elif node is MeshInstance3D: + (node as MeshInstance3D).material_override = material + else: + # Try generic material property + if "material" in node: + node.set("material", material) + else: + return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()]) + + return success({"node_path": node_path, "shader_path": shader_path, "assigned": true}) + + +func _set_shader_param(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "param") + if result2[1] != null: + return result2[1] + var param_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var material: ShaderMaterial = null + if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial: + material = (node as CanvasItem).material + elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial: + material = (node as MeshInstance3D).material_override + + if material == null: + return error(-32000, "Node has no ShaderMaterial") + + var value = params.get("value") + if value is String: + var s: String = value + var expr := Expression.new() + if expr.parse(s) == OK: + var parsed = expr.execute() + if parsed != null: + value = parsed + + material.set_shader_parameter(param_name, value) + + return success({"node_path": node_path, "param": param_name, "value": str(value)}) + + +func _get_shader_params(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null: + return error_not_found("Node at '%s'" % node_path) + + var material: ShaderMaterial = null + if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial: + material = (node as CanvasItem).material + elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial: + material = (node as MeshInstance3D).material_override + + if material == null: + return error(-32000, "Node has no ShaderMaterial") + + var shader_params: Dictionary = {} + for prop in material.get_property_list(): + var pname: String = prop["name"] + if pname.begins_with("shader_parameter/"): + var key := pname.substr(17) + shader_params[key] = str(material.get(pname)) + + return success({"node_path": node_path, "params": shader_params}) diff --git a/addons/godot_mcp/commands/shader_commands.gd.uid b/addons/godot_mcp/commands/shader_commands.gd.uid new file mode 100644 index 0000000..ddab284 --- /dev/null +++ b/addons/godot_mcp/commands/shader_commands.gd.uid @@ -0,0 +1 @@ +uid://dbvpq7u2jbde8 diff --git a/addons/godot_mcp/commands/test_commands.gd b/addons/godot_mcp/commands/test_commands.gd new file mode 100644 index 0000000..ce3497a --- /dev/null +++ b/addons/godot_mcp/commands/test_commands.gd @@ -0,0 +1,580 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + +## Test automation framework tools. +## Editor-side orchestration + runtime assertions via file-based IPC. + + +func get_commands() -> Dictionary: + return { + "run_test_scenario": _run_test_scenario, + "assert_node_state": _assert_node_state, + "assert_screen_text": _assert_screen_text, + "run_stress_test": _run_stress_test, + "get_test_report": _get_test_report, + } + + +# ── Internal test result accumulator ────────────────────────────────────────── + +var _test_results: Array[Dictionary] = [] + + +# ── Commands ────────────────────────────────────────────────────────────────── + +func _run_test_scenario(params: Dictionary) -> Dictionary: + ## Execute a test scenario: optionally play a scene, run a sequence of steps + ## (input simulation, waits, assertions, screenshots), return pass/fail results. + ## + ## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}] + ## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str} + ## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float} + ## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str} + ## - screenshot: {type:"screenshot"} — captures a frame for visual inspection + + if not params.has("steps") or not params["steps"] is Array: + return error_invalid_params("Missing required parameter: steps (Array)") + + var steps: Array = params["steps"] + if steps.is_empty(): + return error_invalid_params("Steps array is empty") + + var scene_path: String = optional_string(params, "scene_path") + var ei := get_editor() + + # Play scene if requested + if not scene_path.is_empty(): + if ei.is_playing_scene(): + ei.stop_playing_scene() + await get_tree().create_timer(0.5).timeout + + if scene_path == "main": + ei.play_main_scene() + elif scene_path == "current": + ei.play_current_scene() + else: + if not FileAccess.file_exists(scene_path): + return error_not_found("Scene file '%s'" % scene_path) + ei.play_custom_scene(scene_path) + + # Wait for game to start + await get_tree().create_timer(1.0).timeout + + # Verify game is running + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", { + "suggestion": "Provide scene_path or use play_scene first" + }) + + var results: Array[Dictionary] = [] + var pass_count: int = 0 + var fail_count: int = 0 + var error_count: int = 0 + + for i in steps.size(): + var step: Dictionary = steps[i] + if not step.has("type"): + results.append({"step": i, "error": "Missing 'type' field"}) + error_count += 1 + continue + + var step_type: String = str(step["type"]) + var step_result: Dictionary = {"step": i, "type": step_type} + + match step_type: + "input": + var input_result := await _execute_input_step(step) + step_result.merge(input_result) + + "wait": + var wait_result := await _execute_wait_step(step) + step_result.merge(wait_result) + + "assert": + var assert_result := await _execute_assert_step(step) + step_result.merge(assert_result) + if assert_result.get("passed", false): + pass_count += 1 + else: + fail_count += 1 + + "screenshot": + var screenshot_result := await _send_game_command("capture_frames", { + "count": 1, + "frame_interval": 1, + "half_resolution": optional_bool(step, "half_resolution", true), + }, 5.0) + if screenshot_result.has("result"): + step_result["captured"] = true + else: + step_result["captured"] = false + step_result["error"] = "Screenshot capture failed" + error_count += 1 + + _: + step_result["error"] = "Unknown step type: %s" % step_type + error_count += 1 + + results.append(step_result) + + # Check if game crashed between steps + if not ei.is_playing_scene(): + results.append({"step": i + 1, "error": "Game stopped unexpectedly"}) + error_count += 1 + break + + var summary := { + "total_steps": steps.size(), + "completed_steps": results.size(), + "assertions_passed": pass_count, + "assertions_failed": fail_count, + "errors": error_count, + "all_passed": fail_count == 0 and error_count == 0, + "results": results, + } + + # Store results for get_test_report + _test_results.append_array(results) + + return success(summary) + + +func _assert_node_state(params: Dictionary) -> Dictionary: + ## Assert a node's property equals expected value in the running game. + ## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is. + ## Returns pass/fail with actual value. + + var path_result := require_string(params, "node_path") + if path_result[1] != null: + return path_result[1] + + var prop_result := require_string(params, "property") + if prop_result[1] != null: + return prop_result[1] + + if not params.has("expected"): + return error_invalid_params("Missing required parameter: expected") + + var operator: String = optional_string(params, "operator", "eq") + var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"] + if operator not in valid_operators: + return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)]) + + var result := await _send_game_command("assert_node_state", { + "node_path": path_result[0], + "property": prop_result[0], + "expected": params["expected"], + "operator": operator, + }, 5.0) + + # Store for test report + if result.has("result"): + _test_results.append(result["result"]) + + return result + + +func _assert_screen_text(params: Dictionary) -> Dictionary: + ## Assert that specific text is visible on screen. + ## Uses find_ui_elements internally to check all visible UI text. + + var text_result := require_string(params, "text") + if text_result[1] != null: + return text_result[1] + + var expected_text: String = text_result[0] + var partial: bool = optional_bool(params, "partial", true) + var case_sensitive: bool = optional_bool(params, "case_sensitive", true) + + # Use find_ui_elements to get all visible UI text + var ui_result := await _send_game_command("find_ui_elements", {}) + if ui_result.has("error"): + return ui_result + + var elements: Array = [] + if ui_result.has("result") and ui_result["result"].has("elements"): + elements = ui_result["result"]["elements"] + + var found := false + var matched_element: Dictionary = {} + var all_texts: Array[String] = [] + + for element: Dictionary in elements: + var element_text: String = str(element.get("text", "")) + if element_text.is_empty(): + continue + all_texts.append(element_text) + + var search_text := expected_text + var compare_text := element_text + if not case_sensitive: + search_text = search_text.to_lower() + compare_text = compare_text.to_lower() + + if partial: + if compare_text.contains(search_text): + found = true + matched_element = element + break + else: + if compare_text == search_text: + found = true + matched_element = element + break + + var assertion := { + "passed": found, + "expected_text": expected_text, + "partial": partial, + "case_sensitive": case_sensitive, + } + + if found: + assertion["matched_element"] = { + "text": matched_element.get("text", ""), + "type": matched_element.get("type", ""), + "path": matched_element.get("path", ""), + } + else: + assertion["visible_texts"] = all_texts + + # Store for test report + _test_results.append(assertion) + + return success(assertion) + + +func _run_stress_test(params: Dictionary) -> Dictionary: + ## Run rapid random inputs for N seconds and check for crashes. + ## Returns frame count, timing, and any errors from game output. + + var duration: float = float(params.get("duration", 5.0)) + if duration <= 0 or duration > 60: + return error_invalid_params("Duration must be between 0 and 60 seconds") + + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", { + "suggestion": "Use play_scene first" + }) + + # Record initial error count from log + var initial_errors := _count_log_errors() + + # Generate random input events + var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"] + # Add common game actions if specified + var custom_actions: Array = params.get("actions", []) + for action in custom_actions: + actions.append(str(action)) + + var events_sent: int = 0 + var start_time := Time.get_ticks_msec() + var duration_ms := int(duration * 1000.0) + + while Time.get_ticks_msec() - start_time < duration_ms: + if not ei.is_playing_scene(): + var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0 + return success({ + "completed": false, + "crashed": true, + "elapsed_seconds": elapsed, + "events_sent": events_sent, + "error": "Game stopped during stress test", + }) + + # Send a batch of random inputs + var batch: Array = [] + for j in 3: + var action_name: String = actions[randi() % actions.size()] + batch.append({ + "type": "action", + "action": action_name, + "pressed": true, + "strength": 1.0, + }) + batch.append({ + "type": "action", + "action": action_name, + "pressed": false, + "strength": 0.0, + }) + + # Write input commands directly (same as input_commands) + var json := JSON.stringify({ + "sequence_events": batch, + "frame_delay": 1, + }) + var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + events_sent += batch.size() + + await get_tree().create_timer(0.1).timeout + + var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0 + var final_errors := _count_log_errors() + var new_errors := final_errors - initial_errors + + # Check if game is still running + var still_running := ei.is_playing_scene() + + return success({ + "completed": true, + "crashed": not still_running, + "duration_seconds": elapsed, + "events_sent": events_sent, + "new_errors": new_errors, + "game_still_running": still_running, + }) + + +func _get_test_report(params: Dictionary) -> Dictionary: + ## Collect and format results from accumulated assertions into a test report. + ## Returns pass count, fail count, and detailed results. + + var clear: bool = optional_bool(params, "clear", true) + + var pass_count: int = 0 + var fail_count: int = 0 + var details: Array[Dictionary] = [] + + for result: Dictionary in _test_results: + var passed: bool = result.get("passed", false) + if passed: + pass_count += 1 + else: + fail_count += 1 + details.append(result) + + var report := { + "total": _test_results.size(), + "passed": pass_count, + "failed": fail_count, + "pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A", + "all_passed": fail_count == 0 and not _test_results.is_empty(), + "details": details, + } + + if clear: + _test_results.clear() + + return success(report) + + +# ── Step Executors (for run_test_scenario) ──────────────────────────────────── + +func _execute_input_step(step: Dictionary) -> Dictionary: + ## Execute an input step: simulate action or key press. + var events: Array = [] + + if step.has("action"): + var pressed: bool = step.get("pressed", true) as bool + events.append({ + "type": "action", + "action": str(step["action"]), + "pressed": pressed, + "strength": float(step.get("strength", 1.0)), + }) + # Auto-release if pressed + if pressed and step.get("auto_release", true): + events.append({ + "type": "action", + "action": str(step["action"]), + "pressed": false, + "strength": 0.0, + }) + elif step.has("keycode"): + var pressed: bool = step.get("pressed", true) as bool + events.append({ + "type": "key", + "keycode": str(step["keycode"]), + "pressed": pressed, + "shift": step.get("shift", false), + "ctrl": step.get("ctrl", false), + "alt": step.get("alt", false), + }) + else: + return {"error": "Input step requires 'action' or 'keycode'"} + + var json := JSON.stringify({ + "sequence_events": events, + "frame_delay": int(step.get("frame_delay", 1)), + }) + var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE) + if file == null: + return {"error": "Failed to write input commands"} + file.store_string(json) + file.close() + + return {"sent": true, "event_count": events.size()} + + +func _execute_wait_step(step: Dictionary) -> Dictionary: + ## Execute a wait step: wait for seconds or wait for a node to appear. + if step.has("node_path"): + var timeout: float = float(step.get("timeout", 5.0)) + var result := await _send_game_command("wait_for_node", { + "node_path": str(step["node_path"]), + "timeout": timeout, + "poll_frames": int(step.get("poll_frames", 5)), + }, timeout + 2.0) + if result.has("error"): + return {"error": "Wait for node failed: %s" % str(result["error"])} + return {"waited_for": str(step["node_path"]), "found": true} + else: + var seconds: float = float(step.get("seconds", 1.0)) + await get_tree().create_timer(seconds).timeout + return {"waited_seconds": seconds} + + +func _execute_assert_step(step: Dictionary) -> Dictionary: + ## Execute an assertion step within a scenario. + if step.has("text"): + # Screen text assertion + var ui_result := await _send_game_command("find_ui_elements", {}) + if ui_result.has("error"): + return {"passed": false, "error": "Could not get UI elements"} + + var elements: Array = [] + if ui_result.has("result") and ui_result["result"].has("elements"): + elements = ui_result["result"]["elements"] + + var expected_text: String = str(step["text"]) + var partial: bool = step.get("partial", true) as bool + for element: Dictionary in elements: + var element_text: String = str(element.get("text", "")) + if partial and element_text.contains(expected_text): + return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text} + elif not partial and element_text == expected_text: + return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text} + + return {"passed": false, "type": "screen_text", "expected": expected_text, "error": "Text not found on screen"} + + elif step.has("node_path") and step.has("property"): + # Node state assertion + var result := await _send_game_command("assert_node_state", { + "node_path": str(step["node_path"]), + "property": str(step["property"]), + "expected": step.get("expected", null), + "operator": str(step.get("operator", "eq")), + }, 5.0) + if result.has("result"): + return result["result"] + elif result.has("error"): + return {"passed": false, "error": str(result["error"])} + return {"passed": false, "error": "Unknown assertion error"} + + else: + return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"} + + +# ── IPC Helper ──────────────────────────────────────────────────────────────── + +func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary: + var ei := get_editor() + if not ei.is_playing_scene(): + return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"}) + + var user_dir := get_game_user_dir() + var request_path := user_dir + "/mcp_game_request" + var response_path := user_dir + "/mcp_game_response" + + # Clean stale response + if FileAccess.file_exists(response_path): + DirAccess.remove_absolute(response_path) + + # Write request + var request_data := JSON.stringify({"command": command, "params": params}) + var req := FileAccess.open(request_path, FileAccess.WRITE) + if req == null: + return error_internal("Could not create game request file") + req.store_string(request_data) + req.close() + + # Poll for response + var attempts := int(timeout_sec / 0.1) + while attempts > 0: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + # Check if game is still running + if not ei.is_playing_scene(): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game stopped during command execution") + attempts -= 1 + + if not FileAccess.file_exists(response_path): + # Try to auto-resume the debugger + if ei.is_playing_scene(): + _try_debugger_continue() + for _retry in 20: + await get_tree().create_timer(0.1).timeout + if FileAccess.file_exists(response_path): + break + + if not FileAccess.file_exists(response_path): + if FileAccess.file_exists(request_path): + DirAccess.remove_absolute(request_path) + return error(-32000, "Game command timed out after %.1fs" % timeout_sec, { + "suggestion": "Ensure the game is running and MCPGameInspector autoload is active", + }) + + # Read response + var file := FileAccess.open(response_path, FileAccess.READ) + if file == null: + return error_internal("Could not read game response file") + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(response_path) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + return error_internal("Invalid response JSON from game") + + if parsed.has("error"): + return error(-32000, str(parsed["error"])) + + return success(parsed) + + +## Press the debugger "Continue" button to resume a paused game process. +func _try_debugger_continue() -> void: + var base := EditorInterface.get_base_control() + if base == null: + return + var queue: Array[Node] = [base] + while not queue.is_empty(): + var node := queue.pop_front() + if node.get_class() == "ScriptEditorDebugger": + var inner: Array[Node] = [node] + while not inner.is_empty(): + var n := inner.pop_front() + if n is Button and n.tooltip_text == "Continue": + n.emit_signal("pressed") + push_warning("[MCP] Auto-resumed debugger after runtime error") + return + for c in n.get_children(): + inner.append(c) + return + for child in node.get_children(): + queue.append(child) + + +# ── Utility ─────────────────────────────────────────────────────────────────── + +func _count_log_errors() -> int: + var count: int = 0 + var log_path := "user://logs/godot.log" + if FileAccess.file_exists(log_path): + var file := FileAccess.open(log_path, FileAccess.READ) + if file: + var content := file.get_as_text() + file.close() + var lines := content.split("\n") + for line: String in lines: + if line.contains("ERROR") or line.contains("SCRIPT ERROR"): + count += 1 + return count diff --git a/addons/godot_mcp/commands/test_commands.gd.uid b/addons/godot_mcp/commands/test_commands.gd.uid new file mode 100644 index 0000000..326c101 --- /dev/null +++ b/addons/godot_mcp/commands/test_commands.gd.uid @@ -0,0 +1 @@ +uid://b785wka7v3u4d diff --git a/addons/godot_mcp/commands/theme_commands.gd b/addons/godot_mcp/commands/theme_commands.gd new file mode 100644 index 0000000..c071950 --- /dev/null +++ b/addons/godot_mcp/commands/theme_commands.gd @@ -0,0 +1,331 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "create_theme": _create_theme, + "set_theme_color": _set_theme_color, + "set_theme_constant": _set_theme_constant, + "set_theme_font_size": _set_theme_font_size, + "set_theme_stylebox": _set_theme_stylebox, + "setup_control": _setup_control, + "get_theme_info": _get_theme_info, + } + + +func _create_theme(params: Dictionary) -> Dictionary: + var result := require_string(params, "path") + if result[1] != null: + return result[1] + var path: String = result[0] + + var theme := Theme.new() + + # Optionally set default font size + var font_size: int = optional_int(params, "default_font_size", 0) + if font_size > 0: + theme.default_font_size = font_size + + var err := ResourceSaver.save(theme, path) + if err != OK: + return error_internal("Failed to save theme: %s" % error_string(err)) + + get_editor().get_resource_filesystem().scan() + return success({"path": path, "created": true}) + + +func _set_theme_color(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var color_name: String = result2[0] + + var result3 := require_string(params, "color") + if result3[1] != null: + return result3[1] + var color_str: String = result3[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var color := Color(color_str) + + var theme_type: String = optional_string(params, "theme_type", "") + if theme_type.is_empty(): + theme_type = control.get_class() + + control.add_theme_color_override(color_name, color) + + return success({"node_path": node_path, "name": color_name, "color": color_str}) + + +func _set_theme_constant(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var const_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var value: int = int(params.get("value", 0)) + + control.add_theme_constant_override(const_name, value) + + return success({"node_path": node_path, "name": const_name, "value": value}) + + +func _set_theme_font_size(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var font_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var size: int = int(params.get("size", 16)) + + control.add_theme_font_size_override(font_name, size) + + return success({"node_path": node_path, "name": font_name, "size": size}) + + +func _set_theme_stylebox(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var result2 := require_string(params, "name") + if result2[1] != null: + return result2[1] + var style_name: String = result2[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + + var stylebox := StyleBoxFlat.new() + + var bg_color: String = optional_string(params, "bg_color", "") + if not bg_color.is_empty(): + stylebox.bg_color = Color(bg_color) + + var border_color: String = optional_string(params, "border_color", "") + if not border_color.is_empty(): + stylebox.border_color = Color(border_color) + + var border_width: int = optional_int(params, "border_width", 0) + if border_width > 0: + stylebox.border_width_left = border_width + stylebox.border_width_top = border_width + stylebox.border_width_right = border_width + stylebox.border_width_bottom = border_width + + var corner_radius: int = optional_int(params, "corner_radius", 0) + if corner_radius > 0: + stylebox.corner_radius_top_left = corner_radius + stylebox.corner_radius_top_right = corner_radius + stylebox.corner_radius_bottom_left = corner_radius + stylebox.corner_radius_bottom_right = corner_radius + + var padding: int = optional_int(params, "padding", 0) + if padding > 0: + stylebox.content_margin_left = padding + stylebox.content_margin_top = padding + stylebox.content_margin_right = padding + stylebox.content_margin_bottom = padding + + control.add_theme_stylebox_override(style_name, stylebox) + + return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"}) + + +func _setup_control(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var applied: Array = [] + + # Anchor preset + var anchor_preset: String = optional_string(params, "anchor_preset", "") + if not anchor_preset.is_empty(): + var preset_map := { + "top_left": Control.PRESET_TOP_LEFT, + "top_right": Control.PRESET_TOP_RIGHT, + "bottom_left": Control.PRESET_BOTTOM_LEFT, + "bottom_right": Control.PRESET_BOTTOM_RIGHT, + "center_left": Control.PRESET_CENTER_LEFT, + "center_top": Control.PRESET_CENTER_TOP, + "center_right": Control.PRESET_CENTER_RIGHT, + "center_bottom": Control.PRESET_CENTER_BOTTOM, + "center": Control.PRESET_CENTER, + "left_wide": Control.PRESET_LEFT_WIDE, + "top_wide": Control.PRESET_TOP_WIDE, + "right_wide": Control.PRESET_RIGHT_WIDE, + "bottom_wide": Control.PRESET_BOTTOM_WIDE, + "vcenter_wide": Control.PRESET_VCENTER_WIDE, + "hcenter_wide": Control.PRESET_HCENTER_WIDE, + "full_rect": Control.PRESET_FULL_RECT, + } + if preset_map.has(anchor_preset): + control.set_anchors_and_offsets_preset(preset_map[anchor_preset]) + applied.append("anchor_preset=%s" % anchor_preset) + + # Min size + var min_size_str: String = optional_string(params, "min_size", "") + if not min_size_str.is_empty(): + var expr := Expression.new() + if expr.parse(min_size_str) == OK: + var val = expr.execute() + if val is Vector2: + control.custom_minimum_size = val + applied.append("min_size=%s" % min_size_str) + + # Size flags horizontal + var sf_h: String = optional_string(params, "size_flags_h", "") + if not sf_h.is_empty(): + var flags_map := { + "fill": Control.SIZE_FILL, + "expand": Control.SIZE_EXPAND, + "fill_expand": Control.SIZE_EXPAND_FILL, + "shrink_center": Control.SIZE_SHRINK_CENTER, + "shrink_end": Control.SIZE_SHRINK_END, + } + if flags_map.has(sf_h): + control.size_flags_horizontal = flags_map[sf_h] + applied.append("size_flags_h=%s" % sf_h) + + # Size flags vertical + var sf_v: String = optional_string(params, "size_flags_v", "") + if not sf_v.is_empty(): + var flags_map := { + "fill": Control.SIZE_FILL, + "expand": Control.SIZE_EXPAND, + "fill_expand": Control.SIZE_EXPAND_FILL, + "shrink_center": Control.SIZE_SHRINK_CENTER, + "shrink_end": Control.SIZE_SHRINK_END, + } + if flags_map.has(sf_v): + control.size_flags_vertical = flags_map[sf_v] + applied.append("size_flags_v=%s" % sf_v) + + # Margins (for MarginContainer) + if params.has("margins") and params["margins"] is Dictionary: + var margins: Dictionary = params["margins"] + if control is MarginContainer: + if margins.has("left"): + control.add_theme_constant_override("margin_left", int(margins["left"])) + if margins.has("top"): + control.add_theme_constant_override("margin_top", int(margins["top"])) + if margins.has("right"): + control.add_theme_constant_override("margin_right", int(margins["right"])) + if margins.has("bottom"): + control.add_theme_constant_override("margin_bottom", int(margins["bottom"])) + applied.append("margins=%s" % str(margins)) + + # Separation (for VBox/HBoxContainer) + if params.has("separation"): + var sep: int = int(params["separation"]) + if control is BoxContainer: + control.add_theme_constant_override("separation", sep) + applied.append("separation=%d" % sep) + + # Grow direction horizontal + var grow_h: String = optional_string(params, "grow_h", "") + if not grow_h.is_empty(): + var grow_map := { + "begin": Control.GROW_DIRECTION_BEGIN, + "end": Control.GROW_DIRECTION_END, + "both": Control.GROW_DIRECTION_BOTH, + } + if grow_map.has(grow_h): + control.grow_horizontal = grow_map[grow_h] + applied.append("grow_h=%s" % grow_h) + + # Grow direction vertical + var grow_v: String = optional_string(params, "grow_v", "") + if not grow_v.is_empty(): + var grow_map := { + "begin": Control.GROW_DIRECTION_BEGIN, + "end": Control.GROW_DIRECTION_END, + "both": Control.GROW_DIRECTION_BOTH, + } + if grow_map.has(grow_v): + control.grow_vertical = grow_map[grow_v] + applied.append("grow_v=%s" % grow_v) + + return success({"node_path": node_path, "applied": applied, "count": applied.size()}) + + +func _get_theme_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var node := find_node_by_path(node_path) + if node == null or not (node is Control): + return error_not_found("Control node at '%s'" % node_path) + + var control: Control = node + var info := {"node_path": node_path, "class": control.get_class()} + + # Check if node has a theme + var theme := control.theme + if theme: + info["theme_path"] = theme.resource_path + info["type_list"] = Array(theme.get_type_list()) + + # List overrides + var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}} + for prop in control.get_property_list(): + var pname: String = prop["name"] + if pname.begins_with("theme_override_colors/"): + var key := pname.substr(22) + overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html() + elif pname.begins_with("theme_override_constants/"): + var key := pname.substr(25) + overrides["constants"][key] = control.get(pname) + elif pname.begins_with("theme_override_font_sizes/"): + var key := pname.substr(26) + overrides["font_sizes"][key] = control.get(pname) + elif pname.begins_with("theme_override_styles/"): + var key := pname.substr(22) + var style = control.get(pname) + overrides["styleboxes"][key] = style.get_class() if style else null + + info["overrides"] = overrides + return success(info) diff --git a/addons/godot_mcp/commands/theme_commands.gd.uid b/addons/godot_mcp/commands/theme_commands.gd.uid new file mode 100644 index 0000000..bea1c16 --- /dev/null +++ b/addons/godot_mcp/commands/theme_commands.gd.uid @@ -0,0 +1 @@ +uid://bx6pai02qmme7 diff --git a/addons/godot_mcp/commands/tilemap_commands.gd b/addons/godot_mcp/commands/tilemap_commands.gd new file mode 100644 index 0000000..0cad08f --- /dev/null +++ b/addons/godot_mcp/commands/tilemap_commands.gd @@ -0,0 +1,163 @@ +@tool +extends "res://addons/godot_mcp/commands/base_command.gd" + + +func get_commands() -> Dictionary: + return { + "tilemap_set_cell": _tilemap_set_cell, + "tilemap_fill_rect": _tilemap_fill_rect, + "tilemap_get_cell": _tilemap_get_cell, + "tilemap_clear": _tilemap_clear, + "tilemap_get_info": _tilemap_get_info, + "tilemap_get_used_cells": _tilemap_get_used_cells, + } + + +func _find_tilemap(node_path: String) -> TileMapLayer: + var node := find_node_by_path(node_path) + if node is TileMapLayer: + return node as TileMapLayer + return null + + +func _tilemap_set_cell(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + var x: int = int(params.get("x", 0)) + var y: int = int(params.get("y", 0)) + var source_id: int = int(params.get("source_id", 0)) + var atlas_x: int = int(params.get("atlas_x", 0)) + var atlas_y: int = int(params.get("atlas_y", 0)) + var alternative: int = int(params.get("alternative", 0)) + + tilemap.set_cell(Vector2i(x, y), source_id, Vector2i(atlas_x, atlas_y), alternative) + + return success({"x": x, "y": y, "source_id": source_id, "atlas_coords": [atlas_x, atlas_y]}) + + +func _tilemap_fill_rect(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + var x1: int = int(params.get("x1", 0)) + var y1: int = int(params.get("y1", 0)) + var x2: int = int(params.get("x2", 0)) + var y2: int = int(params.get("y2", 0)) + var source_id: int = int(params.get("source_id", 0)) + var atlas_x: int = int(params.get("atlas_x", 0)) + var atlas_y: int = int(params.get("atlas_y", 0)) + var alternative: int = int(params.get("alternative", 0)) + + var count := 0 + for cx in range(mini(x1, x2), maxi(x1, x2) + 1): + for cy in range(mini(y1, y2), maxi(y1, y2) + 1): + tilemap.set_cell(Vector2i(cx, cy), source_id, Vector2i(atlas_x, atlas_y), alternative) + count += 1 + + return success({"filled": count, "rect": [x1, y1, x2, y2]}) + + +func _tilemap_get_cell(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + var x: int = int(params.get("x", 0)) + var y: int = int(params.get("y", 0)) + var coords := Vector2i(x, y) + + var source_id := tilemap.get_cell_source_id(coords) + var atlas_coords := tilemap.get_cell_atlas_coords(coords) + var alternative := tilemap.get_cell_alternative_tile(coords) + + return success({ + "x": x, "y": y, + "source_id": source_id, + "atlas_coords": [atlas_coords.x, atlas_coords.y], + "alternative": alternative, + "empty": source_id == -1, + }) + + +func _tilemap_clear(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + tilemap.clear() + return success({"cleared": true}) + + +func _tilemap_get_info(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + var tile_set := tilemap.tile_set + var sources: Array = [] + if tile_set: + for i in tile_set.get_source_count(): + var source_id := tile_set.get_source_id(i) + var source := tile_set.get_source(source_id) + var info := {"id": source_id, "type": source.get_class()} + if source is TileSetAtlasSource: + var atlas: TileSetAtlasSource = source + info["texture"] = atlas.texture.resource_path if atlas.texture else "" + info["tile_count"] = atlas.get_tiles_count() + sources.append(info) + + return success({ + "node_path": node_path, + "used_cells": tilemap.get_used_cells().size(), + "tile_set_sources": sources, + "tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0], + }) + + +func _tilemap_get_used_cells(params: Dictionary) -> Dictionary: + var result := require_string(params, "node_path") + if result[1] != null: + return result[1] + var node_path: String = result[0] + + var tilemap := _find_tilemap(node_path) + if tilemap == null: + return error_not_found("TileMapLayer at '%s'" % node_path) + + var max_count: int = optional_int(params, "max_count", 500) + var cells: Array = [] + var used := tilemap.get_used_cells() + + for i in mini(used.size(), max_count): + var pos: Vector2i = used[i] + cells.append({"x": pos.x, "y": pos.y, "source_id": tilemap.get_cell_source_id(pos)}) + + return success({"cells": cells, "total": used.size(), "returned": cells.size()}) diff --git a/addons/godot_mcp/commands/tilemap_commands.gd.uid b/addons/godot_mcp/commands/tilemap_commands.gd.uid new file mode 100644 index 0000000..29fc9d6 --- /dev/null +++ b/addons/godot_mcp/commands/tilemap_commands.gd.uid @@ -0,0 +1 @@ +uid://2bsflxvsq7a5 diff --git a/addons/godot_mcp/mcp_game_inspector_service.gd b/addons/godot_mcp/mcp_game_inspector_service.gd new file mode 100644 index 0000000..d36a778 --- /dev/null +++ b/addons/godot_mcp/mcp_game_inspector_service.gd @@ -0,0 +1,1659 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Handles runtime game inspection: scene tree, node properties, frame capture, property monitoring. +extends Node + +const REQUEST_PATH := "user://mcp_game_request" +const RESPONSE_PATH := "user://mcp_game_response" + +enum State { IDLE, CAPTURING_FRAMES, MONITORING, RECORDING, MOVING_TO, WATCHING_SIGNALS } + +var _state := State.IDLE +var _pending_command: bool = false # Crash recovery flag + +# Frame capture state +var _capture_frames_remaining: int = 0 +var _capture_frame_interval: int = 1 +var _capture_frame_counter: int = 0 +var _capture_half_res: bool = true +var _captured_images: Array = [] # Array of base64 strings +var _capture_node_path: String = "" # Optional node to track per frame +var _capture_node_props: Array = [] # Properties to snapshot per frame +var _capture_frame_data: Array = [] # Array of per-frame node snapshots + +# Recording state +var _recording_events: Array = [] +var _recording_start_msec: int = 0 + +# Monitor state +var _monitor_node_path: String = "" +var _monitor_properties: Array = [] +var _monitor_frames_remaining: int = 0 +var _monitor_frame_interval: int = 1 +var _monitor_frame_counter: int = 0 +var _monitor_timeline: Array = [] # Array of sample dicts + +# Signal watch state +var _watch_nodes: Array = [] # Array of node paths being watched +var _watch_signal_filter: Array = [] # Optional signal name filter +var _watch_log: Array = [] # Array of {time_ms, node, signal, args} +var _watch_start_msec: int = 0 +var _watch_duration_ms: int = 5000 +var _watch_connections: Array = [] # Array of {node, signal, callable} for cleanup + +# Move-to state +var _moveto_target: Vector3 = Vector3.ZERO +var _moveto_player: Node3D = null +var _moveto_camera_pivot: Node3D = null +var _moveto_arrival_radius: float = 1.5 +var _moveto_timeout: float = 15.0 +var _moveto_elapsed: float = 0.0 +var _moveto_run: bool = false +var _moveto_look_at: bool = true +var _moveto_keys_held: Array = [] # Track injected keys for guaranteed release + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + # Crash recovery: if a command was in progress but never wrote a response + if _pending_command and not FileAccess.file_exists(REQUEST_PATH) and not FileAccess.file_exists(RESPONSE_PATH): + push_warning("[MCP] Recovered from crashed command — writing error response") + _pending_command = false + _state = State.IDLE + # Signal editor plugin to auto-press debugger Continue + var flag := FileAccess.open("user://mcp_debugger_continue", FileAccess.WRITE) + if flag: + flag.close() + _write_response({"error": "Command crashed (runtime error). Check Godot debugger."}) + return + + match _state: + State.IDLE: + if FileAccess.file_exists(REQUEST_PATH): + _handle_request() + State.CAPTURING_FRAMES: + _process_capture() + State.MONITORING: + _process_monitor() + State.RECORDING: + if FileAccess.file_exists(REQUEST_PATH): + _handle_request() + State.MOVING_TO: + _process_move_to(_delta) + State.WATCHING_SIGNALS: + _process_watch_signals() + + +# ── Request handling ────────────────────────────────────────────────────────── + +func _handle_request() -> void: + var file := FileAccess.open(REQUEST_PATH, FileAccess.READ) + if file == null: + return + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(REQUEST_PATH) + + var parsed = JSON.parse_string(text) + if parsed == null or not parsed is Dictionary: + _write_response({"error": "Invalid request JSON"}) + return + + # Abort any in-progress operation + _state = State.IDLE + _pending_command = true + + var command: String = parsed.get("command", "") + var params: Dictionary = parsed.get("params", {}) + + match command: + "get_scene_tree": + _cmd_get_scene_tree(params) + "get_node_properties": + _cmd_get_node_properties(params) + "set_node_property": + _cmd_set_node_property(params) + "capture_frames": + _cmd_capture_frames(params) + "monitor_properties": + _cmd_monitor_properties(params) + "execute_script": + _cmd_execute_script(params) + "start_recording": + _cmd_start_recording(params) + "stop_recording": + _cmd_stop_recording(params) + "replay_recording": + _cmd_replay_recording(params) + "find_nodes_by_script": + _cmd_find_nodes_by_script(params) + "get_autoload": + _cmd_get_autoload(params) + "batch_get_properties": + _cmd_batch_get_properties(params) + "find_ui_elements": + _cmd_find_ui_elements(params) + "click_button_by_text": + _cmd_click_button_by_text(params) + "wait_for_node": + _cmd_wait_for_node(params) + "find_nearby_nodes": + _cmd_find_nearby_nodes(params) + "navigate_to": + _cmd_navigate_to(params) + "move_to": + _cmd_move_to(params) + "watch_signals": + _cmd_watch_signals(params) + _: + _write_response({"error": "Unknown command: %s" % command}) + + +# ── get_scene_tree ──────────────────────────────────────────────────────────── + +func _cmd_get_scene_tree(params: Dictionary) -> void: + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var max_depth: int = params.get("max_depth", -1) + var script_filter: String = params.get("script_filter", "") + var type_filter: String = params.get("type_filter", "") + var named_only: bool = params.get("named_only", false) + + var has_filter: bool = not script_filter.is_empty() or not type_filter.is_empty() or named_only + + if has_filter: + var tree: Variant = _build_filtered_node_tree(root, max_depth, script_filter, type_filter, named_only) + if tree == null: + _write_response({"tree": null, "message": "No nodes matched the filter"}) + else: + _write_response({"tree": tree}) + else: + var tree := _build_node_tree(root, max_depth) + _write_response({"tree": tree}) + + +func _build_node_tree(node: Node, max_depth: int, current_depth: int = 0) -> Dictionary: + var result := { + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()), + } + + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + if max_depth == -1 or current_depth < max_depth: + var children: Array = [] + for child in node.get_children(): + children.append(_build_node_tree(child, max_depth, current_depth + 1)) + if not children.is_empty(): + result["children"] = children + + return result + + +## Build a filtered tree. Returns null if this subtree has no matches. +func _build_filtered_node_tree(node: Node, max_depth: int, script_filter: String, type_filter: String, named_only: bool, current_depth: int = 0) -> Variant: + var node_matches := _node_matches_filter(node, script_filter, type_filter, named_only) + + # Build children first to check if any descendant matches + var matched_children: Array = [] + if max_depth == -1 or current_depth < max_depth: + for child in node.get_children(): + var child_tree: Variant = _build_filtered_node_tree(child, max_depth, script_filter, type_filter, named_only, current_depth + 1) + if child_tree != null: + matched_children.append(child_tree) + + # Include this node if it matches or if any descendant matches + if not node_matches and matched_children.is_empty(): + return null + + var result := { + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()), + } + + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + if not matched_children.is_empty(): + result["children"] = matched_children + + return result + + +func _node_matches_filter(node: Node, script_filter: String, type_filter: String, named_only: bool) -> bool: + if named_only and (node.name as String).begins_with("@"): + return false + if not type_filter.is_empty() and not node.is_class(type_filter): + return false + if not script_filter.is_empty(): + var script: Script = node.get_script() + if script == null: + return false + if not script.resource_path.to_lower().contains(script_filter.to_lower()): + return false + return true + + +# ── get_node_properties ─────────────────────────────────────────────────────── + +func _cmd_get_node_properties(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "node_path is required"}) + return + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + _write_response({"error": "Node not found: %s" % node_path}) + return + + var filter: Array = params.get("properties", []) + var props: Dictionary = {} + + if filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in filter: + var value: Variant = node.get(prop_name) + props[prop_name] = _serialize_value(value) + + _write_response({ + "node_path": str(node.get_path()), + "type": node.get_class(), + "properties": props, + }) + + +# ── capture_frames ──────────────────────────────────────────────────────────── + +func _cmd_capture_frames(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var count: int = clampi(params.get("count", 5), 1, 30) + var interval: int = maxi(params.get("frame_interval", 10), 1) + _capture_half_res = params.get("half_resolution", true) + + # Optional node_data tracking + _capture_node_path = "" + _capture_node_props = [] + _capture_frame_data.clear() + var node_data: Dictionary = params.get("node_data", {}) + if not node_data.is_empty(): + _capture_node_path = node_data.get("node_path", "") + _capture_node_props = node_data.get("properties", []) + + _captured_images.clear() + _capture_frames_remaining = count + _capture_frame_interval = interval + _capture_frame_counter = 0 + _state = State.CAPTURING_FRAMES + _capture_one_frame() + + +func _process_capture() -> void: + if FileAccess.file_exists(REQUEST_PATH): + _state = State.IDLE + _handle_request() + return + + _capture_frame_counter += 1 + if _capture_frame_counter >= _capture_frame_interval: + _capture_frame_counter = 0 + _capture_one_frame() + + +func _capture_one_frame() -> void: + var viewport := get_viewport() + if viewport == null: + _finish_capture() + return + + var image := viewport.get_texture().get_image() + if image == null: + _finish_capture() + return + + if _capture_half_res: + var new_size := image.get_size() / 2 + image.resize(new_size.x, new_size.y, Image.INTERPOLATE_BILINEAR) + + var png_buffer := image.save_png_to_buffer() + _captured_images.append(Marshalls.raw_to_base64(png_buffer)) + + # Snapshot node properties if tracking + if not _capture_node_path.is_empty() and not _capture_node_props.is_empty(): + var snap := {} + var node := get_tree().root.get_node_or_null(_capture_node_path) + if node: + for prop_name in _capture_node_props: + var val = node.get(prop_name) + snap[prop_name] = _serialize_value(val) + _capture_frame_data.append(snap) + + _capture_frames_remaining -= 1 + if _capture_frames_remaining <= 0: + _finish_capture() + + +func _finish_capture() -> void: + _state = State.IDLE + var viewport := get_viewport() + var w := 0 + var h := 0 + if viewport: + var size := viewport.get_visible_rect().size + if _capture_half_res: + size /= 2 + w = int(size.x) + h = int(size.y) + + var response := { + "frames": _captured_images, + "count": _captured_images.size(), + "width": w, + "height": h, + "half_resolution": _capture_half_res, + } + if not _capture_frame_data.is_empty(): + response["frame_data"] = _capture_frame_data + _write_response(response) + _captured_images.clear() + _capture_frame_data.clear() + + +# ── monitor_properties ──────────────────────────────────────────────────────── + +func _cmd_monitor_properties(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + _monitor_node_path = params.get("node_path", "") + _monitor_properties = params.get("properties", []) + if _monitor_node_path.is_empty() or _monitor_properties.is_empty(): + _write_response({"error": "node_path and properties are required"}) + return + + var frame_count: int = clampi(params.get("frame_count", 60), 1, 600) + var interval: int = maxi(params.get("frame_interval", 1), 1) + + _monitor_timeline.clear() + _monitor_frames_remaining = frame_count + _monitor_frame_interval = interval + _monitor_frame_counter = 0 + _state = State.MONITORING + _sample_one_frame() + + +func _process_monitor() -> void: + if FileAccess.file_exists(REQUEST_PATH): + _state = State.IDLE + _handle_request() + return + + _monitor_frame_counter += 1 + if _monitor_frame_counter >= _monitor_frame_interval: + _monitor_frame_counter = 0 + _sample_one_frame() + + +func _sample_one_frame() -> void: + var sample: Dictionary = {} + var node := get_node_or_null(NodePath(_monitor_node_path)) + + if node == null: + for prop_name: String in _monitor_properties: + sample[prop_name] = null + else: + for prop_name: String in _monitor_properties: + sample[prop_name] = _serialize_value(node.get(prop_name)) + + _monitor_timeline.append(sample) + + _monitor_frames_remaining -= 1 + if _monitor_frames_remaining <= 0: + _finish_monitor() + + +func _finish_monitor() -> void: + _state = State.IDLE + _write_response({ + "node_path": _monitor_node_path, + "properties": _monitor_properties, + "samples": _monitor_timeline, + "sample_count": _monitor_timeline.size(), + "frame_interval": _monitor_frame_interval, + }) + _monitor_timeline.clear() + + +# ── watch_signals ──────────────────────────────────────────────────────────── + +func _cmd_watch_signals(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + + if not params.has("node_paths") or not params["node_paths"] is Array: + _write_response({"error": "node_paths array is required"}) + return + + var node_paths: Array = params["node_paths"] + if node_paths.is_empty(): + _write_response({"error": "node_paths array is empty"}) + return + + _watch_signal_filter = params.get("signal_filter", []) if params.has("signal_filter") and params["signal_filter"] is Array else [] + _watch_duration_ms = clampi(params.get("duration_ms", 5000), 500, 30000) + _watch_log.clear() + _watch_connections.clear() + _watch_nodes = node_paths + + # Connect to signals on each node + var connected_count: int = 0 + for node_path_str: String in node_paths: + var node := get_node_or_null(NodePath(node_path_str)) + if node == null: + _watch_log.append({"warning": "Node not found: %s" % node_path_str}) + continue + + for sig_info: Dictionary in node.get_signal_list(): + var sig_name: String = sig_info["name"] + # Apply filter if specified + if not _watch_signal_filter.is_empty(): + var match_found := false + for filter_str: String in _watch_signal_filter: + if sig_name.contains(filter_str): + match_found = true + break + if not match_found: + continue + + # Create a callable matched to the signal's argument count + var arg_count: int = sig_info["args"].size() + var cb := _make_signal_callback(node_path_str, sig_name, arg_count) + if cb.is_valid() and not node.is_connected(sig_name, cb): + node.connect(sig_name, cb) + _watch_connections.append({"node": node, "signal": sig_name, "callable": cb}) + connected_count += 1 + + if connected_count == 0 and _watch_log.is_empty(): + _write_response({"error": "No signals connected. Check node_paths and signal_filter."}) + return + + _watch_start_msec = Time.get_ticks_msec() + _state = State.WATCHING_SIGNALS + + +func _on_signal_fired(node_path_str: String, sig_name: String, args: Array) -> void: + var elapsed: int = Time.get_ticks_msec() - _watch_start_msec + var entry: Dictionary = { + "time_ms": elapsed, + "node": node_path_str, + "signal": sig_name, + } + if not args.is_empty(): + var serialized: Array = [] + for a: Variant in args: + serialized.append(_serialize_value(a)) + entry["args"] = serialized + _watch_log.append(entry) + + +func _make_signal_callback(node_path_str: String, sig_name: String, arg_count: int) -> Callable: + var np := node_path_str + var sn := sig_name + match arg_count: + 0: + return func() -> void: _on_signal_fired(np, sn, []) + 1: + return func(a: Variant) -> void: _on_signal_fired(np, sn, [a]) + 2: + return func(a: Variant, b: Variant) -> void: _on_signal_fired(np, sn, [a, b]) + 3: + return func(a: Variant, b: Variant, c: Variant) -> void: _on_signal_fired(np, sn, [a, b, c]) + 4: + return func(a: Variant, b: Variant, c: Variant, d: Variant) -> void: _on_signal_fired(np, sn, [a, b, c, d]) + _: + # For 5+ args, drop extra args and log without them + var cb := func() -> void: _on_signal_fired(np, sn, []) + return cb.unbind(arg_count) + + +func _process_watch_signals() -> void: + # Check for abort (new request) + if FileAccess.file_exists(REQUEST_PATH): + _finish_watch_signals() + _state = State.IDLE + _handle_request() + return + + var elapsed: int = Time.get_ticks_msec() - _watch_start_msec + if elapsed >= _watch_duration_ms: + _finish_watch_signals() + + +func _finish_watch_signals() -> void: + # Disconnect all watchers + for conn: Dictionary in _watch_connections: + var node: Node = conn["node"] as Node + if is_instance_valid(node): + var sig_name: String = conn["signal"] + var cb: Callable = conn["callable"] + if node.is_connected(sig_name, cb): + node.disconnect(sig_name, cb) + _watch_connections.clear() + + _state = State.IDLE + _write_response({ + "node_paths": _watch_nodes, + "signal_filter": _watch_signal_filter, + "duration_ms": _watch_duration_ms, + "events": _watch_log, + "event_count": _watch_log.size(), + }) + _watch_log.clear() + + +# ── set_node_property ───────────────────────────────────────────────────────── + +func _cmd_set_node_property(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "node_path is required"}) + return + + var property: String = params.get("property", "") + if property.is_empty(): + _write_response({"error": "property is required"}) + return + + if not params.has("value"): + _write_response({"error": "value is required"}) + return + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + _write_response({"error": "Node not found: %s" % node_path}) + return + + var old_value: Variant = node.get(property) + var raw_value: Variant = params.get("value") + var parsed_value: Variant = _parse_value_for_type(raw_value, typeof(old_value)) + + node.set(property, parsed_value) + var new_value: Variant = node.get(property) + + _write_response({ + "node_path": str(node.get_path()), + "property": property, + "old_value": _serialize_value(old_value), + "new_value": _serialize_value(new_value), + }) + + +func _parse_value_for_type(raw: Variant, target_type: int) -> Variant: + if typeof(raw) == target_type: + return raw + + # Handle Dictionary → Vector2/Vector3/Color conversion + if raw is Dictionary: + var dict: Dictionary = raw + match target_type: + TYPE_VECTOR3: + return Vector3( + float(dict.get("x", 0)), + float(dict.get("y", 0)), + float(dict.get("z", 0)) + ) + TYPE_VECTOR3I: + return Vector3i( + int(dict.get("x", 0)), + int(dict.get("y", 0)), + int(dict.get("z", 0)) + ) + TYPE_VECTOR2: + return Vector2( + float(dict.get("x", 0)), + float(dict.get("y", 0)) + ) + TYPE_VECTOR2I: + return Vector2i( + int(dict.get("x", 0)), + int(dict.get("y", 0)) + ) + TYPE_COLOR: + return Color( + float(dict.get("r", 0)), + float(dict.get("g", 0)), + float(dict.get("b", 0)), + float(dict.get("a", 1)) + ) + return raw + + if raw is String: + var raw_str: String = raw + if raw_str.begins_with("#"): + return Color.html(raw_str) + + var expr := Expression.new() + var err := expr.parse(raw_str) + if err == OK: + var result: Variant = expr.execute() + if not expr.has_execute_failed(): + return result + return raw_str + + if raw is float and target_type == TYPE_INT: + return int(raw) + if raw is int and target_type == TYPE_FLOAT: + return float(raw) + + return raw + + +# ── execute_script ──────────────────────────────────────────────────────────── + +func _cmd_execute_script(params: Dictionary) -> void: + var code: String = params.get("code", "") + if code.is_empty(): + _write_response({"error": "code is required"}) + return + + # Normalize indentation: convert leading spaces to tabs + var raw_lines := code.split("\n") + var indent_size := 0 + for raw_line in raw_lines: + var spaces := 0 + while spaces < raw_line.length() and raw_line[spaces] == " ": + spaces += 1 + if spaces > 0 and (indent_size == 0 or spaces < indent_size): + indent_size = spaces + if indent_size > 0: + var space_unit := " ".repeat(indent_size) + for idx in raw_lines.size(): + var rl: String = raw_lines[idx] + var tabs := "" + while rl.begins_with(space_unit): + tabs += "\t" + rl = rl.substr(indent_size) + raw_lines[idx] = tabs + rl + + # Separate top-level func definitions (place at class level, not inside run()) + var class_funcs: PackedStringArray = [] + var body_lines: PackedStringArray = [] + var i := 0 + while i < raw_lines.size(): + var line: String = raw_lines[i] + if not line.begins_with("\t") and not line.begins_with(" ") and line.begins_with("func "): + class_funcs.append(line) + i += 1 + while i < raw_lines.size(): + var next_line: String = raw_lines[i] + if next_line.is_empty() or next_line.begins_with("\t"): + class_funcs.append(next_line) + i += 1 + else: + break + else: + body_lines.append(line) + i += 1 + + var wrapped := """extends Node + +var _mcp_output: Array = [] +var _mcp_error: String = "" + +func _mcp_print(value: Variant) -> void: + _mcp_output.append(str(value)) + +func _safe_get(node: Node, prop: String, default: Variant = null) -> Variant: + if node == null: + return default + return node.get(prop) if prop in node else default + +""" + # Add user's top-level functions at class level + for func_line in class_funcs: + wrapped += func_line + "\n" + if class_funcs.size() > 0: + wrapped += "\n" + + wrapped += "func run() -> Variant:\n" + for line in body_lines: + wrapped += "\t" + line + "\n" + wrapped += "\treturn _mcp_output\n" + + var script := GDScript.new() + script.source_code = wrapped + var err := script.reload() + if err != OK: + _write_response({"error": "Script compilation failed: %s" % error_string(err)}) + return + + var temp_node := Node.new() + temp_node.set_script(script) + get_tree().current_scene.add_child(temp_node) + + var output: Variant = null + if temp_node.has_method("run"): + output = temp_node.run() + + var mcp_output: Array = temp_node.get("_mcp_output") if temp_node.get("_mcp_output") is Array else [] + temp_node.queue_free() + + _write_response({ + "output": mcp_output, + "return_value": str(output) if output != null else null, + }) + + +# ── find_nodes_by_script ────────────────────────────────────────────────────── + +func _cmd_find_nodes_by_script(params: Dictionary) -> void: + var script_name: String = params.get("script", "") + if script_name.is_empty(): + _write_response({"error": "'script' is required"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var prop_filter: Array = params.get("properties", []) + var matches: Array = [] + _find_nodes_by_script_recursive(root, script_name.to_lower(), prop_filter, matches) + + _write_response({"nodes": matches, "count": matches.size()}) + + +func _find_nodes_by_script_recursive(node: Node, script_filter: String, prop_filter: Array, results: Array) -> void: + var script: Script = node.get_script() + if script and script.resource_path.to_lower().contains(script_filter): + var entry := { + "name": node.name, + "path": str(node.get_path()), + "type": node.get_class(), + "script": script.resource_path, + } + var props: Dictionary = {} + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + entry["properties"] = props + results.append(entry) + + for child in node.get_children(): + _find_nodes_by_script_recursive(child, script_filter, prop_filter, results) + + +# ── get_autoload ───────────────────────────────────────────────────────────── + +func _cmd_get_autoload(params: Dictionary) -> void: + var autoload_name: String = params.get("name", "") + if autoload_name.is_empty(): + _write_response({"error": "'name' is required"}) + return + + var node := get_node_or_null(NodePath("/root/" + autoload_name)) + if node == null: + _write_response({"error": "Autoload not found: %s" % autoload_name}) + return + + var prop_filter: Array = params.get("properties", []) + var props: Dictionary = {} + + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + + var result := { + "name": autoload_name, + "path": str(node.get_path()), + "type": node.get_class(), + "properties": props, + } + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + _write_response(result) + + +# ── batch_get_properties ───────────────────────────────────────────────────── + +func _cmd_batch_get_properties(params: Dictionary) -> void: + var nodes: Array = params.get("nodes", []) + if nodes.is_empty(): + _write_response({"error": "'nodes' array is required"}) + return + + var results: Array = [] + for entry: Dictionary in nodes: + var node_path: String = entry.get("path", "") + var prop_filter: Array = entry.get("properties", []) + + if node_path.is_empty(): + results.append({"path": "", "properties": {}, "error": "Empty path"}) + continue + + var node := get_node_or_null(NodePath(node_path)) + if node == null: + results.append({"path": node_path, "properties": {}, "error": "Node not found"}) + continue + + var props: Dictionary = {} + if prop_filter.is_empty(): + for prop_info in node.get_property_list(): + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + if not (usage & PROPERTY_USAGE_EDITOR): + continue + if prop_name.begins_with("_") or prop_name == "script": + continue + props[prop_name] = _serialize_value(node.get(prop_name)) + else: + for prop_name: String in prop_filter: + props[prop_name] = _serialize_value(node.get(prop_name)) + + results.append({"path": node_path, "properties": props}) + + _write_response({"nodes": results, "count": results.size()}) + + +# ── find_ui_elements ───────────────────────────────────────────────────────── + +func _cmd_find_ui_elements(params: Dictionary) -> void: + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var type_filter: String = params.get("type_filter", "") + var elements: Array = [] + _find_ui_recursive(root, type_filter, elements) + _write_response({"elements": elements, "count": elements.size()}) + + +func _find_ui_recursive(node: Node, type_filter: String, results: Array) -> void: + if node is Control and node.visible: + var ctrl: Control = node + var entry: Dictionary = {} + var include := false + + if ctrl is Button: + var btn: Button = ctrl + entry["type"] = "Button" + entry["text"] = btn.text + entry["disabled"] = btn.disabled + include = true + elif ctrl is Label: + var lbl: Label = ctrl + entry["type"] = "Label" + entry["text"] = lbl.text + include = true + elif ctrl is LineEdit: + var le: LineEdit = ctrl + entry["type"] = "LineEdit" + entry["text"] = le.text + entry["placeholder"] = le.placeholder_text + include = true + elif ctrl is TextEdit: + var te: TextEdit = ctrl + entry["type"] = "TextEdit" + entry["text"] = te.text.left(200) + include = true + elif ctrl is OptionButton: + var ob: OptionButton = ctrl + entry["type"] = "OptionButton" + entry["text"] = ob.text + entry["selected"] = ob.selected + include = true + elif ctrl is CheckBox: + var cb: CheckBox = ctrl + entry["type"] = "CheckBox" + entry["text"] = cb.text + entry["checked"] = cb.button_pressed + include = true + elif ctrl is HSlider or ctrl is VSlider: + var sl: Range = ctrl + entry["type"] = "HSlider" if ctrl is HSlider else "VSlider" + entry["value"] = sl.value + entry["min"] = sl.min_value + entry["max"] = sl.max_value + include = true + + if include: + if not type_filter.is_empty() and entry.get("type", "") != type_filter: + pass # Skip non-matching types + else: + var rect := ctrl.get_global_rect() + entry["name"] = str(ctrl.name) + entry["path"] = str(ctrl.get_path()) + entry["rect"] = { + "x": rect.position.x, + "y": rect.position.y, + "width": rect.size.x, + "height": rect.size.y, + } + entry["center"] = { + "x": rect.position.x + rect.size.x / 2.0, + "y": rect.position.y + rect.size.y / 2.0, + } + results.append(entry) + + for child in node.get_children(): + _find_ui_recursive(child, type_filter, results) + + +# ── click_button_by_text ───────────────────────────────────────────────────── + +func _cmd_click_button_by_text(params: Dictionary) -> void: + var text: String = params.get("text", "") + var partial: bool = params.get("partial", true) + if text.is_empty(): + _write_response({"error": "'text' is required"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + var btn: Button = _find_button_by_text(root, text, partial) + if btn == null: + _write_response({"error": "No visible button found with text: '%s'" % text}) + return + + var rect := btn.get_global_rect() + var center := rect.get_center() + var btn_text_value := btn.text + + # Capture button path before clicking — the click may trigger a scene + # transition that removes the node from the tree. + var btn_path := str(btn.get_path()) if btn.is_inside_tree() else "" + + # Emit the pressed signal directly — more reliable than Input.parse_input_event + # which doesn't always reach GUI Controls. + btn.emit_signal("pressed") + + # After the click, the node may have been freed due to scene transition. + # Re-check before accessing any node properties. + if not is_instance_valid(btn) or not btn.is_inside_tree(): + _write_response({ + "clicked": true, + "button_text": btn_text_value, + "button_path": btn_path, + "position": {"x": center.x, "y": center.y}, + "note": "Button was removed from scene tree after click (likely a scene transition)", + }) + return + + _write_response({ + "clicked": true, + "button_text": btn.text, + "button_path": str(btn.get_path()), + "position": {"x": center.x, "y": center.y}, + }) + + +func _find_button_by_text(node: Node, text: String, partial: bool) -> Button: + if node is Button and node.visible: + var btn: Button = node + var btn_text := btn.text.to_lower().strip_edges() + var search_text := text.to_lower().strip_edges() + if partial and btn_text.contains(search_text): + return btn + elif not partial and btn_text == search_text: + return btn + + for child in node.get_children(): + var found := _find_button_by_text(child, text, partial) + if found != null: + return found + return null + + +# ── wait_for_node ──────────────────────────────────────────────────────────── + +func _cmd_wait_for_node(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _write_response({"error": "'node_path' is required"}) + return + + var timeout_sec: float = params.get("timeout", 5.0) + var poll_interval: int = maxi(int(params.get("poll_frames", 5)), 1) + + var attempts := int(timeout_sec / (poll_interval / 60.0)) + var frame_counter := 0 + + for i in attempts: + var node := get_node_or_null(NodePath(node_path)) + if node != null: + var result := { + "found": true, + "node_path": str(node.get_path()), + "type": node.get_class(), + "name": str(node.name), + } + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + _write_response(result) + return + + # Wait poll_interval frames + for _f in poll_interval: + await get_tree().process_frame + + _write_response({ + "found": false, + "node_path": node_path, + "error": "Node not found after %.1fs" % timeout_sec, + }) + + +# ── find_nearby_nodes ───────────────────────────────────────────────────────── + +func _cmd_find_nearby_nodes(params: Dictionary) -> void: + var radius: float = float(params.get("radius", 20.0)) + var max_results: int = int(params.get("max_results", 10)) + var type_filter: String = params.get("type_filter", "") + var group_filter: String = params.get("group_filter", "") + + # Resolve origin position + var origin := Vector3.ZERO + var position_param: Variant = params.get("position", null) + if position_param is String: + # node_path — use its global_position + var origin_node := get_node_or_null(NodePath(position_param as String)) + if origin_node == null: + _write_response({"error": "Origin node not found: %s" % position_param}) + return + if origin_node is Node3D: + origin = (origin_node as Node3D).global_position + elif origin_node is Node2D: + var pos2d: Vector2 = (origin_node as Node2D).global_position + origin = Vector3(pos2d.x, pos2d.y, 0) + else: + _write_response({"error": "Origin node is not Node2D or Node3D: %s" % position_param}) + return + elif position_param is Dictionary: + var dict: Dictionary = position_param + origin = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + elif position_param == null: + _write_response({"error": "'position' is required (node_path string or {x,y,z} object)"}) + return + + var root := get_tree().current_scene + if root == null: + _write_response({"error": "No current scene"}) + return + + # Collect all spatial nodes within radius + var candidates: Array = [] + _find_nearby_recursive(root, origin, radius, type_filter, group_filter, candidates) + + # Sort by distance + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a["distance"] < b["distance"] + ) + + # Limit results + if candidates.size() > max_results: + candidates.resize(max_results) + + _write_response({ + "origin": {"x": origin.x, "y": origin.y, "z": origin.z}, + "radius": radius, + "nodes": candidates, + "count": candidates.size(), + }) + + +func _find_nearby_recursive(node: Node, origin: Vector3, radius: float, type_filter: String, group_filter: String, results: Array) -> void: + var pos := Vector3.ZERO + var is_spatial := false + + if node is Node3D: + pos = (node as Node3D).global_position + is_spatial = true + elif node is Node2D: + var pos2d: Vector2 = (node as Node2D).global_position + pos = Vector3(pos2d.x, pos2d.y, 0) + is_spatial = true + + if is_spatial: + var diff := pos - origin + var dist := diff.length() + if dist <= radius: + # Apply filters + var passes := true + if not type_filter.is_empty() and not node.is_class(type_filter): + passes = false + if not group_filter.is_empty() and not node.is_in_group(group_filter): + passes = false + + if passes: + var entry: Dictionary = { + "node_path": str(node.get_path()), + "name": str(node.name), + "type": node.get_class(), + "distance": snappedf(dist, 0.01), + "global_position": {"x": snappedf(pos.x, 0.01), "y": snappedf(pos.y, 0.01), "z": snappedf(pos.z, 0.01)}, + "direction": {"x": snappedf(diff.x, 0.01), "y": snappedf(diff.y, 0.01), "z": snappedf(diff.z, 0.01)}, + } + var script: Script = node.get_script() + if script: + entry["script"] = script.resource_path + results.append(entry) + + for child in node.get_children(): + _find_nearby_recursive(child, origin, radius, type_filter, group_filter, results) + + +# ── navigate_to ────────────────────────────────────────────────────────────── + +func _cmd_navigate_to(params: Dictionary) -> void: + # Resolve player position + var player_path: String = params.get("player_path", "/root/Main/Player") + var player := get_node_or_null(NodePath(player_path)) + if player == null: + _write_response({"error": "Player not found: %s" % player_path}) + return + + var player_pos := Vector3.ZERO + if player is Node3D: + player_pos = (player as Node3D).global_position + else: + _write_response({"error": "Player is not Node3D: %s" % player_path}) + return + + # Resolve target position + var target_param: Variant = params.get("target", null) + var target_pos := Vector3.ZERO + if target_param is String: + var target_node := get_node_or_null(NodePath(target_param as String)) + if target_node == null: + _write_response({"error": "Target node not found: %s" % target_param}) + return + if target_node is Node3D: + target_pos = (target_node as Node3D).global_position + else: + _write_response({"error": "Target is not Node3D: %s" % target_param}) + return + elif target_param is Dictionary: + var dict: Dictionary = target_param + target_pos = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + else: + _write_response({"error": "'target' is required (node_path string or {x,y,z} object)"}) + return + + # Calculate world direction (XZ plane for 3D movement) + var world_dir := target_pos - player_pos + var distance := world_dir.length() + var flat_dir := Vector3(world_dir.x, 0, world_dir.z).normalized() + + # Find camera for relative direction + var camera_path: String = params.get("camera_path", "") + var camera: Camera3D = null + if not camera_path.is_empty(): + var cam_node := get_node_or_null(NodePath(camera_path)) + if cam_node is Camera3D: + camera = cam_node + else: + # Auto-detect: find Camera3D in scene + camera = get_viewport().get_camera_3d() + + var suggested_keys: Array = [] + var camera_yaw_delta: float = 0.0 + var camera_forward := Vector3.ZERO + + if camera != null: + # Camera forward (XZ plane) + camera_forward = -camera.global_basis.z + var cam_flat := Vector3(camera_forward.x, 0, camera_forward.z).normalized() + var cam_right := Vector3(camera_forward.z, 0, -camera_forward.x).normalized() + + if flat_dir.length() > 0.01: + # Project target direction onto camera axes + var forward_dot := flat_dir.dot(cam_flat) + var right_dot := flat_dir.dot(cam_right) + + # Suggest keys based on dominant direction + if forward_dot > 0.3: + suggested_keys.append("KEY_W") + elif forward_dot < -0.3: + suggested_keys.append("KEY_S") + if right_dot > 0.3: + suggested_keys.append("KEY_D") + elif right_dot < -0.3: + suggested_keys.append("KEY_A") + + # Calculate yaw rotation needed to face target directly (W only) + var angle_to_target := atan2(flat_dir.x, flat_dir.z) + var cam_yaw := atan2(cam_flat.x, cam_flat.z) + camera_yaw_delta = angle_to_target - cam_yaw + # Normalize to [-PI, PI] + while camera_yaw_delta > PI: + camera_yaw_delta -= TAU + while camera_yaw_delta < -PI: + camera_yaw_delta += TAU + + # Estimate walk duration (rough: assume ~5 units/sec movement speed) + var move_speed: float = float(params.get("move_speed", 5.0)) + var estimated_duration := distance / move_speed if move_speed > 0 else 0.0 + + # Convert yaw delta to approximate mouse relative_x pixels + # Typical: 400px mouse movement ≈ PI radians + var mouse_sensitivity_scale: float = 400.0 / PI + var suggested_mouse_x := -camera_yaw_delta * mouse_sensitivity_scale + + _write_response({ + "distance": snappedf(distance, 0.01), + "world_direction": { + "x": snappedf(world_dir.x, 0.01), + "y": snappedf(world_dir.y, 0.01), + "z": snappedf(world_dir.z, 0.01), + }, + "flat_direction": { + "x": snappedf(flat_dir.x, 0.01), + "z": snappedf(flat_dir.z, 0.01), + }, + "suggested_keys": suggested_keys, + "camera_rotation_delta": { + "yaw_radians": snappedf(camera_yaw_delta, 0.001), + "suggested_mouse_relative_x": snappedf(suggested_mouse_x, 1.0), + }, + "estimated_duration": snappedf(estimated_duration, 0.1), + "player_position": {"x": snappedf(player_pos.x, 0.01), "y": snappedf(player_pos.y, 0.01), "z": snappedf(player_pos.z, 0.01)}, + "target_position": {"x": snappedf(target_pos.x, 0.01), "y": snappedf(target_pos.y, 0.01), "z": snappedf(target_pos.z, 0.01)}, + }) + + +# ── move_to ─────────────────────────────────────────────────────────────────── + +func _cmd_move_to(params: Dictionary) -> void: + # Resolve player node + var player_path: String = params.get("player_path", "/root/Main/Player") + var player := get_node_or_null(NodePath(player_path)) + if player == null or not player is Node3D: + _write_response({"error": "Player not found or not Node3D: %s" % player_path}) + return + + _moveto_player = player as Node3D + + # Resolve target position + var target_param: Variant = params.get("target", null) + if target_param is String: + var target_node := get_node_or_null(NodePath(target_param as String)) + if target_node == null: + _write_response({"error": "Target node not found: %s" % target_param}) + return + if target_node is Node3D: + _moveto_target = (target_node as Node3D).global_position + else: + _write_response({"error": "Target is not Node3D: %s" % target_param}) + return + elif target_param is Dictionary: + var dict: Dictionary = target_param + _moveto_target = Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + else: + _write_response({"error": "'target' is required (node_path string or {x,y,z} object)"}) + return + + # Resolve camera pivot + _moveto_camera_pivot = null + var camera_path: String = params.get("camera_path", "") + if not camera_path.is_empty(): + var cam_node := get_node_or_null(NodePath(camera_path)) + if cam_node is Node3D: + _moveto_camera_pivot = cam_node as Node3D + else: + # Auto-detect: look for SpringArm3D child of player + for child in _moveto_player.get_children(): + if child is SpringArm3D: + _moveto_camera_pivot = child as Node3D + break + # Fallback: active Camera3D's parent + if _moveto_camera_pivot == null: + var cam := get_viewport().get_camera_3d() + if cam != null and cam.get_parent() is Node3D and cam.get_parent() != get_tree().root: + _moveto_camera_pivot = cam.get_parent() as Node3D + + # Read params + _moveto_arrival_radius = float(params.get("arrival_radius", 1.5)) + _moveto_timeout = float(params.get("timeout", 15.0)) + _moveto_run = bool(params.get("run", false)) + _moveto_look_at = bool(params.get("look_at_target", true)) + _moveto_elapsed = 0.0 + _moveto_keys_held.clear() + + # Check if already at target + var dist := _moveto_player.global_position.distance_to(_moveto_target) + if dist <= _moveto_arrival_radius: + _write_response({ + "success": true, + "arrived": true, + "final_distance": snappedf(dist, 0.01), + "final_position": _serialize_value(_moveto_player.global_position), + "target_position": _serialize_value(_moveto_target), + "elapsed_time": 0.0, + }) + return + + # Start walking — async state, don't trigger crash recovery + _pending_command = false + _state = State.MOVING_TO + + # Inject walk key + _inject_key(KEY_W, true) + if _moveto_run: + _inject_key(KEY_SHIFT, true) + + +func _process_move_to(delta: float) -> void: + # Check for abort (new command arrived) + if FileAccess.file_exists(REQUEST_PATH): + _finish_move_to(false, "Aborted by new command") + _state = State.IDLE + _handle_request() + return + + _moveto_elapsed += delta + + # Timeout check + if _moveto_elapsed >= _moveto_timeout: + _finish_move_to(false, "Timeout after %.1fs" % _moveto_timeout) + return + + # Safety: player freed + if not is_instance_valid(_moveto_player): + _finish_move_to(false, "Player node was freed") + return + + var player_pos := _moveto_player.global_position + var flat_target := Vector3(_moveto_target.x, player_pos.y, _moveto_target.z) + var dist := player_pos.distance_to(flat_target) + + # Arrival check (XZ distance only, ignore Y) + if dist <= _moveto_arrival_radius: + _finish_move_to(true, "Arrived") + return + + # Rotate camera pivot toward target + if _moveto_look_at and _moveto_camera_pivot != null and is_instance_valid(_moveto_camera_pivot): + var dir := flat_target - player_pos + if dir.length_squared() > 0.01: + var target_yaw := atan2(-dir.x, -dir.z) + var current_yaw: float = _moveto_camera_pivot.rotation.y + # Lerp toward target yaw (~10 rad/s) + var yaw_diff := target_yaw - current_yaw + # Normalize to [-PI, PI] + while yaw_diff > PI: + yaw_diff -= TAU + while yaw_diff < -PI: + yaw_diff += TAU + var max_step := 10.0 * delta + var step := clampf(yaw_diff, -max_step, max_step) + _moveto_camera_pivot.rotation.y += step + + +func _finish_move_to(success: bool, message: String) -> void: + # Release all held keys + _release_all_keys() + _state = State.IDLE + + var final_pos := Vector3.ZERO + var final_dist := 0.0 + if is_instance_valid(_moveto_player): + final_pos = _moveto_player.global_position + final_dist = final_pos.distance_to(_moveto_target) + + _write_response({ + "success": success, + "arrived": success, + "message": message, + "final_distance": snappedf(final_dist, 0.01), + "final_position": _serialize_value(final_pos), + "target_position": _serialize_value(_moveto_target), + "elapsed_time": snappedf(_moveto_elapsed, 0.01), + }) + + +func _inject_key(keycode: int, pressed: bool) -> void: + var event := InputEventKey.new() + event.keycode = keycode + event.pressed = pressed + Input.parse_input_event(event) + if pressed: + _moveto_keys_held.append(keycode) + else: + _moveto_keys_held.erase(keycode) + + +func _release_all_keys() -> void: + for keycode: int in _moveto_keys_held.duplicate(): + var event := InputEventKey.new() + event.keycode = keycode + event.pressed = false + Input.parse_input_event(event) + _moveto_keys_held.clear() + + +# ── Recording ──────────────────────────────────────────────────────────────── + +func _cmd_start_recording(_params: Dictionary) -> void: + _recording_events.clear() + _recording_start_msec = Time.get_ticks_msec() + _state = State.RECORDING + set_process_input(true) + _write_response({"recording": true, "message": "Recording started"}) + + +func _cmd_stop_recording(_params: Dictionary) -> void: + set_process_input(false) + _state = State.IDLE + var events := _recording_events.duplicate() + var duration_ms := Time.get_ticks_msec() - _recording_start_msec + _write_response({ + "recording": false, + "events": events, + "event_count": events.size(), + "duration_ms": duration_ms, + }) + + +func _cmd_replay_recording(params: Dictionary) -> void: + _pending_command = false # Async command — don't trigger crash recovery + var events: Array = params.get("events", []) + if events.is_empty(): + _write_response({"error": "No events to replay"}) + return + + var speed: float = params.get("speed", 1.0) + + var start_msec := Time.get_ticks_msec() + for event_data: Dictionary in events: + var delay_ms: int = event_data.get("time_ms", 0) + var adjusted_delay := int(delay_ms / speed) + + while Time.get_ticks_msec() - start_msec < adjusted_delay: + await get_tree().process_frame + + var event := _reconstruct_event(event_data) + if event != null: + Input.parse_input_event(event) + + _write_response({ + "replayed": true, + "event_count": events.size(), + "speed": speed, + }) + + +func _input(event: InputEvent) -> void: + if _state != State.RECORDING: + return + + var time_ms := Time.get_ticks_msec() - _recording_start_msec + var data: Dictionary = {"time_ms": time_ms} + + if event is InputEventKey: + var key: InputEventKey = event + data["type"] = "key" + data["keycode"] = OS.get_keycode_string(key.keycode) if key.keycode != 0 else "" + data["physical_keycode"] = OS.get_keycode_string(key.physical_keycode) if key.physical_keycode != 0 else "" + data["pressed"] = key.pressed + data["shift"] = key.shift_pressed + data["ctrl"] = key.ctrl_pressed + data["alt"] = key.alt_pressed + elif event is InputEventMouseButton: + var mb: InputEventMouseButton = event + data["type"] = "mouse_button" + data["button"] = mb.button_index + data["pressed"] = mb.pressed + data["position"] = {"x": mb.position.x, "y": mb.position.y} + data["double_click"] = mb.double_click + elif event is InputEventMouseMotion: + var mm: InputEventMouseMotion = event + data["type"] = "mouse_motion" + data["position"] = {"x": mm.position.x, "y": mm.position.y} + data["relative"] = {"x": mm.relative.x, "y": mm.relative.y} + elif event is InputEventAction: + var act: InputEventAction = event + data["type"] = "action" + data["action"] = act.action + data["pressed"] = act.pressed + data["strength"] = act.strength + else: + return + + _recording_events.append(data) + + +func _reconstruct_event(data: Dictionary) -> InputEvent: + var type: String = data.get("type", "") + match type: + "key": + var event := InputEventKey.new() + var keycode_str: String = data.get("keycode", "") + if not keycode_str.is_empty(): + event.keycode = OS.find_keycode_from_string(keycode_str) + event.pressed = data.get("pressed", true) + event.shift_pressed = data.get("shift", false) + event.ctrl_pressed = data.get("ctrl", false) + event.alt_pressed = data.get("alt", false) + return event + "mouse_button": + var event := InputEventMouseButton.new() + event.button_index = data.get("button", MOUSE_BUTTON_LEFT) + event.pressed = data.get("pressed", true) + event.double_click = data.get("double_click", false) + var pos: Dictionary = data.get("position", {}) + event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + event.global_position = event.position + return event + "mouse_motion": + var event := InputEventMouseMotion.new() + var pos: Dictionary = data.get("position", {}) + event.position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + event.global_position = event.position + var rel: Dictionary = data.get("relative", {}) + event.relative = Vector2(rel.get("x", 0.0), rel.get("y", 0.0)) + return event + "action": + var event := InputEventAction.new() + event.action = data.get("action", "") + event.pressed = data.get("pressed", true) + event.strength = data.get("strength", 1.0) + return event + return null + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +func _write_response(data: Dictionary) -> void: + _pending_command = false + var json := JSON.stringify(data) + var file := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE) + if file: + file.store_string(json) + file.close() + + +func _serialize_value(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_VECTOR2: + var v: Vector2 = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR2I: + var v: Vector2i = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR3: + var v: Vector3 = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_VECTOR3I: + var v: Vector3i = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_RECT2: + var r: Rect2 = value + return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y} + TYPE_COLOR: + var c: Color = value + return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()} + TYPE_NODE_PATH: + return str(value) + TYPE_OBJECT: + if value is Resource: + var res: Resource = value + return {"type": res.get_class(), "path": res.resource_path} + return str(value) + TYPE_ARRAY: + var arr: Array = value + var result: Array = [] + for item in arr: + result.append(_serialize_value(item)) + return result + TYPE_DICTIONARY: + var dict: Dictionary = value + var result: Dictionary = {} + for key in dict: + result[str(key)] = _serialize_value(dict[key]) + return result + _: + return value diff --git a/addons/godot_mcp/mcp_game_inspector_service.gd.uid b/addons/godot_mcp/mcp_game_inspector_service.gd.uid new file mode 100644 index 0000000..8234fed --- /dev/null +++ b/addons/godot_mcp/mcp_game_inspector_service.gd.uid @@ -0,0 +1 @@ +uid://bivslqr2p1447 diff --git a/addons/godot_mcp/mcp_input_service.gd b/addons/godot_mcp/mcp_input_service.gd new file mode 100644 index 0000000..01a8475 --- /dev/null +++ b/addons/godot_mcp/mcp_input_service.gd @@ -0,0 +1,199 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Monitors for input commands from the editor and dispatches them as Input events. +extends Node + +const COMMANDS_PATH := "user://mcp_input_commands" + +var _sequence_queue: Array = [] # Array of event dicts +var _sequence_frame_delay: int = 0 +var _sequence_frames_waited: int = 0 + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + # Process queued sequence events + if not _sequence_queue.is_empty(): + _process_sequence_tick() + + # Check for new commands from file + if FileAccess.file_exists(COMMANDS_PATH): + _process_commands() + + +func _process_commands() -> void: + var file := FileAccess.open(COMMANDS_PATH, FileAccess.READ) + if file == null: + return + var text := file.get_as_text() + file.close() + DirAccess.remove_absolute(COMMANDS_PATH) + + var parsed = JSON.parse_string(text) + if parsed == null: + push_warning("[MCP Input] Failed to parse input commands JSON") + return + + # Check if this is a sequence command (dict with "events" and "frame_delay") + if parsed is Dictionary and parsed.has("sequence_events"): + _start_sequence(parsed) + return + + # Otherwise treat as immediate event(s) + var events: Array = parsed if parsed is Array else [parsed] + for event_data: Dictionary in events: + var event := _create_event(event_data) + if event != null: + _dispatch_event(event, event_data) + + +func _start_sequence(data: Dictionary) -> void: + _sequence_queue = data.get("sequence_events", []).duplicate() + _sequence_frame_delay = data.get("frame_delay", 1) + _sequence_frames_waited = 0 + # Dispatch first event immediately + if not _sequence_queue.is_empty(): + _dispatch_next_sequence_event() + + +func _process_sequence_tick() -> void: + _sequence_frames_waited += 1 + if _sequence_frames_waited >= _sequence_frame_delay: + _sequence_frames_waited = 0 + _dispatch_next_sequence_event() + + +func _dispatch_next_sequence_event() -> void: + if _sequence_queue.is_empty(): + return + var event_data: Dictionary = _sequence_queue.pop_front() + var event := _create_event(event_data) + if event != null: + _dispatch_event(event, event_data) + + +## Dispatch an input event using the appropriate method. +## Mouse drag motions (button_mask > 0) and events with "unhandled" flag +## use push_input to bypass GUI consumption and reach _unhandled_input(). +## This fixes camera pan/drag not working when UI Controls consume mouse events. +func _dispatch_event(event: InputEvent, event_data: Dictionary = {}) -> void: + # Mouse drag motions (button_mask > 0) auto-promote to push_input to bypass + # GUI consumption — needed for camera-pan. But for UI drag-and-drop testing + # we want events to reach the GUI dispatcher. Respect an explicit + # "unhandled": false in the event payload; only auto-promote when the + # caller did NOT pass an "unhandled" key. Default behavior preserved. + var force_unhandled: bool + if event_data.has("unhandled"): + force_unhandled = bool(event_data.get("unhandled")) + else: + force_unhandled = event is InputEventMouseMotion and event.button_mask != 0 + if force_unhandled: + var vp := get_viewport() + if vp: + vp.push_input(event, true) + else: + Input.parse_input_event(event) + else: + Input.parse_input_event(event) + + +func _create_event(data: Dictionary) -> InputEvent: + var type: String = data.get("type", "") + match type: + "key": + return _create_key_event(data) + "mouse_button": + return _create_mouse_button_event(data) + "mouse_motion": + return _create_mouse_motion_event(data) + "action": + return _create_action_event(data) + _: + push_warning("[MCP Input] Unknown event type: %s" % type) + return null + + +## Convert viewport coordinates to window coordinates for Input.parse_input_event(). +## Godot applies viewport.get_final_transform() to mouse events internally, +## so we must pass window-space coordinates (pre-transform). +func _viewport_to_window(viewport_pos: Vector2) -> Vector2: + var vp := get_viewport() + if vp == null: + return viewport_pos + var xform := vp.get_final_transform() + return xform * viewport_pos + + +func _create_key_event(data: Dictionary) -> InputEventKey: + var event := InputEventKey.new() + var keycode_str: String = data.get("keycode", "") + if keycode_str.begins_with("KEY_"): + var constant_value = ClassDB.class_get_integer_constant("@GlobalScope", keycode_str) + if constant_value != 0: + event.keycode = constant_value + else: + event.keycode = OS.find_keycode_from_string(keycode_str.substr(4)) + else: + event.keycode = OS.find_keycode_from_string(keycode_str) + event.pressed = data.get("pressed", true) + event.shift_pressed = data.get("shift", false) + event.ctrl_pressed = data.get("ctrl", false) + event.alt_pressed = data.get("alt", false) + return event + + +func _extract_position(data: Dictionary) -> Vector2: + # Support nested {"position": {"x": ..., "y": ...}} or flat {"x": ..., "y": ...} + var pos = data.get("position", null) + if pos is Dictionary: + return Vector2(pos.get("x", 0.0), pos.get("y", 0.0)) + return Vector2(data.get("x", 0.0), data.get("y", 0.0)) + + +func _create_mouse_button_event(data: Dictionary) -> InputEventMouseButton: + var event := InputEventMouseButton.new() + event.button_index = data.get("button", MOUSE_BUTTON_LEFT) + event.pressed = data.get("pressed", true) + event.double_click = data.get("double_click", false) + var window_pos := _viewport_to_window(_extract_position(data)) + event.position = window_pos + event.global_position = window_pos + return event + + +func _create_mouse_motion_event(data: Dictionary) -> InputEventMouseMotion: + var event := InputEventMouseMotion.new() + var window_pos := _viewport_to_window(_extract_position(data)) + event.position = window_pos + event.global_position = window_pos + # Support nested {"relative": {"x": ..., "y": ...}} or flat {"relative_x": ..., "relative_y": ...} + var rel_x: float = 0.0 + var rel_y: float = 0.0 + var rel = data.get("relative", null) + if rel is Dictionary: + rel_x = float(rel.get("x", 0.0)) + rel_y = float(rel.get("y", 0.0)) + else: + rel_x = float(data.get("relative_x", 0.0)) + rel_y = float(data.get("relative_y", 0.0)) + # Scale relative movement by the same transform (scale only, no offset) + var vp := get_viewport() + if vp: + var scale := vp.get_final_transform().get_scale() + event.relative = Vector2(rel_x, rel_y) * scale + else: + event.relative = Vector2(rel_x, rel_y) + # Set button_mask so drag detection works (e.g. camera pan checks button_mask) + var button_mask: int = int(data.get("button_mask", 0)) + event.button_mask = button_mask + return event + + +func _create_action_event(data: Dictionary) -> InputEventAction: + var event := InputEventAction.new() + event.action = data.get("action", "") + event.pressed = data.get("pressed", true) + event.strength = data.get("strength", 1.0) + return event diff --git a/addons/godot_mcp/mcp_input_service.gd.uid b/addons/godot_mcp/mcp_input_service.gd.uid new file mode 100644 index 0000000..f9cf8c6 --- /dev/null +++ b/addons/godot_mcp/mcp_input_service.gd.uid @@ -0,0 +1 @@ +uid://c1furnhig18hg diff --git a/addons/godot_mcp/mcp_screenshot_service.gd b/addons/godot_mcp/mcp_screenshot_service.gd new file mode 100644 index 0000000..2983219 --- /dev/null +++ b/addons/godot_mcp/mcp_screenshot_service.gd @@ -0,0 +1,34 @@ +## Autoload injected by Godot MCP Pro plugin at runtime. +## Monitors for screenshot requests from the editor and captures the game viewport. +extends Node + +const REQUEST_PATH := "user://mcp_screenshot_request" +const SCREENSHOT_PATH := "user://mcp_screenshot.png" + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + + +func _process(_delta: float) -> void: + if FileAccess.file_exists(REQUEST_PATH): + _take_screenshot() + + +func _take_screenshot() -> void: + # Delete request file immediately to avoid re-triggering + DirAccess.remove_absolute(REQUEST_PATH) + + # Wait one frame so the viewport has a fully rendered image + # process_always=true (default) so the timer ticks even when tree is paused + await get_tree().create_timer(0.05).timeout + + var viewport := get_viewport() + if viewport == null: + return + + var image := viewport.get_texture().get_image() + if image == null: + return + + image.save_png(SCREENSHOT_PATH) diff --git a/addons/godot_mcp/mcp_screenshot_service.gd.uid b/addons/godot_mcp/mcp_screenshot_service.gd.uid new file mode 100644 index 0000000..12213d7 --- /dev/null +++ b/addons/godot_mcp/mcp_screenshot_service.gd.uid @@ -0,0 +1 @@ +uid://bmhsrun0pw6ig diff --git a/addons/godot_mcp/plugin.cfg b/addons/godot_mcp/plugin.cfg new file mode 100644 index 0000000..443e86d --- /dev/null +++ b/addons/godot_mcp/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot MCP Pro" +description="通过 AI 直接控制 Godot 编辑器的专业插件,提供 172+ 个编辑器工具。" +author="wxm" +version="1.13.2" +script="plugin.gd" diff --git a/addons/godot_mcp/plugin.gd b/addons/godot_mcp/plugin.gd new file mode 100644 index 0000000..e1f2cc1 --- /dev/null +++ b/addons/godot_mcp/plugin.gd @@ -0,0 +1,188 @@ +@tool +extends EditorPlugin + +const _MCP_AUTOLOADS: Array[Array] = [ + ["autoload/MCPScreenshot", "res://addons/godot_mcp/mcp_screenshot_service.gd"], + ["autoload/MCPInputService", "res://addons/godot_mcp/mcp_input_service.gd"], + ["autoload/MCPGameInspector", "res://addons/godot_mcp/mcp_game_inspector_service.gd"], +] + +const _MCP_TEMP_FILES: Array[String] = [ + "mcp_game_request", + "mcp_game_response", + "mcp_input_commands", + "mcp_screenshot_request", +] + +var websocket_server: Node +var command_router: Node +var status_panel: Control +var auto_dismiss_dialogs: bool = false +# Track which autoloads THIS session injected (vs project-owned) +var _session_injected_autoloads: Array[String] = [] + +func _enter_tree() -> void: + _init_plugin() + + +func _init_plugin() -> void: + """Initialize plugin components.""" + # Create command router + command_router = preload("res://addons/godot_mcp/command_router.gd").new() + command_router.name = "MCPCommandRouter" + command_router.editor_plugin = self + add_child(command_router) + + # Create WebSocket server + websocket_server = preload("res://addons/godot_mcp/websocket_server.gd").new() + websocket_server.name = "MCPWebSocketServer" + websocket_server.command_router = command_router + add_child(websocket_server) + + # Create status panel + var panel_scene: PackedScene = preload("res://addons/godot_mcp/ui/status_panel.tscn") + status_panel = panel_scene.instantiate() + add_control_to_bottom_panel(status_panel, "MCP Pro") + status_panel.call_deferred("setup", websocket_server, command_router) + + # Inject MCP autoloads into project settings + _inject_autoloads() + + websocket_server.start_server() + var cfg := ConfigFile.new() + var ver := "unknown" + if cfg.load("res://addons/godot_mcp/plugin.cfg") == OK: + ver = cfg.get_value("plugin", "version", "unknown") + print("[MCP] Godot MCP Pro v%s started (ports 6505-6514)" % ver) + + +func _exit_tree() -> void: + # Remove MCP autoloads and clean up temp files + _remove_autoloads() + _cleanup_temp_files() + + if websocket_server: + websocket_server.stop_server() + + if status_panel: + remove_control_from_bottom_panel(status_panel) + status_panel.queue_free() + + if command_router: + command_router.queue_free() + + if websocket_server: + websocket_server.queue_free() + + print("[MCP] Godot MCP Pro stopped") + + +func _inject_autoloads() -> void: + _session_injected_autoloads.clear() + var changed := false + for entry: Array in _MCP_AUTOLOADS: + var key: String = entry[0] + var script: String = entry[1] + if not ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, "*" + script) + _session_injected_autoloads.append(key) + changed = true + if changed: + ProjectSettings.save() + + +func _remove_autoloads() -> void: + # Only remove autoloads that THIS session injected. + # Pre-existing project-owned autoloads are preserved. + var changed := false + for key: String in _session_injected_autoloads: + if ProjectSettings.has_setting(key): + ProjectSettings.set_setting(key, null) + changed = true + _session_injected_autoloads.clear() + if changed: + ProjectSettings.save() + + +var _dialog_check_timer: float = 0.0 +const _DIALOG_CHECK_INTERVAL: float = 0.5 # Check every 0.5 seconds + +func _process(delta: float) -> void: + # Check if game inspector requested debugger continue + var flag_path := OS.get_user_data_dir() + "/mcp_debugger_continue" + if FileAccess.file_exists(flag_path): + DirAccess.remove_absolute(flag_path) + _try_debugger_continue() + + # Periodically check for blocking editor dialogs (only when enabled by AI) + if auto_dismiss_dialogs: + _dialog_check_timer += delta + if _dialog_check_timer >= _DIALOG_CHECK_INTERVAL: + _dialog_check_timer = 0.0 + _auto_dismiss_dialogs() + + +func _try_debugger_continue() -> void: + # Last resort: find and press the debugger Continue button to unstick the game + var base: Node = EditorInterface.get_base_control() + var continue_btn := _find_debugger_continue_button(base) + if continue_btn and continue_btn.visible and not continue_btn.disabled: + continue_btn.emit_signal("pressed") + push_warning("[MCP] Auto-pressed debugger Continue button") + else: + push_warning("[MCP] Could not find debugger Continue button") + + +func _find_debugger_continue_button(node: Node) -> Button: + # Search for the Continue button in ScriptEditorDebugger + if node is Button: + var btn: Button = node + if btn.tooltip_text.contains("Continue") or btn.text == "Continue": + return btn + for child in node.get_children(): + var found: Button = _find_debugger_continue_button(child) + if found: + return found + return null + + +func _auto_dismiss_dialogs() -> void: + var base: Node = EditorInterface.get_base_control() + if not base: + return + _find_and_dismiss_dialogs(base) + + +func _find_and_dismiss_dialogs(node: Node) -> void: + if node is AcceptDialog and node.visible: + var dialog: AcceptDialog = node + # Never dismiss file dialogs or non-modal popups + if dialog is FileDialog: + return + if not dialog.exclusive: + return + # Get dialog title/text for logging + var title := dialog.title + var text := dialog.dialog_text + # Accept the dialog (presses OK / confirms) + dialog.get_ok_button().emit_signal("pressed") + push_warning("[MCP] Auto-dismissed editor dialog: '%s' — %s" % [title, text]) + return # One dialog per check cycle to avoid side effects + + for child in node.get_children(): + # Only search visible Windows to keep the scan lightweight + if child is Window and not child.visible: + continue + _find_and_dismiss_dialogs(child) + + +func _cleanup_temp_files() -> void: + var user_dir := OS.get_user_data_dir() + for filename: String in _MCP_TEMP_FILES: + var path := user_dir + "/" + filename + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + # Also clean up screenshot image + var screenshot_path := user_dir + "/mcp_screenshot.png" + if FileAccess.file_exists(screenshot_path): + DirAccess.remove_absolute(screenshot_path) diff --git a/addons/godot_mcp/plugin.gd.uid b/addons/godot_mcp/plugin.gd.uid new file mode 100644 index 0000000..4989432 --- /dev/null +++ b/addons/godot_mcp/plugin.gd.uid @@ -0,0 +1 @@ +uid://d36bcgxlvqnng diff --git a/addons/godot_mcp/skills.es.md b/addons/godot_mcp/skills.es.md new file mode 100644 index 0000000..3f9f8a6 --- /dev/null +++ b/addons/godot_mcp/skills.es.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | Español | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills para Asistentes de IA + +> Copia este archivo a `.claude/skills.md` en la raíz de tu proyecto Godot para darle a Claude Code el contexto completo sobre cómo usar Godot MCP Pro de forma efectiva. + +## ¿Qué es Godot MCP Pro? + +Tienes acceso a 169 herramientas MCP que se conectan directamente al editor de Godot 4. Puedes crear escenas, escribir scripts, simular entrada del jugador, inspeccionar juegos en ejecución y más — todo sin que el usuario salga de esta conversación. Cada cambio pasa por el sistema UndoRedo de Godot, así que el usuario siempre puede hacer Ctrl+Z. + +## Flujos de Trabajo Esenciales + +### 1. Explorar un Proyecto + +Siempre empieza entendiendo el proyecto antes de hacer cambios: + +``` +get_project_info → nombre del proyecto, versión de Godot, renderizador, tamaño del viewport +get_filesystem_tree → estructura de directorios (usa filter: "*.tscn" o "*.gd") +get_scene_tree → jerarquía de nodos de la escena abierta actualmente +read_script → leer cualquier archivo GDScript +get_project_settings → revisar la configuración del proyecto +``` + +### 2. Construir una Escena 2D + +``` +create_scene → crear archivo .tscn con tipo de nodo raíz +add_node → agregar nodos hijos con propiedades +create_script → escribir GDScript para lógica del juego +attach_script → adjuntar script a un nodo +update_property → establecer position, scale, modulate, etc. +save_scene → guardar en disco +``` + +**Ejemplo — creando un jugador:** +1. `create_scene` con root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` tipo `Sprite2D` con propiedad texture +3. `add_node` tipo `CollisionShape2D` +4. `add_resource` para asignar una shape (ej: `RectangleShape2D`) al CollisionShape2D +5. `create_script` con lógica de movimiento +6. `attach_script` al nodo raíz +7. `save_scene` + +### 3. Construir una Escena 3D + +``` +create_scene → root_type: Node3D +add_mesh_instance → agregar primitivas (box, sphere, cylinder, plane) o importar .glb/.gltf +setup_lighting → agregar DirectionalLight3D, OmniLight3D o SpotLight3D +setup_environment → cielo, luz ambiental, niebla, tonemap +setup_camera_3d → cámara con SpringArm3D opcional para tercera persona +set_material_3d → materiales PBR (albedo, metallic, roughness, emission) +setup_collision → agregar shapes de colisión a cuerpos físicos +setup_physics_body → configurar masa, fricción, gravedad +``` + +### 4. Escribir y Editar Scripts + +``` +create_script → crear nuevo archivo .gd (proporciona el contenido completo) +edit_script → modificar scripts existentes + - Usa `replacements: [{search: "old code", replace: "new code"}]` para ediciones específicas + - Usa `content` para reemplazo completo del archivo + - Usa `insert_at_line` + `text` para insertar código +validate_script → verificar errores de sintaxis sin ejecutar +read_script → leer contenido actual antes de editar +``` + +### 5. Probar y Depurar + +``` +play_scene → lanzar el juego (mode: "current", "main" o ruta de archivo) +get_game_screenshot → ver cómo luce el juego en este momento +capture_frames → capturar múltiples frames para observar movimiento/animación +get_game_scene_tree → inspeccionar el árbol de escena en tiempo de ejecución +get_game_node_properties → leer valores en runtime (position, health, state, etc.) +set_game_node_property → modificar valores en el juego en ejecución +simulate_key → presionar teclas (WASD, SPACE, etc.) con duración +simulate_mouse_click → hacer clic en coordenadas del viewport +simulate_action → disparar acciones del InputMap (move_left, jump, etc.) +get_editor_errors → revisar errores de ejecución +stop_scene → detener el juego +``` + +**Ciclo de playtesting:** +1. `play_scene` → iniciar el juego +2. `get_game_screenshot` → ver estado actual +3. `simulate_key` / `simulate_action` → interactuar con el juego +4. `capture_frames` → observar comportamiento a lo largo del tiempo +5. `get_game_node_properties` → verificar valores específicos +6. `stop_scene` → detener cuando termines +7. Corregir problemas en scripts → repetir + +### 6. Animaciones + +``` +# Asegúrate de que exista un nodo AnimationPlayer en la escena +create_animation → nueva animación con duración y modo de loop +add_animation_track → agregar tracks de property/transform/method +set_animation_keyframe → insertar keyframes en tiempos específicos +get_animation_info → inspeccionar animaciones existentes +``` + +**Ejemplo — sprite rebotando:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop lineal) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → cambiar font_color, etc. +set_theme_font_size → ajustar tamaño de texto +set_theme_stylebox → fondos, bordes, esquinas redondeadas +connect_signal → conectar pressed del button, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → revisar fuentes del tile set y disposición del atlas +tilemap_set_cell → colocar tiles individuales +tilemap_fill_rect → rellenar regiones rectangulares +tilemap_get_used_cells → ver qué ya está colocado +tilemap_clear → limpiar todas las celdas +``` + +### 9. Audio + +``` +add_audio_bus → crear buses de audio (SFX, Music, UI) +set_audio_bus → ajustar volumen, solo, mute +add_audio_bus_effect → agregar reverb, delay, compressor, etc. +add_audio_player → agregar nodos AudioStreamPlayer(2D/3D) +``` + +### 10. Configuración del Proyecto + +``` +set_project_setting → cambiar tamaño del viewport, configuraciones de física, etc. +set_input_action → definir mapeos de entrada (move_left → KEY_A, etc.) +add_autoload → registrar singletons autoload +set_physics_layers → nombrar capas de colisión (player, enemy, world, etc.) +``` + +## Reglas Importantes y Trampas + +### Valores de Propiedades +Las propiedades se parsean automáticamente desde strings. Usa estos formatos: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` o `"#ff0000"` +- Bool: `"true"` / `"false"` +- Números: `"42"`, `"3.14"` +- Enums: Usa valores enteros (ej: `0` para el primer valor del enum) + +### Nunca Edites project.godot Directamente +El editor de Godot sobrescribe `project.godot` constantemente. Siempre usa `set_project_setting` para cambiar configuraciones del proyecto. + +### Anotaciones de Tipo en GDScript +Al escribir GDScript con loops `for` sobre arrays sin tipo, usa anotaciones de tipo explícitas: +```gdscript +# MAL — causará errores +for item in some_untyped_array: + var x := item.value # la inferencia de tipos falla + +# BIEN +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Los Cambios en Scripts Necesitan Reload +Después de crear o modificar scripts significativamente, usa `reload_project` para asegurar que Godot reconozca los cambios. Esto es especialmente importante después de `create_script`. + +### Consejos para simulate_key +- Usa **duraciones cortas** (0.3–0.5 segundos) para movimiento preciso +- Duraciones largas (1+ segundo) causan overshooting +- Para pruebas de gameplay, prefiere `simulate_action` sobre `simulate_key` cuando haya acciones del InputMap definidas + +### simulate_mouse_click +- El valor por defecto `auto_release: true` envía press y release — requerido para botones de UI +- Los botones de UI se activan en release, por lo que ambos eventos son necesarios + +### Limitaciones de execute_game_script +- Sin funciones anidadas (`func` dentro de `func`) — causa error de compilación +- Usa `.get("property")` en lugar de `.property` para acceso dinámico +- Los errores de runtime pausan el debugger (se continúa automáticamente, pero evítalo si es posible) + +### Colisión y Áreas de Recolección +- Para ítems recolectables, usa Area3D/Area2D con radio >= 1.5 +- Radios más pequeños son casi imposibles de activar con entrada simulada + +### Guarda Frecuentemente +Llama a `save_scene` después de hacer cambios significativos. Los cambios no guardados pueden perderse si el editor se recarga. + +## Herramientas de Análisis y Depuración + +Cuando algo sale mal, usa estas herramientas para investigar: + +``` +get_editor_errors → revisar errores de script y excepciones de runtime +get_output_log → leer salida de print() y advertencias +analyze_scene_complexity → encontrar cuellos de botella de rendimiento +analyze_signal_flow → visualizar conexiones de signals +detect_circular_dependencies → encontrar referencias circulares de script/escena +find_unused_resources → limpiar archivos no utilizados +get_performance_monitors → FPS, memoria, draw calls, estadísticas de física +``` + +## Pruebas y QA + +``` +run_test_scenario → definir y ejecutar secuencias de prueba automatizadas +assert_node_state → verificar que las propiedades de nodos coincidan con valores esperados +assert_screen_text → verificar que el texto se muestre en pantalla +compare_screenshots → pruebas de regresión visual (usa rutas de archivo, no base64) +run_stress_test → generar muchos nodos para probar rendimiento +``` + +## Patrones Avanzados + +### Operaciones entre Escenas +``` +cross_scene_set_property → modificar nodos en escenas que no están abiertas actualmente +find_node_references → encontrar todos los archivos que referencian un patrón +batch_set_property → establecer una propiedad en todos los nodos de un tipo +``` + +### Flujo de Trabajo con Shaders +``` +create_shader → escribir código shader estilo GLSL +assign_shader_material → aplicar a un nodo +set_shader_param → ajustar uniforms en runtime +get_shader_params → inspeccionar valores actuales +``` + +### Navegación (3D) +``` +setup_navigation_region → definir área transitable +bake_navigation_mesh → generar navmesh +setup_navigation_agent → agregar pathfinding a personajes +``` + +### AnimationTree y Máquinas de Estado +``` +create_animation_tree → configurar AnimationTree con máquina de estado o blend tree +add_state_machine_state → agregar estados (idle, walk, run, jump) +add_state_machine_transition → definir transiciones entre estados +set_tree_parameter → controlar parámetros de blend +``` + +## Orden de Flujo de Trabajo Recomendado + +Al construir un juego nuevo desde cero: + +1. **Configuración del proyecto** — `get_project_info`, `set_project_setting` (viewport, física) +2. **Mapeo de entrada** — `set_input_action` para todos los controles del jugador +3. **Escena principal** — `create_scene`, establecer como escena principal +4. **Jugador** — crear escena del jugador con sprite, colisión, script +5. **Nivel/Mundo** — construir el entorno (TileMap, meshes 3D, etc.) +6. **Lógica del juego** — scripts para enemigos, ítems, UI +7. **Audio** — configurar buses, agregar audio players +8. **Playtesting** — `play_scene`, probar con entrada simulada, corregir bugs +9. **Pulido** — animaciones, partículas, shaders, temas +10. **Exportación** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.hi.md b/addons/godot_mcp/skills.hi.md new file mode 100644 index 0000000..1af0540 --- /dev/null +++ b/addons/godot_mcp/skills.hi.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | हिन्दी + +# Godot MCP Pro — AI Assistants के लिए Skills + +> इस फ़ाइल को अपने Godot प्रोजेक्ट रूट में `.claude/skills.md` पर कॉपी करें ताकि Claude Code को Godot MCP Pro को प्रभावी ढंग से उपयोग करने का पूरा context मिल सके। + +## Godot MCP Pro क्या है? + +आपके पास 169 MCP tools उपलब्ध हैं जो सीधे Godot 4 editor से कनेक्ट होते हैं। आप scenes बना सकते हैं, scripts लिख सकते हैं, player input simulate कर सकते हैं, running games को inspect कर सकते हैं, और बहुत कुछ — सब कुछ बिना user को इस conversation से बाहर जाए। हर बदलाव Godot के UndoRedo system से होता है, इसलिए user हमेशा Ctrl+Z कर सकता है। + +## ज़रूरी Workflows + +### 1. प्रोजेक्ट को Explore करें + +बदलाव करने से पहले हमेशा प्रोजेक्ट को समझें: + +``` +get_project_info → प्रोजेक्ट का नाम, Godot version, renderer, viewport size +get_filesystem_tree → directory structure (filter: "*.tscn" या "*.gd" use करें) +get_scene_tree → currently open scene की node hierarchy +read_script → कोई भी GDScript फ़ाइल पढ़ें +get_project_settings → project configuration चेक करें +``` + +### 2. 2D Scene बनाएं + +``` +create_scene → root node type के साथ .tscn फ़ाइल बनाएं +add_node → properties के साथ child nodes जोड़ें +create_script → game logic के लिए GDScript लिखें +attach_script → node पर script attach करें +update_property → position, scale, modulate आदि सेट करें +save_scene → disk पर save करें +``` + +**उदाहरण — player बनाना:** +1. `create_scene` root_type `CharacterBody2D` के साथ, path `res://scenes/player.tscn` +2. `add_node` type `Sprite2D` texture property के साथ +3. `add_node` type `CollisionShape2D` +4. `add_resource` CollisionShape2D को shape assign करने के लिए (जैसे `RectangleShape2D`) +5. `create_script` movement logic के साथ +6. `attach_script` root node पर +7. `save_scene` + +### 3. 3D Scene बनाएं + +``` +create_scene → root_type: Node3D +add_mesh_instance → primitives (box, sphere, cylinder, plane) जोड़ें या .glb/.gltf import करें +setup_lighting → DirectionalLight3D, OmniLight3D, या SpotLight3D जोड़ें +setup_environment → sky, ambient light, fog, tonemap +setup_camera_3d → camera, optional SpringArm3D के साथ third-person के लिए +set_material_3d → PBR materials (albedo, metallic, roughness, emission) +setup_collision → physics bodies में collision shapes जोड़ें +setup_physics_body → mass, friction, gravity configure करें +``` + +### 4. Scripts लिखें और Edit करें + +``` +create_script → नई .gd फ़ाइल बनाएं (पूरा content दें) +edit_script → existing scripts modify करें + - `replacements: [{search: "old code", replace: "new code"}]` targeted edits के लिए + - `content` पूरी फ़ाइल replace करने के लिए + - `insert_at_line` + `text` code insert करने के लिए +validate_script → बिना run किए syntax errors चेक करें +read_script → edit करने से पहले current content पढ़ें +``` + +### 5. Playtest और Debug करें + +``` +play_scene → game launch करें (mode: "current", "main", या file path) +get_game_screenshot → अभी game कैसा दिख रहा है देखें +capture_frames → motion/animation observe करने के लिए multiple frames capture करें +get_game_scene_tree → runtime पर live scene tree inspect करें +get_game_node_properties → runtime values पढ़ें (position, health, state आदि) +set_game_node_property → running game में values modify करें +simulate_key → keys press करें (WASD, SPACE आदि) duration के साथ +simulate_mouse_click → viewport coordinates पर click करें +simulate_action → InputMap actions trigger करें (move_left, jump आदि) +get_editor_errors → runtime errors चेक करें +stop_scene → game बंद करें +``` + +**Playtesting loop:** +1. `play_scene` → game शुरू करें +2. `get_game_screenshot` → current state देखें +3. `simulate_key` / `simulate_action` → game के साथ interact करें +4. `capture_frames` → समय के साथ behavior observe करें +5. `get_game_node_properties` → specific values चेक करें +6. `stop_scene` → काम हो जाए तो बंद करें +7. Scripts में issues fix करें → दोहराएं + +### 6. Animations + +``` +# Scene में AnimationPlayer node होना ज़रूरी है +create_animation → length और loop mode के साथ नई animation +add_animation_track → property/transform/method tracks जोड़ें +set_animation_keyframe → specific times पर keyframes insert करें +get_animation_info → existing animations inspect करें +``` + +**उदाहरण — bouncing sprite:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect आदि +set_anchor_preset → Controls position करें (full_rect, center, bottom_wide आदि) +set_theme_color → font_color आदि बदलें +set_theme_font_size → text size adjust करें +set_theme_stylebox → backgrounds, borders, rounded corners +connect_signal → button pressed, value_changed आदि wire up करें +``` + +### 8. TileMap + +``` +tilemap_get_info → tile set sources और atlas layout चेक करें +tilemap_set_cell → individual tiles place करें +tilemap_fill_rect → rectangular regions fill करें +tilemap_get_used_cells → देखें क्या पहले से placed है +tilemap_clear → सभी cells clear करें +``` + +### 9. Audio + +``` +add_audio_bus → audio buses बनाएं (SFX, Music, UI) +set_audio_bus → volume, solo, mute adjust करें +add_audio_bus_effect → reverb, delay, compressor आदि जोड़ें +add_audio_player → AudioStreamPlayer(2D/3D) nodes जोड़ें +``` + +### 10. Project Configuration + +``` +set_project_setting → viewport size, physics settings आदि बदलें +set_input_action → input mappings define करें (move_left → KEY_A आदि) +add_autoload → autoload singletons register करें +set_physics_layers → collision layers name करें (player, enemy, world आदि) +``` + +## ज़रूरी Rules और Pitfalls + +### Property Values +Properties strings से auto-parse होती हैं। ये formats use करें: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` या `"#ff0000"` +- Bool: `"true"` / `"false"` +- Numbers: `"42"`, `"3.14"` +- Enums: Integer values use करें (जैसे पहले enum value के लिए `0`) + +### project.godot को कभी सीधे Edit न करें +Godot editor लगातार `project.godot` को overwrite करता है। Project settings बदलने के लिए हमेशा `set_project_setting` use करें। + +### GDScript Type Annotations +Untyped arrays पर `for` loops लिखते समय, explicit type annotations use करें: +```gdscript +# गलत — errors आएंगे +for item in some_untyped_array: + var x := item.value # type inference fail होता है + +# सही +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Script Changes के लिए Reload ज़रूरी +Scripts create या significantly modify करने के बाद, `reload_project` use करें ताकि Godot changes को pick up करे। `create_script` के बाद ये खासकर ज़रूरी है। + +### simulate_key Tips +- Precise movement के लिए **छोटी duration** (0.3–0.5 seconds) use करें +- लंबी duration (1+ second) से overshooting होती है +- Gameplay testing के लिए, जब InputMap actions defined हों तो `simulate_key` की जगह `simulate_action` prefer करें + +### simulate_mouse_click +- Default `auto_release: true` press और release दोनों भेजता है — UI buttons के लिए ज़रूरी है +- UI buttons release पर fire होते हैं, इसलिए दोनों events चाहिए + +### execute_game_script की Limitations +- Nested functions (`func` के अंदर `func`) नहीं चलतीं — compile error आता है +- Dynamic access के लिए `.property` की जगह `.get("property")` use करें +- Runtime errors debugger को pause करती हैं (auto-continue होता है, लेकिन बचना बेहतर) + +### Collision और Pickup Areas +- Collectible items के लिए Area3D/Area2D radius >= 1.5 रखें +- छोटे radius को simulated input से trigger करना लगभग impossible है + +### बार-बार Save करें +बड़े बदलावों के बाद `save_scene` call करें। Unsaved changes editor reload होने पर खो सकते हैं। + +## Analysis और Debugging Tools + +कुछ गलत होने पर, इन tools से investigate करें: + +``` +get_editor_errors → script errors और runtime exceptions चेक करें +get_output_log → print() output और warnings पढ़ें +analyze_scene_complexity → performance bottlenecks खोजें +analyze_signal_flow → signal connections visualize करें +detect_circular_dependencies → circular script/scene references खोजें +find_unused_resources → unused files clean up करें +get_performance_monitors → FPS, memory, draw calls, physics stats +``` + +## Testing और QA + +``` +run_test_scenario → automated test sequences define और run करें +assert_node_state → verify करें कि node properties expected values से match करती हैं +assert_screen_text → verify करें कि text screen पर display हो रहा है +compare_screenshots → visual regression testing (file paths use करें, base64 नहीं) +run_stress_test → performance test के लिए बहुत सारे nodes spawn करें +``` + +## Advanced Patterns + +### Cross-Scene Operations +``` +cross_scene_set_property → उन scenes के nodes modify करें जो अभी open नहीं हैं +find_node_references → किसी pattern को reference करने वाली सभी files खोजें +batch_set_property → किसी type के सभी nodes पर property set करें +``` + +### Shader Workflow +``` +create_shader → GLSL-like shader code लिखें +assign_shader_material → node पर apply करें +set_shader_param → runtime पर uniforms adjust करें +get_shader_params → current values inspect करें +``` + +### Navigation (3D) +``` +setup_navigation_region → walkable area define करें +bake_navigation_mesh → navmesh generate करें +setup_navigation_agent → characters में pathfinding जोड़ें +``` + +### AnimationTree और State Machines +``` +create_animation_tree → state machine या blend tree के साथ AnimationTree set up करें +add_state_machine_state → states जोड़ें (idle, walk, run, jump) +add_state_machine_transition → states के बीच transitions define करें +set_tree_parameter → blend parameters control करें +``` + +## Recommended Workflow Order + +नया game scratch से बनाते समय: + +1. **Project setup** — `get_project_info`, `set_project_setting` (viewport, physics) +2. **Input mapping** — `set_input_action` सभी player controls के लिए +3. **Main scene** — `create_scene`, main scene के रूप में set करें +4. **Player** — sprite, collision, script के साथ player scene बनाएं +5. **Level/World** — environment build करें (TileMap, 3D meshes आदि) +6. **Game logic** — enemies, items, UI के लिए scripts +7. **Audio** — buses set up करें, audio players जोड़ें +8. **Playtest** — `play_scene`, simulated input से test करें, bugs fix करें +9. **Polish** — animations, particles, shaders, themes +10. **Export** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.ja.md b/addons/godot_mcp/skills.ja.md new file mode 100644 index 0000000..b3ac9b4 --- /dev/null +++ b/addons/godot_mcp/skills.ja.md @@ -0,0 +1,276 @@ +> **Language:** [English](skills.md) | 日本語 | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — AIアシスタント向けスキル + +> このファイルをGodotプロジェクトルートの `.claude/skills.md` にコピーすると、Claude CodeがGodot MCP Proを効果的に活用するためのコンテキストを得られます。 + +## Godot MCP Proとは? + +Godot 4エディタに直接接続する169のMCPツールを利用できます。シーンの作成、スクリプトの記述、プレイヤー入力のシミュレーション、実行中のゲームの検査など、ユーザーがこの会話から離れることなく、すべての操作が可能です。すべての変更はGodotのUndoRedoシステムを通じて行われるため、いつでもCtrl+Zで元に戻せます。 + +## 基本ワークフロー + +### 1. プロジェクトの調査 + +変更を加える前に、まずプロジェクトの全体像を把握しましょう: + +``` +get_project_info → プロジェクト名、Godotバージョン、レンダラー、ビューポートサイズ +get_filesystem_tree → ディレクトリ構造(filter: "*.tscn" や "*.gd" が使えます) +get_scene_tree → 現在開いているシーンのノード階層 +read_script → 任意のGDScriptファイルを読む +get_project_settings → プロジェクト設定の確認 +``` + +### 2. 2Dシーンの構築 + +``` +create_scene → .tscnファイルをルートノードタイプ指定で作成 +add_node → プロパティ付きの子ノードを追加 +create_script → ゲームロジック用のGDScriptを作成 +attach_script → ノードにスクリプトをアタッチ +update_property → position、scale、modulateなどを設定 +save_scene → ディスクに保存 +``` + +**例 — プレイヤーの作成:** +1. `create_scene` でroot_type `CharacterBody2D`、path `res://scenes/player.tscn` を指定 +2. `add_node` でtextureプロパティ付きの `Sprite2D` を追加 +3. `add_node` で `CollisionShape2D` を追加 +4. `add_resource` でCollisionShape2Dにシェイプ(例:`RectangleShape2D`)を割り当て +5. `create_script` で移動ロジックを記述 +6. `attach_script` でルートノードにアタッチ +7. `save_scene` + +### 3. 3Dシーンの構築 + +``` +create_scene → root_type: Node3D +add_mesh_instance → プリミティブ(box、sphere、cylinder、plane)の追加、または.glb/.gltfのインポート +setup_lighting → DirectionalLight3D、OmniLight3D、SpotLight3Dの追加 +setup_environment → スカイ、アンビエントライト、フォグ、トーンマップ +setup_camera_3d → カメラ(オプションでSpringArm3Dによる三人称視点) +set_material_3d → PBRマテリアル(albedo、metallic、roughness、emission) +setup_collision → 物理ボディにコリジョンシェイプを追加 +setup_physics_body → 質量、摩擦、重力の設定 +``` + +### 4. スクリプトの作成と編集 + +``` +create_script → 新規.gdファイルを作成(完全な内容を提供) +edit_script → 既存スクリプトを編集 + - `replacements: [{search: "old code", replace: "new code"}]` で部分的な編集 + - `content` でファイル全体を置換 + - `insert_at_line` + `text` でコードを挿入 +validate_script → 実行せずに構文エラーをチェック +read_script → 編集前に現在の内容を確認 +``` + +### 5. プレイテストとデバッグ + +``` +play_scene → ゲームを起動(mode: "current"、"main"、またはファイルパス) +get_game_screenshot → ゲームの現在の見た目を確認 +capture_frames → 複数フレームをキャプチャして動きやアニメーションを観察 +get_game_scene_tree → 実行時のシーンツリーを検査 +get_game_node_properties → ランタイムの値を読み取り(position、health、stateなど) +set_game_node_property → 実行中のゲームの値を変更 +simulate_key → キー入力(WASD、SPACEなど)をduration指定で実行 +simulate_mouse_click → ビューポート座標でクリック +simulate_action → InputMapアクション(move_left、jumpなど)をトリガー +get_editor_errors → ランタイムエラーの確認 +stop_scene → ゲームを停止 +``` + +**プレイテストループ:** +1. `play_scene` → ゲームを開始 +2. `get_game_screenshot` → 現在の状態を確認 +3. `simulate_key` / `simulate_action` → ゲームを操作 +4. `capture_frames` → 時間経過での挙動を観察 +5. `get_game_node_properties` → 特定の値を確認 +6. `stop_scene` → 完了したら停止 +7. スクリプトの問題を修正 → 繰り返し + +### 6. アニメーション + +``` +# シーンにAnimationPlayerノードが存在することを確認 +create_animation → 長さとループモード付きの新規アニメーション +add_animation_track → property/transform/methodトラックの追加 +set_animation_keyframe → 特定時間にキーフレームを挿入 +get_animation_info → 既存アニメーションの情報を取得 +``` + +**例 — バウンドするスプライト:** +1. `create_animation` でname `bounce`、length `1.0`、loop_mode `1`(リニアループ) +2. `add_animation_track` でtrack_path `Sprite2D:position`、track_type `value` +3. `set_animation_keyframe` でtime `0.0`、value `Vector2(0, 0)` +4. `set_animation_keyframe` でtime `0.5`、value `Vector2(0, -50)` +5. `set_animation_keyframe` でtime `1.0`、value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control、Label、Button、TextureRectなど +set_anchor_preset → Controlの配置(full_rect、center、bottom_wideなど) +set_theme_color → font_colorなどの変更 +set_theme_font_size → テキストサイズの調整 +set_theme_stylebox → 背景、ボーダー、角丸 +connect_signal → buttonのpressed、value_changedなどを接続 +``` + +### 8. TileMap + +``` +tilemap_get_info → タイルセットのソースとアトラスレイアウトを確認 +tilemap_set_cell → 個別タイルの配置 +tilemap_fill_rect → 矩形領域を塗りつぶし +tilemap_get_used_cells → 配置済みセルの確認 +tilemap_clear → 全セルをクリア +``` + +### 9. オーディオ + +``` +add_audio_bus → オーディオバスの作成(SFX、Music、UI) +set_audio_bus → ボリューム、ソロ、ミュートの調整 +add_audio_bus_effect → リバーブ、ディレイ、コンプレッサーなどの追加 +add_audio_player → AudioStreamPlayer(2D/3D)ノードの追加 +``` + +### 10. プロジェクト設定 + +``` +set_project_setting → ビューポートサイズ、物理設定などの変更 +set_input_action → 入力マッピングの定義(move_left → KEY_Aなど) +add_autoload → Autoloadシングルトンの登録 +set_physics_layers → コリジョンレイヤーの命名(player、enemy、worldなど) +``` + +## 重要なルールと注意点 + +### プロパティ値 + +プロパティは文字列から自動パースされます。以下のフォーマットを使用してください: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` または `"#ff0000"` +- Bool: `"true"` / `"false"` +- 数値: `"42"`、`"3.14"` +- Enum: 整数値を使用(例:最初のenum値は `0`) + +### project.godotを直接編集しないこと + +Godotエディタは `project.godot` を常に上書きします。プロジェクト設定の変更には必ず `set_project_setting` を使用してください。 + +### GDScriptの型アノテーション + +型なし配列に対する `for` ループでは、明示的な型アノテーションを使用してください: +```gdscript +# NG — エラーの原因になる +for item in some_untyped_array: + var x := item.value # 型推論が失敗 + +# OK +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### スクリプト変更にはリロードが必要 + +スクリプトの作成や大幅な変更の後は、`reload_project` を使用してGodotに変更を反映させましょう。特に `create_script` の後は重要です。 + +### simulate_keyのコツ +- 精密な移動には**短いduration**(0.3〜0.5秒)を使用 +- 長いduration(1秒以上)はオーバーシュートの原因に +- ゲームプレイのテストでは、InputMapアクションが定義されている場合は `simulate_key` より `simulate_action` を推奨 + +### simulate_mouse_click +- デフォルトの `auto_release: true` はpressとreleaseの両方を送信 — UIボタンに必須 +- UIボタンはreleaseで発火するため、両方のイベントが必要 + +### execute_game_scriptの制限事項 +- ネストされた関数(`func` 内の `func`)は不可 — コンパイルエラーになる +- 動的アクセスには `.property` ではなく `.get("property")` を使用 +- ランタイムエラーはデバッガーを一時停止させる(自動再開されるが、できれば避けること) + +### コリジョンとピックアップエリア +- 収集アイテムにはArea3D/Area2Dで半径1.5以上を使用 +- 小さい半径ではシミュレーション入力でのトリガーがほぼ不可能 + +### こまめに保存する + +大きな変更を行った後は `save_scene` を呼んでください。保存していない変更はエディタのリロード時に失われる可能性があります。 + +## 分析とデバッグツール + +問題が発生した場合、以下のツールで調査できます: + +``` +get_editor_errors → スクリプトエラーとランタイム例外を確認 +get_output_log → print()出力と警告を読む +analyze_scene_complexity → パフォーマンスのボトルネックを特定 +analyze_signal_flow → シグナル接続を可視化 +detect_circular_dependencies → 循環参照するスクリプト/シーンを検出 +find_unused_resources → 未使用ファイルのクリーンアップ +get_performance_monitors → FPS、メモリ、ドローコール、物理統計 +``` + +## テストとQA + +``` +run_test_scenario → 自動テストシーケンスの定義と実行 +assert_node_state → ノードプロパティが期待値と一致するか検証 +assert_screen_text → 画面にテキストが表示されているか検証 +compare_screenshots → ビジュアル回帰テスト(base64ではなくファイルパスを使用) +run_stress_test → 多数のノードを生成してパフォーマンスをテスト +``` + +## 高度なパターン + +### クロスシーン操作 +``` +cross_scene_set_property → 現在開いていないシーンのノードを変更 +find_node_references → パターンを参照しているすべてのファイルを検索 +batch_set_property → 特定タイプの全ノードにプロパティを設定 +``` + +### シェーダーワークフロー +``` +create_shader → GLSL風のシェーダーコードを記述 +assign_shader_material → ノードに適用 +set_shader_param → 実行時にuniformを調整 +get_shader_params → 現在の値を取得 +``` + +### ナビゲーション(3D) +``` +setup_navigation_region → 歩行可能エリアの定義 +bake_navigation_mesh → ナビメッシュの生成 +setup_navigation_agent → キャラクターにパスファインディングを追加 +``` + +### AnimationTreeとステートマシン +``` +create_animation_tree → ステートマシンまたはブレンドツリーでAnimationTreeをセットアップ +add_state_machine_state → ステートを追加(idle、walk、run、jump) +add_state_machine_transition → ステート間のトランジションを定義 +set_tree_parameter → ブレンドパラメータを制御 +``` + +## 推奨ワークフロー順序 + +ゲームをゼロから構築する場合の推奨順序: + +1. **プロジェクトセットアップ** — `get_project_info`、`set_project_setting`(ビューポート、物理) +2. **入力マッピング** — `set_input_action` で全プレイヤー操作を定義 +3. **メインシーン** — `create_scene` でメインシーンとして設定 +4. **プレイヤー** — スプライト、コリジョン、スクリプト付きのプレイヤーシーンを作成 +5. **レベル/ワールド** — 環境を構築(TileMap、3Dメッシュなど) +6. **ゲームロジック** — 敵、アイテム、UIのスクリプト +7. **オーディオ** — バスのセットアップ、オーディオプレイヤーの追加 +8. **プレイテスト** — `play_scene` でシミュレーション入力によるテスト、バグ修正 +9. **ポリッシュ** — アニメーション、パーティクル、シェーダー、テーマ +10. **エクスポート** — `list_export_presets`、`export_project` diff --git a/addons/godot_mcp/skills.md b/addons/godot_mcp/skills.md new file mode 100644 index 0000000..1b32f04 --- /dev/null +++ b/addons/godot_mcp/skills.md @@ -0,0 +1,295 @@ +> **Language:** English | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills for AI Assistants + +> Copy this file to `.claude/skills.md` in your Godot project root to give Claude Code full context on how to use Godot MCP Pro effectively. + +## What is Godot MCP Pro? + +You have access to 169 MCP tools that connect directly to the Godot 4 editor. You can create scenes, write scripts, simulate player input, inspect running games, and more — all without the user leaving this conversation. Every change goes through Godot's UndoRedo system, so the user can always Ctrl+Z. + +## Essential Workflows + +### 1. Explore a Project + +Always start by understanding the project before making changes: + +``` +get_project_info → project name, Godot version, renderer, viewport size +get_filesystem_tree → directory structure (use filter: "*.tscn" or "*.gd") +get_scene_tree → node hierarchy of the currently open scene +read_script → read any GDScript file +get_project_settings → check project configuration +``` + +### 2. Build a 2D Scene + +``` +create_scene → create .tscn file with root node type +add_node → add child nodes with properties +create_script → write GDScript for game logic +attach_script → attach script to a node +update_property → set position, scale, modulate, etc. +save_scene → save to disk +``` + +**Example — creating a player:** +1. `create_scene` with root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` type `Sprite2D` with texture property +3. `add_node` type `CollisionShape2D` +4. `add_resource` to assign a shape (e.g., `RectangleShape2D`) to the CollisionShape2D +5. `create_script` with movement logic +6. `attach_script` to the root node +7. `save_scene` + +### 3. Build a 3D Scene + +``` +create_scene → root_type: Node3D +add_mesh_instance → add primitives (box, sphere, cylinder, plane) or import .glb/.gltf +setup_lighting → add DirectionalLight3D, OmniLight3D, or SpotLight3D +setup_environment → sky, ambient light, fog, tonemap +setup_camera_3d → camera with optional SpringArm3D for third-person +set_material_3d → PBR materials (albedo, metallic, roughness, emission) +setup_collision → add collision shapes to physics bodies +setup_physics_body → configure mass, friction, gravity +``` + +### 4. Write & Edit Scripts + +``` +create_script → create new .gd file (provide full content) +edit_script → modify existing scripts + - Use `replacements: [{search: "old code", replace: "new code"}]` for targeted edits + - Use `content` for full file replacement + - Use `insert_at_line` + `text` for inserting code +validate_script → check for syntax errors without running +read_script → read current content before editing +``` + +### 5. Playtest & Debug + +``` +play_scene → launch the game (mode: "current", "main", or file path) +get_game_screenshot → see what the game looks like right now +capture_frames → capture multiple frames to observe motion/animation +get_game_scene_tree → inspect the live scene tree at runtime +get_game_node_properties → read runtime values (position, health, state, etc.) +set_game_node_property → modify values in the running game +simulate_key → press keys (WASD, SPACE, etc.) with duration +simulate_mouse_click → click at viewport coordinates +simulate_action → trigger InputMap actions (move_left, jump, etc.) +get_editor_errors → check for runtime errors +stop_scene → stop the game +``` + +**Playtesting loop:** +1. `play_scene` → start the game +2. `get_game_screenshot` → see current state +3. `simulate_key` / `simulate_action` → interact with the game +4. `capture_frames` → observe behavior over time +5. `get_game_node_properties` → check specific values +6. `stop_scene` → stop when done +7. Fix issues in scripts → repeat + +### 6. Animations + +``` +# Ensure an AnimationPlayer node exists in the scene +create_animation → new animation with length and loop mode +add_animation_track → add property/transform/method tracks +set_animation_keyframe → insert keyframes at specific times +get_animation_info → inspect existing animations +``` + +**Example — bouncing sprite:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (linear loop) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → position Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → change font_color, etc. +set_theme_font_size → adjust text size +set_theme_stylebox → backgrounds, borders, rounded corners +connect_signal → wire up button pressed, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → check tile set sources and atlas layout +tilemap_set_cell → place individual tiles +tilemap_fill_rect → fill rectangular regions +tilemap_get_used_cells → see what's already placed +tilemap_clear → clear all cells +``` + +### 9. Audio + +``` +add_audio_bus → create audio buses (SFX, Music, UI) +set_audio_bus → adjust volume, solo, mute +add_audio_bus_effect → add reverb, delay, compressor, etc. +add_audio_player → add AudioStreamPlayer(2D/3D) nodes +``` + +### 10. Project Configuration + +``` +set_project_setting → change viewport size, physics settings, etc. +set_input_action → define input mappings (move_left → KEY_A, etc.) +add_autoload → register autoload singletons +set_physics_layers → name collision layers (player, enemy, world, etc.) +``` + +## Important Rules & Pitfalls + +### Prefer Inspector Properties Over Code +When changing visual properties (colors, sizes, theme overrides, transforms, etc.), use `update_property` to set them directly on the node. This keeps values visible in the Godot inspector and easy to tweak by hand. Only write GDScript when the property isn't available in the inspector or needs to be dynamic at runtime. + +### Property Values +Properties are auto-parsed from strings. Use these formats: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` or `"#ff0000"` +- Bool: `"true"` / `"false"` +- Numbers: `"42"`, `"3.14"` +- Enums: Use integer values (e.g., `0` for the first enum value) + +### Never Edit project.godot Directly +Godot editor constantly overwrites `project.godot`. Always use `set_project_setting` to change project settings. + +### GDScript Type Annotations +When writing GDScript with `for` loops over untyped arrays, use explicit type annotations: +```gdscript +# BAD — will cause errors +for item in some_untyped_array: + var x := item.value # type inference fails + +# GOOD +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Script Changes Need Reload +After creating or significantly modifying scripts, use `reload_project` to ensure Godot picks up the changes. This is especially important after `create_script`. + +### simulate_key Tips +- Use **short durations** (0.3–0.5 seconds) for precise movement +- Long durations (1+ seconds) cause overshooting +- For gameplay testing, prefer `simulate_action` over `simulate_key` when InputMap actions are defined + +### simulate_mouse_click +- Default `auto_release: true` sends both press and release — required for UI buttons +- UI buttons fire on release, so both events are needed + +### execute_game_script Limitations +- No nested functions (`func` inside `func`) — causes compile error +- Use `.get("property")` instead of `.property` for dynamic access +- Runtime errors will pause the debugger (auto-continued, but avoid if possible) + +### Collision & Pickup Areas +- For collectible items, use Area3D/Area2D with radius ≥ 1.5 +- Smaller radii are nearly impossible to trigger with simulated input + +### Save Frequently +Call `save_scene` after making significant changes. Unsaved changes can be lost if the editor reloads. + +## Analysis & Debugging Tools + +When something goes wrong, use these tools to investigate: + +``` +get_editor_errors → check for script errors and runtime exceptions +get_output_log → read print() output and warnings +analyze_scene_complexity → find performance bottlenecks +analyze_signal_flow → visualize signal connections +detect_circular_dependencies → find circular script/scene references +find_unused_resources → clean up unused files +get_performance_monitors → FPS, memory, draw calls, physics stats +``` + +## Testing & QA + +``` +run_test_scenario → define and run automated test sequences +assert_node_state → verify node properties match expected values +assert_screen_text → verify text is displayed on screen +compare_screenshots → visual regression testing (use file paths, not base64) +run_stress_test → spawn many nodes to test performance +``` + +## Advanced Patterns + +### Cross-Scene Operations +``` +cross_scene_set_property → modify nodes in scenes that aren't currently open +find_node_references → find all files referencing a pattern +batch_set_property → set a property on all nodes of a type +``` + +### Shader Workflow +``` +create_shader → write GLSL-like shader code +assign_shader_material → apply to a node +set_shader_param → adjust uniforms at runtime +get_shader_params → inspect current values +``` + +### Navigation (3D) +``` +setup_navigation_region → define walkable area +bake_navigation_mesh → generate navmesh +setup_navigation_agent → add pathfinding to characters +``` + +### AnimationTree & State Machines +``` +create_animation_tree → set up AnimationTree with state machine or blend tree +add_state_machine_state → add states (idle, walk, run, jump) +add_state_machine_transition → define transitions between states +set_tree_parameter → control blend parameters +``` + +### Code-to-Inspector Migration + +Move hardcoded visual properties from GDScript to the inspector for easier tweaking: + +``` +read_script → find hardcoded property assignments +get_node_properties → check current inspector values +update_property → set values as node properties +edit_script → remove hardcoded lines from script +save_scene → persist inspector changes +validate_script → verify script still compiles +``` + +Example — a script sets `modulate = Color(1, 0, 0, 1)` in `_ready()`: +1. `read_script` to find the line +2. `update_property` with `node_path`, `property: "modulate"`, `value: "Color(1, 0, 0, 1)"` +3. `edit_script` to remove the `modulate = ...` line from `_ready()` +4. `save_scene` + `validate_script` + +This applies to: colors, positions, sizes, theme overrides, material properties, visibility, margins, anchors, and any property that doesn't need to change at runtime. + +## Recommended Workflow Order + +When building a new game from scratch: + +1. **Project setup** — `get_project_info`, `set_project_setting` (viewport, physics) +2. **Input mapping** — `set_input_action` for all player controls +3. **Main scene** — `create_scene`, set as main scene +4. **Player** — create player scene with sprite, collision, script +5. **Level/World** — build environment (TileMap, 3D meshes, etc.) +6. **Game logic** — scripts for enemies, items, UI +7. **Audio** — set up buses, add audio players +8. **Playtest** — `play_scene`, test with simulated input, fix bugs +9. **Polish** — animations, particles, shaders, themes +10. **Export** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.pt-br.md b/addons/godot_mcp/skills.pt-br.md new file mode 100644 index 0000000..ec8319d --- /dev/null +++ b/addons/godot_mcp/skills.pt-br.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | Português (BR) | [Español](skills.es.md) | [Русский](skills.ru.md) | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Skills para Assistentes de IA + +> Copie este arquivo para `.claude/skills.md` na raiz do seu projeto Godot para dar ao Claude Code o contexto completo de como usar o Godot MCP Pro de forma eficiente. + +## O que é o Godot MCP Pro? + +Você tem acesso a 169 ferramentas MCP que se conectam diretamente ao editor Godot 4. Você pode criar cenas, escrever scripts, simular entrada do jogador, inspecionar jogos em execução e muito mais — tudo sem que o usuário precise sair desta conversa. Todas as alterações passam pelo sistema UndoRedo do Godot, então o usuário pode sempre usar Ctrl+Z. + +## Fluxos de Trabalho Essenciais + +### 1. Explorar um Projeto + +Sempre comece entendendo o projeto antes de fazer alterações: + +``` +get_project_info → nome do projeto, versão do Godot, renderizador, tamanho do viewport +get_filesystem_tree → estrutura de diretórios (use filter: "*.tscn" ou "*.gd") +get_scene_tree → hierarquia de nós da cena atualmente aberta +read_script → ler qualquer arquivo GDScript +get_project_settings → verificar configuração do projeto +``` + +### 2. Construir uma Cena 2D + +``` +create_scene → criar arquivo .tscn com tipo de nó raiz +add_node → adicionar nós filhos com propriedades +create_script → escrever GDScript para lógica do jogo +attach_script → anexar script a um nó +update_property → definir position, scale, modulate, etc. +save_scene → salvar no disco +``` + +**Exemplo — criando um jogador:** +1. `create_scene` com root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` tipo `Sprite2D` com propriedade texture +3. `add_node` tipo `CollisionShape2D` +4. `add_resource` para atribuir uma shape (ex: `RectangleShape2D`) ao CollisionShape2D +5. `create_script` com lógica de movimento +6. `attach_script` ao nó raiz +7. `save_scene` + +### 3. Construir uma Cena 3D + +``` +create_scene → root_type: Node3D +add_mesh_instance → adicionar primitivas (box, sphere, cylinder, plane) ou importar .glb/.gltf +setup_lighting → adicionar DirectionalLight3D, OmniLight3D ou SpotLight3D +setup_environment → céu, luz ambiente, neblina, tonemap +setup_camera_3d → câmera com SpringArm3D opcional para terceira pessoa +set_material_3d → materiais PBR (albedo, metallic, roughness, emission) +setup_collision → adicionar shapes de colisão a corpos físicos +setup_physics_body → configurar massa, atrito, gravidade +``` + +### 4. Escrever e Editar Scripts + +``` +create_script → criar novo arquivo .gd (forneça o conteúdo completo) +edit_script → modificar scripts existentes + - Use `replacements: [{search: "old code", replace: "new code"}]` para edições pontuais + - Use `content` para substituição completa do arquivo + - Use `insert_at_line` + `text` para inserir código +validate_script → verificar erros de sintaxe sem executar +read_script → ler conteúdo atual antes de editar +``` + +### 5. Testar e Depurar + +``` +play_scene → iniciar o jogo (mode: "current", "main" ou caminho do arquivo) +get_game_screenshot → ver como o jogo está neste momento +capture_frames → capturar múltiplos frames para observar movimento/animação +get_game_scene_tree → inspecionar a árvore de cena em tempo de execução +get_game_node_properties → ler valores em runtime (position, health, state, etc.) +set_game_node_property → modificar valores no jogo em execução +simulate_key → pressionar teclas (WASD, SPACE, etc.) com duração +simulate_mouse_click → clicar em coordenadas do viewport +simulate_action → disparar ações do InputMap (move_left, jump, etc.) +get_editor_errors → verificar erros de execução +stop_scene → parar o jogo +``` + +**Loop de playtesting:** +1. `play_scene` → iniciar o jogo +2. `get_game_screenshot` → ver estado atual +3. `simulate_key` / `simulate_action` → interagir com o jogo +4. `capture_frames` → observar comportamento ao longo do tempo +5. `get_game_node_properties` → verificar valores específicos +6. `stop_scene` → parar quando terminar +7. Corrigir problemas nos scripts → repetir + +### 6. Animações + +``` +# Certifique-se de que existe um nó AnimationPlayer na cena +create_animation → nova animação com duração e modo de loop +add_animation_track → adicionar tracks de property/transform/method +set_animation_keyframe → inserir keyframes em tempos específicos +get_animation_info → inspecionar animações existentes +``` + +**Exemplo — sprite quicando:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (loop linear) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect, etc. +set_anchor_preset → posicionar Controls (full_rect, center, bottom_wide, etc.) +set_theme_color → alterar font_color, etc. +set_theme_font_size → ajustar tamanho do texto +set_theme_stylebox → fundos, bordas, cantos arredondados +connect_signal → conectar pressed do button, value_changed, etc. +``` + +### 8. TileMap + +``` +tilemap_get_info → verificar fontes do tile set e layout do atlas +tilemap_set_cell → colocar tiles individuais +tilemap_fill_rect → preencher regiões retangulares +tilemap_get_used_cells → ver o que já está colocado +tilemap_clear → limpar todas as células +``` + +### 9. Áudio + +``` +add_audio_bus → criar buses de áudio (SFX, Music, UI) +set_audio_bus → ajustar volume, solo, mute +add_audio_bus_effect → adicionar reverb, delay, compressor, etc. +add_audio_player → adicionar nós AudioStreamPlayer(2D/3D) +``` + +### 10. Configuração do Projeto + +``` +set_project_setting → alterar tamanho do viewport, configurações de física, etc. +set_input_action → definir mapeamentos de entrada (move_left → KEY_A, etc.) +add_autoload → registrar singletons autoload +set_physics_layers → nomear camadas de colisão (player, enemy, world, etc.) +``` + +## Regras Importantes e Armadilhas + +### Valores de Propriedade +Propriedades são parseadas automaticamente de strings. Use estes formatos: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` ou `"#ff0000"` +- Bool: `"true"` / `"false"` +- Números: `"42"`, `"3.14"` +- Enums: Use valores inteiros (ex: `0` para o primeiro valor do enum) + +### Nunca Edite project.godot Diretamente +O editor Godot sobrescreve `project.godot` constantemente. Sempre use `set_project_setting` para alterar configurações do projeto. + +### Anotações de Tipo em GDScript +Ao escrever GDScript com loops `for` sobre arrays sem tipo, use anotações de tipo explícitas: +```gdscript +# RUIM — vai causar erros +for item in some_untyped_array: + var x := item.value # inferência de tipo falha + +# BOM +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Alterações em Scripts Precisam de Reload +Após criar ou modificar scripts significativamente, use `reload_project` para garantir que o Godot reconheça as alterações. Isso é especialmente importante após `create_script`. + +### Dicas para simulate_key +- Use **durações curtas** (0.3–0.5 segundos) para movimentos precisos +- Durações longas (1+ segundo) causam overshooting +- Para testes de gameplay, prefira `simulate_action` em vez de `simulate_key` quando ações do InputMap estiverem definidas + +### simulate_mouse_click +- O padrão `auto_release: true` envia press e release — necessário para botões de UI +- Botões de UI disparam no release, então ambos os eventos são necessários + +### Limitações do execute_game_script +- Sem funções aninhadas (`func` dentro de `func`) — causa erro de compilação +- Use `.get("property")` em vez de `.property` para acesso dinâmico +- Erros de runtime pausam o debugger (continuado automaticamente, mas evite se possível) + +### Colisão e Áreas de Coleta +- Para itens coletáveis, use Area3D/Area2D com raio >= 1.5 +- Raios menores são quase impossíveis de acionar com entrada simulada + +### Salve com Frequência +Chame `save_scene` após fazer alterações significativas. Alterações não salvas podem ser perdidas se o editor recarregar. + +## Ferramentas de Análise e Depuração + +Quando algo der errado, use estas ferramentas para investigar: + +``` +get_editor_errors → verificar erros de script e exceções de runtime +get_output_log → ler saída de print() e avisos +analyze_scene_complexity → encontrar gargalos de performance +analyze_signal_flow → visualizar conexões de signals +detect_circular_dependencies → encontrar referências circulares de script/cena +find_unused_resources → limpar arquivos não utilizados +get_performance_monitors → FPS, memória, draw calls, estatísticas de física +``` + +## Testes e QA + +``` +run_test_scenario → definir e executar sequências de teste automatizadas +assert_node_state → verificar se propriedades de nós correspondem aos valores esperados +assert_screen_text → verificar se texto está exibido na tela +compare_screenshots → teste de regressão visual (use caminhos de arquivo, não base64) +run_stress_test → gerar muitos nós para testar performance +``` + +## Padrões Avançados + +### Operações entre Cenas +``` +cross_scene_set_property → modificar nós em cenas que não estão abertas atualmente +find_node_references → encontrar todos os arquivos que referenciam um padrão +batch_set_property → definir uma propriedade em todos os nós de um tipo +``` + +### Fluxo de Trabalho com Shaders +``` +create_shader → escrever código shader estilo GLSL +assign_shader_material → aplicar a um nó +set_shader_param → ajustar uniforms em runtime +get_shader_params → inspecionar valores atuais +``` + +### Navegação (3D) +``` +setup_navigation_region → definir área caminhável +bake_navigation_mesh → gerar navmesh +setup_navigation_agent → adicionar pathfinding a personagens +``` + +### AnimationTree e Máquinas de Estado +``` +create_animation_tree → configurar AnimationTree com máquina de estado ou blend tree +add_state_machine_state → adicionar estados (idle, walk, run, jump) +add_state_machine_transition → definir transições entre estados +set_tree_parameter → controlar parâmetros de blend +``` + +## Ordem de Fluxo de Trabalho Recomendada + +Ao construir um novo jogo do zero: + +1. **Configuração do projeto** — `get_project_info`, `set_project_setting` (viewport, física) +2. **Mapeamento de entrada** — `set_input_action` para todos os controles do jogador +3. **Cena principal** — `create_scene`, definir como cena principal +4. **Jogador** — criar cena do jogador com sprite, colisão, script +5. **Nível/Mundo** — construir o ambiente (TileMap, meshes 3D, etc.) +6. **Lógica do jogo** — scripts para inimigos, itens, UI +7. **Áudio** — configurar buses, adicionar audio players +8. **Playtesting** — `play_scene`, testar com entrada simulada, corrigir bugs +9. **Polimento** — animações, partículas, shaders, temas +10. **Exportação** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.ru.md b/addons/godot_mcp/skills.ru.md new file mode 100644 index 0000000..a33cc84 --- /dev/null +++ b/addons/godot_mcp/skills.ru.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | Русский | [简体中文](skills.zh.md) | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — Навыки для ИИ-ассистентов + +> Скопируйте этот файл в `.claude/skills.md` в корне вашего проекта Godot, чтобы дать Claude Code полный контекст по эффективному использованию Godot MCP Pro. + +## Что такое Godot MCP Pro? + +Вам доступны 169 MCP-инструмента, которые напрямую подключаются к редактору Godot 4. Вы можете создавать сцены, писать скрипты, симулировать ввод игрока, инспектировать запущенные игры и многое другое — всё это без выхода пользователя из данного разговора. Все изменения проходят через систему UndoRedo Godot, поэтому пользователь всегда может нажать Ctrl+Z. + +## Основные рабочие процессы + +### 1. Изучение проекта + +Всегда начинайте с понимания проекта перед внесением изменений: + +``` +get_project_info → название проекта, версия Godot, рендерер, размер viewport +get_filesystem_tree → структура директорий (используйте filter: "*.tscn" или "*.gd") +get_scene_tree → иерархия нод текущей открытой сцены +read_script → прочитать любой файл GDScript +get_project_settings → проверить конфигурацию проекта +``` + +### 2. Создание 2D-сцены + +``` +create_scene → создать файл .tscn с указанием типа корневой ноды +add_node → добавить дочерние ноды со свойствами +create_script → написать GDScript для игровой логики +attach_script → прикрепить скрипт к ноде +update_property → установить position, scale, modulate и т.д. +save_scene → сохранить на диск +``` + +**Пример — создание игрока:** +1. `create_scene` с root_type `CharacterBody2D`, path `res://scenes/player.tscn` +2. `add_node` типа `Sprite2D` со свойством texture +3. `add_node` типа `CollisionShape2D` +4. `add_resource` для назначения формы (например, `RectangleShape2D`) на CollisionShape2D +5. `create_script` с логикой движения +6. `attach_script` на корневую ноду +7. `save_scene` + +### 3. Создание 3D-сцены + +``` +create_scene → root_type: Node3D +add_mesh_instance → добавить примитивы (box, sphere, cylinder, plane) или импортировать .glb/.gltf +setup_lighting → добавить DirectionalLight3D, OmniLight3D или SpotLight3D +setup_environment → небо, окружающий свет, туман, tonemap +setup_camera_3d → камера с опциональным SpringArm3D для вида от третьего лица +set_material_3d → PBR-материалы (albedo, metallic, roughness, emission) +setup_collision → добавить формы столкновений к физическим телам +setup_physics_body → настроить массу, трение, гравитацию +``` + +### 4. Написание и редактирование скриптов + +``` +create_script → создать новый файл .gd (укажите полное содержимое) +edit_script → изменить существующие скрипты + - Используйте `replacements: [{search: "old code", replace: "new code"}]` для точечных правок + - Используйте `content` для полной замены файла + - Используйте `insert_at_line` + `text` для вставки кода +validate_script → проверить синтаксические ошибки без запуска +read_script → прочитать текущее содержимое перед редактированием +``` + +### 5. Тестирование и отладка + +``` +play_scene → запустить игру (mode: "current", "main" или путь к файлу) +get_game_screenshot → увидеть, как игра выглядит прямо сейчас +capture_frames → захватить несколько кадров для наблюдения за движением/анимацией +get_game_scene_tree → инспектировать дерево сцены в runtime +get_game_node_properties → прочитать значения в runtime (position, health, state и т.д.) +set_game_node_property → изменить значения в запущенной игре +simulate_key → нажать клавиши (WASD, SPACE и т.д.) с указанием длительности +simulate_mouse_click → кликнуть по координатам viewport +simulate_action → вызвать действия InputMap (move_left, jump и т.д.) +get_editor_errors → проверить ошибки выполнения +stop_scene → остановить игру +``` + +**Цикл плейтестинга:** +1. `play_scene` → запустить игру +2. `get_game_screenshot` → увидеть текущее состояние +3. `simulate_key` / `simulate_action` → взаимодействовать с игрой +4. `capture_frames` → наблюдать поведение во времени +5. `get_game_node_properties` → проверить конкретные значения +6. `stop_scene` → остановить по завершении +7. Исправить проблемы в скриптах → повторить + +### 6. Анимации + +``` +# Убедитесь, что в сцене есть нода AnimationPlayer +create_animation → новая анимация с длительностью и режимом зацикливания +add_animation_track → добавить треки property/transform/method +set_animation_keyframe → вставить ключевые кадры в определённые моменты +get_animation_info → просмотреть существующие анимации +``` + +**Пример — прыгающий спрайт:** +1. `create_animation` name `bounce`, length `1.0`, loop_mode `1` (линейный цикл) +2. `add_animation_track` track_path `Sprite2D:position`, track_type `value` +3. `set_animation_keyframe` time `0.0`, value `Vector2(0, 0)` +4. `set_animation_keyframe` time `0.5`, value `Vector2(0, -50)` +5. `set_animation_keyframe` time `1.0`, value `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control, Label, Button, TextureRect и т.д. +set_anchor_preset → позиционирование Controls (full_rect, center, bottom_wide и т.д.) +set_theme_color → изменить font_color и т.д. +set_theme_font_size → настроить размер текста +set_theme_stylebox → фоны, рамки, скруглённые углы +connect_signal → подключить pressed кнопки, value_changed и т.д. +``` + +### 8. TileMap + +``` +tilemap_get_info → проверить источники набора тайлов и раскладку атласа +tilemap_set_cell → разместить отдельные тайлы +tilemap_fill_rect → заполнить прямоугольные области +tilemap_get_used_cells → посмотреть, что уже размещено +tilemap_clear → очистить все ячейки +``` + +### 9. Аудио + +``` +add_audio_bus → создать аудио-шины (SFX, Music, UI) +set_audio_bus → настроить громкость, соло, заглушение +add_audio_bus_effect → добавить реверберацию, задержку, компрессор и т.д. +add_audio_player → добавить ноды AudioStreamPlayer(2D/3D) +``` + +### 10. Конфигурация проекта + +``` +set_project_setting → изменить размер viewport, настройки физики и т.д. +set_input_action → определить маппинг ввода (move_left → KEY_A и т.д.) +add_autoload → зарегистрировать синглтоны autoload +set_physics_layers → именовать слои столкновений (player, enemy, world и т.д.) +``` + +## Важные правила и подводные камни + +### Значения свойств +Свойства автоматически парсятся из строк. Используйте следующие форматы: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` или `"#ff0000"` +- Bool: `"true"` / `"false"` +- Числа: `"42"`, `"3.14"` +- Enum: Используйте целочисленные значения (например, `0` для первого значения enum) + +### Никогда не редактируйте project.godot напрямую +Редактор Godot постоянно перезаписывает `project.godot`. Всегда используйте `set_project_setting` для изменения настроек проекта. + +### Аннотации типов в GDScript +При написании GDScript с циклами `for` по нетипизированным массивам используйте явные аннотации типов: +```gdscript +# ПЛОХО — приведёт к ошибкам +for item in some_untyped_array: + var x := item.value # вывод типов не работает + +# ХОРОШО +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### Изменения скриптов требуют перезагрузки +После создания или значительного изменения скриптов используйте `reload_project`, чтобы Godot подхватил изменения. Особенно важно после `create_script`. + +### Советы по simulate_key +- Используйте **короткие длительности** (0.3–0.5 секунд) для точного перемещения +- Длинные длительности (1+ секунда) приводят к промахам +- Для тестирования геймплея предпочитайте `simulate_action` вместо `simulate_key`, когда определены действия InputMap + +### simulate_mouse_click +- По умолчанию `auto_release: true` отправляет press и release — необходимо для UI-кнопок +- UI-кнопки срабатывают на release, поэтому нужны оба события + +### Ограничения execute_game_script +- Нельзя использовать вложенные функции (`func` внутри `func`) — вызывает ошибку компиляции +- Используйте `.get("property")` вместо `.property` для динамического доступа +- Ошибки выполнения приостанавливают отладчик (автоматически продолжается, но лучше избегать) + +### Коллизии и области подбора +- Для собираемых предметов используйте Area3D/Area2D с радиусом >= 1.5 +- Меньшие радиусы почти невозможно активировать симулированным вводом + +### Сохраняйте часто +Вызывайте `save_scene` после значительных изменений. Несохранённые изменения могут быть потеряны при перезагрузке редактора. + +## Инструменты анализа и отладки + +Когда что-то пошло не так, используйте эти инструменты для расследования: + +``` +get_editor_errors → проверить ошибки скриптов и исключения runtime +get_output_log → прочитать вывод print() и предупреждения +analyze_scene_complexity → найти узкие места производительности +analyze_signal_flow → визуализировать соединения сигналов +detect_circular_dependencies → найти циклические ссылки скриптов/сцен +find_unused_resources → очистить неиспользуемые файлы +get_performance_monitors → FPS, память, draw calls, статистика физики +``` + +## Тестирование и QA + +``` +run_test_scenario → определить и запустить автоматизированные тестовые сценарии +assert_node_state → проверить, что свойства нод соответствуют ожидаемым значениям +assert_screen_text → проверить, что текст отображается на экране +compare_screenshots → визуальное регрессионное тестирование (используйте пути к файлам, не base64) +run_stress_test → создать множество нод для тестирования производительности +``` + +## Продвинутые паттерны + +### Операции между сценами +``` +cross_scene_set_property → изменить ноды в сценах, которые сейчас не открыты +find_node_references → найти все файлы, ссылающиеся на паттерн +batch_set_property → установить свойство для всех нод определённого типа +``` + +### Работа с шейдерами +``` +create_shader → написать шейдерный код в стиле GLSL +assign_shader_material → применить к ноде +set_shader_param → настроить uniform-параметры в runtime +get_shader_params → просмотреть текущие значения +``` + +### Навигация (3D) +``` +setup_navigation_region → определить проходимую область +bake_navigation_mesh → сгенерировать навигационную сетку +setup_navigation_agent → добавить поиск пути для персонажей +``` + +### AnimationTree и конечные автоматы +``` +create_animation_tree → настроить AnimationTree с конечным автоматом или деревом смешивания +add_state_machine_state → добавить состояния (idle, walk, run, jump) +add_state_machine_transition → определить переходы между состояниями +set_tree_parameter → управлять параметрами смешивания +``` + +## Рекомендуемый порядок работы + +При создании новой игры с нуля: + +1. **Настройка проекта** — `get_project_info`, `set_project_setting` (viewport, физика) +2. **Маппинг ввода** — `set_input_action` для всех управлений игрока +3. **Главная сцена** — `create_scene`, установить как главную сцену +4. **Игрок** — создать сцену игрока со спрайтом, коллизией, скриптом +5. **Уровень/Мир** — построить окружение (TileMap, 3D-меши и т.д.) +6. **Игровая логика** — скрипты для врагов, предметов, UI +7. **Аудио** — настроить шины, добавить аудиоплееры +8. **Плейтестинг** — `play_scene`, тест с симулированным вводом, исправление багов +9. **Полировка** — анимации, частицы, шейдеры, темы +10. **Экспорт** — `list_export_presets`, `export_project` diff --git a/addons/godot_mcp/skills.zh.md b/addons/godot_mcp/skills.zh.md new file mode 100644 index 0000000..3d5116f --- /dev/null +++ b/addons/godot_mcp/skills.zh.md @@ -0,0 +1,271 @@ +> **Language:** [English](skills.md) | [日本語](skills.ja.md) | [Português (BR)](skills.pt-br.md) | [Español](skills.es.md) | [Русский](skills.ru.md) | 简体中文 | [हिन्दी](skills.hi.md) + +# Godot MCP Pro — AI 助手技能指南 + +> 将此文件复制到 Godot 项目根目录的 `.claude/skills.md`,以便 Claude Code 获得如何高效使用 Godot MCP Pro 的完整上下文。 + +## 什么是 Godot MCP Pro? + +你可以使用 169 个 MCP 工具直接连接 Godot 4 编辑器。你可以创建场景、编写脚本、模拟玩家输入、检查运行中的游戏等等——所有操作都无需用户离开当前对话。每次更改都通过 Godot 的 UndoRedo 系统进行,因此用户随时可以 Ctrl+Z 撤销。 + +## 核心工作流 + +### 1. 探索项目 + +在进行更改之前,先了解项目全貌: + +``` +get_project_info → 项目名称、Godot 版本、渲染器、视口大小 +get_filesystem_tree → 目录结构(可使用 filter: "*.tscn" 或 "*.gd") +get_scene_tree → 当前打开场景的节点层级 +read_script → 读取任意 GDScript 文件 +get_project_settings → 检查项目配置 +``` + +### 2. 构建 2D 场景 + +``` +create_scene → 创建 .tscn 文件并指定根节点类型 +add_node → 添加带属性的子节点 +create_script → 编写游戏逻辑的 GDScript +attach_script → 将脚本附加到节点 +update_property → 设置 position、scale、modulate 等 +save_scene → 保存到磁盘 +``` + +**示例——创建玩家:** +1. `create_scene` 设置 root_type 为 `CharacterBody2D`,path 为 `res://scenes/player.tscn` +2. `add_node` 类型 `Sprite2D` 并设置 texture 属性 +3. `add_node` 类型 `CollisionShape2D` +4. `add_resource` 为 CollisionShape2D 分配形状(如 `RectangleShape2D`) +5. `create_script` 编写移动逻辑 +6. `attach_script` 附加到根节点 +7. `save_scene` + +### 3. 构建 3D 场景 + +``` +create_scene → root_type: Node3D +add_mesh_instance → 添加基础体(box、sphere、cylinder、plane)或导入 .glb/.gltf +setup_lighting → 添加 DirectionalLight3D、OmniLight3D 或 SpotLight3D +setup_environment → 天空、环境光、雾、色调映射 +setup_camera_3d → 摄像机(可选 SpringArm3D 实现第三人称视角) +set_material_3d → PBR 材质(albedo、metallic、roughness、emission) +setup_collision → 为物理体添加碰撞形状 +setup_physics_body → 配置质量、摩擦力、重力 +``` + +### 4. 编写和编辑脚本 + +``` +create_script → 创建新的 .gd 文件(提供完整内容) +edit_script → 修改现有脚本 + - 使用 `replacements: [{search: "old code", replace: "new code"}]` 进行定向编辑 + - 使用 `content` 完整替换文件 + - 使用 `insert_at_line` + `text` 插入代码 +validate_script → 不运行即可检查语法错误 +read_script → 编辑前读取当前内容 +``` + +### 5. 测试与调试 + +``` +play_scene → 启动游戏(mode: "current"、"main" 或文件路径) +get_game_screenshot → 查看游戏当前画面 +capture_frames → 捕获多帧以观察运动/动画 +get_game_scene_tree → 检查运行时的场景树 +get_game_node_properties → 读取运行时数值(position、health、state 等) +set_game_node_property → 修改运行中游戏的数值 +simulate_key → 按键(WASD、SPACE 等)并指定持续时间 +simulate_mouse_click → 在视口坐标处点击 +simulate_action → 触发 InputMap 动作(move_left、jump 等) +get_editor_errors → 检查运行时错误 +stop_scene → 停止游戏 +``` + +**测试循环:** +1. `play_scene` → 启动游戏 +2. `get_game_screenshot` → 查看当前状态 +3. `simulate_key` / `simulate_action` → 与游戏交互 +4. `capture_frames` → 观察一段时间内的行为 +5. `get_game_node_properties` → 检查特定数值 +6. `stop_scene` → 完成后停止 +7. 修复脚本问题 → 重复 + +### 6. 动画 + +``` +# 确保场景中存在 AnimationPlayer 节点 +create_animation → 创建带时长和循环模式的新动画 +add_animation_track → 添加 property/transform/method 轨道 +set_animation_keyframe → 在特定时间插入关键帧 +get_animation_info → 查看现有动画信息 +``` + +**示例——弹跳精灵:** +1. `create_animation` name 为 `bounce`,length 为 `1.0`,loop_mode 为 `1`(线性循环) +2. `add_animation_track` track_path 为 `Sprite2D:position`,track_type 为 `value` +3. `set_animation_keyframe` time 为 `0.0`,value 为 `Vector2(0, 0)` +4. `set_animation_keyframe` time 为 `0.5`,value 为 `Vector2(0, -50)` +5. `set_animation_keyframe` time 为 `1.0`,value 为 `Vector2(0, 0)` + +### 7. UI / HUD + +``` +add_node → Control、Label、Button、TextureRect 等 +set_anchor_preset → 定位 Control(full_rect、center、bottom_wide 等) +set_theme_color → 修改 font_color 等 +set_theme_font_size → 调整文字大小 +set_theme_stylebox → 背景、边框、圆角 +connect_signal → 连接 button 的 pressed、value_changed 等信号 +``` + +### 8. TileMap + +``` +tilemap_get_info → 检查图块集来源和图集布局 +tilemap_set_cell → 放置单个图块 +tilemap_fill_rect → 填充矩形区域 +tilemap_get_used_cells → 查看已放置的内容 +tilemap_clear → 清除所有单元格 +``` + +### 9. 音频 + +``` +add_audio_bus → 创建音频总线(SFX、Music、UI) +set_audio_bus → 调整音量、独奏、静音 +add_audio_bus_effect → 添加混响、延迟、压缩器等 +add_audio_player → 添加 AudioStreamPlayer(2D/3D) 节点 +``` + +### 10. 项目配置 + +``` +set_project_setting → 修改视口大小、物理设置等 +set_input_action → 定义输入映射(move_left → KEY_A 等) +add_autoload → 注册 autoload 单例 +set_physics_layers → 命名碰撞层(player、enemy、world 等) +``` + +## 重要规则与注意事项 + +### 属性值 +属性会从字符串自动解析。使用以下格式: +- Vector2: `"Vector2(100, 200)"` +- Vector3: `"Vector3(1, 2, 3)"` +- Color: `"Color(1, 0, 0, 1)"` 或 `"#ff0000"` +- Bool: `"true"` / `"false"` +- 数字: `"42"`、`"3.14"` +- 枚举: 使用整数值(例如第一个枚举值用 `0`) + +### 不要直接编辑 project.godot +Godot 编辑器会不断覆盖 `project.godot`。修改项目设置请务必使用 `set_project_setting`。 + +### GDScript 类型注解 +在对无类型数组使用 `for` 循环时,请使用显式类型注解: +```gdscript +# 错误——会导致报错 +for item in some_untyped_array: + var x := item.value # 类型推断失败 + +# 正确 +for i in range(some_untyped_array.size()): + var item: Dictionary = some_untyped_array[i] + var x: int = item.value +``` + +### 脚本更改需要重新加载 +创建或大幅修改脚本后,使用 `reload_project` 确保 Godot 识别更改。在 `create_script` 之后尤其重要。 + +### simulate_key 技巧 +- 精确移动使用**短持续时间**(0.3-0.5 秒) +- 长持续时间(1 秒以上)会导致过冲 +- 游戏测试时,如果已定义 InputMap 动作,优先使用 `simulate_action` 而非 `simulate_key` + +### simulate_mouse_click +- 默认 `auto_release: true` 会同时发送按下和释放事件——UI 按钮必须如此 +- UI 按钮在释放时触发,因此两个事件都必不可少 + +### execute_game_script 限制 +- 不支持嵌套函数(`func` 中的 `func`)——会导致编译错误 +- 动态访问请使用 `.get("property")` 而非 `.property` +- 运行时错误会暂停调试器(会自动继续,但尽量避免) + +### 碰撞与拾取区域 +- 可收集物品请使用 Area3D/Area2D 并设置半径 >= 1.5 +- 较小的半径几乎无法通过模拟输入触发 + +### 经常保存 +进行重大更改后请调用 `save_scene`。未保存的更改可能在编辑器重新加载时丢失。 + +## 分析与调试工具 + +出现问题时,使用以下工具进行排查: + +``` +get_editor_errors → 检查脚本错误和运行时异常 +get_output_log → 读取 print() 输出和警告 +analyze_scene_complexity → 查找性能瓶颈 +analyze_signal_flow → 可视化信号连接 +detect_circular_dependencies → 查找脚本/场景的循环引用 +find_unused_resources → 清理未使用的文件 +get_performance_monitors → FPS、内存、绘制调用、物理统计 +``` + +## 测试与 QA + +``` +run_test_scenario → 定义并运行自动化测试序列 +assert_node_state → 验证节点属性是否匹配预期值 +assert_screen_text → 验证文本是否显示在屏幕上 +compare_screenshots → 视觉回归测试(使用文件路径,不要用 base64) +run_stress_test → 生成大量节点以测试性能 +``` + +## 高级模式 + +### 跨场景操作 +``` +cross_scene_set_property → 修改当前未打开场景中的节点 +find_node_references → 查找引用某个模式的所有文件 +batch_set_property → 为某类型的所有节点设置属性 +``` + +### 着色器工作流 +``` +create_shader → 编写类 GLSL 的着色器代码 +assign_shader_material → 应用到节点 +set_shader_param → 在运行时调整 uniform 参数 +get_shader_params → 查看当前值 +``` + +### 导航(3D) +``` +setup_navigation_region → 定义可行走区域 +bake_navigation_mesh → 生成导航网格 +setup_navigation_agent → 为角色添加寻路功能 +``` + +### AnimationTree 与状态机 +``` +create_animation_tree → 使用状态机或混合树设置 AnimationTree +add_state_machine_state → 添加状态(idle、walk、run、jump) +add_state_machine_transition → 定义状态之间的过渡 +set_tree_parameter → 控制混合参数 +``` + +## 推荐工作流顺序 + +从零开始构建新游戏时: + +1. **项目设置** — `get_project_info`、`set_project_setting`(视口、物理) +2. **输入映射** — `set_input_action` 定义所有玩家控制 +3. **主场景** — `create_scene`,设为主场景 +4. **玩家** — 创建包含精灵、碰撞、脚本的玩家场景 +5. **关卡/世界** — 构建环境(TileMap、3D 网格等) +6. **游戏逻辑** — 敌人、道具、UI 的脚本 +7. **音频** — 设置总线、添加音频播放器 +8. **测试** — `play_scene`,使用模拟输入测试,修复 bug +9. **打磨** — 动画、粒子、着色器、主题 +10. **导出** — `list_export_presets`、`export_project` diff --git a/addons/godot_mcp/ui/status_panel.gd b/addons/godot_mcp/ui/status_panel.gd new file mode 100644 index 0000000..ace37d1 --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.gd @@ -0,0 +1,382 @@ +@tool +extends VBoxContainer + +var websocket_server: Node = null +var command_router: Node = null + +const MAX_LOG_ENTRIES := 200 +const COLOR_CONNECTED := Color(0.2, 0.9, 0.2) +const COLOR_DISCONNECTED := Color(0.9, 0.2, 0.2) +const COLOR_STALE := Color(1.0, 0.7, 0.2) +const COLOR_SUCCESS := Color(0.6, 1, 0.6) +const COLOR_ERROR := Color(1, 0.6, 0.6) +const COLOR_DIM := Color(0.6, 0.6, 0.6) + +const BASE_PORT := 6505 +const MAX_PORT := 6509 + +# Header +var _status_icon: Label +var _status_label: Label +var _client_count_label: Label + +# Tabs +var _tab_container: TabContainer + +# Activity tab +var _show_details_check: CheckBox +var _log_container: VBoxContainer +var _log_scroll: ScrollContainer + +# Clients tab +var _port_labels: Dictionary = {} # port -> {icon: Label, label: Label} + +# Tools tab +var _filter_edit: LineEdit +var _tools_container: VBoxContainer +var _tool_checkboxes: Dictionary = {} # method_name -> CheckBox + + +func _ready() -> void: + _build_ui() + + +func setup(ws_server: Node, cmd_router: Node = null) -> void: + websocket_server = ws_server + command_router = cmd_router + + if websocket_server: + websocket_server.client_connected.connect(_on_client_connected) + websocket_server.client_disconnected.connect(_on_client_disconnected) + if websocket_server.has_signal("command_completed"): + websocket_server.command_completed.connect(_on_command_completed) + else: + websocket_server.command_executed.connect(_on_command_executed) + + if command_router: + _populate_tools_list() + + +func _build_ui() -> void: + # Header bar + var header := HBoxContainer.new() + add_child(header) + + _status_icon = Label.new() + _status_icon.text = "●" + _status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + header.add_child(_status_icon) + + _status_label = Label.new() + _status_label.text = " MCP Pro: Waiting for connection..." + header.add_child(_status_label) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(spacer) + + _client_count_label = Label.new() + _client_count_label.text = "Clients: 0" + header.add_child(_client_count_label) + + # Separator + var sep := HSeparator.new() + add_child(sep) + + # TabContainer + _tab_container = TabContainer.new() + _tab_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(_tab_container) + + _build_activity_tab() + _build_clients_tab() + _build_tools_tab() + + +func _build_activity_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Activity" + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tab_container.add_child(vbox) + + # Controls row + var controls := HBoxContainer.new() + vbox.add_child(controls) + + _show_details_check = CheckBox.new() + _show_details_check.text = "Show Response Details" + _show_details_check.button_pressed = false + _show_details_check.toggled.connect(_on_show_details_toggled) + controls.add_child(_show_details_check) + + var ctrl_spacer := Control.new() + ctrl_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + controls.add_child(ctrl_spacer) + + var clear_btn := Button.new() + clear_btn.text = "Clear" + clear_btn.pressed.connect(_on_clear_log) + controls.add_child(clear_btn) + + # Log scroll + _log_scroll = ScrollContainer.new() + _log_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _log_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _log_scroll.custom_minimum_size.y = 80 + vbox.add_child(_log_scroll) + + _log_container = VBoxContainer.new() + _log_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _log_scroll.add_child(_log_container) + + +func _build_clients_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Clients" + _tab_container.add_child(vbox) + + for p in range(BASE_PORT, MAX_PORT + 1): + var row := HBoxContainer.new() + vbox.add_child(row) + + var icon := Label.new() + icon.text = "○" + icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + row.add_child(icon) + + var lbl := Label.new() + lbl.text = " Port %d — Disconnected" % p + row.add_child(lbl) + + _port_labels[p] = {"icon": icon, "label": lbl} + + +func _build_tools_tab() -> void: + var vbox := VBoxContainer.new() + vbox.name = "Tools" + vbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tab_container.add_child(vbox) + + # Controls + var controls := HBoxContainer.new() + vbox.add_child(controls) + + _filter_edit = LineEdit.new() + _filter_edit.placeholder_text = "Filter tools..." + _filter_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _filter_edit.text_changed.connect(_on_filter_changed) + controls.add_child(_filter_edit) + + var enable_all_btn := Button.new() + enable_all_btn.text = "Enable All" + enable_all_btn.pressed.connect(_on_enable_all) + controls.add_child(enable_all_btn) + + var disable_all_btn := Button.new() + disable_all_btn.text = "Disable All" + disable_all_btn.pressed.connect(_on_disable_all) + controls.add_child(disable_all_btn) + + # Scroll + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.custom_minimum_size.y = 80 + vbox.add_child(scroll) + + _tools_container = VBoxContainer.new() + _tools_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(_tools_container) + + +func _populate_tools_list() -> void: + if not command_router: + return + + # Clear existing + for child in _tools_container.get_children(): + child.queue_free() + _tool_checkboxes.clear() + + var methods: Array = command_router.get_available_methods() + methods.sort() + + for method_name: String in methods: + var cb := CheckBox.new() + cb.text = method_name + cb.button_pressed = not command_router.is_tool_disabled(method_name) + cb.toggled.connect(_on_tool_toggled.bind(method_name)) + _tools_container.add_child(cb) + _tool_checkboxes[method_name] = cb + + +func _process(_delta: float) -> void: + if not websocket_server: + return + + var count: int = websocket_server.get_client_count() + _client_count_label.text = "Clients: %d" % count + + var any_stale := false + if websocket_server.has_method("is_port_stale"): + for p in range(BASE_PORT, MAX_PORT + 1): + if websocket_server.is_port_stale(p): + any_stale = true + break + + if count > 0: + _status_icon.add_theme_color_override("font_color", COLOR_CONNECTED) + _status_label.text = " MCP Pro: Connected" + elif any_stale: + _status_icon.add_theme_color_override("font_color", COLOR_STALE) + _status_label.text = " MCP Pro: ⚠ Reconnecting (stale connection detected)..." + else: + _status_icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + _status_label.text = " MCP Pro: Waiting for connection..." + + # Update clients tab + _update_clients_tab() + + +func _update_clients_tab() -> void: + var connected_ports: Array[int] = [] + if websocket_server.has_method("get_connected_ports"): + connected_ports = websocket_server.get_connected_ports() + + for p: int in _port_labels: + var info: Dictionary = _port_labels[p] + var icon: Label = info["icon"] + var lbl: Label = info["label"] + + var is_stale := false + if websocket_server.has_method("is_port_stale"): + is_stale = websocket_server.is_port_stale(p) + + if p in connected_ports: + var time_str := "" + if websocket_server.has_method("get_port_connect_time"): + var elapsed: float = websocket_server.get_port_connect_time(p) + if elapsed >= 0: + var mins := int(elapsed) / 60 + var secs := int(elapsed) % 60 + time_str = " (%dm %02ds)" % [mins, secs] + + var idle_str := "" + if websocket_server.has_method("get_port_idle_time"): + var idle: float = websocket_server.get_port_idle_time(p) + if idle >= 2.0: + idle_str = " · idle %.0fs" % idle + + icon.text = "●" + icon.add_theme_color_override("font_color", COLOR_CONNECTED) + lbl.text = " Port %d — Connected%s%s" % [p, time_str, idle_str] + elif is_stale: + icon.text = "◐" + icon.add_theme_color_override("font_color", COLOR_STALE) + lbl.text = " Port %d — ⚠ Stale (reconnecting)" % p + else: + icon.text = "○" + icon.add_theme_color_override("font_color", COLOR_DISCONNECTED) + lbl.text = " Port %d — Disconnected" % p + + +# --- Activity callbacks --- + +func _on_client_connected() -> void: + _add_log("Client connected", COLOR_CONNECTED) + + +func _on_client_disconnected() -> void: + _add_log("Client disconnected", COLOR_DISCONNECTED) + + +func _on_command_executed(method: String, ok: bool) -> void: + var color := COLOR_SUCCESS if ok else COLOR_ERROR + var status_icon := "OK" if ok else "ERR" + _add_log("[%s] %s" % [status_icon, method], color) + + +func _on_command_completed(method: String, ok: bool, response: String, source_port: int) -> void: + var color := COLOR_SUCCESS if ok else COLOR_ERROR + var status_icon := "OK" if ok else "ERR" + _add_log("[%s] %s (port %d)" % [status_icon, method, source_port], color, response) + + +func _on_clear_log() -> void: + for child in _log_container.get_children(): + child.queue_free() + + +func _on_show_details_toggled(on: bool) -> void: + for entry in _log_container.get_children(): + if entry is VBoxContainer and entry.get_child_count() > 1: + entry.get_child(1).visible = on + + +func _add_log(text: String, color: Color = Color.WHITE, response: String = "") -> void: + if _log_container == null: + return + + var entry := VBoxContainer.new() + _log_container.add_child(entry) + + var label := Label.new() + var time_str := Time.get_time_string_from_system() + label.text = "[%s] %s" % [time_str, text] + label.add_theme_color_override("font_color", color) + label.add_theme_font_size_override("font_size", 12) + entry.add_child(label) + + if not response.is_empty(): + var detail := RichTextLabel.new() + var preview := response.substr(0, 500) + if response.length() > 500: + preview += "..." + detail.text = preview + detail.fit_content = true + detail.scroll_active = false + detail.add_theme_color_override("default_color", COLOR_DIM) + detail.add_theme_font_size_override("normal_font_size", 11) + detail.custom_minimum_size.y = 0 + detail.visible = _show_details_check.button_pressed if _show_details_check else false + entry.add_child(detail) + + # Limit entries + while _log_container.get_child_count() > MAX_LOG_ENTRIES: + var old: Node = _log_container.get_child(0) + _log_container.remove_child(old) + old.queue_free() + + # Auto scroll to bottom + _auto_scroll.call_deferred() + + +func _auto_scroll() -> void: + if _log_scroll: + _log_scroll.scroll_vertical = int(_log_scroll.get_v_scroll_bar().max_value) + + +# --- Tools callbacks --- + +func _on_filter_changed(filter: String) -> void: + for method_name: String in _tool_checkboxes: + var cb: CheckBox = _tool_checkboxes[method_name] + cb.visible = filter.is_empty() or method_name.containsn(filter) + + +func _on_tool_toggled(enabled: bool, method_name: String) -> void: + if command_router and command_router.has_method("set_tool_disabled"): + command_router.set_tool_disabled(method_name, not enabled) + + +func _on_enable_all() -> void: + if command_router and command_router.has_method("set_all_tools_disabled"): + command_router.set_all_tools_disabled(false) + for method_name: String in _tool_checkboxes: + _tool_checkboxes[method_name].set_pressed_no_signal(true) + + +func _on_disable_all() -> void: + if command_router and command_router.has_method("set_all_tools_disabled"): + command_router.set_all_tools_disabled(true) + for method_name: String in _tool_checkboxes: + _tool_checkboxes[method_name].set_pressed_no_signal(false) diff --git a/addons/godot_mcp/ui/status_panel.gd.uid b/addons/godot_mcp/ui/status_panel.gd.uid new file mode 100644 index 0000000..29a0e1b --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.gd.uid @@ -0,0 +1 @@ +uid://3l5qwyrqbsgn diff --git a/addons/godot_mcp/ui/status_panel.tscn b/addons/godot_mcp/ui/status_panel.tscn new file mode 100644 index 0000000..9343333 --- /dev/null +++ b/addons/godot_mcp/ui/status_panel.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"] + +[node name="MCPStatusPanel" type="VBoxContainer"] +offset_right = 600.0 +offset_bottom = 300.0 +script = ExtResource("1") diff --git a/addons/godot_mcp/utils/node_utils.gd b/addons/godot_mcp/utils/node_utils.gd new file mode 100644 index 0000000..43d6653 --- /dev/null +++ b/addons/godot_mcp/utils/node_utils.gd @@ -0,0 +1,76 @@ +@tool +extends RefCounted + +## Recursively set owner for all children (needed when adding nodes via code) +static func set_owner_recursive(node: Node, owner: Node) -> void: + for child in node.get_children(): + child.owner = owner + set_owner_recursive(child, owner) + + +## Get a simplified tree structure from a node +static func get_node_tree(node: Node, max_depth: int = -1, current_depth: int = 0) -> Dictionary: + var result := { + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()), + } + + # Add script info + var script: Script = node.get_script() + if script: + result["script"] = script.resource_path + + # Add children + if max_depth == -1 or current_depth < max_depth: + var children: Array = [] + for child in node.get_children(): + children.append(get_node_tree(child, max_depth, current_depth + 1)) + if not children.is_empty(): + result["children"] = children + + return result + + +## Get all properties of a node as a serializable dictionary +static func get_node_properties_dict(node: Node) -> Dictionary: + var PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd") + var result: Dictionary = {} + var property_list := node.get_property_list() + + for prop_info in property_list: + var prop_name: String = prop_info["name"] + var usage: int = prop_info["usage"] + + # Only include user-facing properties (PROPERTY_USAGE_EDITOR) + if not (usage & PROPERTY_USAGE_EDITOR): + continue + + # Skip internal/meta properties + if prop_name.begins_with("_") or prop_name in ["script"]: + continue + + var value: Variant = node.get(prop_name) + result[prop_name] = PropertyParser.serialize_value(value) + + return result + + +## Duplicate a node and all its children, properly setting owners +static func duplicate_node_in_scene(node: Node, new_name: String, root: Node) -> Node: + var dup := node.duplicate() + dup.name = new_name + node.get_parent().add_child(dup) + dup.owner = root + set_owner_recursive(dup, root) + return dup + + +## Find node by class type in subtree +static func find_nodes_by_type(root: Node, type_name: String) -> Array[Node]: + var result: Array[Node] = [] + if root.get_class() == type_name or root.is_class(type_name): + result.append(root) + for child in root.get_children(): + result.append_array(find_nodes_by_type(child, type_name)) + return result diff --git a/addons/godot_mcp/utils/node_utils.gd.uid b/addons/godot_mcp/utils/node_utils.gd.uid new file mode 100644 index 0000000..e68a77e --- /dev/null +++ b/addons/godot_mcp/utils/node_utils.gd.uid @@ -0,0 +1 @@ +uid://f0ek7wa1gkmy diff --git a/addons/godot_mcp/utils/property_parser.gd b/addons/godot_mcp/utils/property_parser.gd new file mode 100644 index 0000000..4a744be --- /dev/null +++ b/addons/godot_mcp/utils/property_parser.gd @@ -0,0 +1,203 @@ +@tool +extends RefCounted + +## Parse a string value into the appropriate Godot type +static func parse_value(value: Variant, target_type: int = TYPE_NIL) -> Variant: + if value == null: + return null + + # If already the correct type, return as-is + if target_type == TYPE_NIL: + return _auto_parse(value) + + match target_type: + TYPE_BOOL: + if value is bool: return value + if value is String: return value.to_lower() in ["true", "1", "yes"] + return bool(value) + TYPE_INT: + return int(value) + TYPE_FLOAT: + return float(value) + TYPE_STRING: + return str(value) + TYPE_VECTOR2: + return _parse_vector2(value) + TYPE_VECTOR2I: + return _parse_vector2i(value) + TYPE_VECTOR3: + return _parse_vector3(value) + TYPE_VECTOR3I: + return _parse_vector3i(value) + TYPE_RECT2: + return _parse_rect2(value) + TYPE_COLOR: + return _parse_color(value) + TYPE_NODE_PATH: + return NodePath(str(value)) + TYPE_ARRAY: + if value is Array: return value + return [value] + TYPE_DICTIONARY: + if value is Dictionary: return value + return {} + _: + return value + + +static func _auto_parse(value: Variant) -> Variant: + if not value is String: + return value + + var s: String = value + + # Boolean + if s == "true": return true + if s == "false": return false + + # Integer + if s.is_valid_int(): return s.to_int() + + # Float + if s.is_valid_float(): return s.to_float() + + # Vector2: "Vector2(x, y)" or "(x, y)" or "x, y" + if s.begins_with("Vector2(") or s.begins_with("Vector2i("): + return _parse_vector2(s) + + # Vector3 + if s.begins_with("Vector3(") or s.begins_with("Vector3i("): + return _parse_vector3(s) + + # Color + if s.begins_with("Color(") or s.begins_with("#"): + return _parse_color(s) + + # Rect2 + if s.begins_with("Rect2("): + return _parse_rect2(s) + + return s + + +static func _extract_numbers(s: String) -> PackedFloat64Array: + # Remove type prefix and parentheses + var cleaned := s + for prefix in ["Vector3i(", "Vector3(", "Vector2i(", "Vector2(", "Rect2(", "Color(", "("]: + if cleaned.begins_with(prefix): + cleaned = cleaned.substr(prefix.length()) + break + cleaned = cleaned.trim_suffix(")") + cleaned = cleaned.strip_edges() + + var parts := cleaned.split(",") + var numbers: PackedFloat64Array = [] + for part in parts: + numbers.append(part.strip_edges().to_float()) + return numbers + + +static func _parse_vector2(value: Variant) -> Vector2: + if value is Vector2: return value + if value is Dictionary: + return Vector2(float(value.get("x", 0)), float(value.get("y", 0))) + var nums := _extract_numbers(str(value)) + if nums.size() >= 2: + return Vector2(nums[0], nums[1]) + return Vector2.ZERO + + +static func _parse_vector2i(value: Variant) -> Vector2i: + var v := _parse_vector2(value) + return Vector2i(int(v.x), int(v.y)) + + +static func _parse_vector3(value: Variant) -> Vector3: + if value is Vector3: return value + if value is Dictionary: + return Vector3(float(value.get("x", 0)), float(value.get("y", 0)), float(value.get("z", 0))) + var nums := _extract_numbers(str(value)) + if nums.size() >= 3: + return Vector3(nums[0], nums[1], nums[2]) + return Vector3.ZERO + + +static func _parse_vector3i(value: Variant) -> Vector3i: + var v := _parse_vector3(value) + return Vector3i(int(v.x), int(v.y), int(v.z)) + + +static func _parse_rect2(value: Variant) -> Rect2: + if value is Rect2: return value + if value is Dictionary: + return Rect2( + float(value.get("x", 0)), float(value.get("y", 0)), + float(value.get("w", value.get("width", 0))), + float(value.get("h", value.get("height", 0))) + ) + var nums := _extract_numbers(str(value)) + if nums.size() >= 4: + return Rect2(nums[0], nums[1], nums[2], nums[3]) + return Rect2() + + +static func _parse_color(value: Variant) -> Color: + if value is Color: return value + var s := str(value) + if s.begins_with("#"): + return Color.html(s) + if s.begins_with("Color("): + var nums := _extract_numbers(s) + match nums.size(): + 3: return Color(nums[0], nums[1], nums[2]) + 4: return Color(nums[0], nums[1], nums[2], nums[3]) + # Try named color + if Color.html_is_valid(s): + return Color.html(s) + return Color.WHITE + + +## Serialize a Variant to JSON-safe representation +static func serialize_value(value: Variant) -> Variant: + if value == null: + return null + match typeof(value): + TYPE_VECTOR2: + var v: Vector2 = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR2I: + var v: Vector2i = value + return {"x": v.x, "y": v.y} + TYPE_VECTOR3: + var v: Vector3 = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_VECTOR3I: + var v: Vector3i = value + return {"x": v.x, "y": v.y, "z": v.z} + TYPE_RECT2: + var r: Rect2 = value + return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y} + TYPE_COLOR: + var c: Color = value + return {"r": c.r, "g": c.g, "b": c.b, "a": c.a, "html": "#" + c.to_html()} + TYPE_NODE_PATH: + return str(value) + TYPE_OBJECT: + if value is Resource: + var res: Resource = value + return {"type": res.get_class(), "path": res.resource_path} + return str(value) + TYPE_ARRAY: + var arr: Array = value + var result: Array = [] + for item in arr: + result.append(serialize_value(item)) + return result + TYPE_DICTIONARY: + var dict: Dictionary = value + var result: Dictionary = {} + for key in dict: + result[str(key)] = serialize_value(dict[key]) + return result + _: + return value diff --git a/addons/godot_mcp/utils/property_parser.gd.uid b/addons/godot_mcp/utils/property_parser.gd.uid new file mode 100644 index 0000000..cf0e294 --- /dev/null +++ b/addons/godot_mcp/utils/property_parser.gd.uid @@ -0,0 +1 @@ +uid://dihc7toqvjjhj diff --git a/addons/godot_mcp/websocket_server.gd b/addons/godot_mcp/websocket_server.gd new file mode 100644 index 0000000..7a48eae --- /dev/null +++ b/addons/godot_mcp/websocket_server.gd @@ -0,0 +1,275 @@ +@tool +extends Node + +## Multi-connection WebSocket client. +## Connects to multiple Node.js MCP server instances on ports 6505-6514. +## Each Claude Code session gets its own port; Godot talks to all of them. +## Ports 6505-6509: MCP servers (stdio), 6510-6514: CLI tool connections. + +signal client_connected() +signal client_disconnected() +signal message_received(text: String) +signal command_executed(method: String, success: bool) +signal command_completed(method: String, success: bool, response: String, source_port: int) + +var command_router: Node + +const BASE_PORT := 6505 +const MAX_PORT := 6514 +const RECONNECT_INTERVAL := 3.0 +const BUFFER_SIZE := 16 * 1024 * 1024 # 16MB +const PING_INTERVAL := 5.0 # send ping every N seconds while connected +const INACTIVITY_TIMEOUT := 30.0 # force-close if no message received for N seconds + +# Per-port connection state +var _peers: Dictionary = {} # port -> WebSocketPeer +var _connected: Dictionary = {} # port -> bool +var _timers: Dictionary = {} # port -> float (reconnect countdown) +var _connect_times: Dictionary = {} # port -> float (elapsed seconds since connect) +var _rejected_ports: Dictionary = {} # port -> true (handshake rejected by server) +var _last_activity: Dictionary = {} # port -> float (seconds since last received message) +var _ping_timers: Dictionary = {} # port -> float (seconds since last sent ping) +var _stale_ports: Dictionary = {} # port -> bool (heartbeat timeout flag, exposed to UI) +var _running: bool = false + + +func start_server() -> void: + _running = true + for p in range(BASE_PORT, MAX_PORT + 1): + _connected[p] = false + _timers[p] = 0.0 + _try_connect(p) + print("[MCP] Connecting to ports %d-%d" % [BASE_PORT, MAX_PORT]) + + +func stop_server() -> void: + _running = false + for p in _peers: + var ws: WebSocketPeer = _peers[p] + if ws: + ws.close(1000, "Plugin shutting down") + _peers.clear() + _connected.clear() + _timers.clear() + _connect_times.clear() + _rejected_ports.clear() + _last_activity.clear() + _ping_timers.clear() + _stale_ports.clear() + print("[MCP] WebSocket client stopped") + + +func get_client_count() -> int: + var count: int = 0 + for p in _connected: + if _connected[p]: + count += 1 + return count + + +func get_connected_ports() -> Array[int]: + var ports: Array[int] = [] + for p: int in _connected: + if _connected[p]: + ports.append(p) + return ports + + +func get_port_connect_time(port: int) -> float: + return _connect_times.get(port, -1.0) + + +func get_port_idle_time(port: int) -> float: + return _last_activity.get(port, -1.0) + + +func is_port_stale(port: int) -> bool: + return _stale_ports.get(port, false) + + +func _try_connect(p: int) -> void: + var ws := WebSocketPeer.new() + ws.outbound_buffer_size = BUFFER_SIZE + ws.inbound_buffer_size = BUFFER_SIZE + var err := ws.connect_to_url("ws://127.0.0.1:%d" % p) + if err == OK: + _peers[p] = ws + else: + _peers[p] = null + + +func _process(delta: float) -> void: + if not _running: + return + + for p in range(BASE_PORT, MAX_PORT + 1): + var ws: WebSocketPeer = _peers.get(p) + + # No peer - try reconnect on timer (skip rejected ports) + if ws == null: + if _rejected_ports.get(p, false): + continue + _timers[p] = _timers.get(p, 0.0) + delta + if _timers[p] >= RECONNECT_INTERVAL: + _timers[p] = 0.0 + _try_connect(p) + continue + + ws.poll() + var state := ws.get_ready_state() + + match state: + WebSocketPeer.STATE_OPEN: + if not _connected.get(p, false): + _connected[p] = true + _connect_times[p] = 0.0 + _last_activity[p] = 0.0 + _ping_timers[p] = 0.0 + _stale_ports[p] = false + _timers[p] = 0.0 + print_verbose("[MCP] Connected on port %d" % p) + _send_handshake(p) + client_connected.emit() + else: + _connect_times[p] = _connect_times.get(p, 0.0) + delta + _last_activity[p] = _last_activity.get(p, 0.0) + delta + _ping_timers[p] = _ping_timers.get(p, 0.0) + delta + + var received_any := false + while ws.get_available_packet_count() > 0: + var packet := ws.get_packet() + var text := packet.get_string_from_utf8() + received_any = true + _dispatch_message(text, p) + + if received_any: + _last_activity[p] = 0.0 + if _stale_ports.get(p, false): + _stale_ports[p] = false + print("[MCP] Port %d recovered from stale state" % p) + + # Force-close if no message received for INACTIVITY_TIMEOUT. + # The MCP server pings every 10s, so 30s of silence means the + # connection is half-open and reconnect is the only way out. + if _last_activity.get(p, 0.0) > INACTIVITY_TIMEOUT: + push_warning("[MCP] Port %d silent for %.1fs — forcing reconnect" % [p, _last_activity[p]]) + _stale_ports[p] = true + ws.close(4000, "Heartbeat timeout") + _connected[p] = false + _peers[p] = null + _timers[p] = 0.0 + client_disconnected.emit() + continue + + # Send periodic ping so the server can detect our death too, + # and so any reply resets our own inactivity timer. + if _ping_timers.get(p, 0.0) >= PING_INTERVAL: + _ping_timers[p] = 0.0 + ws.send_text(JSON.stringify({"jsonrpc": "2.0", "method": "ping", "params": {}})) + + WebSocketPeer.STATE_CLOSING: + pass + + WebSocketPeer.STATE_CLOSED: + if _connected.get(p, false): + _connected[p] = false + print_verbose("[MCP] Disconnected from port %d" % p) + client_disconnected.emit() + # Check for project-mismatch rejection (close code 1008) + if ws and ws.get_close_code() == 1008: + _rejected_ports[p] = true + push_warning("[MCP] Port %d rejected by server (project mismatch), won't retry" % p) + if not _rejected_ports.get(p, false): + _peers[p] = null + _timers[p] = 0.0 + else: + _peers[p] = null # still clear peer, timer stays 0 to block reconnect + _last_activity[p] = 0.0 + _ping_timers[p] = 0.0 + + WebSocketPeer.STATE_CONNECTING: + pass + + +func _send_handshake(p: int) -> void: + var project_path := ProjectSettings.globalize_path("res://").replace("\\", "/").rstrip("/").to_lower() + var msg := JSON.stringify({ + "method": "handshake", + "params": {"project_path": project_path} + }) + _send_to_port(p, msg) + +func _send_to_port(p: int, text: String) -> void: + var ws: WebSocketPeer = _peers.get(p) + if ws and _connected.get(p, false): + ws.send_text(text) + + +func send_message(text: String) -> void: + # Broadcast to all connected peers + for p in _peers: + _send_to_port(p, text) + + +## Synchronous dispatch - parse JSON, handle ping/pong, queue command execution +func _dispatch_message(text: String, source_port: int) -> void: + message_received.emit(text) + + var json := JSON.new() + var err := json.parse(text) + if err != OK: + _send_response(source_port, null, null, {"code": -32700, "message": "Parse error"}) + return + + var msg: Variant = json.data + if not msg is Dictionary: + _send_response(source_port, null, null, {"code": -32600, "message": "Invalid request"}) + return + + var msg_dict: Dictionary = msg + + if msg_dict.get("method") == "ping": + _send_to_port(source_port, JSON.stringify({"jsonrpc": "2.0", "method": "pong", "params": {}})) + return + + if msg_dict.get("method") == "pong": + return + + var id: Variant = msg_dict.get("id") + var method: String = msg_dict.get("method", "") + var params: Dictionary = msg_dict.get("params", {}) + + if method.is_empty(): + _send_response(source_port, id, null, {"code": -32600, "message": "Missing method"}) + return + + if not command_router: + _send_response(source_port, id, null, {"code": -32603, "message": "No command router"}) + return + + _execute_command.call_deferred(source_port, id, method, params) + + +func _execute_command(source_port: int, id: Variant, method: String, params: Dictionary) -> void: + var cmd_result: Dictionary = await command_router.execute(method, params) + if cmd_result.has("error"): + var err_data: Variant = cmd_result["error"] + _send_response(source_port, id, null, err_data) + var response_text := JSON.stringify(err_data) + command_executed.emit(method, false) + command_completed.emit(method, false, response_text, source_port) + else: + var result_data: Variant = cmd_result.get("result", {}) + _send_response(source_port, id, result_data, null) + var response_text := JSON.stringify(result_data) + command_executed.emit(method, true) + command_completed.emit(method, true, response_text, source_port) + + +func _send_response(source_port: int, id: Variant, result: Variant, err: Variant) -> void: + var response: Dictionary = {"jsonrpc": "2.0", "id": id} + if err != null: + response["error"] = err + else: + response["result"] = result if result != null else {} + _send_to_port(source_port, JSON.stringify(response)) diff --git a/addons/godot_mcp/websocket_server.gd.uid b/addons/godot_mcp/websocket_server.gd.uid new file mode 100644 index 0000000..be99fe1 --- /dev/null +++ b/addons/godot_mcp/websocket_server.gd.uid @@ -0,0 +1 @@ +uid://bs6o0t7258m5x diff --git a/assets/art/characters/boss/2_way_shoot_gun_1.png b/assets/art/characters/boss/2_way_shoot_gun_1.png new file mode 100644 index 0000000..72aa9b6 Binary files /dev/null and b/assets/art/characters/boss/2_way_shoot_gun_1.png differ diff --git a/assets/art/characters/boss/2_way_shoot_gun_1.png.import b/assets/art/characters/boss/2_way_shoot_gun_1.png.import new file mode 100644 index 0000000..52decab --- /dev/null +++ b/assets/art/characters/boss/2_way_shoot_gun_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://32w0yxa63p34" +path="res://.godot/imported/2_way_shoot_gun_1.png-836bd6d9c129b9a42da29ea92cf33f59.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/2_way_shoot_gun_1.png" +dest_files=["res://.godot/imported/2_way_shoot_gun_1.png-836bd6d9c129b9a42da29ea92cf33f59.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/2_way_shoot_gun_2.png b/assets/art/characters/boss/2_way_shoot_gun_2.png new file mode 100644 index 0000000..dc1e6a5 Binary files /dev/null and b/assets/art/characters/boss/2_way_shoot_gun_2.png differ diff --git a/assets/art/characters/boss/2_way_shoot_gun_2.png.import b/assets/art/characters/boss/2_way_shoot_gun_2.png.import new file mode 100644 index 0000000..cf8a746 --- /dev/null +++ b/assets/art/characters/boss/2_way_shoot_gun_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dptqlwupasm1m" +path="res://.godot/imported/2_way_shoot_gun_2.png-e617c7ad6d9ff6ef64b0751ca2c17ce4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/2_way_shoot_gun_2.png" +dest_files=["res://.godot/imported/2_way_shoot_gun_2.png-e617c7ad6d9ff6ef64b0751ca2c17ce4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/air_combo_1.png b/assets/art/characters/boss/air_combo_1.png new file mode 100644 index 0000000..ac04a4c Binary files /dev/null and b/assets/art/characters/boss/air_combo_1.png differ diff --git a/assets/art/characters/boss/air_combo_1.png.import b/assets/art/characters/boss/air_combo_1.png.import new file mode 100644 index 0000000..123ffa9 --- /dev/null +++ b/assets/art/characters/boss/air_combo_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxdx0qxq5vsv1" +path="res://.godot/imported/air_combo_1.png-46f8a7605ed3835f567f28ada730602c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/air_combo_1.png" +dest_files=["res://.godot/imported/air_combo_1.png-46f8a7605ed3835f567f28ada730602c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/air_combo_2.png b/assets/art/characters/boss/air_combo_2.png new file mode 100644 index 0000000..a938e7b Binary files /dev/null and b/assets/art/characters/boss/air_combo_2.png differ diff --git a/assets/art/characters/boss/air_combo_2.png.import b/assets/art/characters/boss/air_combo_2.png.import new file mode 100644 index 0000000..547bc6d --- /dev/null +++ b/assets/art/characters/boss/air_combo_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xtgdjwdetlv7" +path="res://.godot/imported/air_combo_2.png-285f4eaf93bc14da5cc7a80d7c0e71b9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/air_combo_2.png" +dest_files=["res://.godot/imported/air_combo_2.png-285f4eaf93bc14da5cc7a80d7c0e71b9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/air_combo_3.png b/assets/art/characters/boss/air_combo_3.png new file mode 100644 index 0000000..bdfd667 Binary files /dev/null and b/assets/art/characters/boss/air_combo_3.png differ diff --git a/assets/art/characters/boss/air_combo_3.png.import b/assets/art/characters/boss/air_combo_3.png.import new file mode 100644 index 0000000..569b9be --- /dev/null +++ b/assets/art/characters/boss/air_combo_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bynbutty3wol1" +path="res://.godot/imported/air_combo_3.png-48729080eefe4179fc1678034a955d1e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/air_combo_3.png" +dest_files=["res://.godot/imported/air_combo_3.png-48729080eefe4179fc1678034a955d1e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/air_shoot_gun_1.png b/assets/art/characters/boss/air_shoot_gun_1.png new file mode 100644 index 0000000..f2800c3 Binary files /dev/null and b/assets/art/characters/boss/air_shoot_gun_1.png differ diff --git a/assets/art/characters/boss/air_shoot_gun_1.png.import b/assets/art/characters/boss/air_shoot_gun_1.png.import new file mode 100644 index 0000000..efbe938 --- /dev/null +++ b/assets/art/characters/boss/air_shoot_gun_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cog6ryblxfsbu" +path="res://.godot/imported/air_shoot_gun_1.png-f2596b973ce068b4feb1a666a507649d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/air_shoot_gun_1.png" +dest_files=["res://.godot/imported/air_shoot_gun_1.png-f2596b973ce068b4feb1a666a507649d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/air_shoot_gun_2.png b/assets/art/characters/boss/air_shoot_gun_2.png new file mode 100644 index 0000000..4705d2a Binary files /dev/null and b/assets/art/characters/boss/air_shoot_gun_2.png differ diff --git a/assets/art/characters/boss/air_shoot_gun_2.png.import b/assets/art/characters/boss/air_shoot_gun_2.png.import new file mode 100644 index 0000000..4177778 --- /dev/null +++ b/assets/art/characters/boss/air_shoot_gun_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfrmmmbmuk5i8" +path="res://.godot/imported/air_shoot_gun_2.png-6da5f2bbf321ec085b82fc58c4ab28e2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/air_shoot_gun_2.png" +dest_files=["res://.godot/imported/air_shoot_gun_2.png-6da5f2bbf321ec085b82fc58c4ab28e2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_2way_shoot_1.png b/assets/art/characters/boss/boss_2way_shoot_1.png new file mode 100644 index 0000000..72aa9b6 Binary files /dev/null and b/assets/art/characters/boss/boss_2way_shoot_1.png differ diff --git a/assets/art/characters/boss/boss_2way_shoot_1.png.import b/assets/art/characters/boss/boss_2way_shoot_1.png.import new file mode 100644 index 0000000..5bd4151 --- /dev/null +++ b/assets/art/characters/boss/boss_2way_shoot_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1c3a2umxrw6d" +path="res://.godot/imported/boss_2way_shoot_1.png-105e5ca67b10b13e7cad96d51ee06ffc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_2way_shoot_1.png" +dest_files=["res://.godot/imported/boss_2way_shoot_1.png-105e5ca67b10b13e7cad96d51ee06ffc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_2way_shoot_2.png b/assets/art/characters/boss/boss_2way_shoot_2.png new file mode 100644 index 0000000..dc1e6a5 Binary files /dev/null and b/assets/art/characters/boss/boss_2way_shoot_2.png differ diff --git a/assets/art/characters/boss/boss_2way_shoot_2.png.import b/assets/art/characters/boss/boss_2way_shoot_2.png.import new file mode 100644 index 0000000..902cf3f --- /dev/null +++ b/assets/art/characters/boss/boss_2way_shoot_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvsgusskbcrfg" +path="res://.godot/imported/boss_2way_shoot_2.png-9f02c05700bf232f0d3d49671d744f9a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_2way_shoot_2.png" +dest_files=["res://.godot/imported/boss_2way_shoot_2.png-9f02c05700bf232f0d3d49671d744f9a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_air_combo_1.png b/assets/art/characters/boss/boss_air_combo_1.png new file mode 100644 index 0000000..ac04a4c Binary files /dev/null and b/assets/art/characters/boss/boss_air_combo_1.png differ diff --git a/assets/art/characters/boss/boss_air_combo_1.png.import b/assets/art/characters/boss/boss_air_combo_1.png.import new file mode 100644 index 0000000..29e0a67 --- /dev/null +++ b/assets/art/characters/boss/boss_air_combo_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyjsd81iayv0o" +path="res://.godot/imported/boss_air_combo_1.png-461b66c4428560da492c5172693c5e69.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_air_combo_1.png" +dest_files=["res://.godot/imported/boss_air_combo_1.png-461b66c4428560da492c5172693c5e69.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_air_combo_2.png b/assets/art/characters/boss/boss_air_combo_2.png new file mode 100644 index 0000000..a938e7b Binary files /dev/null and b/assets/art/characters/boss/boss_air_combo_2.png differ diff --git a/assets/art/characters/boss/boss_air_combo_2.png.import b/assets/art/characters/boss/boss_air_combo_2.png.import new file mode 100644 index 0000000..1b56840 --- /dev/null +++ b/assets/art/characters/boss/boss_air_combo_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bp6frt5odm48e" +path="res://.godot/imported/boss_air_combo_2.png-0eac852d0dea9ac4ba038c63c5027518.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_air_combo_2.png" +dest_files=["res://.godot/imported/boss_air_combo_2.png-0eac852d0dea9ac4ba038c63c5027518.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_air_combo_3.png b/assets/art/characters/boss/boss_air_combo_3.png new file mode 100644 index 0000000..bdfd667 Binary files /dev/null and b/assets/art/characters/boss/boss_air_combo_3.png differ diff --git a/assets/art/characters/boss/boss_air_combo_3.png.import b/assets/art/characters/boss/boss_air_combo_3.png.import new file mode 100644 index 0000000..4e6e486 --- /dev/null +++ b/assets/art/characters/boss/boss_air_combo_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8utuq363ejrk" +path="res://.godot/imported/boss_air_combo_3.png-7385f81b15c61d5628da07b6149e9ae1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_air_combo_3.png" +dest_files=["res://.godot/imported/boss_air_combo_3.png-7385f81b15c61d5628da07b6149e9ae1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_air_shoot_1.png b/assets/art/characters/boss/boss_air_shoot_1.png new file mode 100644 index 0000000..f2800c3 Binary files /dev/null and b/assets/art/characters/boss/boss_air_shoot_1.png differ diff --git a/assets/art/characters/boss/boss_air_shoot_1.png.import b/assets/art/characters/boss/boss_air_shoot_1.png.import new file mode 100644 index 0000000..9c7b028 --- /dev/null +++ b/assets/art/characters/boss/boss_air_shoot_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c10nvcq02uu0e" +path="res://.godot/imported/boss_air_shoot_1.png-c5726c40d642336ff47ae5c05c83799e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_air_shoot_1.png" +dest_files=["res://.godot/imported/boss_air_shoot_1.png-c5726c40d642336ff47ae5c05c83799e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_air_shoot_2.png b/assets/art/characters/boss/boss_air_shoot_2.png new file mode 100644 index 0000000..4705d2a Binary files /dev/null and b/assets/art/characters/boss/boss_air_shoot_2.png differ diff --git a/assets/art/characters/boss/boss_air_shoot_2.png.import b/assets/art/characters/boss/boss_air_shoot_2.png.import new file mode 100644 index 0000000..1ecf190 --- /dev/null +++ b/assets/art/characters/boss/boss_air_shoot_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://baowufi1mn6ps" +path="res://.godot/imported/boss_air_shoot_2.png-1b704465938678c8ea3a4db7f2f33a7a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_air_shoot_2.png" +dest_files=["res://.godot/imported/boss_air_shoot_2.png-1b704465938678c8ea3a4db7f2f33a7a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_dash.png b/assets/art/characters/boss/boss_dash.png new file mode 100644 index 0000000..171e29f Binary files /dev/null and b/assets/art/characters/boss/boss_dash.png differ diff --git a/assets/art/characters/boss/boss_dash.png.import b/assets/art/characters/boss/boss_dash.png.import new file mode 100644 index 0000000..14a2db2 --- /dev/null +++ b/assets/art/characters/boss/boss_dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1op43u05o7t0" +path="res://.godot/imported/boss_dash.png-d0cdc02d5fd65ba054eb385ec71c7331.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_dash.png" +dest_files=["res://.godot/imported/boss_dash.png-d0cdc02d5fd65ba054eb385ec71c7331.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_die.png b/assets/art/characters/boss/boss_die.png new file mode 100644 index 0000000..8e9304e Binary files /dev/null and b/assets/art/characters/boss/boss_die.png differ diff --git a/assets/art/characters/boss/boss_die.png.import b/assets/art/characters/boss/boss_die.png.import new file mode 100644 index 0000000..e86932c --- /dev/null +++ b/assets/art/characters/boss/boss_die.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqed0collo78g" +path="res://.godot/imported/boss_die.png-7ae85dacd27a921d4ccb82867b9e6940.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_die.png" +dest_files=["res://.godot/imported/boss_die.png-7ae85dacd27a921d4ccb82867b9e6940.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_ground_combo_1.png b/assets/art/characters/boss/boss_ground_combo_1.png new file mode 100644 index 0000000..240b28c Binary files /dev/null and b/assets/art/characters/boss/boss_ground_combo_1.png differ diff --git a/assets/art/characters/boss/boss_ground_combo_1.png.import b/assets/art/characters/boss/boss_ground_combo_1.png.import new file mode 100644 index 0000000..f46477b --- /dev/null +++ b/assets/art/characters/boss/boss_ground_combo_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtq7gsshx374h" +path="res://.godot/imported/boss_ground_combo_1.png-bf1ba1fd07fedb1a6d976d9d1ab2feec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_ground_combo_1.png" +dest_files=["res://.godot/imported/boss_ground_combo_1.png-bf1ba1fd07fedb1a6d976d9d1ab2feec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_ground_combo_2.png b/assets/art/characters/boss/boss_ground_combo_2.png new file mode 100644 index 0000000..c223b38 Binary files /dev/null and b/assets/art/characters/boss/boss_ground_combo_2.png differ diff --git a/assets/art/characters/boss/boss_ground_combo_2.png.import b/assets/art/characters/boss/boss_ground_combo_2.png.import new file mode 100644 index 0000000..f2decac --- /dev/null +++ b/assets/art/characters/boss/boss_ground_combo_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c13lkv6pqqmq4" +path="res://.godot/imported/boss_ground_combo_2.png-1a0ce07fd0e611495504c34a9d8e5685.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_ground_combo_2.png" +dest_files=["res://.godot/imported/boss_ground_combo_2.png-1a0ce07fd0e611495504c34a9d8e5685.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_ground_combo_3.png b/assets/art/characters/boss/boss_ground_combo_3.png new file mode 100644 index 0000000..6422143 Binary files /dev/null and b/assets/art/characters/boss/boss_ground_combo_3.png differ diff --git a/assets/art/characters/boss/boss_ground_combo_3.png.import b/assets/art/characters/boss/boss_ground_combo_3.png.import new file mode 100644 index 0000000..7a5df11 --- /dev/null +++ b/assets/art/characters/boss/boss_ground_combo_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjjp6cb1fyqrl" +path="res://.godot/imported/boss_ground_combo_3.png-afa139b04cfb030b837fea467e3cea2b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_ground_combo_3.png" +dest_files=["res://.godot/imported/boss_ground_combo_3.png-afa139b04cfb030b837fea467e3cea2b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_idle.png b/assets/art/characters/boss/boss_idle.png new file mode 100644 index 0000000..e24dbc7 Binary files /dev/null and b/assets/art/characters/boss/boss_idle.png differ diff --git a/assets/art/characters/boss/boss_idle.png.import b/assets/art/characters/boss/boss_idle.png.import new file mode 100644 index 0000000..f3c0ebc --- /dev/null +++ b/assets/art/characters/boss/boss_idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2x7g4eslsmbd" +path="res://.godot/imported/boss_idle.png-3cbcd77d59aff5e54306df33df449ad3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_idle.png" +dest_files=["res://.godot/imported/boss_idle.png-3cbcd77d59aff5e54306df33df449ad3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_jump_fall.png b/assets/art/characters/boss/boss_jump_fall.png new file mode 100644 index 0000000..b332e2c Binary files /dev/null and b/assets/art/characters/boss/boss_jump_fall.png differ diff --git a/assets/art/characters/boss/boss_jump_fall.png.import b/assets/art/characters/boss/boss_jump_fall.png.import new file mode 100644 index 0000000..d81eac7 --- /dev/null +++ b/assets/art/characters/boss/boss_jump_fall.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcnschu3gasrr" +path="res://.godot/imported/boss_jump_fall.png-2cc9d90f529fd85cd6f761e95ac210dd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_jump_fall.png" +dest_files=["res://.godot/imported/boss_jump_fall.png-2cc9d90f529fd85cd6f761e95ac210dd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_lunging_stab.png b/assets/art/characters/boss/boss_lunging_stab.png new file mode 100644 index 0000000..cb6e70b Binary files /dev/null and b/assets/art/characters/boss/boss_lunging_stab.png differ diff --git a/assets/art/characters/boss/boss_lunging_stab.png.import b/assets/art/characters/boss/boss_lunging_stab.png.import new file mode 100644 index 0000000..46be341 --- /dev/null +++ b/assets/art/characters/boss/boss_lunging_stab.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3tkykbinkcfo" +path="res://.godot/imported/boss_lunging_stab.png-405a2268025b7b606f421f91a1db79a9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_lunging_stab.png" +dest_files=["res://.godot/imported/boss_lunging_stab.png-405a2268025b7b606f421f91a1db79a9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_run.png b/assets/art/characters/boss/boss_run.png new file mode 100644 index 0000000..ab3e955 Binary files /dev/null and b/assets/art/characters/boss/boss_run.png differ diff --git a/assets/art/characters/boss/boss_run.png.import b/assets/art/characters/boss/boss_run.png.import new file mode 100644 index 0000000..a1cbd93 --- /dev/null +++ b/assets/art/characters/boss/boss_run.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pxv28vixapdc" +path="res://.godot/imported/boss_run.png-007779d040963f6a8e563188f36c6978.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_run.png" +dest_files=["res://.godot/imported/boss_run.png-007779d040963f6a8e563188f36c6978.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_shoot_1.png b/assets/art/characters/boss/boss_shoot_1.png new file mode 100644 index 0000000..16037fc Binary files /dev/null and b/assets/art/characters/boss/boss_shoot_1.png differ diff --git a/assets/art/characters/boss/boss_shoot_1.png.import b/assets/art/characters/boss/boss_shoot_1.png.import new file mode 100644 index 0000000..ea5773e --- /dev/null +++ b/assets/art/characters/boss/boss_shoot_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://knpsxyirn1j0" +path="res://.godot/imported/boss_shoot_1.png-86896e5a568293980762f71e1a7d18cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_shoot_1.png" +dest_files=["res://.godot/imported/boss_shoot_1.png-86896e5a568293980762f71e1a7d18cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_shoot_2.png b/assets/art/characters/boss/boss_shoot_2.png new file mode 100644 index 0000000..bb3cd0b Binary files /dev/null and b/assets/art/characters/boss/boss_shoot_2.png differ diff --git a/assets/art/characters/boss/boss_shoot_2.png.import b/assets/art/characters/boss/boss_shoot_2.png.import new file mode 100644 index 0000000..af4f57e --- /dev/null +++ b/assets/art/characters/boss/boss_shoot_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkwlsts8g1ntc" +path="res://.godot/imported/boss_shoot_2.png-202e042892f99dbcc0d9dd66acc00158.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_shoot_2.png" +dest_files=["res://.godot/imported/boss_shoot_2.png-202e042892f99dbcc0d9dd66acc00158.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/boss_take_hit.png b/assets/art/characters/boss/boss_take_hit.png new file mode 100644 index 0000000..f676a45 Binary files /dev/null and b/assets/art/characters/boss/boss_take_hit.png differ diff --git a/assets/art/characters/boss/boss_take_hit.png.import b/assets/art/characters/boss/boss_take_hit.png.import new file mode 100644 index 0000000..fa38aa6 --- /dev/null +++ b/assets/art/characters/boss/boss_take_hit.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://16vtjn3c03av" +path="res://.godot/imported/boss_take_hit.png-51bc28a52b3a11db26818acff92009c1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/boss_take_hit.png" +dest_files=["res://.godot/imported/boss_take_hit.png-51bc28a52b3a11db26818acff92009c1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/dash.png b/assets/art/characters/boss/dash.png new file mode 100644 index 0000000..171e29f Binary files /dev/null and b/assets/art/characters/boss/dash.png differ diff --git a/assets/art/characters/boss/dash.png.import b/assets/art/characters/boss/dash.png.import new file mode 100644 index 0000000..d1d0ad8 --- /dev/null +++ b/assets/art/characters/boss/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hqj0e2qdofx6" +path="res://.godot/imported/dash.png-a941c76df0de05c8cbe56a75cf6d0768.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/dash.png" +dest_files=["res://.godot/imported/dash.png-a941c76df0de05c8cbe56a75cf6d0768.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/die.png b/assets/art/characters/boss/die.png new file mode 100644 index 0000000..8e9304e Binary files /dev/null and b/assets/art/characters/boss/die.png differ diff --git a/assets/art/characters/boss/die.png.import b/assets/art/characters/boss/die.png.import new file mode 100644 index 0000000..4d9ed29 --- /dev/null +++ b/assets/art/characters/boss/die.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bq6evsvjp0i26" +path="res://.godot/imported/die.png-846bef198a5b0e9c44d82e773d13d1f0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/die.png" +dest_files=["res://.godot/imported/die.png-846bef198a5b0e9c44d82e773d13d1f0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/ground_combo_1.png b/assets/art/characters/boss/ground_combo_1.png new file mode 100644 index 0000000..240b28c Binary files /dev/null and b/assets/art/characters/boss/ground_combo_1.png differ diff --git a/assets/art/characters/boss/ground_combo_1.png.import b/assets/art/characters/boss/ground_combo_1.png.import new file mode 100644 index 0000000..886dbb5 --- /dev/null +++ b/assets/art/characters/boss/ground_combo_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://briqouejroe8l" +path="res://.godot/imported/ground_combo_1.png-258e9a34073f93da850fec7961f0fc85.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/ground_combo_1.png" +dest_files=["res://.godot/imported/ground_combo_1.png-258e9a34073f93da850fec7961f0fc85.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/ground_combo_2.png b/assets/art/characters/boss/ground_combo_2.png new file mode 100644 index 0000000..c223b38 Binary files /dev/null and b/assets/art/characters/boss/ground_combo_2.png differ diff --git a/assets/art/characters/boss/ground_combo_2.png.import b/assets/art/characters/boss/ground_combo_2.png.import new file mode 100644 index 0000000..608ebb3 --- /dev/null +++ b/assets/art/characters/boss/ground_combo_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctoy4lv3bsr0k" +path="res://.godot/imported/ground_combo_2.png-bfc8dac4fccca7bf930528b706be6155.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/ground_combo_2.png" +dest_files=["res://.godot/imported/ground_combo_2.png-bfc8dac4fccca7bf930528b706be6155.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/ground_combo_3.png b/assets/art/characters/boss/ground_combo_3.png new file mode 100644 index 0000000..6422143 Binary files /dev/null and b/assets/art/characters/boss/ground_combo_3.png differ diff --git a/assets/art/characters/boss/ground_combo_3.png.import b/assets/art/characters/boss/ground_combo_3.png.import new file mode 100644 index 0000000..ce6d41f --- /dev/null +++ b/assets/art/characters/boss/ground_combo_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://deoye1thqw3a4" +path="res://.godot/imported/ground_combo_3.png-02cd130703e621dc54c27f2991eca039.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/ground_combo_3.png" +dest_files=["res://.godot/imported/ground_combo_3.png-02cd130703e621dc54c27f2991eca039.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/idle.png b/assets/art/characters/boss/idle.png new file mode 100644 index 0000000..e24dbc7 Binary files /dev/null and b/assets/art/characters/boss/idle.png differ diff --git a/assets/art/characters/boss/idle.png.import b/assets/art/characters/boss/idle.png.import new file mode 100644 index 0000000..ceda5d4 --- /dev/null +++ b/assets/art/characters/boss/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dn561nvux3nya" +path="res://.godot/imported/idle.png-3d9c9a157a01b6a932116f49031a760c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/idle.png" +dest_files=["res://.godot/imported/idle.png-3d9c9a157a01b6a932116f49031a760c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/jump_and_fall.png b/assets/art/characters/boss/jump_and_fall.png new file mode 100644 index 0000000..b332e2c Binary files /dev/null and b/assets/art/characters/boss/jump_and_fall.png differ diff --git a/assets/art/characters/boss/jump_and_fall.png.import b/assets/art/characters/boss/jump_and_fall.png.import new file mode 100644 index 0000000..daec4e4 --- /dev/null +++ b/assets/art/characters/boss/jump_and_fall.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5oncdbcpdri3" +path="res://.godot/imported/jump_and_fall.png-af4be62fe5712cde243617da4ba9fa19.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/jump_and_fall.png" +dest_files=["res://.godot/imported/jump_and_fall.png-af4be62fe5712cde243617da4ba9fa19.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/lunging_stab.png b/assets/art/characters/boss/lunging_stab.png new file mode 100644 index 0000000..cb6e70b Binary files /dev/null and b/assets/art/characters/boss/lunging_stab.png differ diff --git a/assets/art/characters/boss/lunging_stab.png.import b/assets/art/characters/boss/lunging_stab.png.import new file mode 100644 index 0000000..54dd701 --- /dev/null +++ b/assets/art/characters/boss/lunging_stab.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dmyogu14n0wql" +path="res://.godot/imported/lunging_stab.png-1a6e0ed963c55f79e589f143e1d99130.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/lunging_stab.png" +dest_files=["res://.godot/imported/lunging_stab.png-1a6e0ed963c55f79e589f143e1d99130.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/png.txt b/assets/art/characters/boss/png.txt new file mode 100644 index 0000000..a24aff8 --- /dev/null +++ b/assets/art/characters/boss/png.txt @@ -0,0 +1,19 @@ +2WayShootGun1: 一行两列 +2WayShootGun2: 一行两列 +AirCombo1: 一行七列 +AirCombo2: 一行七列 +AirCombo3: 一行九列 +AirShootGun1: 一行两列 +AirShootGun2: 一行两列 +Dash: 一行六列 +Die: 一行四列 +GroundCombo1: 一行八列 +GroundCombo2: 一行十列 +GroundCombo3: 一行十四列 +Idle: 一行一列 +JumpAndFall: 一行两列 +LungingStab: 一行十五列 +Run: 一行八列 +ShootGun1: 一行两列 +ShootGun2: 一行两列 +TakeHit: 一行一列 \ No newline at end of file diff --git a/assets/art/characters/boss/run.png b/assets/art/characters/boss/run.png new file mode 100644 index 0000000..ab3e955 Binary files /dev/null and b/assets/art/characters/boss/run.png differ diff --git a/assets/art/characters/boss/run.png.import b/assets/art/characters/boss/run.png.import new file mode 100644 index 0000000..1b0ed22 --- /dev/null +++ b/assets/art/characters/boss/run.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cytnr7vhvykv3" +path="res://.godot/imported/run.png-f328b97c416fc523989723fcb0d37156.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/run.png" +dest_files=["res://.godot/imported/run.png-f328b97c416fc523989723fcb0d37156.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/shoot_gun_1.png b/assets/art/characters/boss/shoot_gun_1.png new file mode 100644 index 0000000..16037fc Binary files /dev/null and b/assets/art/characters/boss/shoot_gun_1.png differ diff --git a/assets/art/characters/boss/shoot_gun_1.png.import b/assets/art/characters/boss/shoot_gun_1.png.import new file mode 100644 index 0000000..0d89348 --- /dev/null +++ b/assets/art/characters/boss/shoot_gun_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bp5cets33rxbf" +path="res://.godot/imported/shoot_gun_1.png-94a99f52b53b7ef87e01b47b13aa6423.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/shoot_gun_1.png" +dest_files=["res://.godot/imported/shoot_gun_1.png-94a99f52b53b7ef87e01b47b13aa6423.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/shoot_gun_2.png b/assets/art/characters/boss/shoot_gun_2.png new file mode 100644 index 0000000..bb3cd0b Binary files /dev/null and b/assets/art/characters/boss/shoot_gun_2.png differ diff --git a/assets/art/characters/boss/shoot_gun_2.png.import b/assets/art/characters/boss/shoot_gun_2.png.import new file mode 100644 index 0000000..a764c01 --- /dev/null +++ b/assets/art/characters/boss/shoot_gun_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://4fujltlr7wyf" +path="res://.godot/imported/shoot_gun_2.png-5bfa08e844e9a1c84cc42e92c2f676be.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/shoot_gun_2.png" +dest_files=["res://.godot/imported/shoot_gun_2.png-5bfa08e844e9a1c84cc42e92c2f676be.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/boss/take_hit.png b/assets/art/characters/boss/take_hit.png new file mode 100644 index 0000000..f676a45 Binary files /dev/null and b/assets/art/characters/boss/take_hit.png differ diff --git a/assets/art/characters/boss/take_hit.png.import b/assets/art/characters/boss/take_hit.png.import new file mode 100644 index 0000000..0fd0a7e --- /dev/null +++ b/assets/art/characters/boss/take_hit.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kyedmn7ge0da" +path="res://.godot/imported/take_hit.png-2f42969b28b0013793ccf00f9327765e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/boss/take_hit.png" +dest_files=["res://.godot/imported/take_hit.png-2f42969b28b0013793ccf00f9327765e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_1/attack.png b/assets/art/characters/minions/jin_zhan_1/attack.png new file mode 100644 index 0000000..a5e4763 Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_1/attack.png differ diff --git a/assets/art/characters/minions/jin_zhan_1/attack.png.import b/assets/art/characters/minions/jin_zhan_1/attack.png.import new file mode 100644 index 0000000..4ff38a1 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_1/attack.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bd23tvcdrneiy" +path="res://.godot/imported/attack.png-ae30fa92abf4f421e357e1de5b9cc089.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_1/attack.png" +dest_files=["res://.godot/imported/attack.png-ae30fa92abf4f421e357e1de5b9cc089.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_1/dash.png b/assets/art/characters/minions/jin_zhan_1/dash.png new file mode 100644 index 0000000..92b296d Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_1/dash.png differ diff --git a/assets/art/characters/minions/jin_zhan_1/dash.png.import b/assets/art/characters/minions/jin_zhan_1/dash.png.import new file mode 100644 index 0000000..1f0bd0c --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_1/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpmjp25bh5foq" +path="res://.godot/imported/dash.png-df627170157d668a022519f2b90946dc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_1/dash.png" +dest_files=["res://.godot/imported/dash.png-df627170157d668a022519f2b90946dc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_1/death.png b/assets/art/characters/minions/jin_zhan_1/death.png new file mode 100644 index 0000000..4590648 Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_1/death.png differ diff --git a/assets/art/characters/minions/jin_zhan_1/death.png.import b/assets/art/characters/minions/jin_zhan_1/death.png.import new file mode 100644 index 0000000..37aae93 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_1/death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4ibq4a4f3uwg" +path="res://.godot/imported/death.png-056726fdbdab40b57fa11bce54cc3afc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_1/death.png" +dest_files=["res://.godot/imported/death.png-056726fdbdab40b57fa11bce54cc3afc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_1/idle.png b/assets/art/characters/minions/jin_zhan_1/idle.png new file mode 100644 index 0000000..4a60b47 Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_1/idle.png differ diff --git a/assets/art/characters/minions/jin_zhan_1/idle.png.import b/assets/art/characters/minions/jin_zhan_1/idle.png.import new file mode 100644 index 0000000..5187e35 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_1/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dawx3iqdvejq2" +path="res://.godot/imported/idle.png-e10babc859585553de9b89cf20a67ffb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_1/idle.png" +dest_files=["res://.godot/imported/idle.png-e10babc859585553de9b89cf20a67ffb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/attack_1.png b/assets/art/characters/minions/jin_zhan_3/attack_1.png new file mode 100644 index 0000000..ecfba8f Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/attack_1.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/attack_1.png.import b/assets/art/characters/minions/jin_zhan_3/attack_1.png.import new file mode 100644 index 0000000..cdb6211 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/attack_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d7nsv45n7al5" +path="res://.godot/imported/attack_1.png-c301570661a35af56e407ef1b5f91909.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/attack_1.png" +dest_files=["res://.godot/imported/attack_1.png-c301570661a35af56e407ef1b5f91909.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/attack_2.png b/assets/art/characters/minions/jin_zhan_3/attack_2.png new file mode 100644 index 0000000..1be22c1 Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/attack_2.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/attack_2.png.import b/assets/art/characters/minions/jin_zhan_3/attack_2.png.import new file mode 100644 index 0000000..a03bad1 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/attack_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://irfy64au20w6" +path="res://.godot/imported/attack_2.png-346fe47b6c2db559110010e71f4131ba.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/attack_2.png" +dest_files=["res://.godot/imported/attack_2.png-346fe47b6c2db559110010e71f4131ba.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/attack_3.png b/assets/art/characters/minions/jin_zhan_3/attack_3.png new file mode 100644 index 0000000..d1c98ad Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/attack_3.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/attack_3.png.import b/assets/art/characters/minions/jin_zhan_3/attack_3.png.import new file mode 100644 index 0000000..69ea222 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/attack_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://p4a8ihp74ftr" +path="res://.godot/imported/attack_3.png-4fca4eab40cb2980d291ca11f198a2bc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/attack_3.png" +dest_files=["res://.godot/imported/attack_3.png-4fca4eab40cb2980d291ca11f198a2bc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/dash.png b/assets/art/characters/minions/jin_zhan_3/dash.png new file mode 100644 index 0000000..794557a Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/dash.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/dash.png.import b/assets/art/characters/minions/jin_zhan_3/dash.png.import new file mode 100644 index 0000000..7bfa71a --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccadi8mbycfim" +path="res://.godot/imported/dash.png-e553dd65f879f3f9da73c7f1869562de.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/dash.png" +dest_files=["res://.godot/imported/dash.png-e553dd65f879f3f9da73c7f1869562de.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/death.png b/assets/art/characters/minions/jin_zhan_3/death.png new file mode 100644 index 0000000..5ffdc5d Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/death.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/death.png.import b/assets/art/characters/minions/jin_zhan_3/death.png.import new file mode 100644 index 0000000..c8c4a71 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dom3e0ry3yvit" +path="res://.godot/imported/death.png-85092e82902a212a33b4b387ef2a8079.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/death.png" +dest_files=["res://.godot/imported/death.png-85092e82902a212a33b4b387ef2a8079.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/jin_zhan_3/idle.png b/assets/art/characters/minions/jin_zhan_3/idle.png new file mode 100644 index 0000000..47ddca9 Binary files /dev/null and b/assets/art/characters/minions/jin_zhan_3/idle.png differ diff --git a/assets/art/characters/minions/jin_zhan_3/idle.png.import b/assets/art/characters/minions/jin_zhan_3/idle.png.import new file mode 100644 index 0000000..ff24643 --- /dev/null +++ b/assets/art/characters/minions/jin_zhan_3/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhlmbm47hy46o" +path="res://.godot/imported/idle.png-c3a5d8dd29160db87de1530431d4fefe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/jin_zhan_3/idle.png" +dest_files=["res://.godot/imported/idle.png-c3a5d8dd29160db87de1530431d4fefe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_1/attack_1.png b/assets/art/characters/minions/yuan_cheng_1/attack_1.png new file mode 100644 index 0000000..2a84ba9 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_1/attack_1.png differ diff --git a/assets/art/characters/minions/yuan_cheng_1/attack_1.png.import b/assets/art/characters/minions/yuan_cheng_1/attack_1.png.import new file mode 100644 index 0000000..b3c301d --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_1/attack_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ml0usjq4rfvv" +path="res://.godot/imported/attack_1.png-ec7e07ac02362bf86360f340bcc01bea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_1/attack_1.png" +dest_files=["res://.godot/imported/attack_1.png-ec7e07ac02362bf86360f340bcc01bea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_1/attack_2.png b/assets/art/characters/minions/yuan_cheng_1/attack_2.png new file mode 100644 index 0000000..5722dd7 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_1/attack_2.png differ diff --git a/assets/art/characters/minions/yuan_cheng_1/attack_2.png.import b/assets/art/characters/minions/yuan_cheng_1/attack_2.png.import new file mode 100644 index 0000000..1d76daa --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_1/attack_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsjw6a57e5f5o" +path="res://.godot/imported/attack_2.png-d180d179af18b666bf85aeee9eae69d4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_1/attack_2.png" +dest_files=["res://.godot/imported/attack_2.png-d180d179af18b666bf85aeee9eae69d4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_1/dash.png b/assets/art/characters/minions/yuan_cheng_1/dash.png new file mode 100644 index 0000000..1b8b3a2 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_1/dash.png differ diff --git a/assets/art/characters/minions/yuan_cheng_1/dash.png.import b/assets/art/characters/minions/yuan_cheng_1/dash.png.import new file mode 100644 index 0000000..9cd45da --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_1/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bd481s55j1i2b" +path="res://.godot/imported/dash.png-2696721e25ec97d3983913244635d708.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_1/dash.png" +dest_files=["res://.godot/imported/dash.png-2696721e25ec97d3983913244635d708.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_1/death.png b/assets/art/characters/minions/yuan_cheng_1/death.png new file mode 100644 index 0000000..3b16da6 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_1/death.png differ diff --git a/assets/art/characters/minions/yuan_cheng_1/death.png.import b/assets/art/characters/minions/yuan_cheng_1/death.png.import new file mode 100644 index 0000000..7770ce9 --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_1/death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfpx43wqnodsf" +path="res://.godot/imported/death.png-51880ca16b93a81162eb379239f18bd4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_1/death.png" +dest_files=["res://.godot/imported/death.png-51880ca16b93a81162eb379239f18bd4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_1/idle.png b/assets/art/characters/minions/yuan_cheng_1/idle.png new file mode 100644 index 0000000..2cba61a Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_1/idle.png differ diff --git a/assets/art/characters/minions/yuan_cheng_1/idle.png.import b/assets/art/characters/minions/yuan_cheng_1/idle.png.import new file mode 100644 index 0000000..8d162c6 --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_1/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpkxd5414ed0w" +path="res://.godot/imported/idle.png-6e91c5c591353f0a7f95985fd2dee0ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_1/idle.png" +dest_files=["res://.godot/imported/idle.png-6e91c5c591353f0a7f95985fd2dee0ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_2/attack.png b/assets/art/characters/minions/yuan_cheng_2/attack.png new file mode 100644 index 0000000..5143769 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_2/attack.png differ diff --git a/assets/art/characters/minions/yuan_cheng_2/attack.png.import b/assets/art/characters/minions/yuan_cheng_2/attack.png.import new file mode 100644 index 0000000..f7c79bb --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_2/attack.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtub77ctmuhjb" +path="res://.godot/imported/attack.png-c3ad50a885c319a42f454b4b2fb0a7e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_2/attack.png" +dest_files=["res://.godot/imported/attack.png-c3ad50a885c319a42f454b4b2fb0a7e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_2/dash.png b/assets/art/characters/minions/yuan_cheng_2/dash.png new file mode 100644 index 0000000..575c6d2 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_2/dash.png differ diff --git a/assets/art/characters/minions/yuan_cheng_2/dash.png.import b/assets/art/characters/minions/yuan_cheng_2/dash.png.import new file mode 100644 index 0000000..835ba3f --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_2/dash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmrcm8uaev5m2" +path="res://.godot/imported/dash.png-5d64d4aa76f3a0b9b3f93daa7b920a42.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_2/dash.png" +dest_files=["res://.godot/imported/dash.png-5d64d4aa76f3a0b9b3f93daa7b920a42.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_2/death.png b/assets/art/characters/minions/yuan_cheng_2/death.png new file mode 100644 index 0000000..e690135 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_2/death.png differ diff --git a/assets/art/characters/minions/yuan_cheng_2/death.png.import b/assets/art/characters/minions/yuan_cheng_2/death.png.import new file mode 100644 index 0000000..eace98d --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_2/death.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0uutti3e4hfr" +path="res://.godot/imported/death.png-32f511c8f62511544e8ba419dd50d9a2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_2/death.png" +dest_files=["res://.godot/imported/death.png-32f511c8f62511544e8ba419dd50d9a2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/minions/yuan_cheng_2/idle.png b/assets/art/characters/minions/yuan_cheng_2/idle.png new file mode 100644 index 0000000..43cf3d6 Binary files /dev/null and b/assets/art/characters/minions/yuan_cheng_2/idle.png differ diff --git a/assets/art/characters/minions/yuan_cheng_2/idle.png.import b/assets/art/characters/minions/yuan_cheng_2/idle.png.import new file mode 100644 index 0000000..08844c9 --- /dev/null +++ b/assets/art/characters/minions/yuan_cheng_2/idle.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyjhn5l13y34x" +path="res://.godot/imported/idle.png-278b16809b615420a0a637bf1fb5aeca.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/minions/yuan_cheng_2/idle.png" +dest_files=["res://.godot/imported/idle.png-278b16809b615420a0a637bf1fb5aeca.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/01_basic_attack_i/air_prep.png b/assets/art/characters/player/01_basic_attack_i/air_prep.png new file mode 100644 index 0000000..9a1a0be Binary files /dev/null and b/assets/art/characters/player/01_basic_attack_i/air_prep.png differ diff --git a/assets/art/characters/player/01_basic_attack_i/air_prep.png.import b/assets/art/characters/player/01_basic_attack_i/air_prep.png.import new file mode 100644 index 0000000..26b3f9a --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/air_prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ev67ggo5sqke" +path="res://.godot/imported/air_prep.png-0f433228dfc4296d973aa9a779436d20.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/01_basic_attack_i/air_prep.png" +dest_files=["res://.godot/imported/air_prep.png-0f433228dfc4296d973aa9a779436d20.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/01_basic_attack_i/air_swing.png b/assets/art/characters/player/01_basic_attack_i/air_swing.png new file mode 100644 index 0000000..e59735a Binary files /dev/null and b/assets/art/characters/player/01_basic_attack_i/air_swing.png differ diff --git a/assets/art/characters/player/01_basic_attack_i/air_swing.png.import b/assets/art/characters/player/01_basic_attack_i/air_swing.png.import new file mode 100644 index 0000000..bd934ce --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/air_swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drp03akucitt1" +path="res://.godot/imported/air_swing.png-cca44df4ff226790d405b687154db815.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/01_basic_attack_i/air_swing.png" +dest_files=["res://.godot/imported/air_swing.png-cca44df4ff226790d405b687154db815.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/01_basic_attack_i/basic_attack_i.txt b/assets/art/characters/player/01_basic_attack_i/basic_attack_i.txt new file mode 100644 index 0000000..ed6f499 --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/basic_attack_i.txt @@ -0,0 +1,15 @@ +动作名称:普攻一段 +编号:01 +分类:普攻 +释放条件:[地面] +按键判定:[A] +技能描述:向前小幅移动并挥出一剑,是地面连段的起手动作。 +前缀动作:无 +后缀动作:普攻二段 [A][A] / 突进 [A][sp] / A 蓄力 +效果:无 +基础伤害:100 +基础横向位移:100 +基础纵向位移:0 +能量回复:10 +能量消耗:0 +动作时长:20FPS \ No newline at end of file diff --git a/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png b/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png new file mode 100644 index 0000000..1d389dc Binary files /dev/null and b/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png differ diff --git a/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png.import b/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png.import new file mode 100644 index 0000000..3cf0e4a --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d36hkby5rhr7k" +path="res://.godot/imported/enemy_contact_fx.png-e31546e6e35b86e2978f80dc78260259.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png" +dest_files=["res://.godot/imported/enemy_contact_fx.png-e31546e6e35b86e2978f80dc78260259.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/01_basic_attack_i/png.txt b/assets/art/characters/player/01_basic_attack_i/png.txt new file mode 100644 index 0000000..88fe743 --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/png.txt @@ -0,0 +1,4 @@ +[AIR] 1 PREP: 一行三列 +[AIR] 1 SWING: 一行三列 +ENEMY CONTACT FX: 一行七列 +SLASH OVERLAY FX: 一行五列 \ No newline at end of file diff --git a/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png b/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png new file mode 100644 index 0000000..5215ac0 Binary files /dev/null and b/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png differ diff --git a/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png.import b/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png.import new file mode 100644 index 0000000..5a554b2 --- /dev/null +++ b/assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://iejaydybpgpn" +path="res://.godot/imported/slash_overlay_fx.png-e5daa864d02c1d9e27fa2059e4353d32.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png" +dest_files=["res://.godot/imported/slash_overlay_fx.png-e5daa864d02c1d9e27fa2059e4353d32.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/02_basic_attack_ii/air_prep.png b/assets/art/characters/player/02_basic_attack_ii/air_prep.png new file mode 100644 index 0000000..bf4b4df Binary files /dev/null and b/assets/art/characters/player/02_basic_attack_ii/air_prep.png differ diff --git a/assets/art/characters/player/02_basic_attack_ii/air_prep.png.import b/assets/art/characters/player/02_basic_attack_ii/air_prep.png.import new file mode 100644 index 0000000..b7fd10e --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/air_prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7m1g4dg7q4kr" +path="res://.godot/imported/air_prep.png-5099f2f2a184b2ae77cc4daeaa4224e3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/02_basic_attack_ii/air_prep.png" +dest_files=["res://.godot/imported/air_prep.png-5099f2f2a184b2ae77cc4daeaa4224e3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/02_basic_attack_ii/air_swing.png b/assets/art/characters/player/02_basic_attack_ii/air_swing.png new file mode 100644 index 0000000..082d4ed Binary files /dev/null and b/assets/art/characters/player/02_basic_attack_ii/air_swing.png differ diff --git a/assets/art/characters/player/02_basic_attack_ii/air_swing.png.import b/assets/art/characters/player/02_basic_attack_ii/air_swing.png.import new file mode 100644 index 0000000..2f4a1b8 --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/air_swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4var8np8tux8" +path="res://.godot/imported/air_swing.png-aac79867c75e9e1ffe5f8dc7689bb5c4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/02_basic_attack_ii/air_swing.png" +dest_files=["res://.godot/imported/air_swing.png-aac79867c75e9e1ffe5f8dc7689bb5c4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/02_basic_attack_ii/basic_attack_ii.txt b/assets/art/characters/player/02_basic_attack_ii/basic_attack_ii.txt new file mode 100644 index 0000000..4c53372 --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/basic_attack_ii.txt @@ -0,0 +1,15 @@ +动作名称:普攻二段 +编号:02 +分类:普攻 +释放条件:[地面] +按键判定:[A][A] +技能描述:在普攻一段后继续向前追击,挥出第二剑。 +前缀动作:普攻一段 [A] +后缀动作:普攻三段 [A][A][A] / 连段终结 [A][A][sp] / A 蓄力 +效果:无 +基础伤害:120 +基础横向位移:110 +基础纵向位移:0 +能量回复:12 +能量消耗:0 +动作时长:20FPS \ No newline at end of file diff --git a/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png b/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png new file mode 100644 index 0000000..dadc522 Binary files /dev/null and b/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png differ diff --git a/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png.import b/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png.import new file mode 100644 index 0000000..afc088c --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://klrlbtb40prs" +path="res://.godot/imported/enemy_contact_fx.png-2119699fecd3bdfeb1e7d15f6cd113cb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png" +dest_files=["res://.godot/imported/enemy_contact_fx.png-2119699fecd3bdfeb1e7d15f6cd113cb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/02_basic_attack_ii/png.txt b/assets/art/characters/player/02_basic_attack_ii/png.txt new file mode 100644 index 0000000..272b981 --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/png.txt @@ -0,0 +1,4 @@ +[AIR] 1 PREP: 一行三列 +[AIR] 2 SWING: 一行三列 +ENEMY CONTACT FX: 一行七列 +SLASH OVERLAY FX: 一行五列 \ No newline at end of file diff --git a/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png b/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png new file mode 100644 index 0000000..629f7f0 Binary files /dev/null and b/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png differ diff --git a/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png.import b/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png.import new file mode 100644 index 0000000..11293e7 --- /dev/null +++ b/assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgqcnq8g8swiq" +path="res://.godot/imported/slash_overlay_fx.png-12a6f2f05e2b397bfac6fc4e9610b837.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png" +dest_files=["res://.godot/imported/slash_overlay_fx.png-12a6f2f05e2b397bfac6fc4e9610b837.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/03_basic_attack_iii/air_prep.png b/assets/art/characters/player/03_basic_attack_iii/air_prep.png new file mode 100644 index 0000000..56a5c74 Binary files /dev/null and b/assets/art/characters/player/03_basic_attack_iii/air_prep.png differ diff --git a/assets/art/characters/player/03_basic_attack_iii/air_prep.png.import b/assets/art/characters/player/03_basic_attack_iii/air_prep.png.import new file mode 100644 index 0000000..011aca7 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/air_prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bs5lk5upl4vl8" +path="res://.godot/imported/air_prep.png-bd7b91ebbbebeffe864032fe640e1db2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/03_basic_attack_iii/air_prep.png" +dest_files=["res://.godot/imported/air_prep.png-bd7b91ebbbebeffe864032fe640e1db2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/03_basic_attack_iii/air_swing.png b/assets/art/characters/player/03_basic_attack_iii/air_swing.png new file mode 100644 index 0000000..2958478 Binary files /dev/null and b/assets/art/characters/player/03_basic_attack_iii/air_swing.png differ diff --git a/assets/art/characters/player/03_basic_attack_iii/air_swing.png.import b/assets/art/characters/player/03_basic_attack_iii/air_swing.png.import new file mode 100644 index 0000000..e19d582 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/air_swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cawjktwokxxja" +path="res://.godot/imported/air_swing.png-12e623f8b7e1eb6f3ea6530b711237f2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/03_basic_attack_iii/air_swing.png" +dest_files=["res://.godot/imported/air_swing.png-12e623f8b7e1eb6f3ea6530b711237f2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/03_basic_attack_iii/basic_attack_iii.txt b/assets/art/characters/player/03_basic_attack_iii/basic_attack_iii.txt new file mode 100644 index 0000000..ee2e603 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/basic_attack_iii.txt @@ -0,0 +1,15 @@ +动作名称:普攻三段 +编号:03 +分类:普攻 +释放条件:[地面] +按键判定:[A][A][A] +技能描述:地面普通连段的第三击,向前压制敌人。 +前缀动作:普攻二段 [A][A] +后缀动作:震地击飞 [A][A][A][sp] / A 蓄力 +效果:无 +基础伤害:150 +基础横向位移:120 +基础纵向位移:0 +能量回复:15 +能量消耗:0 +动作时长:20FPS \ No newline at end of file diff --git a/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png b/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png new file mode 100644 index 0000000..0f6ff72 Binary files /dev/null and b/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png differ diff --git a/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png.import b/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png.import new file mode 100644 index 0000000..3dc3321 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://coeku42t5s4g1" +path="res://.godot/imported/enemy_contact_fx.png-f3cfeafea695875103b714e2fea367a4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png" +dest_files=["res://.godot/imported/enemy_contact_fx.png-f3cfeafea695875103b714e2fea367a4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/03_basic_attack_iii/png.txt b/assets/art/characters/player/03_basic_attack_iii/png.txt new file mode 100644 index 0000000..8914f11 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/png.txt @@ -0,0 +1,4 @@ +[AIR] 1 PREP: 一行三列 +[AIR] 2 SWING: 一行三列 +ENEMY CONTACT FX: 一行七列 +SLASH OVERLAY FX: 一行四列 \ No newline at end of file diff --git a/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png b/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png new file mode 100644 index 0000000..2a7e382 Binary files /dev/null and b/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png differ diff --git a/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png.import b/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png.import new file mode 100644 index 0000000..7d074f8 --- /dev/null +++ b/assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://be3y2xmnk0ljl" +path="res://.godot/imported/slash_overlay_fx.png-2603fe68cf0e60a290cd631021d68991.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png" +dest_files=["res://.godot/imported/slash_overlay_fx.png-2603fe68cf0e60a290cd631021d68991.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png b/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png new file mode 100644 index 0000000..75868f1 Binary files /dev/null and b/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png differ diff --git a/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png.import b/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png.import new file mode 100644 index 0000000..f15fb7b --- /dev/null +++ b/assets/art/characters/player/04_dash_slash/dash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c083m6rciffvu" +path="res://.godot/imported/dash_overlay_fx.png-9c90c46385d6e053f913a4c3d840c544.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/04_dash_slash/dash_overlay_fx.png" +dest_files=["res://.godot/imported/dash_overlay_fx.png-9c90c46385d6e053f913a4c3d840c544.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/04_dash_slash/dash_slash.txt b/assets/art/characters/player/04_dash_slash/dash_slash.txt new file mode 100644 index 0000000..010e725 --- /dev/null +++ b/assets/art/characters/player/04_dash_slash/dash_slash.txt @@ -0,0 +1,15 @@ +动作名称:突进 +编号:04 +分类:技能 +释放条件:[地面] +按键判定:[A][sp] +技能描述:向前高速突进并攻击路径上的敌人。 +前缀动作:普攻一段 [A] +后缀动作:无 +效果:冲刺 +基础伤害:160 +基础横向位移:260 +基础纵向位移:0 +能量回复:0 +能量消耗:20 +动作时长:25FPS \ No newline at end of file diff --git a/assets/art/characters/player/04_dash_slash/dashattack.png b/assets/art/characters/player/04_dash_slash/dashattack.png new file mode 100644 index 0000000..cca4236 Binary files /dev/null and b/assets/art/characters/player/04_dash_slash/dashattack.png differ diff --git a/assets/art/characters/player/04_dash_slash/dashattack.png.import b/assets/art/characters/player/04_dash_slash/dashattack.png.import new file mode 100644 index 0000000..37c0daa --- /dev/null +++ b/assets/art/characters/player/04_dash_slash/dashattack.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://civnuiq18hs7c" +path="res://.godot/imported/dashattack.png-5240d6bf94910b426c44d816f36c39be.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/04_dash_slash/dashattack.png" +dest_files=["res://.godot/imported/dashattack.png-5240d6bf94910b426c44d816f36c39be.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/04_dash_slash/png.txt b/assets/art/characters/player/04_dash_slash/png.txt new file mode 100644 index 0000000..94ce021 --- /dev/null +++ b/assets/art/characters/player/04_dash_slash/png.txt @@ -0,0 +1,2 @@ +DASH OVERLAY FX: 三行四列 +DASHATTACK: 两行六列 \ No newline at end of file diff --git a/assets/art/characters/player/05_combo_finisher/air_swing.png b/assets/art/characters/player/05_combo_finisher/air_swing.png new file mode 100644 index 0000000..6417de7 Binary files /dev/null and b/assets/art/characters/player/05_combo_finisher/air_swing.png differ diff --git a/assets/art/characters/player/05_combo_finisher/air_swing.png.import b/assets/art/characters/player/05_combo_finisher/air_swing.png.import new file mode 100644 index 0000000..90e2b91 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/air_swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0cuu64vx8fac" +path="res://.godot/imported/air_swing.png-94d39e0189271540a7279073a57fa573.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/05_combo_finisher/air_swing.png" +dest_files=["res://.godot/imported/air_swing.png-94d39e0189271540a7279073a57fa573.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/05_combo_finisher/combo_finisher.txt b/assets/art/characters/player/05_combo_finisher/combo_finisher.txt new file mode 100644 index 0000000..1dc9f50 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/combo_finisher.txt @@ -0,0 +1,15 @@ +动作名称:连段终结 +编号:05 +分类:技能 +释放条件:[地面] +按键判定:[A][A][sp] +技能描述:在二段普攻后接出强力终结斩,造成更高伤害。 +前缀动作:普攻二段 [A][A] +后缀动作:无 +效果:无 +基础伤害:220 +基础横向位移:130 +基础纵向位移:0 +能量回复:0 +能量消耗:30 +动作时长:20FPS \ No newline at end of file diff --git a/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png b/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png new file mode 100644 index 0000000..4c26fc0 Binary files /dev/null and b/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png differ diff --git a/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png.import b/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png.import new file mode 100644 index 0000000..804a2a1 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkwyx2axhy7l6" +path="res://.godot/imported/enemy_contact_fx.png-7313c6c10f3eba75c366ca5045e42e47.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png" +dest_files=["res://.godot/imported/enemy_contact_fx.png-7313c6c10f3eba75c366ca5045e42e47.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/05_combo_finisher/png.txt b/assets/art/characters/player/05_combo_finisher/png.txt new file mode 100644 index 0000000..ec8f491 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/png.txt @@ -0,0 +1,4 @@ +[2 AIR] SWING: 一行六列 +1 PREP: 一行五列 +ENEMY CONTACT FX: 一行七列 +SLASH OVERLAY FX: 一行四列 \ No newline at end of file diff --git a/assets/art/characters/player/05_combo_finisher/prep.png b/assets/art/characters/player/05_combo_finisher/prep.png new file mode 100644 index 0000000..ee1d0ca Binary files /dev/null and b/assets/art/characters/player/05_combo_finisher/prep.png differ diff --git a/assets/art/characters/player/05_combo_finisher/prep.png.import b/assets/art/characters/player/05_combo_finisher/prep.png.import new file mode 100644 index 0000000..2d23f56 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2d6fl37qdysx" +path="res://.godot/imported/prep.png-41b9ddbc3d22c79f130b2b2f31fa1bce.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/05_combo_finisher/prep.png" +dest_files=["res://.godot/imported/prep.png-41b9ddbc3d22c79f130b2b2f31fa1bce.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png b/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png new file mode 100644 index 0000000..a909eb9 Binary files /dev/null and b/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png differ diff --git a/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png.import b/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png.import new file mode 100644 index 0000000..09f9128 --- /dev/null +++ b/assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b38vmgstvr8j8" +path="res://.godot/imported/slash_overlay_fx.png-939ad96c644b705971bc7b2876feb77c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png" +dest_files=["res://.godot/imported/slash_overlay_fx.png-939ad96c644b705971bc7b2876feb77c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/06_ground_smash/ground_smash.txt b/assets/art/characters/player/06_ground_smash/ground_smash.txt new file mode 100644 index 0000000..5d906de --- /dev/null +++ b/assets/art/characters/player/06_ground_smash/ground_smash.txt @@ -0,0 +1,15 @@ +动作名称:震地击飞 +编号:06 +分类:技能 +释放条件:[地面] +按键判定:[A][A][A][sp] +技能描述:三段普攻后释放重击,将敌人打离地面。 +前缀动作:普攻三段 [A][A][A] +后缀动作:无 +效果:浮空 +基础伤害:280 +基础横向位移:80 +基础纵向位移:0 +能量回复:0 +能量消耗:40 +动作时长:20FPS \ No newline at end of file diff --git a/assets/art/characters/player/06_ground_smash/png.txt b/assets/art/characters/player/06_ground_smash/png.txt new file mode 100644 index 0000000..1471b7e --- /dev/null +++ b/assets/art/characters/player/06_ground_smash/png.txt @@ -0,0 +1,3 @@ +1 PREP: 两行四列 +2 SWING: 两行三列 +SHOCKWAVE TILE: 两行六列 diff --git a/assets/art/characters/player/06_ground_smash/prep.png b/assets/art/characters/player/06_ground_smash/prep.png new file mode 100644 index 0000000..b522e34 Binary files /dev/null and b/assets/art/characters/player/06_ground_smash/prep.png differ diff --git a/assets/art/characters/player/06_ground_smash/prep.png.import b/assets/art/characters/player/06_ground_smash/prep.png.import new file mode 100644 index 0000000..9a34e4e --- /dev/null +++ b/assets/art/characters/player/06_ground_smash/prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7p8orde37o8f" +path="res://.godot/imported/prep.png-551306128e510ae19ee56dda3b35e9d4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/06_ground_smash/prep.png" +dest_files=["res://.godot/imported/prep.png-551306128e510ae19ee56dda3b35e9d4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/06_ground_smash/shockwave_tile.png b/assets/art/characters/player/06_ground_smash/shockwave_tile.png new file mode 100644 index 0000000..e47718d Binary files /dev/null and b/assets/art/characters/player/06_ground_smash/shockwave_tile.png differ diff --git a/assets/art/characters/player/06_ground_smash/shockwave_tile.png.import b/assets/art/characters/player/06_ground_smash/shockwave_tile.png.import new file mode 100644 index 0000000..9acd9b4 --- /dev/null +++ b/assets/art/characters/player/06_ground_smash/shockwave_tile.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3l7wkmymnypq" +path="res://.godot/imported/shockwave_tile.png-62be17e3f7fcd2e3219c3e1eba8d81cd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/06_ground_smash/shockwave_tile.png" +dest_files=["res://.godot/imported/shockwave_tile.png-62be17e3f7fcd2e3219c3e1eba8d81cd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/06_ground_smash/swing.png b/assets/art/characters/player/06_ground_smash/swing.png new file mode 100644 index 0000000..1de9b82 Binary files /dev/null and b/assets/art/characters/player/06_ground_smash/swing.png differ diff --git a/assets/art/characters/player/06_ground_smash/swing.png.import b/assets/art/characters/player/06_ground_smash/swing.png.import new file mode 100644 index 0000000..7bd0b27 --- /dev/null +++ b/assets/art/characters/player/06_ground_smash/swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddhh0jao2pjin" +path="res://.godot/imported/swing.png-8763839722e3a0b7955afcb16a843c16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/06_ground_smash/swing.png" +dest_files=["res://.godot/imported/swing.png-8763839722e3a0b7955afcb16a843c16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/07_rising_slash/png.txt b/assets/art/characters/player/07_rising_slash/png.txt new file mode 100644 index 0000000..ef09ea3 --- /dev/null +++ b/assets/art/characters/player/07_rising_slash/png.txt @@ -0,0 +1,3 @@ +1 PREP: 一行三列 +2 SWING: 两行五列 +SLASH OVERLAY FX: 两行五列 \ No newline at end of file diff --git a/assets/art/characters/player/07_rising_slash/prep.png b/assets/art/characters/player/07_rising_slash/prep.png new file mode 100644 index 0000000..c910745 Binary files /dev/null and b/assets/art/characters/player/07_rising_slash/prep.png differ diff --git a/assets/art/characters/player/07_rising_slash/prep.png.import b/assets/art/characters/player/07_rising_slash/prep.png.import new file mode 100644 index 0000000..399fc1e --- /dev/null +++ b/assets/art/characters/player/07_rising_slash/prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://t6gfuiby2b82" +path="res://.godot/imported/prep.png-8d387c4682de904993a234cc12852e71.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/07_rising_slash/prep.png" +dest_files=["res://.godot/imported/prep.png-8d387c4682de904993a234cc12852e71.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/07_rising_slash/rising_slash.txt b/assets/art/characters/player/07_rising_slash/rising_slash.txt new file mode 100644 index 0000000..b8a268e --- /dev/null +++ b/assets/art/characters/player/07_rising_slash/rising_slash.txt @@ -0,0 +1,15 @@ +动作名称:上挑 +编号:07 +分类:普攻 +释放条件:[地面] +按键判定:[W] +技能描述:向上挥剑攻击,将自身带入空中,并可衔接空中动作。 +前缀动作:无 +后缀动作:空中连斩 [空中A] / 下坠攻击 [空中S] +效果:浮空 +基础伤害:120 +基础横向位移:60 +基础纵向位移:180 +能量回复:12 +能量消耗:0 +动作时长:33FPS \ No newline at end of file diff --git a/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png b/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png new file mode 100644 index 0000000..9a5d404 Binary files /dev/null and b/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png differ diff --git a/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png.import b/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png.import new file mode 100644 index 0000000..b51d2a6 --- /dev/null +++ b/assets/art/characters/player/07_rising_slash/slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://tnpos2wv5tbt" +path="res://.godot/imported/slash_overlay_fx.png-11622383159145fc84891ae89e3bc22c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/07_rising_slash/slash_overlay_fx.png" +dest_files=["res://.godot/imported/slash_overlay_fx.png-11622383159145fc84891ae89e3bc22c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/07_rising_slash/swing.png b/assets/art/characters/player/07_rising_slash/swing.png new file mode 100644 index 0000000..bf3f095 Binary files /dev/null and b/assets/art/characters/player/07_rising_slash/swing.png differ diff --git a/assets/art/characters/player/07_rising_slash/swing.png.import b/assets/art/characters/player/07_rising_slash/swing.png.import new file mode 100644 index 0000000..f4b9f32 --- /dev/null +++ b/assets/art/characters/player/07_rising_slash/swing.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5ajmfpemkpus" +path="res://.godot/imported/swing.png-7b00ed9d43dda99b646030116a611760.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/07_rising_slash/swing.png" +dest_files=["res://.godot/imported/swing.png-7b00ed9d43dda99b646030116a611760.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/08_sword_charge/air_cast_prep.png b/assets/art/characters/player/08_sword_charge/air_cast_prep.png new file mode 100644 index 0000000..292cc0b Binary files /dev/null and b/assets/art/characters/player/08_sword_charge/air_cast_prep.png differ diff --git a/assets/art/characters/player/08_sword_charge/air_cast_prep.png.import b/assets/art/characters/player/08_sword_charge/air_cast_prep.png.import new file mode 100644 index 0000000..8883276 --- /dev/null +++ b/assets/art/characters/player/08_sword_charge/air_cast_prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmurggey7edle" +path="res://.godot/imported/air_cast_prep.png-42e21793d52d946f97b43b008171d3ac.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/08_sword_charge/air_cast_prep.png" +dest_files=["res://.godot/imported/air_cast_prep.png-42e21793d52d946f97b43b008171d3ac.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/08_sword_charge/png.txt b/assets/art/characters/player/08_sword_charge/png.txt new file mode 100644 index 0000000..2c61510 --- /dev/null +++ b/assets/art/characters/player/08_sword_charge/png.txt @@ -0,0 +1,2 @@ +[1 AIR] CAST PREP: 两行十列 +1 SWORD RAIN PREP OVERLAY FX: 两行十列 diff --git a/assets/art/characters/player/08_sword_charge/sword_charge.txt b/assets/art/characters/player/08_sword_charge/sword_charge.txt new file mode 100644 index 0000000..dcb7303 --- /dev/null +++ b/assets/art/characters/player/08_sword_charge/sword_charge.txt @@ -0,0 +1,18 @@ +动作名称:A 蓄力 +编号:08 +分类:蓄力 +释放条件:[地面] +按键判定:[A长按] +技能描述:A 按下时先释放当前普攻段;如果在普攻尾段仍保持 A,则进入蓄力状态。蓄力分为 3 个等级,等级越高,后续剑雨伤害越高、消耗越高。 +前缀动作:普攻一段 [A] / 普攻二段 [A][A] / 普攻三段 [A][A][A] +后缀动作:剑雨 [A松开] +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:持续蓄力,最多 3 级 + +Sword rain prep overlay: 25FPS +Rain landing tiles: 20FPS \ No newline at end of file diff --git a/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png b/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png new file mode 100644 index 0000000..1b37953 Binary files /dev/null and b/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png differ diff --git a/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png.import b/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png.import new file mode 100644 index 0000000..88f2b9c --- /dev/null +++ b/assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dggkfgdlefmxl" +path="res://.godot/imported/sword_rain_prep_overlay_fx.png-30aa16841f08f80d98516e266a2f7b7f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png" +dest_files=["res://.godot/imported/sword_rain_prep_overlay_fx.png-30aa16841f08f80d98516e266a2f7b7f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/air_casting_loop.png b/assets/art/characters/player/09_blade_rain/air_casting_loop.png new file mode 100644 index 0000000..ddb376d Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/air_casting_loop.png differ diff --git a/assets/art/characters/player/09_blade_rain/air_casting_loop.png.import b/assets/art/characters/player/09_blade_rain/air_casting_loop.png.import new file mode 100644 index 0000000..4d145b2 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/air_casting_loop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cytiedupiuaxg" +path="res://.godot/imported/air_casting_loop.png-e631757e60b4aa37c9a92d194ee76261.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/air_casting_loop.png" +dest_files=["res://.godot/imported/air_casting_loop.png-e631757e60b4aa37c9a92d194ee76261.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/blade_rain.txt b/assets/art/characters/player/09_blade_rain/blade_rain.txt new file mode 100644 index 0000000..04842c9 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/blade_rain.txt @@ -0,0 +1,19 @@ +动作名称:剑雨 +编号:09 +分类:蓄力 +释放条件:[地面] +按键判定:[A蓄力后松开] +技能描述:结束 A 蓄力后释放剑雨攻击。蓄力等级越高,伤害越高,攻击范围越强,能量消耗越高。 +前缀动作:A 蓄力 +后缀动作:无 +效果:霸体 +基础伤害:等级1:180 / 等级2:280 / 等级3:420 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:等级1:20 / 等级2:35 / 等级3:50 +动作时长: + + +Sword rain prep overlay: 25FPS +Rain landing tiles: 20FPS \ No newline at end of file diff --git a/assets/art/characters/player/09_blade_rain/png.txt b/assets/art/characters/player/09_blade_rain/png.txt new file mode 100644 index 0000000..b5f417a --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/png.txt @@ -0,0 +1,9 @@ +[2 AIR] CASTING [LOOP]: 一行两列 +''RAINDROP'' 1: 一行一列 +''RAINDROP'' 2: 一行一列 +''RAINDROP'' 3: 一行一列 +''RAINDROP'' 4: 一行一列 +RAIN LADING TILE 1: 两行六列 +RAIN LADING TILE 2: 一行九列 +RAIN LADING TILE 3: 两行五列 +RAIN LADING TILE 4: 两行五列 diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png b/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png new file mode 100644 index 0000000..4c45bbb Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png differ diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png.import b/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png.import new file mode 100644 index 0000000..afddee7 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3gwa1dg0wxdj" +path="res://.godot/imported/rain_lading_tile_1.png-ecf1eddb15beda678da57b1ffb689a53.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png" +dest_files=["res://.godot/imported/rain_lading_tile_1.png-ecf1eddb15beda678da57b1ffb689a53.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png b/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png new file mode 100644 index 0000000..3c10720 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png differ diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png.import b/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png.import new file mode 100644 index 0000000..05f9eaf --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ya3d8lyvtd0l" +path="res://.godot/imported/rain_lading_tile_2.png-1259d84861e71812139e091cd9d6a56b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png" +dest_files=["res://.godot/imported/rain_lading_tile_2.png-1259d84861e71812139e091cd9d6a56b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png b/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png new file mode 100644 index 0000000..7444da9 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png differ diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png.import b/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png.import new file mode 100644 index 0000000..c546e9c --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bfgpdwyc2lyuf" +path="res://.godot/imported/rain_lading_tile_3.png-2ee7c2f064ed909a889ed3d1ae84050c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png" +dest_files=["res://.godot/imported/rain_lading_tile_3.png-2ee7c2f064ed909a889ed3d1ae84050c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png b/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png new file mode 100644 index 0000000..4919dc9 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png differ diff --git a/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png.import b/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png.import new file mode 100644 index 0000000..35265c7 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ct48t0ip3coj0" +path="res://.godot/imported/rain_lading_tile_4.png-64505f92fde150b0f4776c7438c3d394.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png" +dest_files=["res://.godot/imported/rain_lading_tile_4.png-64505f92fde150b0f4776c7438c3d394.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/raindrop_1.png b/assets/art/characters/player/09_blade_rain/raindrop_1.png new file mode 100644 index 0000000..adea7e7 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/raindrop_1.png differ diff --git a/assets/art/characters/player/09_blade_rain/raindrop_1.png.import b/assets/art/characters/player/09_blade_rain/raindrop_1.png.import new file mode 100644 index 0000000..98dc0e2 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/raindrop_1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://s16fgf08vwwf" +path="res://.godot/imported/raindrop_1.png-2d9e63b6b25f67a09689c23e1e940072.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/raindrop_1.png" +dest_files=["res://.godot/imported/raindrop_1.png-2d9e63b6b25f67a09689c23e1e940072.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/raindrop_2.png b/assets/art/characters/player/09_blade_rain/raindrop_2.png new file mode 100644 index 0000000..f107507 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/raindrop_2.png differ diff --git a/assets/art/characters/player/09_blade_rain/raindrop_2.png.import b/assets/art/characters/player/09_blade_rain/raindrop_2.png.import new file mode 100644 index 0000000..fd16bee --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/raindrop_2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://eeux0t0ej3vl" +path="res://.godot/imported/raindrop_2.png-7a150bbc12a4f4382a527e6641fccf61.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/raindrop_2.png" +dest_files=["res://.godot/imported/raindrop_2.png-7a150bbc12a4f4382a527e6641fccf61.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/raindrop_3.png b/assets/art/characters/player/09_blade_rain/raindrop_3.png new file mode 100644 index 0000000..c7dbbe9 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/raindrop_3.png differ diff --git a/assets/art/characters/player/09_blade_rain/raindrop_3.png.import b/assets/art/characters/player/09_blade_rain/raindrop_3.png.import new file mode 100644 index 0000000..a4b4c70 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/raindrop_3.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c0hfs4nc66qd7" +path="res://.godot/imported/raindrop_3.png-aa7ef2bf520606cfd1d977ca1126fe48.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/raindrop_3.png" +dest_files=["res://.godot/imported/raindrop_3.png-aa7ef2bf520606cfd1d977ca1126fe48.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/09_blade_rain/raindrop_4.png b/assets/art/characters/player/09_blade_rain/raindrop_4.png new file mode 100644 index 0000000..ea1e477 Binary files /dev/null and b/assets/art/characters/player/09_blade_rain/raindrop_4.png differ diff --git a/assets/art/characters/player/09_blade_rain/raindrop_4.png.import b/assets/art/characters/player/09_blade_rain/raindrop_4.png.import new file mode 100644 index 0000000..eaf9a53 --- /dev/null +++ b/assets/art/characters/player/09_blade_rain/raindrop_4.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://deqp8l0krhgkn" +path="res://.godot/imported/raindrop_4.png-33a121f665dba33e39cf75786ff17c27.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/09_blade_rain/raindrop_4.png" +dest_files=["res://.godot/imported/raindrop_4.png-33a121f665dba33e39cf75786ff17c27.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/air_cast.png b/assets/art/characters/player/10_wave_charge/air_cast.png new file mode 100644 index 0000000..570a2f9 Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/air_cast.png differ diff --git a/assets/art/characters/player/10_wave_charge/air_cast.png.import b/assets/art/characters/player/10_wave_charge/air_cast.png.import new file mode 100644 index 0000000..de0caaa --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/air_cast.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpmwo410krsn6" +path="res://.godot/imported/air_cast.png-0c32e7767dd221b813a7f68361f7806b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/air_cast.png" +dest_files=["res://.godot/imported/air_cast.png-0c32e7767dd221b813a7f68361f7806b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/cast.png b/assets/art/characters/player/10_wave_charge/cast.png new file mode 100644 index 0000000..d9589fd Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/cast.png differ diff --git a/assets/art/characters/player/10_wave_charge/cast.png.import b/assets/art/characters/player/10_wave_charge/cast.png.import new file mode 100644 index 0000000..1bab56b --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/cast.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cm4hj6i4rr7g5" +path="res://.godot/imported/cast.png-46f2249bbe83ea9ec1ab00062a36a3ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/cast.png" +dest_files=["res://.godot/imported/cast.png-46f2249bbe83ea9ec1ab00062a36a3ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png b/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png new file mode 100644 index 0000000..91789b1 Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png.import b/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png.import new file mode 100644 index 0000000..f440660 --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6nlc01v41eec" +path="res://.godot/imported/cast_slash_overlay_fx.png-096c7f1654e6af30ba59cdc0f9359645.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/cast_slash_overlay_fx.png" +dest_files=["res://.godot/imported/cast_slash_overlay_fx.png-096c7f1654e6af30ba59cdc0f9359645.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png b/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png new file mode 100644 index 0000000..722b989 Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png differ diff --git a/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png.import b/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png.import new file mode 100644 index 0000000..db49fd4 --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bnurqa2qu0s7c" +path="res://.godot/imported/charging_up_overlay_fx.png-e1a13494b3b7b145ed9ccd349f02bd7c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png" +dest_files=["res://.godot/imported/charging_up_overlay_fx.png-e1a13494b3b7b145ed9ccd349f02bd7c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/png.txt b/assets/art/characters/player/10_wave_charge/png.txt new file mode 100644 index 0000000..069ba0f --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/png.txt @@ -0,0 +1,6 @@ +[AIR] CAST: 一行四列 +CAST SLASH OVERLAY FX: 一行五列 +CAST: 一行四列 +CHARGING UP OVERLAY FX: 五行五列 +PROJECTILE ENEMY CONTACT: 一行五列 +PROJECTILE: 一行五列 \ No newline at end of file diff --git a/assets/art/characters/player/10_wave_charge/projectile.png b/assets/art/characters/player/10_wave_charge/projectile.png new file mode 100644 index 0000000..4a9fb8f Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/projectile.png differ diff --git a/assets/art/characters/player/10_wave_charge/projectile.png.import b/assets/art/characters/player/10_wave_charge/projectile.png.import new file mode 100644 index 0000000..fc4b1a8 --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/projectile.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://baouvsu116py3" +path="res://.godot/imported/projectile.png-1ab978d9ac136ecee6a2680f4b358a80.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/projectile.png" +dest_files=["res://.godot/imported/projectile.png-1ab978d9ac136ecee6a2680f4b358a80.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png b/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png new file mode 100644 index 0000000..3373534 Binary files /dev/null and b/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png differ diff --git a/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png.import b/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png.import new file mode 100644 index 0000000..31880ab --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqxmjx8ngovm8" +path="res://.godot/imported/projectile_enemy_contact.png-27a6cdea4b76a1ea891259de19e0c57e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/10_wave_charge/projectile_enemy_contact.png" +dest_files=["res://.godot/imported/projectile_enemy_contact.png-27a6cdea4b76a1ea891259de19e0c57e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/10_wave_charge/wave_charge.txt b/assets/art/characters/player/10_wave_charge/wave_charge.txt new file mode 100644 index 0000000..c39bf30 --- /dev/null +++ b/assets/art/characters/player/10_wave_charge/wave_charge.txt @@ -0,0 +1,19 @@ +动作名称:S 蓄力 +编号:10 +分类:蓄力 +释放条件:[地面] +按键判定:[S长按] +技能描述:按住 S 进入远程蓄力状态。蓄力分为 3 个等级,等级越高,后续斩波伤害越高、消耗越高。 +前缀动作:无 +后缀动作:斩波 [S长按][sp] +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:持续蓄力,最多 3 级 + +Charge overlay: 50FPS +Cast: 20FPS +Projectile: 33FPS \ No newline at end of file diff --git a/assets/art/characters/player/11_blade_wave/air_cast.png b/assets/art/characters/player/11_blade_wave/air_cast.png new file mode 100644 index 0000000..570a2f9 Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/air_cast.png differ diff --git a/assets/art/characters/player/11_blade_wave/air_cast.png.import b/assets/art/characters/player/11_blade_wave/air_cast.png.import new file mode 100644 index 0000000..48ad38e --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/air_cast.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcc0nxqe4jcn" +path="res://.godot/imported/air_cast.png-48f35a1f1a654808d1c08c21d13f6b0c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/air_cast.png" +dest_files=["res://.godot/imported/air_cast.png-48f35a1f1a654808d1c08c21d13f6b0c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/11_blade_wave/blade_wave.txt b/assets/art/characters/player/11_blade_wave/blade_wave.txt new file mode 100644 index 0000000..b72d088 --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/blade_wave.txt @@ -0,0 +1,19 @@ +动作名称:斩波 +编号:11 +分类:蓄力 +释放条件:[地面] +按键判定:[S长按][sp] +技能描述:S 蓄力期间按下 Space,向前释放远程斩波。蓄力等级越高,斩波伤害越高,能量消耗越高。 +前缀动作:S 蓄力 +后缀动作:无 +效果:无 +基础伤害:等级1:120 / 等级2:200 / 等级3:320 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:等级1:15 / 等级2:25 / 等级3:40 +动作时长: +Charge overlay: 50FPS +Cast: 20FPS +Projectile: 33FPS + diff --git a/assets/art/characters/player/11_blade_wave/cast.png b/assets/art/characters/player/11_blade_wave/cast.png new file mode 100644 index 0000000..d9589fd Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/cast.png differ diff --git a/assets/art/characters/player/11_blade_wave/cast.png.import b/assets/art/characters/player/11_blade_wave/cast.png.import new file mode 100644 index 0000000..b78a739 --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/cast.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bekocp7we1s5x" +path="res://.godot/imported/cast.png-63334facb169b7b0565d177e820755fc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/cast.png" +dest_files=["res://.godot/imported/cast.png-63334facb169b7b0565d177e820755fc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png b/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png new file mode 100644 index 0000000..91789b1 Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png.import b/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png.import new file mode 100644 index 0000000..a9692a5 --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvu0ko1kuvud6" +path="res://.godot/imported/cast_slash_overlay_fx.png-6423708bba3b45daec467ab9d277acdb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png" +dest_files=["res://.godot/imported/cast_slash_overlay_fx.png-6423708bba3b45daec467ab9d277acdb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png b/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png new file mode 100644 index 0000000..722b989 Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png differ diff --git a/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png.import b/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png.import new file mode 100644 index 0000000..4bc9fe0 --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bctyc2vv8ul5m" +path="res://.godot/imported/charging_up_overlay_fx.png-a24de2f6884cf0b83d455235272371ed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/charging_up_overlay_fx.png" +dest_files=["res://.godot/imported/charging_up_overlay_fx.png-a24de2f6884cf0b83d455235272371ed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/11_blade_wave/png.txt b/assets/art/characters/player/11_blade_wave/png.txt new file mode 100644 index 0000000..069ba0f --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/png.txt @@ -0,0 +1,6 @@ +[AIR] CAST: 一行四列 +CAST SLASH OVERLAY FX: 一行五列 +CAST: 一行四列 +CHARGING UP OVERLAY FX: 五行五列 +PROJECTILE ENEMY CONTACT: 一行五列 +PROJECTILE: 一行五列 \ No newline at end of file diff --git a/assets/art/characters/player/11_blade_wave/projectile.png b/assets/art/characters/player/11_blade_wave/projectile.png new file mode 100644 index 0000000..4a9fb8f Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/projectile.png differ diff --git a/assets/art/characters/player/11_blade_wave/projectile.png.import b/assets/art/characters/player/11_blade_wave/projectile.png.import new file mode 100644 index 0000000..ae5788a --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/projectile.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwf6xxcsh255k" +path="res://.godot/imported/projectile.png-be3470adedc4783e1b64268eca7fe4f7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/projectile.png" +dest_files=["res://.godot/imported/projectile.png-be3470adedc4783e1b64268eca7fe4f7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png b/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png new file mode 100644 index 0000000..3373534 Binary files /dev/null and b/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png differ diff --git a/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png.import b/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png.import new file mode 100644 index 0000000..cf7f44e --- /dev/null +++ b/assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bh1rjn2yxoygm" +path="res://.godot/imported/projectile_enemy_contact.png-9d6a8144674824a8c31d7d198196f5d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png" +dest_files=["res://.godot/imported/projectile_enemy_contact.png-9d6a8144674824a8c31d7d198196f5d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/aerial_combo.txt b/assets/art/characters/player/12_aerial_combo/aerial_combo.txt new file mode 100644 index 0000000..30d7a5e --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/aerial_combo.txt @@ -0,0 +1,20 @@ +动作名称:空中连斩 +编号:12 +分类:普攻 +释放条件:[空中] +按键判定:[A] +技能描述:在空中快速挥剑连斩,主要用于空中追击。 +前缀动作:上挑 [W] / 其他进入空中的动作 +后缀动作:下坠攻击 [空中S] +效果:无 +基础伤害:130 +基础横向位移:120 +基础纵向位移:20 +能量回复:10 +能量消耗:0 +动作时长: +Prep: 25FPS +Swing start: 25FPS +Falling loop: 20FPS +Land: 20FPS + diff --git a/assets/art/characters/player/12_aerial_combo/falling_loop.png b/assets/art/characters/player/12_aerial_combo/falling_loop.png new file mode 100644 index 0000000..8df11b5 Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/falling_loop.png differ diff --git a/assets/art/characters/player/12_aerial_combo/falling_loop.png.import b/assets/art/characters/player/12_aerial_combo/falling_loop.png.import new file mode 100644 index 0000000..32b7773 --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/falling_loop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1ik2eg1bs6e6" +path="res://.godot/imported/falling_loop.png-4c218862b5e10c9a5992c7307ffa3b91.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/falling_loop.png" +dest_files=["res://.godot/imported/falling_loop.png-4c218862b5e10c9a5992c7307ffa3b91.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png b/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png new file mode 100644 index 0000000..b84b1cf Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png.import b/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png.import new file mode 100644 index 0000000..611c75c --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsox8fnft7m3j" +path="res://.godot/imported/fx_2_swing_start_slash_overlay_fx.png-4ed6e566c2a6a1c9a8b8db055c4cd72f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png" +dest_files=["res://.godot/imported/fx_2_swing_start_slash_overlay_fx.png-4ed6e566c2a6a1c9a8b8db055c4cd72f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png b/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png new file mode 100644 index 0000000..d7becbc Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png differ diff --git a/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png.import b/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png.import new file mode 100644 index 0000000..b103bc6 --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bpllfpl52sxvq" +path="res://.godot/imported/fx_3_falling_slash_overlay_fx_hold.png-f07cec4f37f19883b9c021c21c4afcf8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png" +dest_files=["res://.godot/imported/fx_3_falling_slash_overlay_fx_hold.png-f07cec4f37f19883b9c021c21c4afcf8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png b/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png new file mode 100644 index 0000000..05ab931 Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png.import b/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png.import new file mode 100644 index 0000000..ee7ce30 --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6a4mu82htbef" +path="res://.godot/imported/fx_4_land_slash_overlay_fx.png-ded2730ff400f42c2e4ff7b1f7c462d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png" +dest_files=["res://.godot/imported/fx_4_land_slash_overlay_fx.png-ded2730ff400f42c2e4ff7b1f7c462d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/land.png b/assets/art/characters/player/12_aerial_combo/land.png new file mode 100644 index 0000000..1de9b82 Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/land.png differ diff --git a/assets/art/characters/player/12_aerial_combo/land.png.import b/assets/art/characters/player/12_aerial_combo/land.png.import new file mode 100644 index 0000000..5c34060 --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/land.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1ppryp73gbx5" +path="res://.godot/imported/land.png-055fe767e2ffbddf7b585bea73bb61cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/land.png" +dest_files=["res://.godot/imported/land.png-055fe767e2ffbddf7b585bea73bb61cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/png.txt b/assets/art/characters/player/12_aerial_combo/png.txt new file mode 100644 index 0000000..477256d --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/png.txt @@ -0,0 +1,7 @@ +[FX 2] SWING START SLASH OVERLAY FX: 一行四列 +[FX 3] FALLING SLASH OVERLAY FX [HOLD]: 一行两列 +[FX 4] LAND SLASH OVERLAY FX: 一行四列 +1 PREP: 一行两列 +2 SWING START: 一行三列 +3 FALLING [LOOP]: 一行三列 +4 LAND: 两行三列 \ No newline at end of file diff --git a/assets/art/characters/player/12_aerial_combo/prep.png b/assets/art/characters/player/12_aerial_combo/prep.png new file mode 100644 index 0000000..ff8062e Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/prep.png differ diff --git a/assets/art/characters/player/12_aerial_combo/prep.png.import b/assets/art/characters/player/12_aerial_combo/prep.png.import new file mode 100644 index 0000000..53695e2 --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyuxcd0ih6y63" +path="res://.godot/imported/prep.png-b4c1f12a6bb7fe395391d3053f47a52e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/prep.png" +dest_files=["res://.godot/imported/prep.png-b4c1f12a6bb7fe395391d3053f47a52e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/12_aerial_combo/swing_start.png b/assets/art/characters/player/12_aerial_combo/swing_start.png new file mode 100644 index 0000000..22df589 Binary files /dev/null and b/assets/art/characters/player/12_aerial_combo/swing_start.png differ diff --git a/assets/art/characters/player/12_aerial_combo/swing_start.png.import b/assets/art/characters/player/12_aerial_combo/swing_start.png.import new file mode 100644 index 0000000..48fc44e --- /dev/null +++ b/assets/art/characters/player/12_aerial_combo/swing_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xag3nbjllayl" +path="res://.godot/imported/swing_start.png-25e8bb386f2f529705de7ad742cf60f0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/12_aerial_combo/swing_start.png" +dest_files=["res://.godot/imported/swing_start.png-25e8bb386f2f529705de7ad742cf60f0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/falling_loop.png b/assets/art/characters/player/13_plunging_strike/falling_loop.png new file mode 100644 index 0000000..8df11b5 Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/falling_loop.png differ diff --git a/assets/art/characters/player/13_plunging_strike/falling_loop.png.import b/assets/art/characters/player/13_plunging_strike/falling_loop.png.import new file mode 100644 index 0000000..b1a61eb --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/falling_loop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxiprexsxvjhv" +path="res://.godot/imported/falling_loop.png-c96c8cc3864462d6be011800d9551527.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/falling_loop.png" +dest_files=["res://.godot/imported/falling_loop.png-c96c8cc3864462d6be011800d9551527.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png b/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png new file mode 100644 index 0000000..b84b1cf Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png.import b/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png.import new file mode 100644 index 0000000..fa95317 --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bw0bensvgdk4a" +path="res://.godot/imported/fx_2_swing_start_slash_overlay_fx.png-d20d58c429612dfa0582b403fca3691b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png" +dest_files=["res://.godot/imported/fx_2_swing_start_slash_overlay_fx.png-d20d58c429612dfa0582b403fca3691b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png b/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png new file mode 100644 index 0000000..d7becbc Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png differ diff --git a/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png.import b/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png.import new file mode 100644 index 0000000..bd77afb --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c3lmqe6sw3cdi" +path="res://.godot/imported/fx_3_falling_slash_overlay_fx_hold.png-5bb7d0352a7913c65aa787efd2c21091.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png" +dest_files=["res://.godot/imported/fx_3_falling_slash_overlay_fx_hold.png-5bb7d0352a7913c65aa787efd2c21091.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png b/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png new file mode 100644 index 0000000..05ab931 Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png differ diff --git a/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png.import b/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png.import new file mode 100644 index 0000000..3bd9d4f --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2fkptham83l7" +path="res://.godot/imported/fx_4_land_slash_overlay_fx.png-40f189b76cd3073bd75ce3eeb010398c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/fx_4_land_slash_overlay_fx.png" +dest_files=["res://.godot/imported/fx_4_land_slash_overlay_fx.png-40f189b76cd3073bd75ce3eeb010398c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/land.png b/assets/art/characters/player/13_plunging_strike/land.png new file mode 100644 index 0000000..1de9b82 Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/land.png differ diff --git a/assets/art/characters/player/13_plunging_strike/land.png.import b/assets/art/characters/player/13_plunging_strike/land.png.import new file mode 100644 index 0000000..3af6940 --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/land.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://j0hg0r21yj3p" +path="res://.godot/imported/land.png-41f537b1922dc051eb8e48badf632f51.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/land.png" +dest_files=["res://.godot/imported/land.png-41f537b1922dc051eb8e48badf632f51.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/plunging_strike.txt b/assets/art/characters/player/13_plunging_strike/plunging_strike.txt new file mode 100644 index 0000000..8920a0b --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/plunging_strike.txt @@ -0,0 +1,19 @@ +动作名称:下坠攻击 +编号:13 +分类:普攻 +释放条件:[空中] +按键判定:[S] +技能描述:从空中向下坠落攻击,命中或落地时造成冲击。 +前缀动作:上挑 [W] / 空中连斩 [空中A] / 其他进入空中的动作 +后缀动作:无 +效果:浮空 +基础伤害:180 +基础横向位移:0 +基础纵向位移:-260 +能量回复:15 +能量消耗:0 +动作时长: +Prep: 25FPS +Swing start: 25FPS +Falling loop: 20FPS +Land: 20FPS diff --git a/assets/art/characters/player/13_plunging_strike/png.txt b/assets/art/characters/player/13_plunging_strike/png.txt new file mode 100644 index 0000000..477256d --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/png.txt @@ -0,0 +1,7 @@ +[FX 2] SWING START SLASH OVERLAY FX: 一行四列 +[FX 3] FALLING SLASH OVERLAY FX [HOLD]: 一行两列 +[FX 4] LAND SLASH OVERLAY FX: 一行四列 +1 PREP: 一行两列 +2 SWING START: 一行三列 +3 FALLING [LOOP]: 一行三列 +4 LAND: 两行三列 \ No newline at end of file diff --git a/assets/art/characters/player/13_plunging_strike/prep.png b/assets/art/characters/player/13_plunging_strike/prep.png new file mode 100644 index 0000000..ff8062e Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/prep.png differ diff --git a/assets/art/characters/player/13_plunging_strike/prep.png.import b/assets/art/characters/player/13_plunging_strike/prep.png.import new file mode 100644 index 0000000..eaaf7b2 --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/prep.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5pdlvcsxf622" +path="res://.godot/imported/prep.png-6d5362007bbdca8ce82b8e2664912010.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/prep.png" +dest_files=["res://.godot/imported/prep.png-6d5362007bbdca8ce82b8e2664912010.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/13_plunging_strike/swing_start.png b/assets/art/characters/player/13_plunging_strike/swing_start.png new file mode 100644 index 0000000..22df589 Binary files /dev/null and b/assets/art/characters/player/13_plunging_strike/swing_start.png differ diff --git a/assets/art/characters/player/13_plunging_strike/swing_start.png.import b/assets/art/characters/player/13_plunging_strike/swing_start.png.import new file mode 100644 index 0000000..b9ac886 --- /dev/null +++ b/assets/art/characters/player/13_plunging_strike/swing_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjpvyiopwlmo4" +path="res://.godot/imported/swing_start.png-40f3fe964f7d5bd3f954e0943bafee40.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/13_plunging_strike/swing_start.png" +dest_files=["res://.godot/imported/swing_start.png-40f3fe964f7d5bd3f954e0943bafee40.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/14_hit_stun/damaged.png b/assets/art/characters/player/14_hit_stun/damaged.png new file mode 100644 index 0000000..bfad2a6 Binary files /dev/null and b/assets/art/characters/player/14_hit_stun/damaged.png differ diff --git a/assets/art/characters/player/14_hit_stun/damaged.png.import b/assets/art/characters/player/14_hit_stun/damaged.png.import new file mode 100644 index 0000000..dff0144 --- /dev/null +++ b/assets/art/characters/player/14_hit_stun/damaged.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu105ulwhypsk" +path="res://.godot/imported/damaged.png-c1d63fab7dc3955447c626fe77ad35a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/14_hit_stun/damaged.png" +dest_files=["res://.godot/imported/damaged.png-c1d63fab7dc3955447c626fe77ad35a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/14_hit_stun/hit_stun.txt b/assets/art/characters/player/14_hit_stun/hit_stun.txt new file mode 100644 index 0000000..949a67c --- /dev/null +++ b/assets/art/characters/player/14_hit_stun/hit_stun.txt @@ -0,0 +1,15 @@ +动作名称:受击 +编号:14 +分类:生命状态 +释放条件:[地面] / [空中] +按键判定:无 +技能描述:角色受到可打断攻击后进入受击状态,短时间无法行动。 +前缀动作:任意可被打断动作 +后缀动作:待机 +效果:无 +基础伤害:0 +基础横向位移:按敌人攻击配置 +基础纵向位移:按敌人攻击配置 +能量回复:0 +能量消耗:0 +动作时长:14FPS \ No newline at end of file diff --git a/assets/art/characters/player/14_hit_stun/png.txt b/assets/art/characters/player/14_hit_stun/png.txt new file mode 100644 index 0000000..bc98ed3 --- /dev/null +++ b/assets/art/characters/player/14_hit_stun/png.txt @@ -0,0 +1 @@ +DAMAGED: 一行三列 \ No newline at end of file diff --git a/assets/art/characters/player/15_death/death.txt b/assets/art/characters/player/15_death/death.txt new file mode 100644 index 0000000..f686c41 --- /dev/null +++ b/assets/art/characters/player/15_death/death.txt @@ -0,0 +1,15 @@ +动作名称:死亡 +编号:15 +分类:生命状态 +释放条件:[地面] / [空中] +按键判定:无 +技能描述:生命值归零后进入死亡状态,停止所有输入和动作。 +前缀动作:任意状态 +后缀动作:无 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:按死亡动画长度 20FPS \ No newline at end of file diff --git a/assets/art/characters/player/15_death/death_feint.png b/assets/art/characters/player/15_death/death_feint.png new file mode 100644 index 0000000..02cbb6f Binary files /dev/null and b/assets/art/characters/player/15_death/death_feint.png differ diff --git a/assets/art/characters/player/15_death/death_feint.png.import b/assets/art/characters/player/15_death/death_feint.png.import new file mode 100644 index 0000000..4c8663f --- /dev/null +++ b/assets/art/characters/player/15_death/death_feint.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2v1rof7404q" +path="res://.godot/imported/death_feint.png-484cd69b7cf7a869d446a63609a1e398.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/death_feint.png" +dest_files=["res://.godot/imported/death_feint.png-484cd69b7cf7a869d446a63609a1e398.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/death_spin.png b/assets/art/characters/player/15_death/death_spin.png new file mode 100644 index 0000000..dc55802 Binary files /dev/null and b/assets/art/characters/player/15_death/death_spin.png differ diff --git a/assets/art/characters/player/15_death/death_spin.png.import b/assets/art/characters/player/15_death/death_spin.png.import new file mode 100644 index 0000000..398daba --- /dev/null +++ b/assets/art/characters/player/15_death/death_spin.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bv67xo03a1ov8" +path="res://.godot/imported/death_spin.png-8162e55e28dae7030362c9876e001b2b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/death_spin.png" +dest_files=["res://.godot/imported/death_spin.png-8162e55e28dae7030362c9876e001b2b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/defy_hold.png b/assets/art/characters/player/15_death/defy_hold.png new file mode 100644 index 0000000..e24e361 Binary files /dev/null and b/assets/art/characters/player/15_death/defy_hold.png differ diff --git a/assets/art/characters/player/15_death/defy_hold.png.import b/assets/art/characters/player/15_death/defy_hold.png.import new file mode 100644 index 0000000..5f0a064 --- /dev/null +++ b/assets/art/characters/player/15_death/defy_hold.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://copbl55ieogls" +path="res://.godot/imported/defy_hold.png-c71a3a0146e6bee4e57e929d4215b9be.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/defy_hold.png" +dest_files=["res://.godot/imported/defy_hold.png-c71a3a0146e6bee4e57e929d4215b9be.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/fall_back.png b/assets/art/characters/player/15_death/fall_back.png new file mode 100644 index 0000000..f5335bd Binary files /dev/null and b/assets/art/characters/player/15_death/fall_back.png differ diff --git a/assets/art/characters/player/15_death/fall_back.png.import b/assets/art/characters/player/15_death/fall_back.png.import new file mode 100644 index 0000000..310267d --- /dev/null +++ b/assets/art/characters/player/15_death/fall_back.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtw7em4d5c0lm" +path="res://.godot/imported/fall_back.png-e2b8e0c7d560c1587430a3892714e587.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/fall_back.png" +dest_files=["res://.godot/imported/fall_back.png-e2b8e0c7d560c1587430a3892714e587.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/he_s_finally_dead_hold.png b/assets/art/characters/player/15_death/he_s_finally_dead_hold.png new file mode 100644 index 0000000..bf5d13a Binary files /dev/null and b/assets/art/characters/player/15_death/he_s_finally_dead_hold.png differ diff --git a/assets/art/characters/player/15_death/he_s_finally_dead_hold.png.import b/assets/art/characters/player/15_death/he_s_finally_dead_hold.png.import new file mode 100644 index 0000000..bb548dc --- /dev/null +++ b/assets/art/characters/player/15_death/he_s_finally_dead_hold.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5pksge5nagpt" +path="res://.godot/imported/he_s_finally_dead_hold.png-ded2fb393f85baaa578d30454beca962.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/he_s_finally_dead_hold.png" +dest_files=["res://.godot/imported/he_s_finally_dead_hold.png-ded2fb393f85baaa578d30454beca962.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/initial_death_stun_hold.png b/assets/art/characters/player/15_death/initial_death_stun_hold.png new file mode 100644 index 0000000..aa035d8 Binary files /dev/null and b/assets/art/characters/player/15_death/initial_death_stun_hold.png differ diff --git a/assets/art/characters/player/15_death/initial_death_stun_hold.png.import b/assets/art/characters/player/15_death/initial_death_stun_hold.png.import new file mode 100644 index 0000000..189b262 --- /dev/null +++ b/assets/art/characters/player/15_death/initial_death_stun_hold.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://myel28yjsoos" +path="res://.godot/imported/initial_death_stun_hold.png-2b57bab37154b4d7cd90f945a1148061.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/15_death/initial_death_stun_hold.png" +dest_files=["res://.godot/imported/initial_death_stun_hold.png-2b57bab37154b4d7cd90f945a1148061.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/15_death/png.txt b/assets/art/characters/player/15_death/png.txt new file mode 100644 index 0000000..0f4f11e --- /dev/null +++ b/assets/art/characters/player/15_death/png.txt @@ -0,0 +1,6 @@ +1 INITIAL DEATH-STUN [HOLD]: 一行一列 +2 DEATH SPIN: 三行十一列 +3 DEATH FEINT: 三行七列 +4 DEFY [HOLD]: 一行一列 +5 FALL BACK: 两行五列 +6 HE'S FINALLY DEAD! [HOLD]: 一行一列 \ No newline at end of file diff --git a/assets/art/characters/player/16_idle/idle.txt b/assets/art/characters/player/16_idle/idle.txt new file mode 100644 index 0000000..814d397 --- /dev/null +++ b/assets/art/characters/player/16_idle/idle.txt @@ -0,0 +1,15 @@ +动作名称:待机 +编号:16 +分类:基础状态 +释放条件:[地面] +按键判定:无 +技能描述:角色没有执行动作时的默认状态。 +前缀动作:任意动作结束 +后缀动作:任意可释放动作 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:循环 20FPS \ No newline at end of file diff --git a/assets/art/characters/player/16_idle/idle_loop.png b/assets/art/characters/player/16_idle/idle_loop.png new file mode 100644 index 0000000..1375c27 Binary files /dev/null and b/assets/art/characters/player/16_idle/idle_loop.png differ diff --git a/assets/art/characters/player/16_idle/idle_loop.png.import b/assets/art/characters/player/16_idle/idle_loop.png.import new file mode 100644 index 0000000..e77c18b --- /dev/null +++ b/assets/art/characters/player/16_idle/idle_loop.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://54c84vklaqo5" +path="res://.godot/imported/idle_loop.png-3aa38e26ac7e7c83ac20691efb5f3a0b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/16_idle/idle_loop.png" +dest_files=["res://.godot/imported/idle_loop.png-3aa38e26ac7e7c83ac20691efb5f3a0b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/16_idle/png.txt b/assets/art/characters/player/16_idle/png.txt new file mode 100644 index 0000000..7cc01b2 --- /dev/null +++ b/assets/art/characters/player/16_idle/png.txt @@ -0,0 +1 @@ +1 IDLE [LOOP]: 两行四列 \ No newline at end of file diff --git a/assets/art/characters/player/17_turn_around/png.txt b/assets/art/characters/player/17_turn_around/png.txt new file mode 100644 index 0000000..415886b --- /dev/null +++ b/assets/art/characters/player/17_turn_around/png.txt @@ -0,0 +1 @@ +TURN: 两行三列 \ No newline at end of file diff --git a/assets/art/characters/player/17_turn_around/turn.png b/assets/art/characters/player/17_turn_around/turn.png new file mode 100644 index 0000000..cb1323f Binary files /dev/null and b/assets/art/characters/player/17_turn_around/turn.png differ diff --git a/assets/art/characters/player/17_turn_around/turn.png.import b/assets/art/characters/player/17_turn_around/turn.png.import new file mode 100644 index 0000000..11a26f3 --- /dev/null +++ b/assets/art/characters/player/17_turn_around/turn.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ct5uisnuiy8m6" +path="res://.godot/imported/turn.png-1efedda1b477d20f4d3c5f36e292923b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/17_turn_around/turn.png" +dest_files=["res://.godot/imported/turn.png-1efedda1b477d20f4d3c5f36e292923b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/17_turn_around/turn_around.txt b/assets/art/characters/player/17_turn_around/turn_around.txt new file mode 100644 index 0000000..232fd76 --- /dev/null +++ b/assets/art/characters/player/17_turn_around/turn_around.txt @@ -0,0 +1,15 @@ +动作名称:转身 +编号:17 +分类:基础状态 +释放条件:[地面] +按键判定:[A] / [D] +技能描述:角色根据输入方向调整朝向,用于决定后续攻击方向。 +前缀动作:待机 / 动作结束 +后缀动作:普攻一段 / 突进 / 其他方向相关动作 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长: 25FPS \ No newline at end of file diff --git a/assets/art/characters/player/18_block/air_block_start.png b/assets/art/characters/player/18_block/air_block_start.png new file mode 100644 index 0000000..253085f Binary files /dev/null and b/assets/art/characters/player/18_block/air_block_start.png differ diff --git a/assets/art/characters/player/18_block/air_block_start.png.import b/assets/art/characters/player/18_block/air_block_start.png.import new file mode 100644 index 0000000..0e69f69 --- /dev/null +++ b/assets/art/characters/player/18_block/air_block_start.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwul2qsificua" +path="res://.godot/imported/air_block_start.png-b4cb60d5f4378eeda7bbff0c46875290.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/18_block/air_block_start.png" +dest_files=["res://.godot/imported/air_block_start.png-b4cb60d5f4378eeda7bbff0c46875290.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/credits_blair_ceradsky.txt b/assets/art/characters/player/credits_blair_ceradsky.txt new file mode 100644 index 0000000..a9dadd6 --- /dev/null +++ b/assets/art/characters/player/credits_blair_ceradsky.txt @@ -0,0 +1,76 @@ + 2021. This work is licensed under CC4.0 https://creativecommons.org/licenses/by/4.0/ +Credit to Blair Ceradsky (Twitter: @MookaTheCaveman), please clearly state that the work was edited if you alter the art. + +THANK YOU FOR YOUR PURCHASE! + + + + + + + +SUGGESTED COLLISION BOX: +Included in this folder is a mock-up image of what the player's hitbox proportions might be. Keeping at-least +the leftmost edge faithful to this example is recommended for the wall-slide animation +(otherwise your player might not visually connect with wall-tiles correctly). + + +SUGGESTED GAME-OBJECT CENTER: +Also included in this folder is a suggestion image for where the center of the game-object should be. Using this +should help ensure that the turn animation/game-object flipping doesn't awkwardly snap the player too far +in one direction when turning. + + + + + + + +FX RULES: +You'll find many "OVERLAY FX" animations in the folder "2 Hiro's Attacks And Spells". +Since the effects had to have larger borders than the player sprites; their proportions do not match up with the +corresponding player animations (which they are meant to be played simultaneously on-top of). So to help you +know exactly where to position them in relationship to the player; all "overlay FX" animations have +the first frame of player's idle animation in them. You should Use this frame to as reference to find the +correct position for the FX, but should NOT include it in the FX animation itself. + + +If an FX animation has an empty frame after the idle image; it's apart of the animation and should be played +(It's just there to help keep proper timing with the associated player animation). + + +Some attacks have "contact FX" included with them. Play these on-top of enemies when they are struck by those +attacks. Not all attacks have their own contacts, but you can easily re-use some from other attacks as desired. + + + + + + + +FPS GUIDES, ETC.: +The sub-folders also contain more text files like this to instruct you on exact FPS values for each animation, +as well as other tips that might aid in design and sprite implementation. + + +All animations that are meant to be looped have [LOOP] written at the end of their filename. There are also some single-frame +""animations"" that are meant to be held for a moment; these have [HOLD] written at the end of their filename. + + +The player has alternate air versions of some animations (so that his feet don't awkwardly look like they are planted +on the ground when he is performing actions in the air!). These animations have [AIR] written first on their filenames. + + +If the animation states should play in a set order in-game, they will often have number-ordered filenames. + + +SPAGHETTI STATE-MACHINE WARNING: there are many player actions and transitional animations included in this pack and +therefore many possible conflicting states. I'm sorry if things get confusing. Just try to keep to clean practices. + + + + + + + +Enjoy :) \ No newline at end of file diff --git a/assets/art/characters/player/reference/suggested_collision_box.png b/assets/art/characters/player/reference/suggested_collision_box.png new file mode 100644 index 0000000..80abc2e Binary files /dev/null and b/assets/art/characters/player/reference/suggested_collision_box.png differ diff --git a/assets/art/characters/player/reference/suggested_collision_box.png.import b/assets/art/characters/player/reference/suggested_collision_box.png.import new file mode 100644 index 0000000..fa7c07f --- /dev/null +++ b/assets/art/characters/player/reference/suggested_collision_box.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d2oksu8xlw47o" +path="res://.godot/imported/suggested_collision_box.png-5abfffa7dc7beacbe57e043ad7ba9032.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/reference/suggested_collision_box.png" +dest_files=["res://.godot/imported/suggested_collision_box.png-5abfffa7dc7beacbe57e043ad7ba9032.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/characters/player/reference/suggested_gameobject_center.png b/assets/art/characters/player/reference/suggested_gameobject_center.png new file mode 100644 index 0000000..62974f5 Binary files /dev/null and b/assets/art/characters/player/reference/suggested_gameobject_center.png differ diff --git a/assets/art/characters/player/reference/suggested_gameobject_center.png.import b/assets/art/characters/player/reference/suggested_gameobject_center.png.import new file mode 100644 index 0000000..c945a68 --- /dev/null +++ b/assets/art/characters/player/reference/suggested_gameobject_center.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1o6ky0ba4us8" +path="res://.godot/imported/suggested_gameobject_center.png-6b29a6a001596ea70a2e7e6732229df3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/characters/player/reference/suggested_gameobject_center.png" +dest_files=["res://.godot/imported/suggested_gameobject_center.png-6b29a6a001596ea70a2e7e6732229df3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/effects/effect_sheet.png b/assets/art/effects/effect_sheet.png new file mode 100644 index 0000000..4f94b21 Binary files /dev/null and b/assets/art/effects/effect_sheet.png differ diff --git a/assets/art/effects/effect_sheet.png.import b/assets/art/effects/effect_sheet.png.import new file mode 100644 index 0000000..16fdc40 --- /dev/null +++ b/assets/art/effects/effect_sheet.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgpl0g56pw1qu" +path="res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/effects/effect_sheet.png" +dest_files=["res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/future/b02.png b/assets/art/ground/future/b02.png new file mode 100644 index 0000000..f08e05b Binary files /dev/null and b/assets/art/ground/future/b02.png differ diff --git a/assets/art/ground/future/b02.png.import b/assets/art/ground/future/b02.png.import new file mode 100644 index 0000000..5531f0b --- /dev/null +++ b/assets/art/ground/future/b02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bl6n8cv21fbnd" +path="res://.godot/imported/b02.png-37181d3d8d40925cd78500d294c46711.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/future/b02.png" +dest_files=["res://.godot/imported/b02.png-37181d3d8d40925cd78500d294c46711.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/future/future.png b/assets/art/ground/future/future.png new file mode 100644 index 0000000..409aee6 Binary files /dev/null and b/assets/art/ground/future/future.png differ diff --git a/assets/art/ground/future/future.png.import b/assets/art/ground/future/future.png.import new file mode 100644 index 0000000..8e89e1a --- /dev/null +++ b/assets/art/ground/future/future.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cf7524ogutydk" +path="res://.godot/imported/future.png-7367b8733372a46d8b4fffe440233fe3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/future/future.png" +dest_files=["res://.godot/imported/future.png-7367b8733372a46d8b4fffe440233fe3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/future/g02.png b/assets/art/ground/future/g02.png new file mode 100644 index 0000000..57c4859 Binary files /dev/null and b/assets/art/ground/future/g02.png differ diff --git a/assets/art/ground/future/g02.png.import b/assets/art/ground/future/g02.png.import new file mode 100644 index 0000000..0fe064e --- /dev/null +++ b/assets/art/ground/future/g02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crohj554ukhx4" +path="res://.godot/imported/g02.png-b7f813b1d849e64e132cfe1e91bd02c3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/future/g02.png" +dest_files=["res://.godot/imported/g02.png-b7f813b1d849e64e132cfe1e91bd02c3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/future/m02.png b/assets/art/ground/future/m02.png new file mode 100644 index 0000000..2a42982 Binary files /dev/null and b/assets/art/ground/future/m02.png differ diff --git a/assets/art/ground/future/m02.png.import b/assets/art/ground/future/m02.png.import new file mode 100644 index 0000000..de18b34 --- /dev/null +++ b/assets/art/ground/future/m02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e3rlac7cswuu" +path="res://.godot/imported/m02.png-25ab758cc6377f4f67fcbc064a047ed6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/future/m02.png" +dest_files=["res://.godot/imported/m02.png-25ab758cc6377f4f67fcbc064a047ed6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/ground.png b/assets/art/ground/ground.png new file mode 100644 index 0000000..854effd Binary files /dev/null and b/assets/art/ground/ground.png differ diff --git a/assets/art/ground/ground.png.import b/assets/art/ground/ground.png.import new file mode 100644 index 0000000..a108646 --- /dev/null +++ b/assets/art/ground/ground.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://df1i0jo40oefn" +path="res://.godot/imported/ground.png-b386c27e6f69367042db3831c84dc404.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/ground.png" +dest_files=["res://.godot/imported/ground.png-b386c27e6f69367042db3831c84dc404.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/long.png b/assets/art/ground/long.png new file mode 100644 index 0000000..7dc60c4 Binary files /dev/null and b/assets/art/ground/long.png differ diff --git a/assets/art/ground/long.png.import b/assets/art/ground/long.png.import new file mode 100644 index 0000000..8cb800a --- /dev/null +++ b/assets/art/ground/long.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bune6d01i3ami" +path="res://.godot/imported/long.png-f5da2e6f125b76d45bea3f23473b7bf8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/long.png" +dest_files=["res://.godot/imported/long.png-f5da2e6f125b76d45bea3f23473b7bf8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/mid.png b/assets/art/ground/mid.png new file mode 100644 index 0000000..d7744ca Binary files /dev/null and b/assets/art/ground/mid.png differ diff --git a/assets/art/ground/mid.png.import b/assets/art/ground/mid.png.import new file mode 100644 index 0000000..527e43e --- /dev/null +++ b/assets/art/ground/mid.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dovcwfp6kjcrk" +path="res://.godot/imported/mid.png-6f08992ae3acffc54c0ca0c770bdd217.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/mid.png" +dest_files=["res://.godot/imported/mid.png-6f08992ae3acffc54c0ca0c770bdd217.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past/b01.png b/assets/art/ground/past/b01.png new file mode 100644 index 0000000..6beaf11 Binary files /dev/null and b/assets/art/ground/past/b01.png differ diff --git a/assets/art/ground/past/b01.png.import b/assets/art/ground/past/b01.png.import new file mode 100644 index 0000000..8bb6a2f --- /dev/null +++ b/assets/art/ground/past/b01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1ek45yqrr31d" +path="res://.godot/imported/b01.png-6d8352e186308447e6ebb84952fb3af1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past/b01.png" +dest_files=["res://.godot/imported/b01.png-6d8352e186308447e6ebb84952fb3af1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past/g01.png b/assets/art/ground/past/g01.png new file mode 100644 index 0000000..9a28db7 Binary files /dev/null and b/assets/art/ground/past/g01.png differ diff --git a/assets/art/ground/past/g01.png.import b/assets/art/ground/past/g01.png.import new file mode 100644 index 0000000..7b95d75 --- /dev/null +++ b/assets/art/ground/past/g01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4n4pj2os80y8" +path="res://.godot/imported/g01.png-a0c52075edfa5f8a7e8183fe52a75cf0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past/g01.png" +dest_files=["res://.godot/imported/g01.png-a0c52075edfa5f8a7e8183fe52a75cf0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past/m01.png b/assets/art/ground/past/m01.png new file mode 100644 index 0000000..ca5d674 Binary files /dev/null and b/assets/art/ground/past/m01.png differ diff --git a/assets/art/ground/past/m01.png.import b/assets/art/ground/past/m01.png.import new file mode 100644 index 0000000..a99eb17 --- /dev/null +++ b/assets/art/ground/past/m01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhynostadpife" +path="res://.godot/imported/m01.png-70020625f5be545722b697e3ea116eed.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past/m01.png" +dest_files=["res://.godot/imported/m01.png-70020625f5be545722b697e3ea116eed.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past/past.png b/assets/art/ground/past/past.png new file mode 100644 index 0000000..25c0af4 Binary files /dev/null and b/assets/art/ground/past/past.png differ diff --git a/assets/art/ground/past/past.png.import b/assets/art/ground/past/past.png.import new file mode 100644 index 0000000..5a09c99 --- /dev/null +++ b/assets/art/ground/past/past.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bemm87vaphsor" +path="res://.godot/imported/past.png-96d40b53202860c6fab522d077db17eb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past/past.png" +dest_files=["res://.godot/imported/past.png-96d40b53202860c6fab522d077db17eb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past01.png b/assets/art/ground/past01.png new file mode 100644 index 0000000..9a28db7 Binary files /dev/null and b/assets/art/ground/past01.png differ diff --git a/assets/art/ground/past01.png.import b/assets/art/ground/past01.png.import new file mode 100644 index 0000000..aeb6920 --- /dev/null +++ b/assets/art/ground/past01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dq24t85q5c2u" +path="res://.godot/imported/past01.png-92df5f27f9555055d948cc1cf1134bd9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past01.png" +dest_files=["res://.godot/imported/past01.png-92df5f27f9555055d948cc1cf1134bd9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/art/ground/past03.png b/assets/art/ground/past03.png new file mode 100644 index 0000000..7ee277f Binary files /dev/null and b/assets/art/ground/past03.png differ diff --git a/assets/art/ground/past03.png.import b/assets/art/ground/past03.png.import new file mode 100644 index 0000000..77b0318 --- /dev/null +++ b/assets/art/ground/past03.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvi6shxcyhv7i" +path="res://.godot/imported/past03.png-150746e95df789be028ae8ede8f4dd44.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/art/ground/past03.png" +dest_files=["res://.godot/imported/past03.png-150746e95df789be028ae8ede8f4dd44.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/audio/ev_past1.mp3 b/assets/audio/ev_past1.mp3 new file mode 100644 index 0000000..1b43c3f Binary files /dev/null and b/assets/audio/ev_past1.mp3 differ diff --git a/assets/audio/ev_past1.mp3.import b/assets/audio/ev_past1.mp3.import new file mode 100644 index 0000000..726db2f --- /dev/null +++ b/assets/audio/ev_past1.mp3.import @@ -0,0 +1,19 @@ +[remap] + +importer="mp3" +type="AudioStreamMP3" +uid="uid://bqi0gq2u2d7pc" +path="res://.godot/imported/ev_past1.mp3-67173490140b2e30640da750ea29ca7d.mp3str" + +[deps] + +source_file="res://assets/audio/ev_past1.mp3" +dest_files=["res://.godot/imported/ev_past1.mp3-67173490140b2e30640da750ea29ca7d.mp3str"] + +[params] + +loop=true +loop_offset=0 +bpm=129.2 +beat_count=34 +bar_beats=4 diff --git a/assets/audio/song.ogg b/assets/audio/song.ogg new file mode 100644 index 0000000..0e221ec Binary files /dev/null and b/assets/audio/song.ogg differ diff --git a/assets/audio/song.ogg.import b/assets/audio/song.ogg.import new file mode 100644 index 0000000..29077dc --- /dev/null +++ b/assets/audio/song.ogg.import @@ -0,0 +1,19 @@ +[remap] + +importer="oggvorbisstr" +type="AudioStreamOggVorbis" +uid="uid://di5ceecn088rk" +path="res://.godot/imported/song.ogg-5000fbe4452bca6107c0b100390ff64c.oggvorbisstr" + +[deps] + +source_file="res://assets/audio/song.ogg" +dest_files=["res://.godot/imported/song.ogg-5000fbe4452bca6107c0b100390ff64c.oggvorbisstr"] + +[params] + +loop=false +loop_offset=0 +bpm=0 +beat_count=0 +bar_beats=4 diff --git a/assets/fonts/fzzdhjw.ttf b/assets/fonts/fzzdhjw.ttf new file mode 100644 index 0000000..d5567cd Binary files /dev/null and b/assets/fonts/fzzdhjw.ttf differ diff --git a/assets/fonts/fzzdhjw.ttf.import b/assets/fonts/fzzdhjw.ttf.import new file mode 100644 index 0000000..35a4f98 --- /dev/null +++ b/assets/fonts/fzzdhjw.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://fywt1yq0ovym" +path="res://.godot/imported/fzzdhjw.ttf-0c177ec62e181e7371f8870c604cac87.fontdata" + +[deps] + +source_file="res://assets/fonts/fzzdhjw.ttf" +dest_files=["res://.godot/imported/fzzdhjw.ttf-0c177ec62e181e7371f8870c604cac87.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=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/assets/ui/buff/buff_off.png b/assets/ui/buff/buff_off.png new file mode 100644 index 0000000..3dd0292 Binary files /dev/null and b/assets/ui/buff/buff_off.png differ diff --git a/assets/ui/buff/buff_off.png.import b/assets/ui/buff/buff_off.png.import new file mode 100644 index 0000000..7f32812 --- /dev/null +++ b/assets/ui/buff/buff_off.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ch1r36c5amuac" +path="res://.godot/imported/buff_off.png-f55dc62cffeb668b237d1fdc1670defe.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff/buff_off.png" +dest_files=["res://.godot/imported/buff_off.png-f55dc62cffeb668b237d1fdc1670defe.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff/buff_on.png b/assets/ui/buff/buff_on.png new file mode 100644 index 0000000..28f309d Binary files /dev/null and b/assets/ui/buff/buff_on.png differ diff --git a/assets/ui/buff/buff_on.png.import b/assets/ui/buff/buff_on.png.import new file mode 100644 index 0000000..24e7808 --- /dev/null +++ b/assets/ui/buff/buff_on.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2kj461r610ej" +path="res://.godot/imported/buff_on.png-f9d5de9310d95cbacbc004d8c982f5f2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff/buff_on.png" +dest_files=["res://.godot/imported/buff_on.png-f9d5de9310d95cbacbc004d8c982f5f2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0000.png b/assets/ui/buff_effect/frame0000.png new file mode 100644 index 0000000..b9c699f Binary files /dev/null and b/assets/ui/buff_effect/frame0000.png differ diff --git a/assets/ui/buff_effect/frame0000.png.import b/assets/ui/buff_effect/frame0000.png.import new file mode 100644 index 0000000..e513e3f --- /dev/null +++ b/assets/ui/buff_effect/frame0000.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bjeq1xe8sui46" +path="res://.godot/imported/frame0000.png-51b24f3f49f90977c16d2d0a9a3c9e13.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0000.png" +dest_files=["res://.godot/imported/frame0000.png-51b24f3f49f90977c16d2d0a9a3c9e13.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0001.png b/assets/ui/buff_effect/frame0001.png new file mode 100644 index 0000000..a5bb5f8 Binary files /dev/null and b/assets/ui/buff_effect/frame0001.png differ diff --git a/assets/ui/buff_effect/frame0001.png.import b/assets/ui/buff_effect/frame0001.png.import new file mode 100644 index 0000000..0672ce5 --- /dev/null +++ b/assets/ui/buff_effect/frame0001.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bocsbv3cd2f7h" +path="res://.godot/imported/frame0001.png-da07a84f209b7bb9c9af41322042b50f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0001.png" +dest_files=["res://.godot/imported/frame0001.png-da07a84f209b7bb9c9af41322042b50f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0002.png b/assets/ui/buff_effect/frame0002.png new file mode 100644 index 0000000..d2c4a6e Binary files /dev/null and b/assets/ui/buff_effect/frame0002.png differ diff --git a/assets/ui/buff_effect/frame0002.png.import b/assets/ui/buff_effect/frame0002.png.import new file mode 100644 index 0000000..2c45b28 --- /dev/null +++ b/assets/ui/buff_effect/frame0002.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://71fual7o5qws" +path="res://.godot/imported/frame0002.png-24770335732b63bab5b67f4e0f240ec8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0002.png" +dest_files=["res://.godot/imported/frame0002.png-24770335732b63bab5b67f4e0f240ec8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0003.png b/assets/ui/buff_effect/frame0003.png new file mode 100644 index 0000000..17f90c0 Binary files /dev/null and b/assets/ui/buff_effect/frame0003.png differ diff --git a/assets/ui/buff_effect/frame0003.png.import b/assets/ui/buff_effect/frame0003.png.import new file mode 100644 index 0000000..1dfa2f8 --- /dev/null +++ b/assets/ui/buff_effect/frame0003.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0xjyg5fskldr" +path="res://.godot/imported/frame0003.png-cd408bcb74a7a717cd24b70a1e00c0ec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0003.png" +dest_files=["res://.godot/imported/frame0003.png-cd408bcb74a7a717cd24b70a1e00c0ec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0004.png b/assets/ui/buff_effect/frame0004.png new file mode 100644 index 0000000..00312fe Binary files /dev/null and b/assets/ui/buff_effect/frame0004.png differ diff --git a/assets/ui/buff_effect/frame0004.png.import b/assets/ui/buff_effect/frame0004.png.import new file mode 100644 index 0000000..6d03f26 --- /dev/null +++ b/assets/ui/buff_effect/frame0004.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddhpa0svioqqj" +path="res://.godot/imported/frame0004.png-f84a4eb9fa6494de30cafc1c753ba22a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0004.png" +dest_files=["res://.godot/imported/frame0004.png-f84a4eb9fa6494de30cafc1c753ba22a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0005.png b/assets/ui/buff_effect/frame0005.png new file mode 100644 index 0000000..01697c7 Binary files /dev/null and b/assets/ui/buff_effect/frame0005.png differ diff --git a/assets/ui/buff_effect/frame0005.png.import b/assets/ui/buff_effect/frame0005.png.import new file mode 100644 index 0000000..27af4fe --- /dev/null +++ b/assets/ui/buff_effect/frame0005.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2e86vk28meo" +path="res://.godot/imported/frame0005.png-9d9b9266e35bdba1317ed4ef861e2ee8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0005.png" +dest_files=["res://.godot/imported/frame0005.png-9d9b9266e35bdba1317ed4ef861e2ee8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0006.png b/assets/ui/buff_effect/frame0006.png new file mode 100644 index 0000000..b0d5dd4 Binary files /dev/null and b/assets/ui/buff_effect/frame0006.png differ diff --git a/assets/ui/buff_effect/frame0006.png.import b/assets/ui/buff_effect/frame0006.png.import new file mode 100644 index 0000000..48e825c --- /dev/null +++ b/assets/ui/buff_effect/frame0006.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y0jrk32va3ai" +path="res://.godot/imported/frame0006.png-4919843ab93da700aad83cfbf67ba17d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0006.png" +dest_files=["res://.godot/imported/frame0006.png-4919843ab93da700aad83cfbf67ba17d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0007.png b/assets/ui/buff_effect/frame0007.png new file mode 100644 index 0000000..2395e10 Binary files /dev/null and b/assets/ui/buff_effect/frame0007.png differ diff --git a/assets/ui/buff_effect/frame0007.png.import b/assets/ui/buff_effect/frame0007.png.import new file mode 100644 index 0000000..eb3517d --- /dev/null +++ b/assets/ui/buff_effect/frame0007.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3usk41b1acif" +path="res://.godot/imported/frame0007.png-eb770fad3be8bbddc6248fb50ee15e32.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0007.png" +dest_files=["res://.godot/imported/frame0007.png-eb770fad3be8bbddc6248fb50ee15e32.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0008.png b/assets/ui/buff_effect/frame0008.png new file mode 100644 index 0000000..06f6a9a Binary files /dev/null and b/assets/ui/buff_effect/frame0008.png differ diff --git a/assets/ui/buff_effect/frame0008.png.import b/assets/ui/buff_effect/frame0008.png.import new file mode 100644 index 0000000..c69ef9f --- /dev/null +++ b/assets/ui/buff_effect/frame0008.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://besqwli52lg6s" +path="res://.godot/imported/frame0008.png-e40a98b0939a881e0c676c33b2ad9b6e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0008.png" +dest_files=["res://.godot/imported/frame0008.png-e40a98b0939a881e0c676c33b2ad9b6e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0009.png b/assets/ui/buff_effect/frame0009.png new file mode 100644 index 0000000..5a138aa Binary files /dev/null and b/assets/ui/buff_effect/frame0009.png differ diff --git a/assets/ui/buff_effect/frame0009.png.import b/assets/ui/buff_effect/frame0009.png.import new file mode 100644 index 0000000..dac1a7f --- /dev/null +++ b/assets/ui/buff_effect/frame0009.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwcn3xddcg5e4" +path="res://.godot/imported/frame0009.png-412b21f307b710f6c314bbe9755867a1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0009.png" +dest_files=["res://.godot/imported/frame0009.png-412b21f307b710f6c314bbe9755867a1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0010.png b/assets/ui/buff_effect/frame0010.png new file mode 100644 index 0000000..52ee2fb Binary files /dev/null and b/assets/ui/buff_effect/frame0010.png differ diff --git a/assets/ui/buff_effect/frame0010.png.import b/assets/ui/buff_effect/frame0010.png.import new file mode 100644 index 0000000..ac7f03f --- /dev/null +++ b/assets/ui/buff_effect/frame0010.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://1mshuja2bt2a" +path="res://.godot/imported/frame0010.png-2367ae187d288b30d40087185013d2fa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0010.png" +dest_files=["res://.godot/imported/frame0010.png-2367ae187d288b30d40087185013d2fa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0011.png b/assets/ui/buff_effect/frame0011.png new file mode 100644 index 0000000..48c87d2 Binary files /dev/null and b/assets/ui/buff_effect/frame0011.png differ diff --git a/assets/ui/buff_effect/frame0011.png.import b/assets/ui/buff_effect/frame0011.png.import new file mode 100644 index 0000000..93d2893 --- /dev/null +++ b/assets/ui/buff_effect/frame0011.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bj6pll08ry0j7" +path="res://.godot/imported/frame0011.png-80ac30ffd884cb46bce1d36f3c86e4e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0011.png" +dest_files=["res://.godot/imported/frame0011.png-80ac30ffd884cb46bce1d36f3c86e4e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0012.png b/assets/ui/buff_effect/frame0012.png new file mode 100644 index 0000000..d8945b4 Binary files /dev/null and b/assets/ui/buff_effect/frame0012.png differ diff --git a/assets/ui/buff_effect/frame0012.png.import b/assets/ui/buff_effect/frame0012.png.import new file mode 100644 index 0000000..4e34d83 --- /dev/null +++ b/assets/ui/buff_effect/frame0012.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pedvbwsdmtts" +path="res://.godot/imported/frame0012.png-4b59eea5d04528e158e70c81f0ee7e2a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0012.png" +dest_files=["res://.godot/imported/frame0012.png-4b59eea5d04528e158e70c81f0ee7e2a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0013.png b/assets/ui/buff_effect/frame0013.png new file mode 100644 index 0000000..0547778 Binary files /dev/null and b/assets/ui/buff_effect/frame0013.png differ diff --git a/assets/ui/buff_effect/frame0013.png.import b/assets/ui/buff_effect/frame0013.png.import new file mode 100644 index 0000000..e0de69e --- /dev/null +++ b/assets/ui/buff_effect/frame0013.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0ltbtv5mvaqw" +path="res://.godot/imported/frame0013.png-51e0e97da301a75cf77c8ced4fc10cb5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0013.png" +dest_files=["res://.godot/imported/frame0013.png-51e0e97da301a75cf77c8ced4fc10cb5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0014.png b/assets/ui/buff_effect/frame0014.png new file mode 100644 index 0000000..d816fd8 Binary files /dev/null and b/assets/ui/buff_effect/frame0014.png differ diff --git a/assets/ui/buff_effect/frame0014.png.import b/assets/ui/buff_effect/frame0014.png.import new file mode 100644 index 0000000..1f61bda --- /dev/null +++ b/assets/ui/buff_effect/frame0014.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bj0hlvy5243ku" +path="res://.godot/imported/frame0014.png-1f9d6fd3ec538bf6199f7ff96389d816.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0014.png" +dest_files=["res://.godot/imported/frame0014.png-1f9d6fd3ec538bf6199f7ff96389d816.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0015.png b/assets/ui/buff_effect/frame0015.png new file mode 100644 index 0000000..76eeba5 Binary files /dev/null and b/assets/ui/buff_effect/frame0015.png differ diff --git a/assets/ui/buff_effect/frame0015.png.import b/assets/ui/buff_effect/frame0015.png.import new file mode 100644 index 0000000..7d90aa9 --- /dev/null +++ b/assets/ui/buff_effect/frame0015.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d8hlnjlpqycu" +path="res://.godot/imported/frame0015.png-db1abd57f25a8953af7ee96704385dc5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0015.png" +dest_files=["res://.godot/imported/frame0015.png-db1abd57f25a8953af7ee96704385dc5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0016.png b/assets/ui/buff_effect/frame0016.png new file mode 100644 index 0000000..7bdfbbb Binary files /dev/null and b/assets/ui/buff_effect/frame0016.png differ diff --git a/assets/ui/buff_effect/frame0016.png.import b/assets/ui/buff_effect/frame0016.png.import new file mode 100644 index 0000000..fc81aa3 --- /dev/null +++ b/assets/ui/buff_effect/frame0016.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhmiw64chyqwu" +path="res://.godot/imported/frame0016.png-051f85dffaed5387fc43656e76c7843f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0016.png" +dest_files=["res://.godot/imported/frame0016.png-051f85dffaed5387fc43656e76c7843f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0017.png b/assets/ui/buff_effect/frame0017.png new file mode 100644 index 0000000..760b294 Binary files /dev/null and b/assets/ui/buff_effect/frame0017.png differ diff --git a/assets/ui/buff_effect/frame0017.png.import b/assets/ui/buff_effect/frame0017.png.import new file mode 100644 index 0000000..e4f4cd3 --- /dev/null +++ b/assets/ui/buff_effect/frame0017.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddke2hfxtltjy" +path="res://.godot/imported/frame0017.png-a87ea205d3c83016b8cbac73183dd557.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0017.png" +dest_files=["res://.godot/imported/frame0017.png-a87ea205d3c83016b8cbac73183dd557.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0018.png b/assets/ui/buff_effect/frame0018.png new file mode 100644 index 0000000..8673526 Binary files /dev/null and b/assets/ui/buff_effect/frame0018.png differ diff --git a/assets/ui/buff_effect/frame0018.png.import b/assets/ui/buff_effect/frame0018.png.import new file mode 100644 index 0000000..8744c1c --- /dev/null +++ b/assets/ui/buff_effect/frame0018.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blbs16glp7568" +path="res://.godot/imported/frame0018.png-c350f432dc4deff87b6023f4c2c4e413.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0018.png" +dest_files=["res://.godot/imported/frame0018.png-c350f432dc4deff87b6023f4c2c4e413.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0019.png b/assets/ui/buff_effect/frame0019.png new file mode 100644 index 0000000..fb8b851 Binary files /dev/null and b/assets/ui/buff_effect/frame0019.png differ diff --git a/assets/ui/buff_effect/frame0019.png.import b/assets/ui/buff_effect/frame0019.png.import new file mode 100644 index 0000000..ed0525a --- /dev/null +++ b/assets/ui/buff_effect/frame0019.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dl4ac0htqrbom" +path="res://.godot/imported/frame0019.png-a7981ea48b7a083a9ec582cd71e6041c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0019.png" +dest_files=["res://.godot/imported/frame0019.png-a7981ea48b7a083a9ec582cd71e6041c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0020.png b/assets/ui/buff_effect/frame0020.png new file mode 100644 index 0000000..e8235e1 Binary files /dev/null and b/assets/ui/buff_effect/frame0020.png differ diff --git a/assets/ui/buff_effect/frame0020.png.import b/assets/ui/buff_effect/frame0020.png.import new file mode 100644 index 0000000..9f7add3 --- /dev/null +++ b/assets/ui/buff_effect/frame0020.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dj2vmh27us2uj" +path="res://.godot/imported/frame0020.png-fa0df1c2b4281bc16e393fb0a306b98b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0020.png" +dest_files=["res://.godot/imported/frame0020.png-fa0df1c2b4281bc16e393fb0a306b98b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0021.png b/assets/ui/buff_effect/frame0021.png new file mode 100644 index 0000000..05b1cf8 Binary files /dev/null and b/assets/ui/buff_effect/frame0021.png differ diff --git a/assets/ui/buff_effect/frame0021.png.import b/assets/ui/buff_effect/frame0021.png.import new file mode 100644 index 0000000..0cafcab --- /dev/null +++ b/assets/ui/buff_effect/frame0021.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyg6qu2wiecjt" +path="res://.godot/imported/frame0021.png-423bb43a149fe71337385f49021daf5c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0021.png" +dest_files=["res://.godot/imported/frame0021.png-423bb43a149fe71337385f49021daf5c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0022.png b/assets/ui/buff_effect/frame0022.png new file mode 100644 index 0000000..ae85bbd Binary files /dev/null and b/assets/ui/buff_effect/frame0022.png differ diff --git a/assets/ui/buff_effect/frame0022.png.import b/assets/ui/buff_effect/frame0022.png.import new file mode 100644 index 0000000..1be2c69 --- /dev/null +++ b/assets/ui/buff_effect/frame0022.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cqet4ffvvt1u3" +path="res://.godot/imported/frame0022.png-79a83692ee929bb630fe4a4de4215c75.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0022.png" +dest_files=["res://.godot/imported/frame0022.png-79a83692ee929bb630fe4a4de4215c75.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/buff_effect/frame0023.png b/assets/ui/buff_effect/frame0023.png new file mode 100644 index 0000000..eb8c59a Binary files /dev/null and b/assets/ui/buff_effect/frame0023.png differ diff --git a/assets/ui/buff_effect/frame0023.png.import b/assets/ui/buff_effect/frame0023.png.import new file mode 100644 index 0000000..484d03e --- /dev/null +++ b/assets/ui/buff_effect/frame0023.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ymxa1rjmp1a1" +path="res://.godot/imported/frame0023.png-243552f7f8e9ec6a90813a5b209385bd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/buff_effect/frame0023.png" +dest_files=["res://.godot/imported/frame0023.png-243552f7f8e9ec6a90813a5b209385bd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/combo/four_slot_judgement_frame.png b/assets/ui/combo/four_slot_judgement_frame.png new file mode 100644 index 0000000..c8d08a4 Binary files /dev/null and b/assets/ui/combo/four_slot_judgement_frame.png differ diff --git a/assets/ui/combo/four_slot_judgement_frame.png.import b/assets/ui/combo/four_slot_judgement_frame.png.import new file mode 100644 index 0000000..dc6d2a1 --- /dev/null +++ b/assets/ui/combo/four_slot_judgement_frame.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c38jkh3qt1pt7" +path="res://.godot/imported/four_slot_judgement_frame.png-110c1fdcf7466c4eae0c73a6ce6dd743.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/combo/four_slot_judgement_frame.png" +dest_files=["res://.godot/imported/four_slot_judgement_frame.png-110c1fdcf7466c4eae0c73a6ce6dd743.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/judgements/bad.png b/assets/ui/judgements/bad.png new file mode 100644 index 0000000..094be5e Binary files /dev/null and b/assets/ui/judgements/bad.png differ diff --git a/assets/ui/judgements/bad.png.import b/assets/ui/judgements/bad.png.import new file mode 100644 index 0000000..044b881 --- /dev/null +++ b/assets/ui/judgements/bad.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://yg5jk73guamf" +path="res://.godot/imported/bad.png-74b1646eda68bbb977a53eb570d8072e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/judgements/bad.png" +dest_files=["res://.godot/imported/bad.png-74b1646eda68bbb977a53eb570d8072e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/judgements/good.png b/assets/ui/judgements/good.png new file mode 100644 index 0000000..ef29577 Binary files /dev/null and b/assets/ui/judgements/good.png differ diff --git a/assets/ui/judgements/good.png.import b/assets/ui/judgements/good.png.import new file mode 100644 index 0000000..2f4d695 --- /dev/null +++ b/assets/ui/judgements/good.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkrus10kk26gk" +path="res://.godot/imported/good.png-8d5b777705d48943a58552a76e754d74.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/judgements/good.png" +dest_files=["res://.godot/imported/good.png-8d5b777705d48943a58552a76e754d74.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/judgements/miss.png b/assets/ui/judgements/miss.png new file mode 100644 index 0000000..f495ea2 Binary files /dev/null and b/assets/ui/judgements/miss.png differ diff --git a/assets/ui/judgements/miss.png.import b/assets/ui/judgements/miss.png.import new file mode 100644 index 0000000..0b612c2 --- /dev/null +++ b/assets/ui/judgements/miss.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgwule0p0yo7u" +path="res://.godot/imported/miss.png-511fc1d888138a59f80dab39fac41a27.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/judgements/miss.png" +dest_files=["res://.godot/imported/miss.png-511fc1d888138a59f80dab39fac41a27.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/judgements/perfect.png b/assets/ui/judgements/perfect.png new file mode 100644 index 0000000..1354b55 Binary files /dev/null and b/assets/ui/judgements/perfect.png differ diff --git a/assets/ui/judgements/perfect.png.import b/assets/ui/judgements/perfect.png.import new file mode 100644 index 0000000..83b5d44 --- /dev/null +++ b/assets/ui/judgements/perfect.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://by0ssc8r0veqv" +path="res://.godot/imported/perfect.png-f44af0a55841bbd9bc0f35fbb597c84f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/judgements/perfect.png" +dest_files=["res://.godot/imported/perfect.png-f44af0a55841bbd9bc0f35fbb597c84f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/back_title.png b/assets/ui/menu/back_title.png new file mode 100644 index 0000000..2d96957 Binary files /dev/null and b/assets/ui/menu/back_title.png differ diff --git a/assets/ui/menu/back_title.png.import b/assets/ui/menu/back_title.png.import new file mode 100644 index 0000000..e176f4a --- /dev/null +++ b/assets/ui/menu/back_title.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bg5na8itrficn" +path="res://.godot/imported/back_title.png-27e7fc7d9718094a100435a7709ced40.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/back_title.png" +dest_files=["res://.godot/imported/back_title.png-27e7fc7d9718094a100435a7709ced40.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/boss_reference.png b/assets/ui/menu/boss_reference.png new file mode 100644 index 0000000..3984ad1 Binary files /dev/null and b/assets/ui/menu/boss_reference.png differ diff --git a/assets/ui/menu/boss_reference.png.import b/assets/ui/menu/boss_reference.png.import new file mode 100644 index 0000000..6f5170e --- /dev/null +++ b/assets/ui/menu/boss_reference.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cl5frvmkqdbxg" +path="res://.godot/imported/boss_reference.png-2c54a88dfc0bcd3ee041cd327f824123.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/boss_reference.png" +dest_files=["res://.godot/imported/boss_reference.png-2c54a88dfc0bcd3ee041cd327f824123.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/button_continue_game.png b/assets/ui/menu/button_continue_game.png new file mode 100644 index 0000000..6543252 Binary files /dev/null and b/assets/ui/menu/button_continue_game.png differ diff --git a/assets/ui/menu/button_continue_game.png.import b/assets/ui/menu/button_continue_game.png.import new file mode 100644 index 0000000..099ab02 --- /dev/null +++ b/assets/ui/menu/button_continue_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bf3pdwuk1pxq5" +path="res://.godot/imported/button_continue_game.png-a2cbe53abc3e6a2114e9234639b12785.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/button_continue_game.png" +dest_files=["res://.godot/imported/button_continue_game.png-a2cbe53abc3e6a2114e9234639b12785.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/button_difficulty.png b/assets/ui/menu/button_difficulty.png new file mode 100644 index 0000000..8fa2de2 Binary files /dev/null and b/assets/ui/menu/button_difficulty.png differ diff --git a/assets/ui/menu/button_difficulty.png.import b/assets/ui/menu/button_difficulty.png.import new file mode 100644 index 0000000..879fb14 --- /dev/null +++ b/assets/ui/menu/button_difficulty.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckbwx3wk6y6ok" +path="res://.godot/imported/button_difficulty.png-a25e06b8679c5c3ad49a4f8cafb70d7a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/button_difficulty.png" +dest_files=["res://.godot/imported/button_difficulty.png-a25e06b8679c5c3ad49a4f8cafb70d7a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/button_quit_game.png b/assets/ui/menu/button_quit_game.png new file mode 100644 index 0000000..a12a035 Binary files /dev/null and b/assets/ui/menu/button_quit_game.png differ diff --git a/assets/ui/menu/button_quit_game.png.import b/assets/ui/menu/button_quit_game.png.import new file mode 100644 index 0000000..15b8479 --- /dev/null +++ b/assets/ui/menu/button_quit_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dydr15bjc3t88" +path="res://.godot/imported/button_quit_game.png-f21afbcbda5163269c92bf121ec9270f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/button_quit_game.png" +dest_files=["res://.godot/imported/button_quit_game.png-f21afbcbda5163269c92bf121ec9270f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/button_return_title.png b/assets/ui/menu/button_return_title.png new file mode 100644 index 0000000..2d96957 Binary files /dev/null and b/assets/ui/menu/button_return_title.png differ diff --git a/assets/ui/menu/button_return_title.png.import b/assets/ui/menu/button_return_title.png.import new file mode 100644 index 0000000..28a8f60 --- /dev/null +++ b/assets/ui/menu/button_return_title.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bi7wglvpr8q3s" +path="res://.godot/imported/button_return_title.png-4807117f72008d116ba6fbf66d2a6fff.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/button_return_title.png" +dest_files=["res://.godot/imported/button_return_title.png-4807117f72008d116ba6fbf66d2a6fff.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/button_start_game.png b/assets/ui/menu/button_start_game.png new file mode 100644 index 0000000..d96a333 Binary files /dev/null and b/assets/ui/menu/button_start_game.png differ diff --git a/assets/ui/menu/button_start_game.png.import b/assets/ui/menu/button_start_game.png.import new file mode 100644 index 0000000..ee38e39 --- /dev/null +++ b/assets/ui/menu/button_start_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dssgha33qf06s" +path="res://.godot/imported/button_start_game.png-2b8bfc563960a30977ca7602c365ad89.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/button_start_game.png" +dest_files=["res://.godot/imported/button_start_game.png-2b8bfc563960a30977ca7602c365ad89.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/continue_game.png b/assets/ui/menu/continue_game.png new file mode 100644 index 0000000..6543252 Binary files /dev/null and b/assets/ui/menu/continue_game.png differ diff --git a/assets/ui/menu/continue_game.png.import b/assets/ui/menu/continue_game.png.import new file mode 100644 index 0000000..3f16a21 --- /dev/null +++ b/assets/ui/menu/continue_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://35bdtm4rctab" +path="res://.godot/imported/continue_game.png-82e6caeaa8c69b04183dbaf808cffca6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/continue_game.png" +dest_files=["res://.godot/imported/continue_game.png-82e6caeaa8c69b04183dbaf808cffca6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/difficulty_easy.png b/assets/ui/menu/difficulty_easy.png new file mode 100644 index 0000000..3248107 Binary files /dev/null and b/assets/ui/menu/difficulty_easy.png differ diff --git a/assets/ui/menu/difficulty_easy.png.import b/assets/ui/menu/difficulty_easy.png.import new file mode 100644 index 0000000..97e1f3a --- /dev/null +++ b/assets/ui/menu/difficulty_easy.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cb7pagfsmuv4" +path="res://.godot/imported/difficulty_easy.png-9fe66f76cef0d5ecb44c7ed65c3e7e48.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/difficulty_easy.png" +dest_files=["res://.godot/imported/difficulty_easy.png-9fe66f76cef0d5ecb44c7ed65c3e7e48.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/difficulty_easy_text.png b/assets/ui/menu/difficulty_easy_text.png new file mode 100644 index 0000000..72818f0 Binary files /dev/null and b/assets/ui/menu/difficulty_easy_text.png differ diff --git a/assets/ui/menu/difficulty_easy_text.png.import b/assets/ui/menu/difficulty_easy_text.png.import new file mode 100644 index 0000000..3c520b7 --- /dev/null +++ b/assets/ui/menu/difficulty_easy_text.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nibjw20ky7od" +path="res://.godot/imported/difficulty_easy_text.png-bfa74c6eb71a496c21c1c6a39b8ce87e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/difficulty_easy_text.png" +dest_files=["res://.godot/imported/difficulty_easy_text.png-bfa74c6eb71a496c21c1c6a39b8ce87e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/difficulty_hard.png b/assets/ui/menu/difficulty_hard.png new file mode 100644 index 0000000..5f16274 Binary files /dev/null and b/assets/ui/menu/difficulty_hard.png differ diff --git a/assets/ui/menu/difficulty_hard.png.import b/assets/ui/menu/difficulty_hard.png.import new file mode 100644 index 0000000..cc9e786 --- /dev/null +++ b/assets/ui/menu/difficulty_hard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y4x3q0u8uum1" +path="res://.godot/imported/difficulty_hard.png-7ce4409b517dfed0b67f9a627e5e9666.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/difficulty_hard.png" +dest_files=["res://.godot/imported/difficulty_hard.png-7ce4409b517dfed0b67f9a627e5e9666.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/difficulty_normal.png b/assets/ui/menu/difficulty_normal.png new file mode 100644 index 0000000..312634c Binary files /dev/null and b/assets/ui/menu/difficulty_normal.png differ diff --git a/assets/ui/menu/difficulty_normal.png.import b/assets/ui/menu/difficulty_normal.png.import new file mode 100644 index 0000000..19bfde2 --- /dev/null +++ b/assets/ui/menu/difficulty_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bsaor08rfjab7" +path="res://.godot/imported/difficulty_normal.png-aef1a24ffad1fdac81109e38bbf549d7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/difficulty_normal.png" +dest_files=["res://.godot/imported/difficulty_normal.png-aef1a24ffad1fdac81109e38bbf549d7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/difficulty_select.png b/assets/ui/menu/difficulty_select.png new file mode 100644 index 0000000..8fa2de2 Binary files /dev/null and b/assets/ui/menu/difficulty_select.png differ diff --git a/assets/ui/menu/difficulty_select.png.import b/assets/ui/menu/difficulty_select.png.import new file mode 100644 index 0000000..949885d --- /dev/null +++ b/assets/ui/menu/difficulty_select.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmrlhryb6vl1g" +path="res://.godot/imported/difficulty_select.png-bcc1c1572bf6c3e7e385eaac324637a2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/difficulty_select.png" +dest_files=["res://.godot/imported/difficulty_select.png-bcc1c1572bf6c3e7e385eaac324637a2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/exit_game.png b/assets/ui/menu/exit_game.png new file mode 100644 index 0000000..a12a035 Binary files /dev/null and b/assets/ui/menu/exit_game.png differ diff --git a/assets/ui/menu/exit_game.png.import b/assets/ui/menu/exit_game.png.import new file mode 100644 index 0000000..7a6d7a1 --- /dev/null +++ b/assets/ui/menu/exit_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://toamde7chg4c" +path="res://.godot/imported/exit_game.png-deb58db48e93fdbffe743a24609d514e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/exit_game.png" +dest_files=["res://.godot/imported/exit_game.png-deb58db48e93fdbffe743a24609d514e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/frame_tall.png b/assets/ui/menu/frame_tall.png new file mode 100644 index 0000000..7649125 Binary files /dev/null and b/assets/ui/menu/frame_tall.png differ diff --git a/assets/ui/menu/frame_tall.png.import b/assets/ui/menu/frame_tall.png.import new file mode 100644 index 0000000..014c684 --- /dev/null +++ b/assets/ui/menu/frame_tall.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sblvbxrcjkdn" +path="res://.godot/imported/frame_tall.png-f77c1dfedcdfb2ca1f2743a93595951b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/frame_tall.png" +dest_files=["res://.godot/imported/frame_tall.png-f77c1dfedcdfb2ca1f2743a93595951b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/level1_reference.png b/assets/ui/menu/level1_reference.png new file mode 100644 index 0000000..f403041 Binary files /dev/null and b/assets/ui/menu/level1_reference.png differ diff --git a/assets/ui/menu/level1_reference.png.import b/assets/ui/menu/level1_reference.png.import new file mode 100644 index 0000000..8138cb8 --- /dev/null +++ b/assets/ui/menu/level1_reference.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfww5ai0pwwxv" +path="res://.godot/imported/level1_reference.png-04a6991dfbae301723f9b9eb5cf02dee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/level1_reference.png" +dest_files=["res://.godot/imported/level1_reference.png-04a6991dfbae301723f9b9eb5cf02dee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/pause_menu.png b/assets/ui/menu/pause_menu.png new file mode 100644 index 0000000..dee3eb4 Binary files /dev/null and b/assets/ui/menu/pause_menu.png differ diff --git a/assets/ui/menu/pause_menu.png.import b/assets/ui/menu/pause_menu.png.import new file mode 100644 index 0000000..73e816c --- /dev/null +++ b/assets/ui/menu/pause_menu.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blprww008ck4m" +path="res://.godot/imported/pause_menu.png-cb727487577319b4fbbc4376591c0dbf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/pause_menu.png" +dest_files=["res://.godot/imported/pause_menu.png-cb727487577319b4fbbc4376591c0dbf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/pause_panel.png b/assets/ui/menu/pause_panel.png new file mode 100644 index 0000000..dee3eb4 Binary files /dev/null and b/assets/ui/menu/pause_panel.png differ diff --git a/assets/ui/menu/pause_panel.png.import b/assets/ui/menu/pause_panel.png.import new file mode 100644 index 0000000..1123e0a --- /dev/null +++ b/assets/ui/menu/pause_panel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dydo1by7v4qjq" +path="res://.godot/imported/pause_panel.png-a7cf96bbe259ffd6839b9b80aaa4efb0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/pause_panel.png" +dest_files=["res://.godot/imported/pause_panel.png-a7cf96bbe259ffd6839b9b80aaa4efb0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/selection_normal.png b/assets/ui/menu/selection_normal.png new file mode 100644 index 0000000..765e969 Binary files /dev/null and b/assets/ui/menu/selection_normal.png differ diff --git a/assets/ui/menu/selection_normal.png.import b/assets/ui/menu/selection_normal.png.import new file mode 100644 index 0000000..29299ae --- /dev/null +++ b/assets/ui/menu/selection_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drv17nwxbbhim" +path="res://.godot/imported/selection_normal.png-14eb8519c3c7649c5cfa89cfe4aaf8c6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/selection_normal.png" +dest_files=["res://.godot/imported/selection_normal.png-14eb8519c3c7649c5cfa89cfe4aaf8c6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/selection_selected.png b/assets/ui/menu/selection_selected.png new file mode 100644 index 0000000..c7d8689 Binary files /dev/null and b/assets/ui/menu/selection_selected.png differ diff --git a/assets/ui/menu/selection_selected.png.import b/assets/ui/menu/selection_selected.png.import new file mode 100644 index 0000000..f2a0608 --- /dev/null +++ b/assets/ui/menu/selection_selected.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://tqjb2vn34h1o" +path="res://.godot/imported/selection_selected.png-2805ba7b1075496f1a17880545142a94.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/selection_selected.png" +dest_files=["res://.godot/imported/selection_selected.png-2805ba7b1075496f1a17880545142a94.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/start_background_future.png b/assets/ui/menu/start_background_future.png new file mode 100644 index 0000000..0ec7317 Binary files /dev/null and b/assets/ui/menu/start_background_future.png differ diff --git a/assets/ui/menu/start_background_future.png.import b/assets/ui/menu/start_background_future.png.import new file mode 100644 index 0000000..a0131ef --- /dev/null +++ b/assets/ui/menu/start_background_future.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7e2ht7cgcuy5" +path="res://.godot/imported/start_background_future.png-d7a706569846b9c7d91a171928738ce9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/start_background_future.png" +dest_files=["res://.godot/imported/start_background_future.png-d7a706569846b9c7d91a171928738ce9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/start_background_past.png b/assets/ui/menu/start_background_past.png new file mode 100644 index 0000000..481cdda Binary files /dev/null and b/assets/ui/menu/start_background_past.png differ diff --git a/assets/ui/menu/start_background_past.png.import b/assets/ui/menu/start_background_past.png.import new file mode 100644 index 0000000..c4ab63a --- /dev/null +++ b/assets/ui/menu/start_background_past.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cvp36fdlj4att" +path="res://.godot/imported/start_background_past.png-dba42e5e2d22a021e2ea6333772ed961.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/start_background_past.png" +dest_files=["res://.godot/imported/start_background_past.png-dba42e5e2d22a021e2ea6333772ed961.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/start_difficulty.png b/assets/ui/menu/start_difficulty.png new file mode 100644 index 0000000..0880df0 Binary files /dev/null and b/assets/ui/menu/start_difficulty.png differ diff --git a/assets/ui/menu/start_difficulty.png.import b/assets/ui/menu/start_difficulty.png.import new file mode 100644 index 0000000..8ad87ee --- /dev/null +++ b/assets/ui/menu/start_difficulty.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ggo84o6ogtve" +path="res://.godot/imported/start_difficulty.png-168ab325c80cdea92aa23959f70b978e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/start_difficulty.png" +dest_files=["res://.godot/imported/start_difficulty.png-168ab325c80cdea92aa23959f70b978e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/start_game.png b/assets/ui/menu/start_game.png new file mode 100644 index 0000000..d96a333 Binary files /dev/null and b/assets/ui/menu/start_game.png differ diff --git a/assets/ui/menu/start_game.png.import b/assets/ui/menu/start_game.png.import new file mode 100644 index 0000000..a27a6eb --- /dev/null +++ b/assets/ui/menu/start_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cc1dxjhkipijs" +path="res://.godot/imported/start_game.png-eb2274589a36bb75441f90f3905d2ceb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/start_game.png" +dest_files=["res://.godot/imported/start_game.png-eb2274589a36bb75441f90f3905d2ceb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/start_main.png b/assets/ui/menu/start_main.png new file mode 100644 index 0000000..ae1db91 Binary files /dev/null and b/assets/ui/menu/start_main.png differ diff --git a/assets/ui/menu/start_main.png.import b/assets/ui/menu/start_main.png.import new file mode 100644 index 0000000..205c4fd --- /dev/null +++ b/assets/ui/menu/start_main.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3743w760w2wf" +path="res://.godot/imported/start_main.png-270a051512ccfd2d6bc60bc20abdaa12.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/start_main.png" +dest_files=["res://.godot/imported/start_main.png-270a051512ccfd2d6bc60bc20abdaa12.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/title_difficulty_panel.png b/assets/ui/menu/title_difficulty_panel.png new file mode 100644 index 0000000..0880df0 Binary files /dev/null and b/assets/ui/menu/title_difficulty_panel.png differ diff --git a/assets/ui/menu/title_difficulty_panel.png.import b/assets/ui/menu/title_difficulty_panel.png.import new file mode 100644 index 0000000..a2b323d --- /dev/null +++ b/assets/ui/menu/title_difficulty_panel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kr57ra5dcefx" +path="res://.godot/imported/title_difficulty_panel.png-a4b8cd74572cbd837b0fc87639a32dd4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/title_difficulty_panel.png" +dest_files=["res://.godot/imported/title_difficulty_panel.png-a4b8cd74572cbd837b0fc87639a32dd4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/title_main_panel.png b/assets/ui/menu/title_main_panel.png new file mode 100644 index 0000000..ae1db91 Binary files /dev/null and b/assets/ui/menu/title_main_panel.png differ diff --git a/assets/ui/menu/title_main_panel.png.import b/assets/ui/menu/title_main_panel.png.import new file mode 100644 index 0000000..c8e74de --- /dev/null +++ b/assets/ui/menu/title_main_panel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxjaue07ms4eq" +path="res://.godot/imported/title_main_panel.png-17af39fb53ef1d4cd712dc5b6690bb5f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/title_main_panel.png" +dest_files=["res://.godot/imported/title_main_panel.png-17af39fb53ef1d4cd712dc5b6690bb5f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu/title_name.png b/assets/ui/menu/title_name.png new file mode 100644 index 0000000..1648e46 Binary files /dev/null and b/assets/ui/menu/title_name.png differ diff --git a/assets/ui/menu/title_name.png.import b/assets/ui/menu/title_name.png.import new file mode 100644 index 0000000..ee9eaae --- /dev/null +++ b/assets/ui/menu/title_name.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b4x3hvo1j4vfw" +path="res://.godot/imported/title_name.png-ca66d2db4a753ecede3e5507c43bf3ae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu/title_name.png" +dest_files=["res://.godot/imported/title_name.png-ca66d2db4a753ecede3e5507c43bf3ae.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/button_base_hover.png b/assets/ui/menu2/button_base_hover.png new file mode 100644 index 0000000..c7d8689 Binary files /dev/null and b/assets/ui/menu2/button_base_hover.png differ diff --git a/assets/ui/menu2/button_base_hover.png.import b/assets/ui/menu2/button_base_hover.png.import new file mode 100644 index 0000000..d732c19 --- /dev/null +++ b/assets/ui/menu2/button_base_hover.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0iia4pwvve02" +path="res://.godot/imported/button_base_hover.png-f93419e17e34e7b16e4ba620da5e2662.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/button_base_hover.png" +dest_files=["res://.godot/imported/button_base_hover.png-f93419e17e34e7b16e4ba620da5e2662.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/button_base_normal.png b/assets/ui/menu2/button_base_normal.png new file mode 100644 index 0000000..765e969 Binary files /dev/null and b/assets/ui/menu2/button_base_normal.png differ diff --git a/assets/ui/menu2/button_base_normal.png.import b/assets/ui/menu2/button_base_normal.png.import new file mode 100644 index 0000000..4c71533 --- /dev/null +++ b/assets/ui/menu2/button_base_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctn6fr8hdv4cx" +path="res://.godot/imported/button_base_normal.png-4886b06fb2042fb751cfbbd1b36928c3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/button_base_normal.png" +dest_files=["res://.godot/imported/button_base_normal.png-4886b06fb2042fb751cfbbd1b36928c3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/panel_tall.png b/assets/ui/menu2/panel_tall.png new file mode 100644 index 0000000..7649125 Binary files /dev/null and b/assets/ui/menu2/panel_tall.png differ diff --git a/assets/ui/menu2/panel_tall.png.import b/assets/ui/menu2/panel_tall.png.import new file mode 100644 index 0000000..f63fcd1 --- /dev/null +++ b/assets/ui/menu2/panel_tall.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://15elfu3ecu6j" +path="res://.godot/imported/panel_tall.png-5f4ee9cf0c8c73b32234b90f7b2bf8ee.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/panel_tall.png" +dest_files=["res://.godot/imported/panel_tall.png-5f4ee9cf0c8c73b32234b90f7b2bf8ee.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_cancel.png b/assets/ui/menu2/text_cancel.png new file mode 100644 index 0000000..203bb29 Binary files /dev/null and b/assets/ui/menu2/text_cancel.png differ diff --git a/assets/ui/menu2/text_cancel.png.import b/assets/ui/menu2/text_cancel.png.import new file mode 100644 index 0000000..05b1075 --- /dev/null +++ b/assets/ui/menu2/text_cancel.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cs8hq5ywo1xor" +path="res://.godot/imported/text_cancel.png-c43c79f95fab3847f5059fc1c546234c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_cancel.png" +dest_files=["res://.godot/imported/text_cancel.png-c43c79f95fab3847f5059fc1c546234c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_confirm.png b/assets/ui/menu2/text_confirm.png new file mode 100644 index 0000000..0eb3128 Binary files /dev/null and b/assets/ui/menu2/text_confirm.png differ diff --git a/assets/ui/menu2/text_confirm.png.import b/assets/ui/menu2/text_confirm.png.import new file mode 100644 index 0000000..7838748 --- /dev/null +++ b/assets/ui/menu2/text_confirm.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ce7xicjefdlpn" +path="res://.godot/imported/text_confirm.png-17272fbf1c50d152ae7ef790f85602a7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_confirm.png" +dest_files=["res://.godot/imported/text_confirm.png-17272fbf1c50d152ae7ef790f85602a7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_continue_game.png b/assets/ui/menu2/text_continue_game.png new file mode 100644 index 0000000..6543252 Binary files /dev/null and b/assets/ui/menu2/text_continue_game.png differ diff --git a/assets/ui/menu2/text_continue_game.png.import b/assets/ui/menu2/text_continue_game.png.import new file mode 100644 index 0000000..73a90d8 --- /dev/null +++ b/assets/ui/menu2/text_continue_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ofdm1mdxcfxo" +path="res://.godot/imported/text_continue_game.png-dde1456d1a6b857f66488272a92c1ea7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_continue_game.png" +dest_files=["res://.godot/imported/text_continue_game.png-dde1456d1a6b857f66488272a92c1ea7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_defeat.png b/assets/ui/menu2/text_defeat.png new file mode 100644 index 0000000..31c5bea Binary files /dev/null and b/assets/ui/menu2/text_defeat.png differ diff --git a/assets/ui/menu2/text_defeat.png.import b/assets/ui/menu2/text_defeat.png.import new file mode 100644 index 0000000..f61c22b --- /dev/null +++ b/assets/ui/menu2/text_defeat.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkm6ri5pjd6rq" +path="res://.godot/imported/text_defeat.png-0ed06ac8856e57b3ecb2dea83444e63f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_defeat.png" +dest_files=["res://.godot/imported/text_defeat.png-0ed06ac8856e57b3ecb2dea83444e63f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_difficulty_select.png b/assets/ui/menu2/text_difficulty_select.png new file mode 100644 index 0000000..8fa2de2 Binary files /dev/null and b/assets/ui/menu2/text_difficulty_select.png differ diff --git a/assets/ui/menu2/text_difficulty_select.png.import b/assets/ui/menu2/text_difficulty_select.png.import new file mode 100644 index 0000000..301ec48 --- /dev/null +++ b/assets/ui/menu2/text_difficulty_select.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0uqscxaloxjj" +path="res://.godot/imported/text_difficulty_select.png-b2155a53df9a9cd4b97981dc52e903df.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_difficulty_select.png" +dest_files=["res://.godot/imported/text_difficulty_select.png-b2155a53df9a9cd4b97981dc52e903df.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_easy.png b/assets/ui/menu2/text_easy.png new file mode 100644 index 0000000..72818f0 Binary files /dev/null and b/assets/ui/menu2/text_easy.png differ diff --git a/assets/ui/menu2/text_easy.png.import b/assets/ui/menu2/text_easy.png.import new file mode 100644 index 0000000..19d1627 --- /dev/null +++ b/assets/ui/menu2/text_easy.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1thqeisljxol" +path="res://.godot/imported/text_easy.png-efa67c414055e6f4d4d1cb256641db85.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_easy.png" +dest_files=["res://.godot/imported/text_easy.png-efa67c414055e6f4d4d1cb256641db85.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_hard.png b/assets/ui/menu2/text_hard.png new file mode 100644 index 0000000..5f16274 Binary files /dev/null and b/assets/ui/menu2/text_hard.png differ diff --git a/assets/ui/menu2/text_hard.png.import b/assets/ui/menu2/text_hard.png.import new file mode 100644 index 0000000..0aa5115 --- /dev/null +++ b/assets/ui/menu2/text_hard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyqprl3s87ujn" +path="res://.godot/imported/text_hard.png-18db32f48e2cac0b08be953307e49398.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_hard.png" +dest_files=["res://.godot/imported/text_hard.png-18db32f48e2cac0b08be953307e49398.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_normal.png b/assets/ui/menu2/text_normal.png new file mode 100644 index 0000000..312634c Binary files /dev/null and b/assets/ui/menu2/text_normal.png differ diff --git a/assets/ui/menu2/text_normal.png.import b/assets/ui/menu2/text_normal.png.import new file mode 100644 index 0000000..801afa1 --- /dev/null +++ b/assets/ui/menu2/text_normal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://besfbkoq7oowb" +path="res://.godot/imported/text_normal.png-c4a6e99ac5a990ec40b7906f442e5a99.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_normal.png" +dest_files=["res://.godot/imported/text_normal.png-c4a6e99ac5a990ec40b7906f442e5a99.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_quit_game.png b/assets/ui/menu2/text_quit_game.png new file mode 100644 index 0000000..a12a035 Binary files /dev/null and b/assets/ui/menu2/text_quit_game.png differ diff --git a/assets/ui/menu2/text_quit_game.png.import b/assets/ui/menu2/text_quit_game.png.import new file mode 100644 index 0000000..1ca0c0b --- /dev/null +++ b/assets/ui/menu2/text_quit_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://do51jip1ggu6p" +path="res://.godot/imported/text_quit_game.png-08e9b2de0a4d8a9dbda5f5bc8bba631b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_quit_game.png" +dest_files=["res://.godot/imported/text_quit_game.png-08e9b2de0a4d8a9dbda5f5bc8bba631b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_result_title.png b/assets/ui/menu2/text_result_title.png new file mode 100644 index 0000000..105da2f Binary files /dev/null and b/assets/ui/menu2/text_result_title.png differ diff --git a/assets/ui/menu2/text_result_title.png.import b/assets/ui/menu2/text_result_title.png.import new file mode 100644 index 0000000..494c539 --- /dev/null +++ b/assets/ui/menu2/text_result_title.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bvcchxf1jopxv" +path="res://.godot/imported/text_result_title.png-e0c050a173a0d9f263b1346599f6a7e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_result_title.png" +dest_files=["res://.godot/imported/text_result_title.png-e0c050a173a0d9f263b1346599f6a7e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_return_title.png b/assets/ui/menu2/text_return_title.png new file mode 100644 index 0000000..2d96957 Binary files /dev/null and b/assets/ui/menu2/text_return_title.png differ diff --git a/assets/ui/menu2/text_return_title.png.import b/assets/ui/menu2/text_return_title.png.import new file mode 100644 index 0000000..e877861 --- /dev/null +++ b/assets/ui/menu2/text_return_title.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://duapouw8wgk1x" +path="res://.godot/imported/text_return_title.png-be6fd3944f97f5c4745b8581931f0c66.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_return_title.png" +dest_files=["res://.godot/imported/text_return_title.png-be6fd3944f97f5c4745b8581931f0c66.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_start_game.png b/assets/ui/menu2/text_start_game.png new file mode 100644 index 0000000..d96a333 Binary files /dev/null and b/assets/ui/menu2/text_start_game.png differ diff --git a/assets/ui/menu2/text_start_game.png.import b/assets/ui/menu2/text_start_game.png.import new file mode 100644 index 0000000..65af4ee --- /dev/null +++ b/assets/ui/menu2/text_start_game.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://de8s1iugta3vy" +path="res://.godot/imported/text_start_game.png-fabf70175550d6e10248378d172a5dea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_start_game.png" +dest_files=["res://.godot/imported/text_start_game.png-fabf70175550d6e10248378d172a5dea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/text_victory.png b/assets/ui/menu2/text_victory.png new file mode 100644 index 0000000..97555d5 Binary files /dev/null and b/assets/ui/menu2/text_victory.png differ diff --git a/assets/ui/menu2/text_victory.png.import b/assets/ui/menu2/text_victory.png.import new file mode 100644 index 0000000..9d3aa6f --- /dev/null +++ b/assets/ui/menu2/text_victory.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwcig4yeepyho" +path="res://.godot/imported/text_victory.png-015f0a2033282e64bb4760a29b1fae19.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/text_victory.png" +dest_files=["res://.godot/imported/text_victory.png-015f0a2033282e64bb4760a29b1fae19.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/menu2/title_logo.png b/assets/ui/menu2/title_logo.png new file mode 100644 index 0000000..1648e46 Binary files /dev/null and b/assets/ui/menu2/title_logo.png differ diff --git a/assets/ui/menu2/title_logo.png.import b/assets/ui/menu2/title_logo.png.import new file mode 100644 index 0000000..07ae11c --- /dev/null +++ b/assets/ui/menu2/title_logo.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hl8pjsqma5x3" +path="res://.godot/imported/title_logo.png-db9b05ff0ecdf2617176c058dcf0dcc5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/menu2/title_logo.png" +dest_files=["res://.godot/imported/title_logo.png-db9b05ff0ecdf2617176c058dcf0dcc5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/boss_portrait.png b/assets/ui/panels/boss_portrait.png new file mode 100644 index 0000000..6179f0d Binary files /dev/null and b/assets/ui/panels/boss_portrait.png differ diff --git a/assets/ui/panels/boss_portrait.png.import b/assets/ui/panels/boss_portrait.png.import new file mode 100644 index 0000000..7aefa2e --- /dev/null +++ b/assets/ui/panels/boss_portrait.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://co5xfbp12oxkm" +path="res://.godot/imported/boss_portrait.png-9903a08ebe8f190ce7599eb168bc5291.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/boss_portrait.png" +dest_files=["res://.godot/imported/boss_portrait.png-9903a08ebe8f190ce7599eb168bc5291.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/boss_red_ui.png b/assets/ui/panels/boss_red_ui.png new file mode 100644 index 0000000..41407c8 Binary files /dev/null and b/assets/ui/panels/boss_red_ui.png differ diff --git a/assets/ui/panels/boss_red_ui.png.import b/assets/ui/panels/boss_red_ui.png.import new file mode 100644 index 0000000..54a9db7 --- /dev/null +++ b/assets/ui/panels/boss_red_ui.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2i4gxu07l8pd" +path="res://.godot/imported/boss_red_ui.png-6a13d5aabce76e848a3b704f693604e0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/boss_red_ui.png" +dest_files=["res://.godot/imported/boss_red_ui.png-6a13d5aabce76e848a3b704f693604e0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/boss_ui.png b/assets/ui/panels/boss_ui.png new file mode 100644 index 0000000..df5f61c Binary files /dev/null and b/assets/ui/panels/boss_ui.png differ diff --git a/assets/ui/panels/boss_ui.png.import b/assets/ui/panels/boss_ui.png.import new file mode 100644 index 0000000..2ad5077 --- /dev/null +++ b/assets/ui/panels/boss_ui.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bra1w65idoypp" +path="res://.godot/imported/boss_ui.png-cfc171639de0a4df3f191416e094bfcd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/boss_ui.png" +dest_files=["res://.godot/imported/boss_ui.png-cfc171639de0a4df3f191416e094bfcd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/player_blue_ui.png b/assets/ui/panels/player_blue_ui.png new file mode 100644 index 0000000..fb9e921 Binary files /dev/null and b/assets/ui/panels/player_blue_ui.png differ diff --git a/assets/ui/panels/player_blue_ui.png.import b/assets/ui/panels/player_blue_ui.png.import new file mode 100644 index 0000000..96fbed7 --- /dev/null +++ b/assets/ui/panels/player_blue_ui.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cb0372i3701xg" +path="res://.godot/imported/player_blue_ui.png-1ac218f8e4852f1c23c79db623db5cae.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/player_blue_ui.png" +dest_files=["res://.godot/imported/player_blue_ui.png-1ac218f8e4852f1c23c79db623db5cae.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/player_red_ui.png b/assets/ui/panels/player_red_ui.png new file mode 100644 index 0000000..eae6f11 Binary files /dev/null and b/assets/ui/panels/player_red_ui.png differ diff --git a/assets/ui/panels/player_red_ui.png.import b/assets/ui/panels/player_red_ui.png.import new file mode 100644 index 0000000..66a471b --- /dev/null +++ b/assets/ui/panels/player_red_ui.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvvfegdyyadmt" +path="res://.godot/imported/player_red_ui.png-ba300e017b8b95595302fbeb8dbe144f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/player_red_ui.png" +dest_files=["res://.godot/imported/player_red_ui.png-ba300e017b8b95595302fbeb8dbe144f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/panels/playerui.png b/assets/ui/panels/playerui.png new file mode 100644 index 0000000..5f62427 Binary files /dev/null and b/assets/ui/panels/playerui.png differ diff --git a/assets/ui/panels/playerui.png.import b/assets/ui/panels/playerui.png.import new file mode 100644 index 0000000..1047628 --- /dev/null +++ b/assets/ui/panels/playerui.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://28b848eqeh1m" +path="res://.godot/imported/playerui.png-50301cac6e53147f54070fff22cff382.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/panels/playerui.png" +dest_files=["res://.godot/imported/playerui.png-50301cac6e53147f54070fff22cff382.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/all_reference.png b/assets/ui/rhythm/all_reference.png new file mode 100644 index 0000000..728b40b Binary files /dev/null and b/assets/ui/rhythm/all_reference.png differ diff --git a/assets/ui/rhythm/all_reference.png.import b/assets/ui/rhythm/all_reference.png.import new file mode 100644 index 0000000..36c6522 --- /dev/null +++ b/assets/ui/rhythm/all_reference.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://den1v6daqrai" +path="res://.godot/imported/all_reference.png-eadbe74085b3d1638949f893b1eb2fde.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/all_reference.png" +dest_files=["res://.godot/imported/all_reference.png-eadbe74085b3d1638949f893b1eb2fde.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/anchor01.png b/assets/ui/rhythm/anchor01.png new file mode 100644 index 0000000..d4ae0d1 Binary files /dev/null and b/assets/ui/rhythm/anchor01.png differ diff --git a/assets/ui/rhythm/anchor01.png.import b/assets/ui/rhythm/anchor01.png.import new file mode 100644 index 0000000..2301938 --- /dev/null +++ b/assets/ui/rhythm/anchor01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dml5nkni6bpoq" +path="res://.godot/imported/anchor01.png-176ae2f2ce47157d0fa6ae45dda6ca9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/anchor01.png" +dest_files=["res://.godot/imported/anchor01.png-176ae2f2ce47157d0fa6ae45dda6ca9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/anchor01d.png b/assets/ui/rhythm/anchor01d.png new file mode 100644 index 0000000..03b6deb Binary files /dev/null and b/assets/ui/rhythm/anchor01d.png differ diff --git a/assets/ui/rhythm/anchor01d.png.import b/assets/ui/rhythm/anchor01d.png.import new file mode 100644 index 0000000..39c7d21 --- /dev/null +++ b/assets/ui/rhythm/anchor01d.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bq7ibv646b37y" +path="res://.godot/imported/anchor01d.png-2a82d0479a7bb6516e70f5748a3e4755.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/anchor01d.png" +dest_files=["res://.godot/imported/anchor01d.png-2a82d0479a7bb6516e70f5748a3e4755.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/anchor02.png b/assets/ui/rhythm/anchor02.png new file mode 100644 index 0000000..2d2b5e2 Binary files /dev/null and b/assets/ui/rhythm/anchor02.png differ diff --git a/assets/ui/rhythm/anchor02.png.import b/assets/ui/rhythm/anchor02.png.import new file mode 100644 index 0000000..eca0494 --- /dev/null +++ b/assets/ui/rhythm/anchor02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cv7lf1biuxl8" +path="res://.godot/imported/anchor02.png-4e39adc8cfefb85bb4b385ba009aef15.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/anchor02.png" +dest_files=["res://.godot/imported/anchor02.png-4e39adc8cfefb85bb4b385ba009aef15.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/anchor02d.png b/assets/ui/rhythm/anchor02d.png new file mode 100644 index 0000000..5a4ffac Binary files /dev/null and b/assets/ui/rhythm/anchor02d.png differ diff --git a/assets/ui/rhythm/anchor02d.png.import b/assets/ui/rhythm/anchor02d.png.import new file mode 100644 index 0000000..002b742 --- /dev/null +++ b/assets/ui/rhythm/anchor02d.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dygh6j1dr0m8r" +path="res://.godot/imported/anchor02d.png-f49429b378e4616f80ef0dc0b6ed0ba5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/anchor02d.png" +dest_files=["res://.godot/imported/anchor02d.png-f49429b378e4616f80ef0dc0b6ed0ba5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/b.png b/assets/ui/rhythm/b.png new file mode 100644 index 0000000..26b713c Binary files /dev/null and b/assets/ui/rhythm/b.png differ diff --git a/assets/ui/rhythm/b.png.import b/assets/ui/rhythm/b.png.import new file mode 100644 index 0000000..72b9a46 --- /dev/null +++ b/assets/ui/rhythm/b.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6qsuvcixdqmp" +path="res://.godot/imported/b.png-19eae4ae54d3e24b1c646ba89532b3e1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/b.png" +dest_files=["res://.godot/imported/b.png-19eae4ae54d3e24b1c646ba89532b3e1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/bd.png b/assets/ui/rhythm/bd.png new file mode 100644 index 0000000..576d586 Binary files /dev/null and b/assets/ui/rhythm/bd.png differ diff --git a/assets/ui/rhythm/bd.png.import b/assets/ui/rhythm/bd.png.import new file mode 100644 index 0000000..6ec7ae4 --- /dev/null +++ b/assets/ui/rhythm/bd.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvy2a2q8ev2b8" +path="res://.godot/imported/bd.png-18a6863e21a5e6283a579673ac5e0f18.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/bd.png" +dest_files=["res://.godot/imported/bd.png-18a6863e21a5e6283a579673ac5e0f18.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/blue_ball.png b/assets/ui/rhythm/blue_ball.png new file mode 100644 index 0000000..0c651a1 Binary files /dev/null and b/assets/ui/rhythm/blue_ball.png differ diff --git a/assets/ui/rhythm/blue_ball.png.import b/assets/ui/rhythm/blue_ball.png.import new file mode 100644 index 0000000..1e0faa3 --- /dev/null +++ b/assets/ui/rhythm/blue_ball.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dbmdivnpjf48l" +path="res://.godot/imported/blue_ball.png-b0673f07bbea4f056c272bfc8d262d90.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/blue_ball.png" +dest_files=["res://.godot/imported/blue_ball.png-b0673f07bbea4f056c272bfc8d262d90.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/c01.png b/assets/ui/rhythm/c01.png new file mode 100644 index 0000000..db3f486 Binary files /dev/null and b/assets/ui/rhythm/c01.png differ diff --git a/assets/ui/rhythm/c01.png.import b/assets/ui/rhythm/c01.png.import new file mode 100644 index 0000000..0d845b2 --- /dev/null +++ b/assets/ui/rhythm/c01.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgswvlcqufim" +path="res://.godot/imported/c01.png-0270901c94fd5abe57b81f58fafebdf1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/c01.png" +dest_files=["res://.godot/imported/c01.png-0270901c94fd5abe57b81f58fafebdf1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/c01d.png b/assets/ui/rhythm/c01d.png new file mode 100644 index 0000000..876f45e Binary files /dev/null and b/assets/ui/rhythm/c01d.png differ diff --git a/assets/ui/rhythm/c01d.png.import b/assets/ui/rhythm/c01d.png.import new file mode 100644 index 0000000..fb1a3ba --- /dev/null +++ b/assets/ui/rhythm/c01d.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hs8pp1pcomq1" +path="res://.godot/imported/c01d.png-7ac0a9698fec4a4e3a35680828c3cd21.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/c01d.png" +dest_files=["res://.godot/imported/c01d.png-7ac0a9698fec4a4e3a35680828c3cd21.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/c02.png b/assets/ui/rhythm/c02.png new file mode 100644 index 0000000..6d0f043 Binary files /dev/null and b/assets/ui/rhythm/c02.png differ diff --git a/assets/ui/rhythm/c02.png.import b/assets/ui/rhythm/c02.png.import new file mode 100644 index 0000000..ed39606 --- /dev/null +++ b/assets/ui/rhythm/c02.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://do5kbnn026p5p" +path="res://.godot/imported/c02.png-6c3c32ecd0706b457d60d37817e6a165.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/c02.png" +dest_files=["res://.godot/imported/c02.png-6c3c32ecd0706b457d60d37817e6a165.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/c02d.png b/assets/ui/rhythm/c02d.png new file mode 100644 index 0000000..2dc3850 Binary files /dev/null and b/assets/ui/rhythm/c02d.png differ diff --git a/assets/ui/rhythm/c02d.png.import b/assets/ui/rhythm/c02d.png.import new file mode 100644 index 0000000..a747521 --- /dev/null +++ b/assets/ui/rhythm/c02d.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://3gx8jlfv26td" +path="res://.godot/imported/c02d.png-bb51cfc73e9bf02754a88ed39fabf676.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/c02d.png" +dest_files=["res://.godot/imported/c02d.png-bb51cfc73e9bf02754a88ed39fabf676.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/center.png b/assets/ui/rhythm/center.png new file mode 100644 index 0000000..74d0465 Binary files /dev/null and b/assets/ui/rhythm/center.png differ diff --git a/assets/ui/rhythm/center.png.import b/assets/ui/rhythm/center.png.import new file mode 100644 index 0000000..76b3aa1 --- /dev/null +++ b/assets/ui/rhythm/center.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://brqr1gyyxth8p" +path="res://.godot/imported/center.png-72e8c656c19b1bb8e6fc1eadfa1bac1d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/center.png" +dest_files=["res://.godot/imported/center.png-72e8c656c19b1bb8e6fc1eadfa1bac1d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/center_flash.png b/assets/ui/rhythm/center_flash.png new file mode 100644 index 0000000..52bed98 Binary files /dev/null and b/assets/ui/rhythm/center_flash.png differ diff --git a/assets/ui/rhythm/center_flash.png.import b/assets/ui/rhythm/center_flash.png.import new file mode 100644 index 0000000..d26b119 --- /dev/null +++ b/assets/ui/rhythm/center_flash.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bkqec7mh5yfrd" +path="res://.godot/imported/center_flash.png-bb5a63407a5c90e3b1bf929be62a1057.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/center_flash.png" +dest_files=["res://.godot/imported/center_flash.png-bb5a63407a5c90e3b1bf929be62a1057.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/line.png b/assets/ui/rhythm/line.png new file mode 100644 index 0000000..90f79bc Binary files /dev/null and b/assets/ui/rhythm/line.png differ diff --git a/assets/ui/rhythm/line.png.import b/assets/ui/rhythm/line.png.import new file mode 100644 index 0000000..6c64b9c --- /dev/null +++ b/assets/ui/rhythm/line.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtnosjyeuyabw" +path="res://.godot/imported/line.png-50852c37f2af8bc3e97ea39171a18d7c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/line.png" +dest_files=["res://.godot/imported/line.png-50852c37f2af8bc3e97ea39171a18d7c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/lined.png b/assets/ui/rhythm/lined.png new file mode 100644 index 0000000..946e9b0 Binary files /dev/null and b/assets/ui/rhythm/lined.png differ diff --git a/assets/ui/rhythm/lined.png.import b/assets/ui/rhythm/lined.png.import new file mode 100644 index 0000000..3080fff --- /dev/null +++ b/assets/ui/rhythm/lined.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b1e87ewrtw0se" +path="res://.godot/imported/lined.png-78229ab1fb25ed44d5a0ae828e54ac95.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/lined.png" +dest_files=["res://.godot/imported/lined.png-78229ab1fb25ed44d5a0ae828e54ac95.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/rod.png b/assets/ui/rhythm/rod.png new file mode 100644 index 0000000..0865e4b Binary files /dev/null and b/assets/ui/rhythm/rod.png differ diff --git a/assets/ui/rhythm/rod.png.import b/assets/ui/rhythm/rod.png.import new file mode 100644 index 0000000..0f77247 --- /dev/null +++ b/assets/ui/rhythm/rod.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cj5pa4c3arevn" +path="res://.godot/imported/rod.png-16d3d0fe01c9415aa4697ed2f5bac9d8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/rod.png" +dest_files=["res://.godot/imported/rod.png-16d3d0fe01c9415aa4697ed2f5bac9d8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/sanchor.png b/assets/ui/rhythm/sanchor.png new file mode 100644 index 0000000..f43fbe6 Binary files /dev/null and b/assets/ui/rhythm/sanchor.png differ diff --git a/assets/ui/rhythm/sanchor.png.import b/assets/ui/rhythm/sanchor.png.import new file mode 100644 index 0000000..d3a1bcf --- /dev/null +++ b/assets/ui/rhythm/sanchor.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cj0rrs5gq35f4" +path="res://.godot/imported/sanchor.png-579ccaeb5049f980a0e3efe010f14301.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/sanchor.png" +dest_files=["res://.godot/imported/sanchor.png-579ccaeb5049f980a0e3efe010f14301.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/sanchord.png b/assets/ui/rhythm/sanchord.png new file mode 100644 index 0000000..7e966f2 Binary files /dev/null and b/assets/ui/rhythm/sanchord.png differ diff --git a/assets/ui/rhythm/sanchord.png.import b/assets/ui/rhythm/sanchord.png.import new file mode 100644 index 0000000..31f3768 --- /dev/null +++ b/assets/ui/rhythm/sanchord.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dvilgn6pyj8uq" +path="res://.godot/imported/sanchord.png-fe035019abdbef722bf04a77295fc6b3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/sanchord.png" +dest_files=["res://.godot/imported/sanchord.png-fe035019abdbef722bf04a77295fc6b3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/star.png b/assets/ui/rhythm/star.png new file mode 100644 index 0000000..173c0d1 Binary files /dev/null and b/assets/ui/rhythm/star.png differ diff --git a/assets/ui/rhythm/star.png.import b/assets/ui/rhythm/star.png.import new file mode 100644 index 0000000..a96ce1b --- /dev/null +++ b/assets/ui/rhythm/star.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rt631il3y6h7" +path="res://.godot/imported/star.png-030c9dbfc7898ee38e7f3d54dca1f905.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/star.png" +dest_files=["res://.godot/imported/star.png-030c9dbfc7898ee38e7f3d54dca1f905.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/stard.png b/assets/ui/rhythm/stard.png new file mode 100644 index 0000000..07ccaf1 Binary files /dev/null and b/assets/ui/rhythm/stard.png differ diff --git a/assets/ui/rhythm/stard.png.import b/assets/ui/rhythm/stard.png.import new file mode 100644 index 0000000..f4afabc --- /dev/null +++ b/assets/ui/rhythm/stard.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b31wxo82rmk6w" +path="res://.godot/imported/stard.png-f1f220c53ecec82d6fcd03adb31175e4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/stard.png" +dest_files=["res://.godot/imported/stard.png-f1f220c53ecec82d6fcd03adb31175e4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/rhythm/yellow_ball.png b/assets/ui/rhythm/yellow_ball.png new file mode 100644 index 0000000..ae9dc17 Binary files /dev/null and b/assets/ui/rhythm/yellow_ball.png differ diff --git a/assets/ui/rhythm/yellow_ball.png.import b/assets/ui/rhythm/yellow_ball.png.import new file mode 100644 index 0000000..6a39821 --- /dev/null +++ b/assets/ui/rhythm/yellow_ball.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ewr8k3lwpcna" +path="res://.godot/imported/yellow_ball.png-38368c65178aa328fe69756c227e07e9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/rhythm/yellow_ball.png" +dest_files=["res://.godot/imported/yellow_ball.png-38368c65178aa328fe69756c227e07e9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_000.png b/assets/ui/strong_buff/Effect_Anima_1_000.png new file mode 100644 index 0000000..e660d6e Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_000.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_000.png.import b/assets/ui/strong_buff/Effect_Anima_1_000.png.import new file mode 100644 index 0000000..a8f50d2 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_000.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d0hqr8hcnjg2e" +path="res://.godot/imported/Effect_Anima_1_000.png-5dfc6ea5abc191d9082136290e570770.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_000.png" +dest_files=["res://.godot/imported/Effect_Anima_1_000.png-5dfc6ea5abc191d9082136290e570770.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_001.png b/assets/ui/strong_buff/Effect_Anima_1_001.png new file mode 100644 index 0000000..69f2d58 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_001.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_001.png.import b/assets/ui/strong_buff/Effect_Anima_1_001.png.import new file mode 100644 index 0000000..28d806e --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_001.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ptwumj4a2dsu" +path="res://.godot/imported/Effect_Anima_1_001.png-409766197d2dd3667c6f53a00a8a583e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_001.png" +dest_files=["res://.godot/imported/Effect_Anima_1_001.png-409766197d2dd3667c6f53a00a8a583e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_002.png b/assets/ui/strong_buff/Effect_Anima_1_002.png new file mode 100644 index 0000000..2a713c8 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_002.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_002.png.import b/assets/ui/strong_buff/Effect_Anima_1_002.png.import new file mode 100644 index 0000000..c8c6c69 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_002.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bj7kaf5ini2v2" +path="res://.godot/imported/Effect_Anima_1_002.png-f5542f6ba0e84aba406b7ceb43663684.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_002.png" +dest_files=["res://.godot/imported/Effect_Anima_1_002.png-f5542f6ba0e84aba406b7ceb43663684.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_003.png b/assets/ui/strong_buff/Effect_Anima_1_003.png new file mode 100644 index 0000000..fab3d27 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_003.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_003.png.import b/assets/ui/strong_buff/Effect_Anima_1_003.png.import new file mode 100644 index 0000000..255bf05 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_003.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqpjp2jvsyihs" +path="res://.godot/imported/Effect_Anima_1_003.png-9071e84876a940ad3cb4260d75e6c4f8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_003.png" +dest_files=["res://.godot/imported/Effect_Anima_1_003.png-9071e84876a940ad3cb4260d75e6c4f8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_004.png b/assets/ui/strong_buff/Effect_Anima_1_004.png new file mode 100644 index 0000000..061900a Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_004.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_004.png.import b/assets/ui/strong_buff/Effect_Anima_1_004.png.import new file mode 100644 index 0000000..ae97896 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_004.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ktxo8drx28wl" +path="res://.godot/imported/Effect_Anima_1_004.png-b588655bcc96705a845c0d16cae1ecd8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_004.png" +dest_files=["res://.godot/imported/Effect_Anima_1_004.png-b588655bcc96705a845c0d16cae1ecd8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_005.png b/assets/ui/strong_buff/Effect_Anima_1_005.png new file mode 100644 index 0000000..a1cc80c Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_005.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_005.png.import b/assets/ui/strong_buff/Effect_Anima_1_005.png.import new file mode 100644 index 0000000..49f0f40 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_005.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgvww5p4o0jx3" +path="res://.godot/imported/Effect_Anima_1_005.png-2d93d463b772bb7077570afd2156e2c0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_005.png" +dest_files=["res://.godot/imported/Effect_Anima_1_005.png-2d93d463b772bb7077570afd2156e2c0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_006.png b/assets/ui/strong_buff/Effect_Anima_1_006.png new file mode 100644 index 0000000..19c1411 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_006.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_006.png.import b/assets/ui/strong_buff/Effect_Anima_1_006.png.import new file mode 100644 index 0000000..f7dae11 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_006.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cyb74mcepkuy8" +path="res://.godot/imported/Effect_Anima_1_006.png-e2d34296b2b53e08192e57780fb97295.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_006.png" +dest_files=["res://.godot/imported/Effect_Anima_1_006.png-e2d34296b2b53e08192e57780fb97295.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_007.png b/assets/ui/strong_buff/Effect_Anima_1_007.png new file mode 100644 index 0000000..7579576 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_007.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_007.png.import b/assets/ui/strong_buff/Effect_Anima_1_007.png.import new file mode 100644 index 0000000..155f3c6 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_007.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfd6jrg5vdcc8" +path="res://.godot/imported/Effect_Anima_1_007.png-67d24c8f9fba6aea8e5efa6522d14c83.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_007.png" +dest_files=["res://.godot/imported/Effect_Anima_1_007.png-67d24c8f9fba6aea8e5efa6522d14c83.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_008.png b/assets/ui/strong_buff/Effect_Anima_1_008.png new file mode 100644 index 0000000..0be98ac Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_008.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_008.png.import b/assets/ui/strong_buff/Effect_Anima_1_008.png.import new file mode 100644 index 0000000..edeb092 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_008.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://l1rfbql2ecm4" +path="res://.godot/imported/Effect_Anima_1_008.png-2e45f2129484805621aebe82bea5e8f5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_008.png" +dest_files=["res://.godot/imported/Effect_Anima_1_008.png-2e45f2129484805621aebe82bea5e8f5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_009.png b/assets/ui/strong_buff/Effect_Anima_1_009.png new file mode 100644 index 0000000..95b81cb Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_009.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_009.png.import b/assets/ui/strong_buff/Effect_Anima_1_009.png.import new file mode 100644 index 0000000..fdc80e0 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_009.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4apt50qou1qe" +path="res://.godot/imported/Effect_Anima_1_009.png-078e0a7b620a0a36c9bee2f5a5b73185.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_009.png" +dest_files=["res://.godot/imported/Effect_Anima_1_009.png-078e0a7b620a0a36c9bee2f5a5b73185.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_010.png b/assets/ui/strong_buff/Effect_Anima_1_010.png new file mode 100644 index 0000000..5a679db Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_010.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_010.png.import b/assets/ui/strong_buff/Effect_Anima_1_010.png.import new file mode 100644 index 0000000..c6d3884 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_010.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djod4jaw1cmaa" +path="res://.godot/imported/Effect_Anima_1_010.png-12acbf35fc6d3b929be69046bfa818c7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_010.png" +dest_files=["res://.godot/imported/Effect_Anima_1_010.png-12acbf35fc6d3b929be69046bfa818c7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_011.png b/assets/ui/strong_buff/Effect_Anima_1_011.png new file mode 100644 index 0000000..4bde83c Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_011.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_011.png.import b/assets/ui/strong_buff/Effect_Anima_1_011.png.import new file mode 100644 index 0000000..400d19e --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_011.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ck21g3o04ian4" +path="res://.godot/imported/Effect_Anima_1_011.png-fca7a2438b45466a07b9f7ee64b87438.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_011.png" +dest_files=["res://.godot/imported/Effect_Anima_1_011.png-fca7a2438b45466a07b9f7ee64b87438.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_012.png b/assets/ui/strong_buff/Effect_Anima_1_012.png new file mode 100644 index 0000000..660caf9 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_012.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_012.png.import b/assets/ui/strong_buff/Effect_Anima_1_012.png.import new file mode 100644 index 0000000..7dcaeed --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_012.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c8gots27kovp8" +path="res://.godot/imported/Effect_Anima_1_012.png-80b0a850802d315d4066ba31b841b935.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_012.png" +dest_files=["res://.godot/imported/Effect_Anima_1_012.png-80b0a850802d315d4066ba31b841b935.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_013.png b/assets/ui/strong_buff/Effect_Anima_1_013.png new file mode 100644 index 0000000..211f9a5 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_013.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_013.png.import b/assets/ui/strong_buff/Effect_Anima_1_013.png.import new file mode 100644 index 0000000..a627a45 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_013.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://be065iif1yggf" +path="res://.godot/imported/Effect_Anima_1_013.png-c454524ca47bed7c29829c67873ba96f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_013.png" +dest_files=["res://.godot/imported/Effect_Anima_1_013.png-c454524ca47bed7c29829c67873ba96f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_014.png b/assets/ui/strong_buff/Effect_Anima_1_014.png new file mode 100644 index 0000000..5a0bfdc Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_014.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_014.png.import b/assets/ui/strong_buff/Effect_Anima_1_014.png.import new file mode 100644 index 0000000..f686fb6 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_014.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgsvrdukacumi" +path="res://.godot/imported/Effect_Anima_1_014.png-6f1948dd1bf3c0a97f2e517aa84e9beb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_014.png" +dest_files=["res://.godot/imported/Effect_Anima_1_014.png-6f1948dd1bf3c0a97f2e517aa84e9beb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_015.png b/assets/ui/strong_buff/Effect_Anima_1_015.png new file mode 100644 index 0000000..5c0cfd4 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_015.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_015.png.import b/assets/ui/strong_buff/Effect_Anima_1_015.png.import new file mode 100644 index 0000000..b470930 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_015.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgi6cnusvtna2" +path="res://.godot/imported/Effect_Anima_1_015.png-9e6dfbf469dce4a82c4074fb782c37ba.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_015.png" +dest_files=["res://.godot/imported/Effect_Anima_1_015.png-9e6dfbf469dce4a82c4074fb782c37ba.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_016.png b/assets/ui/strong_buff/Effect_Anima_1_016.png new file mode 100644 index 0000000..f4742a4 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_016.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_016.png.import b/assets/ui/strong_buff/Effect_Anima_1_016.png.import new file mode 100644 index 0000000..17e62f9 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_016.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lt1topvuuoec" +path="res://.godot/imported/Effect_Anima_1_016.png-0f29f1ece2f4e58c4a50c4b4ab2028b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_016.png" +dest_files=["res://.godot/imported/Effect_Anima_1_016.png-0f29f1ece2f4e58c4a50c4b4ab2028b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_017.png b/assets/ui/strong_buff/Effect_Anima_1_017.png new file mode 100644 index 0000000..b9afb1e Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_017.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_017.png.import b/assets/ui/strong_buff/Effect_Anima_1_017.png.import new file mode 100644 index 0000000..4682215 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_017.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw12gqjeyp5op" +path="res://.godot/imported/Effect_Anima_1_017.png-9ade86f77fef6ad8cd448518e11e3837.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_017.png" +dest_files=["res://.godot/imported/Effect_Anima_1_017.png-9ade86f77fef6ad8cd448518e11e3837.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_018.png b/assets/ui/strong_buff/Effect_Anima_1_018.png new file mode 100644 index 0000000..d87a6a7 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_018.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_018.png.import b/assets/ui/strong_buff/Effect_Anima_1_018.png.import new file mode 100644 index 0000000..1cd9522 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_018.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://gxp6hqrrv5f4" +path="res://.godot/imported/Effect_Anima_1_018.png-b2a31c1dc6f878d5d071eacc247190cb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_018.png" +dest_files=["res://.godot/imported/Effect_Anima_1_018.png-b2a31c1dc6f878d5d071eacc247190cb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_019.png b/assets/ui/strong_buff/Effect_Anima_1_019.png new file mode 100644 index 0000000..34c6d4b Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_019.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_019.png.import b/assets/ui/strong_buff/Effect_Anima_1_019.png.import new file mode 100644 index 0000000..e5b1072 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_019.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu73ydw74xg60" +path="res://.godot/imported/Effect_Anima_1_019.png-c533149d2b05fa5193c84229b09ed5bb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_019.png" +dest_files=["res://.godot/imported/Effect_Anima_1_019.png-c533149d2b05fa5193c84229b09ed5bb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_020.png b/assets/ui/strong_buff/Effect_Anima_1_020.png new file mode 100644 index 0000000..6013cbf Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_020.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_020.png.import b/assets/ui/strong_buff/Effect_Anima_1_020.png.import new file mode 100644 index 0000000..4825d68 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_020.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bhv7p3qakoqr8" +path="res://.godot/imported/Effect_Anima_1_020.png-b248106eafcc43f022608e705042b815.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_020.png" +dest_files=["res://.godot/imported/Effect_Anima_1_020.png-b248106eafcc43f022608e705042b815.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_021.png b/assets/ui/strong_buff/Effect_Anima_1_021.png new file mode 100644 index 0000000..f73d166 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_021.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_021.png.import b/assets/ui/strong_buff/Effect_Anima_1_021.png.import new file mode 100644 index 0000000..97b5a12 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_021.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cherndh6xv00l" +path="res://.godot/imported/Effect_Anima_1_021.png-abe7bb268bc36e13c6c4962034c58c16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_021.png" +dest_files=["res://.godot/imported/Effect_Anima_1_021.png-abe7bb268bc36e13c6c4962034c58c16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_022.png b/assets/ui/strong_buff/Effect_Anima_1_022.png new file mode 100644 index 0000000..47143aa Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_022.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_022.png.import b/assets/ui/strong_buff/Effect_Anima_1_022.png.import new file mode 100644 index 0000000..d384a86 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_022.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1vnul76aubsc" +path="res://.godot/imported/Effect_Anima_1_022.png-fe83c3dad3cb0551659a48dd4731eb92.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_022.png" +dest_files=["res://.godot/imported/Effect_Anima_1_022.png-fe83c3dad3cb0551659a48dd4731eb92.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_023.png b/assets/ui/strong_buff/Effect_Anima_1_023.png new file mode 100644 index 0000000..bb8f095 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_023.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_023.png.import b/assets/ui/strong_buff/Effect_Anima_1_023.png.import new file mode 100644 index 0000000..87814c4 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_023.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://qf7ey1tihhcc" +path="res://.godot/imported/Effect_Anima_1_023.png-d6f67c1e44bdb3632458ee04e201908a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_023.png" +dest_files=["res://.godot/imported/Effect_Anima_1_023.png-d6f67c1e44bdb3632458ee04e201908a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_024.png b/assets/ui/strong_buff/Effect_Anima_1_024.png new file mode 100644 index 0000000..2759a0b Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_024.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_024.png.import b/assets/ui/strong_buff/Effect_Anima_1_024.png.import new file mode 100644 index 0000000..829187f --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_024.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7qdidfqs1roq" +path="res://.godot/imported/Effect_Anima_1_024.png-0e998acb07e6c001715ffd522c18ab86.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_024.png" +dest_files=["res://.godot/imported/Effect_Anima_1_024.png-0e998acb07e6c001715ffd522c18ab86.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_025.png b/assets/ui/strong_buff/Effect_Anima_1_025.png new file mode 100644 index 0000000..f03d88e Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_025.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_025.png.import b/assets/ui/strong_buff/Effect_Anima_1_025.png.import new file mode 100644 index 0000000..5332a59 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_025.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ccc60fg1ip8vi" +path="res://.godot/imported/Effect_Anima_1_025.png-496d7b0529930b322a96d5d20a52acdb.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_025.png" +dest_files=["res://.godot/imported/Effect_Anima_1_025.png-496d7b0529930b322a96d5d20a52acdb.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_026.png b/assets/ui/strong_buff/Effect_Anima_1_026.png new file mode 100644 index 0000000..1686eed Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_026.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_026.png.import b/assets/ui/strong_buff/Effect_Anima_1_026.png.import new file mode 100644 index 0000000..86c531e --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_026.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bn5uk5gx2sung" +path="res://.godot/imported/Effect_Anima_1_026.png-91aef2abaf501eb8ddcb074f8090bba1.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_026.png" +dest_files=["res://.godot/imported/Effect_Anima_1_026.png-91aef2abaf501eb8ddcb074f8090bba1.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_027.png b/assets/ui/strong_buff/Effect_Anima_1_027.png new file mode 100644 index 0000000..ec28e39 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_027.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_027.png.import b/assets/ui/strong_buff/Effect_Anima_1_027.png.import new file mode 100644 index 0000000..9603e19 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_027.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bcjr4w6y07ilg" +path="res://.godot/imported/Effect_Anima_1_027.png-7140bf486dec9a06bf0ee1d2621f0cfc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_027.png" +dest_files=["res://.godot/imported/Effect_Anima_1_027.png-7140bf486dec9a06bf0ee1d2621f0cfc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_028.png b/assets/ui/strong_buff/Effect_Anima_1_028.png new file mode 100644 index 0000000..2879f67 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_028.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_028.png.import b/assets/ui/strong_buff/Effect_Anima_1_028.png.import new file mode 100644 index 0000000..a8ea712 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_028.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clytrraipqnbt" +path="res://.godot/imported/Effect_Anima_1_028.png-31db2b05ae19400d1ca619c9ec4f7ee5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_028.png" +dest_files=["res://.godot/imported/Effect_Anima_1_028.png-31db2b05ae19400d1ca619c9ec4f7ee5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/ui/strong_buff/Effect_Anima_1_029.png b/assets/ui/strong_buff/Effect_Anima_1_029.png new file mode 100644 index 0000000..1d4afd2 Binary files /dev/null and b/assets/ui/strong_buff/Effect_Anima_1_029.png differ diff --git a/assets/ui/strong_buff/Effect_Anima_1_029.png.import b/assets/ui/strong_buff/Effect_Anima_1_029.png.import new file mode 100644 index 0000000..80f3073 --- /dev/null +++ b/assets/ui/strong_buff/Effect_Anima_1_029.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bgm8xtlu7thl3" +path="res://.godot/imported/Effect_Anima_1_029.png-e36180d8db31ff14fbd51bf33922b763.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/strong_buff/Effect_Anima_1_029.png" +dest_files=["res://.godot/imported/Effect_Anima_1_029.png-e36180d8db31ff14fbd51bf33922b763.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/autoload/combat_manager.gd b/autoload/combat_manager.gd new file mode 100644 index 0000000..b9979ed --- /dev/null +++ b/autoload/combat_manager.gd @@ -0,0 +1,176 @@ +extends Node + +const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd") +const CombatResolverScript := preload("res://scripts/resolvers/combat_resolver.gd") + +var _resolved_pairs: Dictionary = {} + + +func _physics_process(_delta: float) -> void: + _resolved_pairs.clear() + + +func resolve_damage(base_attack: float, action: Resource, judgement: Dictionary, buffs: Variant = null, burst: Variant = null) -> float: + return StatResolverScript.resolve_damage(base_attack, action, judgement, buffs, burst) + + +func resolve_cost(action: Resource, judgement: Variant = {}, effects: Variant = null) -> float: + return StatResolverScript.resolve_cost(action, judgement, effects) + + +func resolve_reward(action: Resource, judgement: Dictionary, effects: Variant = null) -> float: + return StatResolverScript.resolve_reward(action, judgement, effects) + + +func resolve_move(action: Resource, judgement: Dictionary, burst: Variant = null) -> Vector2: + return StatResolverScript.resolve_move(action, judgement, burst) + + +func resolve_action_snapshot(action: Resource, judgement: Dictionary = {}, effects: Variant = null) -> Dictionary: + return StatResolverScript.resolve_action_snapshot(action, judgement, effects) + + +func resolve_hit(emitter: Area2D, receiver: Area2D) -> Dictionary: + if emitter == null or receiver == null: + return {} + if _receiver_is_dead(receiver): + return {} + var key := "%s:%s" % [emitter.get_instance_id(), receiver.get_instance_id()] + if _resolved_pairs.has(key): + return _resolved_pairs[key] + var result := CombatResolverScript.resolve_hit(emitter, receiver) + _resolved_pairs[key] = result + _apply_interrupt(receiver, result) + _apply_target_health(receiver, result) + _apply_knockback(receiver, result) + if receiver.has_method("receive_hit"): + receiver.call("receive_hit", result) + _apply_action_on_hit_effects(receiver, result) + _dispatch_effect_events(emitter, receiver, result) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("hit_confirmed"): + bus.emit_signal("hit_confirmed", result) + return result + + +func _apply_target_health(receiver: Area2D, result: Dictionary) -> void: + var target := receiver.get_parent() + if target == null: + return + var health_component := target.get_node_or_null("HealthComponent") + if health_component != null and health_component.has_method("receive_hit"): + health_component.call("receive_hit", result) + + +func _apply_interrupt(receiver: Area2D, result: Dictionary) -> void: + if not bool(result.get("interrupts", true)): + return + var target := receiver.get_parent() + if target == null: + return + var action_controller := target.get_node_or_null("ActionController") + if action_controller != null and action_controller.has_method("cancel_current"): + action_controller.call("cancel_current", _cancel_reason_for_hit(target, result)) + + +func _cancel_reason_for_hit(target: Node, result: Dictionary) -> StringName: + var health_component := target.get_node_or_null("HealthComponent") + if health_component != null and int(result.get("damage", 0)) > 0: + var current := int(health_component.get("current")) + if current > 0 and int(result.get("damage", 0)) >= current: + return &"death" + return &"interrupt" + + +func _apply_knockback(receiver: Area2D, result: Dictionary) -> void: + var knockback := result.get("knockback", Vector2.ZERO) as Vector2 + if knockback == Vector2.ZERO: + return + var target := receiver.get_parent() + if target == null: + return + knockback.x = _knockback_x_away_from_attacker(target, result, knockback.x) + knockback.x *= _knockback_velocity_scale_for_receiver(target) + var movement_motor := target.get_node_or_null("MovementMotor") + if movement_motor != null and movement_motor.has_method("apply_knockback"): + movement_motor.call("apply_knockback", knockback) + + +## 受击方可选钩子(鸭子类型,同 shrugs_off_hit):knockback_distance_taken_mult() +## 返回"水平滑行距离"倍数。线性摩擦下距离 ∝ 初速²,距离 ×N 需初速 ×√N。 +func _knockback_velocity_scale_for_receiver(target: Node) -> float: + if not target.has_method("knockback_distance_taken_mult"): + return 1.0 + return sqrt(maxf(float(target.call("knockback_distance_taken_mult")), 0.0)) + + +func _knockback_x_away_from_attacker(target: Node, result: Dictionary, knockback_x: float) -> float: + # resolve_knockback 输出无符号水平分量(数值契约,禁改);施加时按受击者 + # 相对攻击者的位置定向为"背离攻击者"。dx 恰好为 0 时沿用历史 +X 语义。 + if not (target is Node2D): + return knockback_x + var from = result.get("from") + if not (from is Vector2): + return knockback_x + var dx := (target as Node2D).global_position.x - (from as Vector2).x + if dx == 0.0: + return knockback_x + return signf(dx) * absf(knockback_x) + + +func _receiver_is_dead(receiver: Area2D) -> bool: + var target := receiver.get_parent() + if target == null: + return false + var state_machine := target.get_node_or_null("StateMachine") + if state_machine == null or not state_machine.has_method("build_context"): + return false + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead" + + +func _dispatch_effect_events(emitter: Area2D, receiver: Area2D, result: Dictionary) -> void: + _dispatch_actor_effect_event(emitter.get_parent(), &"on_hit", result) + _dispatch_actor_effect_event(receiver.get_parent(), &"on_hurt", result) + if _result_defense_state(result) == &"Parrying": + _dispatch_actor_effect_event(receiver.get_parent(), &"on_parry_success", result) + if int(result.get("damage", 0)) > 0 and _receiver_is_dead(receiver): + _dispatch_actor_effect_event(emitter.get_parent(), &"on_kill", result) + + +func _apply_action_on_hit_effects(receiver: Area2D, result: Dictionary) -> void: + var target := receiver.get_parent() + if target == null: + return + var effect_container := target.get_node_or_null("EffectContainer") + if effect_container == null or not effect_container.has_method("add_effect"): + return + var action: Resource = result.get("action", null) as Resource + if action == null: + return + var effects = action.get("on_hit_effects") + if not effects is Array: + return + for definition: Resource in effects: + if definition != null: + effect_container.call("add_effect", definition, StringName(str(action.get("id")))) + + +func _dispatch_actor_effect_event(actor: Node, event_name: StringName, result: Dictionary) -> void: + if actor == null: + return + var effect_container := actor.get_node_or_null("EffectContainer") + if effect_container != null and effect_container.has_method("dispatch_event"): + effect_container.call("dispatch_event", event_name, {"result": result}) + + +func _result_defense_state(result: Dictionary) -> StringName: + var defense = result.get("defense", {}) + if defense is Dictionary: + return StringName(str(defense.get("defense_state", &"Vulnerable"))) + return &"Vulnerable" + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/autoload/combat_manager.gd.uid b/autoload/combat_manager.gd.uid new file mode 100644 index 0000000..603311c --- /dev/null +++ b/autoload/combat_manager.gd.uid @@ -0,0 +1 @@ +uid://dmeiefmd38a30 diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd new file mode 100644 index 0000000..7fc52eb --- /dev/null +++ b/autoload/event_bus.gd @@ -0,0 +1,30 @@ +extends Node + +signal beat_ticked(beat_index: int) +signal judgement_made(quality: StringName, offset_ms: float, beat_index: int) +signal intent_replaced(previous_intent, next_intent) +signal action_started(action: Resource, intent) +signal action_cancelled(action: Resource, reason: StringName) +signal hit_confirmed(result: Dictionary) +signal chart_event_upcoming(event: Resource, time_to_event: float) +signal chart_event_triggered(event: Resource) +signal chart_reset(chart_id: StringName) + +signal skill_executed(skill: Resource, judgement: StringName) +signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary) +signal damage_dealt(target: Node, amount: int, hit_type: StringName) + +signal time_phase_changed(previous: StringName, current: StringName, reason: StringName) +signal time_anchor_scheduled(anchor: Dictionary) +signal time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) +signal streak_changed(count: int) +signal attack_buff_changed(stacks: int, max_stacks: int) + +signal flow_state_changed(previous: StringName, current: StringName) +signal front_area_cleared + +signal player_health_changed(current: int, max_value: int) +signal player_energy_changed(current: float, max_value: float) +signal player_charge_changed(current: float, max_value: float, ready: bool, active: bool) +signal combo_updated(inputs: Array[StringName]) +signal combo_cleared(reason: StringName) diff --git a/autoload/event_bus.gd.uid b/autoload/event_bus.gd.uid new file mode 100644 index 0000000..b0b9a86 --- /dev/null +++ b/autoload/event_bus.gd.uid @@ -0,0 +1 @@ +uid://cpgixq8ibqhh4 diff --git a/autoload/game_flow_manager.gd b/autoload/game_flow_manager.gd new file mode 100644 index 0000000..40231be --- /dev/null +++ b/autoload/game_flow_manager.gd @@ -0,0 +1,964 @@ +extends Node + +## Outer game-flow state machine (AnchorV1.0 §4.5 / §19.5 / §21.7). +## Owns Title / DifficultySelect / Pause / VolumeSettings / confirm dialogs / +## Defeat / Victory / Result and the front-area → boss-room progression. +## It never judges input or resolves combat — battle systems stay untouched; +## pausing is the SceneTree pause plus the RhythmManager clock freeze. + +signal flow_state_changed(previous: StringName, current: StringName) + +const STATE_TITLE := &"Title" +const STATE_DIFFICULTY := &"DifficultySelect" +const STATE_FRONT := &"Gameplay_FrontArea" +const STATE_BOSS_ROOM := &"Gameplay_BossRoom" +const STATE_PAUSED := &"Paused" +const STATE_VOLUME := &"VolumeSettings" +const STATE_EXIT_CONFIRM := &"ExitGameConfirm" +const STATE_RETURN_CONFIRM := &"ReturnTitleConfirm" +const STATE_DEFEAT := &"Defeat" +const STATE_VICTORY := &"Victory" +const STATE_RESULT := &"Result" + +const GAMEPLAY_STATES: Array[StringName] = [STATE_FRONT, STATE_BOSS_ROOM] + +const BOSS_ENTRY_MAX_ATTACK_BUFF := 3 +# 2026-07-05 策划定案:胜负后的停留时间为最初值(1.6 / 1.2)的 3 倍。 +const DEFEAT_SCREEN_DELAY := 4.8 +const VICTORY_SCREEN_DELAY := 3.6 + +const TITLE_BG_COLOR := Color(0.06, 0.05, 0.1, 1.0) +const OVERLAY_BG_COLOR := Color(0.03, 0.03, 0.06, 0.78) +const ACCENT_COLOR := Color(1.0, 0.84, 0.4) +const FUTURE_ACCENT_COLOR := Color(0.45, 0.85, 1.0) +const DIFFICULTY_ORDER: Array[StringName] = [&"easy", &"normal", &"hard"] +const DIFFICULTY_NAMES := {&"easy": "简单", &"normal": "普通", &"hard": "困难"} + +## 2026-07-05 组件化菜单套件:面板边框 + 按钮底两态 + 文字贴图,不再用整张菜单截图。 +const MENU_FONT_PATH := "res://assets/fonts/fzzdhjw.ttf" +const PANEL_TALL := "res://assets/ui/menu2/panel_tall.png" +const BUTTON_BASE_NORMAL := "res://assets/ui/menu2/button_base_normal.png" +const BUTTON_BASE_HOVER := "res://assets/ui/menu2/button_base_hover.png" + +## Title-side states keep the rhythm music playing; entering gameplay stops it +## so the run restarts the track from beat zero (§20 双层音乐 stays untouched). +const TITLE_MUSIC_STATES: Array[StringName] = [STATE_TITLE, STATE_DIFFICULTY, STATE_EXIT_CONFIRM] +const TITLE_MUSIC_STREAM_PATH := "res://assets/audio/ev_past1.mp3" +## 2026-07-05 策划:标题背景以 2 秒为单位在过去/未来间轮流切换,渐入过渡。 +const TITLE_BG_HOLD_SECONDS := 2.0 +const TITLE_BG_FADE_SECONDS := 0.8 +## 标题/难度背景改用无 Logo 烙印的干净关卡原画;Logo 由独立悬浮节点叠加, +## 背景渐变时 Logo 与菜单保持固定,不再出现顶部重叠感。 +const TITLE_BG_PAST_ART := "res://assets/art/ground/past/past.png" +const TITLE_BG_FUTURE_ART := "res://assets/art/ground/future/future.png" +const TITLE_LOGO_ART := "res://assets/ui/menu2/title_logo.png" +const TITLE_LOGO_RECT := Rect2(390.0, 140.0, 616.0, 300.0) + +## Skips scene reloads / quit in headless test runs. +var manage_scene := true + +var state: StringName = STATE_TITLE +var _screens: Dictionary = {} +var _ui_layer: CanvasLayer +var _pending_difficulty: StringName = &"" +var _difficulty_buttons: Dictionary = {} +var _run_finished := false +var _victory := false +var _end_timer: SceneTreeTimer +var _boss_health: Node +var _music_slider: HSlider +var _sfx_slider: HSlider +var _music_value_label: Label +var _sfx_value_label: Label +var _title_music_player: AudioStreamPlayer +var _title_bg_future: TextureRect +var _title_bg_tween: Tween +var _menu_font: Font +var _title_difficulty_value: Label +var _difficulty_footer_value: Label + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + # Headless runs (tests / CI) must never boot into the paused title overlay. + if DisplayServer.get_name() == "headless" and name == "GameFlowManager": + manage_scene = false + _build_ui() + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("player_health_changed") and not bus.is_connected("player_health_changed", _on_player_health_changed): + bus.connect("player_health_changed", _on_player_health_changed) + if manage_scene: + call_deferred("_enter_title") + + +func _unhandled_input(event: InputEvent) -> void: + if not event.is_action_pressed("ui_cancel"): + return + match state: + STATE_FRONT, STATE_BOSS_ROOM: + pause_game() + STATE_PAUSED: + resume_game() + STATE_VOLUME: + _set_state(STATE_PAUSED) + STATE_RETURN_CONFIRM: + _set_state(STATE_PAUSED) + STATE_EXIT_CONFIRM: + _set_state(STATE_TITLE) + + +func is_gameplay_state(value: StringName = &"") -> bool: + var checked := value if not value.is_empty() else state + return GAMEPLAY_STATES.has(checked) + + +## ------------------------------------------------------------------ actions + + +func start_game() -> void: + _play_ui_sound() + _run_finished = false + _victory = false + if manage_scene: + await _reload_gameplay_scene() + _reset_run_state() + _set_state(STATE_FRONT) + _resume_tree() + _connect_boss_health() + + +func pause_game() -> void: + if not is_gameplay_state(): + return + _pause_tree() + _set_state(STATE_PAUSED) + + +func resume_game() -> void: + if state != STATE_PAUSED and state != STATE_VOLUME: + return + _set_state(STATE_BOSS_ROOM if _boss_room_entered else STATE_FRONT) + _resume_tree() + + +func return_to_title() -> void: + # Abandoning the run (spec: 回到标题视为放弃本局). + _stats_stop() + _pause_tree() + _enter_title() + + +func quit_game() -> void: + if manage_scene: + get_tree().quit() + + +var _boss_room_entered := false + + +func enter_boss_room() -> void: + ## Boss-room entry rules (spec §4.5): combo cleared, four-slot cleared, + ## attack buff capped at 3, door locked behind the player. + if _boss_room_entered or not is_gameplay_state(): + return + _boss_room_entered = true + var player := _player_or_null() + if player != null: + var streak_counter := player.get_node_or_null("StreakCounter") + if streak_counter != null and streak_counter.has_method("reset"): + streak_counter.call("reset") + var combo_window := player.get_node_or_null("ComboWindow") + if combo_window != null and combo_window.has_method("clear"): + combo_window.call("clear", &"boss_room_entry") + var buff := player.get_node_or_null("AttackBuffComponent") + if buff != null and buff.has_method("cap_stacks"): + buff.call("cap_stacks", BOSS_ENTRY_MAX_ATTACK_BUFF) + var boss := _boss_or_null() + if boss != null: + boss.set("combat_enabled", true) + boss.set("stationary", false) + var stage := _stage_or_null() + if stage != null and stage.has_method("enter_boss_room_view"): + stage.call("enter_boss_room_view") + _set_state(STATE_BOSS_ROOM) + + +func notify_boss_defeated() -> void: + if _run_finished or not is_gameplay_state(): + return + _run_finished = true + _victory = true + _stats_stop() + _end_timer = get_tree().create_timer(VICTORY_SCREEN_DELAY, false) + _end_timer.timeout.connect(func() -> void: + # 回标题 / 重开新局会把 _run_finished 清掉;被暂停冻结的旧计时器 + # 恢复后不得把结算画面砸进新一局。 + if not _run_finished: + return + _pause_tree() + _set_state(STATE_VICTORY) + ) + + +func notify_player_defeated() -> void: + if _run_finished or not is_gameplay_state(): + return + _run_finished = true + _victory = false + _stats_stop() + _end_timer = get_tree().create_timer(DEFEAT_SCREEN_DELAY, false) + _end_timer.timeout.connect(func() -> void: + if not _run_finished: + return + _pause_tree() + _set_state(STATE_DEFEAT) + ) + + +## ------------------------------------------------------------ state plumbing + + +func _enter_title() -> void: + _boss_room_entered = false + _run_finished = false + _pause_tree() + _set_state(STATE_TITLE) + + +func _set_state(next: StringName) -> void: + if next == state and _screens.has(next) and (_screens[next] as Control).visible: + return + var previous := state + state = next + _refresh_screens() + flow_state_changed.emit(previous, state) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("flow_state_changed"): + bus.emit_signal("flow_state_changed", previous, state) + + +func _pause_tree() -> void: + if not is_inside_tree(): + return + get_tree().paused = true + var rhythm := _rhythm_manager_or_null() + if rhythm != null and rhythm.has_method("pause_clock"): + rhythm.call("pause_clock") + + +func _resume_tree() -> void: + if not is_inside_tree(): + return + var rhythm := _rhythm_manager_or_null() + if rhythm != null and rhythm.has_method("resume_clock"): + rhythm.call("resume_clock") + get_tree().paused = false + + +func _reload_gameplay_scene() -> void: + var tree := get_tree() + if tree.current_scene == null: + return + tree.reload_current_scene() + await tree.process_frame + await tree.process_frame + + +func _reset_run_state() -> void: + _boss_room_entered = false + var rhythm := _rhythm_manager_or_null() + if rhythm != null and rhythm.has_method("start"): + rhythm.call("start") + # The reloaded scene started its music layers on the stale clock position; + # snap them to the freshly zeroed clock so the run's music restarts from beat 0. + var audio_layers := _audio_layers_or_null() + if audio_layers != null and audio_layers.has_method("restart_layers"): + audio_layers.call("restart_layers") + var runner := _chart_runner_or_null() + if runner != null and runner.has_method("reset"): + runner.call("reset") + var stats := _game_stats_or_null() + if stats != null and stats.has_method("start_run"): + stats.call("start_run") + + +func _stats_stop() -> void: + var stats := _game_stats_or_null() + if stats != null and stats.has_method("stop_tracking"): + stats.call("stop_tracking") + + +func _on_player_health_changed(current: int, _maximum: int) -> void: + if current <= 0: + notify_player_defeated() + + +func _connect_boss_health() -> void: + _boss_health = null + var boss := _boss_or_null() + if boss == null: + return + _boss_health = boss.get_node_or_null("HealthComponent") + if _boss_health == null: + return + var callback := Callable(self, "_on_boss_health_changed") + if not _boss_health.is_connected("health_changed", callback): + _boss_health.connect("health_changed", callback) + + +func _on_boss_health_changed(current: int, _maximum: int) -> void: + if current <= 0: + notify_boss_defeated() + + +## ------------------------------------------------------------------- lookup + + +func _player_or_null() -> Node: + var scene := get_tree().current_scene if is_inside_tree() else null + if scene == null: + return null + return scene.get_node_or_null("Stage/ActorsContainer/Player") + + +func _boss_or_null() -> Node: + var scene := get_tree().current_scene if is_inside_tree() else null + if scene == null: + return null + return scene.get_node_or_null("Stage/ActorsContainer/Boss") + + +func _stage_or_null() -> Node: + var scene := get_tree().current_scene if is_inside_tree() else null + if scene == null: + return null + return scene.get_node_or_null("Stage") + + +func _chart_runner_or_null() -> Node: + var scene := get_tree().current_scene if is_inside_tree() else null + if scene == null: + return null + return scene.get_node_or_null("ChartRunner") + + +func _rhythm_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("RhythmManager") + + +func _audio_layers_or_null() -> Node: + var scene := get_tree().current_scene if is_inside_tree() else null + if scene == null: + return null + return scene.get_node_or_null("AudioLayerController") + + +func _game_settings_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("GameSettings") + + +func _game_stats_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("GameStats") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") + + +func _play_ui_sound() -> void: + var sfx := get_tree().root.get_node_or_null("SfxManager") if is_inside_tree() else null + if sfx != null and sfx.has_method("play_sfx"): + sfx.call("play_sfx", &"ui_click") + + +## ------------------------------------------------------------- title ambience +## 标题侧播放与游戏内相同的节奏音乐(Title / DifficultySelect / ExitConfirm), +## 由本节点直接持有播放器,因此标题界面暂停 SceneTree 时音乐照常播放。 + + +func _setup_title_music() -> void: + _title_music_player = AudioStreamPlayer.new() + _title_music_player.name = "TitleMusicPlayer" + _title_music_player.process_mode = Node.PROCESS_MODE_ALWAYS + if AudioServer.get_bus_index("Music") != -1: + _title_music_player.bus = "Music" + if DisplayServer.get_name() != "headless" and ResourceLoader.exists(TITLE_MUSIC_STREAM_PATH): + _title_music_player.stream = load(TITLE_MUSIC_STREAM_PATH) + add_child(_title_music_player) + + +func _should_play_title_music() -> bool: + return TITLE_MUSIC_STATES.has(state) + + +func _update_title_music() -> void: + if _title_music_player == null: + return + if _should_play_title_music(): + if not _title_music_player.playing: + _title_music_player.play(0.0) + elif _title_music_player.playing: + _title_music_player.stop() + + +## 标题背景在「现在」与「过去」两张场景图之间循环交叉淡入淡出。 +func _start_title_background_loop() -> void: + if _title_bg_future == null: + return + if _title_bg_tween != null and _title_bg_tween.is_valid(): + return + _title_bg_future.modulate.a = 1.0 + _title_bg_tween = _title_bg_future.create_tween() + _title_bg_tween.set_loops() + _title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS) + _title_bg_tween.tween_property(_title_bg_future, "modulate:a", 0.0, TITLE_BG_FADE_SECONDS) + _title_bg_tween.tween_interval(TITLE_BG_HOLD_SECONDS) + _title_bg_tween.tween_property(_title_bg_future, "modulate:a", 1.0, TITLE_BG_FADE_SECONDS) + + +func _stop_title_background_loop() -> void: + if _title_bg_tween != null and _title_bg_tween.is_valid(): + _title_bg_tween.kill() + _title_bg_tween = null + + +## ----------------------------------------------------------------------- UI +## Flow screens are assembled from the modular menu kit (assets/ui/menu2/): +## panel frame + two-state button bases + text stamps, per the 2026-07-05 +## designer mockups — no whole-screen menu art. State and settings logic +## stays in this manager. + + +func _build_ui() -> void: + _ui_layer = CanvasLayer.new() + _ui_layer.name = "FlowUI" + _ui_layer.layer = 100 + _ui_layer.process_mode = Node.PROCESS_MODE_ALWAYS + add_child(_ui_layer) + _setup_title_music() + if ResourceLoader.exists(MENU_FONT_PATH): + _menu_font = load(MENU_FONT_PATH) + _screens[STATE_TITLE] = _build_title_screen() + _screens[STATE_DIFFICULTY] = _build_difficulty_screen() + _screens[STATE_PAUSED] = _build_pause_screen() + _screens[STATE_VOLUME] = _build_volume_screen() + _screens[STATE_EXIT_CONFIRM] = _build_confirm_screen(STATE_EXIT_CONFIRM, "确定要离开游戏吗?", func() -> void: quit_game(), func() -> void: _set_state(STATE_TITLE)) + _screens[STATE_RETURN_CONFIRM] = _build_confirm_screen(STATE_RETURN_CONFIRM, "回到标题将放弃本局,确定吗?", func() -> void: return_to_title(), func() -> void: _set_state(STATE_PAUSED)) + _screens[STATE_DEFEAT] = _build_end_splash(STATE_DEFEAT, "res://assets/ui/menu2/text_defeat.png") + _screens[STATE_VICTORY] = _build_end_splash(STATE_VICTORY, "res://assets/ui/menu2/text_victory.png") + _screens[STATE_RESULT] = _build_result_screen() + _refresh_screens() + + +func _refresh_screens() -> void: + for key: StringName in _screens.keys(): + (_screens[key] as Control).visible = key == state + if state == STATE_VOLUME: + _sync_volume_widgets() + if state == STATE_DIFFICULTY: + _sync_difficulty_buttons() + if state == STATE_RESULT: + _populate_result_screen() + if state == STATE_TITLE or state == STATE_DIFFICULTY: + _sync_difficulty_footers() + if state == STATE_TITLE: + _start_title_background_loop() + else: + _stop_title_background_loop() + _update_title_music() + + +func _build_title_screen() -> Control: + var root := _make_screen_root("TitleScreen", TITLE_BG_COLOR) + # 标题背景循环动画:「过去」场景打底,「未来」场景叠在上层做透明度循环。 + # 两张都是干净原画(Logo 不烙在背景里),渐变期间悬浮 Logo/菜单纹丝不动。 + _add_art_background(root, TITLE_BG_PAST_ART, "SceneBackgroundPast") + _title_bg_future = _add_art_background(root, TITLE_BG_FUTURE_ART, "SceneBackgroundFuture") + _add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0)) + _add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size) + _make_art_button(root, "StartGameButton", Rect2(84, 230, 218, 72), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0)) + var open_difficulty := func() -> void: + _pending_difficulty = _current_difficulty() + _set_state(STATE_DIFFICULTY) + _make_art_button(root, "DifficultyButton", Rect2(84, 318, 218, 72), open_difficulty, "res://assets/ui/menu2/text_difficulty_select.png", Vector2(153.0, 41.0)) + _make_art_button(root, "QuitGameButton", Rect2(84, 406, 218, 72), func() -> void: _set_state(STATE_EXIT_CONFIRM), "res://assets/ui/menu2/text_quit_game.png", Vector2(153.0, 40.0)) + var caption := _make_title_label("当前难度:", 18, Color(0.85, 0.86, 0.96)) + caption.name = "TitleDifficultyCaption" + caption.position = Vector2(84.0, 498.0) + caption.size = Vector2(218.0, 24.0) + root.add_child(caption) + _title_difficulty_value = _make_title_label("普通", 26, ACCENT_COLOR) + _title_difficulty_value.name = "TitleDifficultyValue" + _title_difficulty_value.position = Vector2(84.0, 526.0) + _title_difficulty_value.size = Vector2(218.0, 34.0) + root.add_child(_title_difficulty_value) + return root + + +func _build_difficulty_screen() -> Control: + var root := _make_screen_root("DifficultyScreen", TITLE_BG_COLOR) + _add_art_background(root, TITLE_BG_FUTURE_ART) + _add_menu_texture(root, "TitleLogo", TITLE_LOGO_ART, TITLE_LOGO_RECT.position, TITLE_LOGO_RECT.size) + _add_menu_art(root, PANEL_TALL, Vector2(38.0, 31.0)) + _add_menu_texture(root, "DifficultyTitle", "res://assets/ui/menu2/text_difficulty_select.png", Vector2(112.0, 88.0), Vector2(153.0, 41.0)) + _difficulty_buttons[&"easy"] = _make_difficulty_option( + root, + "EasyButton", + Rect2(84.0, 160.0, 218.0, 72.0), + &"easy", + "res://assets/ui/menu2/text_easy.png", + Vector2(81.0, 42.0) + ) + _difficulty_buttons[&"normal"] = _make_difficulty_option( + root, + "NormalButton", + Rect2(84.0, 250.0, 218.0, 72.0), + &"normal", + "res://assets/ui/menu2/text_normal.png", + Vector2(83.0, 42.0) + ) + _difficulty_buttons[&"hard"] = _make_difficulty_option( + root, + "HardButton", + Rect2(84.0, 340.0, 218.0, 72.0), + &"hard", + "res://assets/ui/menu2/text_hard.png", + Vector2(82.0, 42.0) + ) + _make_art_button(root, "ConfirmStartButton", Rect2(84.0, 440.0, 218.0, 56.0), func() -> void: start_game(), "res://assets/ui/menu2/text_start_game.png", Vector2(155.0, 41.0)) + _make_art_button(root, "BackTitleButton", Rect2(84.0, 504.0, 218.0, 56.0), func() -> void: _set_state(STATE_TITLE), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0)) + _difficulty_footer_value = _make_title_label("当前难度:普通", 18, ACCENT_COLOR) + _difficulty_footer_value.name = "DifficultyFooterValue" + _difficulty_footer_value.position = Vector2(84.0, 572.0) + _difficulty_footer_value.size = Vector2(218.0, 26.0) + root.add_child(_difficulty_footer_value) + return root + + +func _build_pause_screen() -> Control: + var root := _make_screen_root("PauseScreen", OVERLAY_BG_COLOR) + _make_panel(root, "PausePanel", Rect2(425.0, 180.0, 302.0, 287.0)) + _make_art_button(root, "ContinueButton", Rect2(468, 236, 216, 74), func() -> void: resume_game(), "res://assets/ui/menu2/text_continue_game.png", Vector2(156.0, 41.0)) + _make_art_button(root, "ReturnTitleButton", Rect2(468, 334, 216, 74), func() -> void: _set_state(STATE_RETURN_CONFIRM), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0)) + return root + + +func _build_volume_screen() -> Control: + var root := _make_screen_root("VolumeScreen", OVERLAY_BG_COLOR) + var box := _make_center_menu(root) + box.add_child(_make_title_label("音量设置", 40, ACCENT_COLOR)) + box.add_child(_make_spacer(14)) + var music_row := _make_slider_row("音乐音量") + _music_slider = music_row[1] + _music_value_label = music_row[2] + _music_slider.value_changed.connect(func(value: float) -> void: + var settings := _game_settings_or_null() + if settings != null: + settings.call("set_music_volume", int(value)) + _music_value_label.text = str(int(value)) + ) + box.add_child(music_row[0]) + var sfx_row := _make_slider_row("音效音量") + _sfx_slider = sfx_row[1] + _sfx_value_label = sfx_row[2] + _sfx_slider.value_changed.connect(func(value: float) -> void: + var settings := _game_settings_or_null() + if settings != null: + settings.call("set_sfx_volume", int(value)) + _sfx_value_label.text = str(int(value)) + ) + _sfx_slider.drag_ended.connect(func(_changed: bool) -> void: _play_ui_sound()) + box.add_child(sfx_row[0]) + box.add_child(_make_spacer(16)) + box.add_child(_make_menu_button("返回", func() -> void: _set_state(STATE_PAUSED))) + return root + + +func _build_confirm_screen(screen_state: StringName, question: String, on_confirm: Callable, on_cancel: Callable) -> Control: + var root := _make_screen_root("%sScreen" % screen_state, OVERLAY_BG_COLOR) + _make_panel(root, "ConfirmPanel", Rect2(425.0, 194.0, 302.0, 260.0)) + var prompt := _make_title_label(question, 20, Color(0.92, 0.93, 1.0)) + prompt.name = "ConfirmPrompt" + prompt.position = Vector2(455.0, 224.0) + prompt.size = Vector2(242.0, 64.0) + prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + root.add_child(prompt) + _make_art_button(root, "ConfirmButton", Rect2(467.0, 300.0, 218.0, 56.0), on_confirm, "res://assets/ui/menu2/text_confirm.png", Vector2(88.0, 44.0)) + _make_art_button(root, "CancelButton", Rect2(467.0, 366.0, 218.0, 56.0), on_cancel, "res://assets/ui/menu2/text_cancel.png", Vector2(85.0, 43.0)) + return root + + +func _build_end_splash(splash_state: StringName, banner_texture_path: String) -> Control: + var root := _make_screen_root("%sScreen" % splash_state, OVERLAY_BG_COLOR) + _make_panel(root, "SplashPanel", Rect2(425.0, 186.0, 302.0, 276.0)) + # 胜利/失败字样按 2 倍整数放大,配 NEAREST 过滤保持像素锐利。 + var banner := _add_menu_texture(root, "SplashBanner", banner_texture_path, Vector2.ZERO, Vector2.ZERO) + if banner.texture != null: + var tex_size: Vector2 = banner.texture.get_size() * 2.0 + banner.size = tex_size + banner.position = Vector2(425.0 + (302.0 - tex_size.x) * 0.5, 224.0) + banner.stretch_mode = TextureRect.STRETCH_SCALE + banner.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _make_art_button(root, "ShowResultButton", Rect2(467.0, 356.0, 218.0, 56.0), func() -> void: _set_state(STATE_RESULT), "", Vector2.ZERO, "查看结算") + return root + + +var _result_labels: Dictionary = {} + + +func _build_result_screen() -> Control: + var root := _make_screen_root("ResultScreen", TITLE_BG_COLOR) + _add_menu_art(root, PANEL_TALL, Vector2(425.0, 31.0)) + _add_menu_texture(root, "ResultTitle", "res://assets/ui/menu2/text_result_title.png", Vector2(503.0, 62.0), Vector2(145.0, 38.0)) + var row_y := 130.0 + for entry: Array in [ + ["max_combo", "最大连击"], + ["perfect", "完美"], + ["good", "良好"], + ["bad", "勉强"], + ["miss", "失误"], + ["anchor_success", "锚点成功"], + ["phase_shift_count", "相位切换"], + ]: + var name_label := _make_title_label(str(entry[1]), 18, Color(0.78, 0.8, 0.92)) + name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + name_label.position = Vector2(465.0, row_y) + name_label.size = Vector2(140.0, 26.0) + root.add_child(name_label) + var value_label := _make_title_label("0", 18, Color(1, 1, 1)) + value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + value_label.position = Vector2(560.0, row_y) + value_label.size = Vector2(127.0, 26.0) + root.add_child(value_label) + _result_labels[entry[0]] = value_label + row_y += 36.0 + var rank_label := _make_title_label("RANK -", 40, FUTURE_ACCENT_COLOR) + rank_label.name = "RankLabel" + rank_label.position = Vector2(465.0, 396.0) + rank_label.size = Vector2(222.0, 56.0) + _result_labels["rank"] = rank_label + root.add_child(rank_label) + _make_art_button(root, "ReturnTitleButton", Rect2(467.0, 486.0, 218.0, 56.0), func() -> void: return_to_title(), "res://assets/ui/menu2/text_return_title.png", Vector2(153.0, 41.0)) + return root + + +func _populate_result_screen() -> void: + var stats := _game_stats_or_null() + if stats == null: + return + var summary: Dictionary = stats.call("summary") + for key: String in ["max_combo", "perfect", "good", "bad", "miss", "anchor_success", "phase_shift_count"]: + if _result_labels.has(key): + (_result_labels[key] as Label).text = str(int(summary.get(key, 0))) + var rank_label := _result_labels.get("rank", null) as Label + if rank_label == null: + return + if _victory: + # Rank only on victory (§19.6); defeat shows stats without a rank. + # 2026-07-05 策划定案:rank 后只保留等级,不再附带分数。 + rank_label.visible = true + rank_label.text = "RANK %s" % str(stats.call("current_rank")) + else: + rank_label.visible = false + + +func _sync_volume_widgets() -> void: + var settings := _game_settings_or_null() + if settings == null or _music_slider == null: + return + _music_slider.set_value_no_signal(float(int(settings.get("music_volume")))) + _sfx_slider.set_value_no_signal(float(int(settings.get("sfx_volume")))) + _music_value_label.text = str(int(settings.get("music_volume"))) + _sfx_value_label.text = str(int(settings.get("sfx_volume"))) + + +func _sync_difficulty_buttons() -> void: + if _pending_difficulty.is_empty(): + _pending_difficulty = _current_difficulty() + for key: Variant in _difficulty_buttons.keys(): + var button := _difficulty_buttons[key] as Button + if button == null: + continue + var selected := StringName(str(key)) == _pending_difficulty + button.set_pressed_no_signal(selected) + # 选中项换成高亮橙框底,未选中项回普通蓝框底并略压暗。 + button.add_theme_stylebox_override("normal", _base_stylebox(selected)) + button.modulate = Color(1.0, 1.0, 1.0, 1.0) if selected else Color(0.8, 0.8, 0.86, 1.0) + + +func _select_difficulty(value: StringName) -> void: + if not DIFFICULTY_ORDER.has(value): + return + _pending_difficulty = value + var settings := _game_settings_or_null() + if settings != null: + settings.call("set_difficulty", _pending_difficulty) + _sync_difficulty_buttons() + _sync_difficulty_footers() + + +func _sync_difficulty_footers() -> void: + var display: String = DIFFICULTY_NAMES.get(_current_difficulty(), "普通") + if _title_difficulty_value != null: + _title_difficulty_value.text = display + if _difficulty_footer_value != null: + _difficulty_footer_value.text = "当前难度:%s" % display + + +func _current_difficulty() -> StringName: + var settings := _game_settings_or_null() + if settings == null: + return &"normal" + return StringName(str(settings.get("difficulty"))) + + +## --------------------------------------------------------------- UI helpers + + +func _make_screen_root(node_name: String, bg_color: Color) -> Control: + var root := Control.new() + root.name = node_name + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.visible = false + root.mouse_filter = Control.MOUSE_FILTER_STOP + var background := ColorRect.new() + background.name = "Background" + background.color = bg_color + background.set_anchors_preset(Control.PRESET_FULL_RECT) + root.add_child(background) + _ui_layer.add_child(root) + return root + + +## Backgrounds must be added right after _make_screen_root so they stack above +## the ColorRect and below the menu art, in call order. +func _add_art_background(root: Control, texture_path: String, art_name := "SceneBackground") -> TextureRect: + var art := TextureRect.new() + art.name = art_name + art.texture = load(texture_path) + art.set_anchors_preset(Control.PRESET_FULL_RECT) + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + art.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(art) + return art + + +func _add_menu_art(root: Control, texture_path: String, position: Vector2) -> TextureRect: + var art := TextureRect.new() + art.name = "MenuArt" + art.texture = load(texture_path) + art.position = position + if art.texture != null: + art.size = art.texture.get_size() + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_KEEP + art.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(art) + return art + + +func _add_menu_texture(root: Control, node_name: String, texture_path: String, position: Vector2, size: Vector2) -> TextureRect: + var art := TextureRect.new() + art.name = node_name + art.texture = load(texture_path) + # expand/stretch 必须先于 size 赋值:默认 EXPAND_KEEP_SIZE 下最小尺寸 + # 等于贴图原生尺寸,会把传入的 size 钳回原生值(Logo 缩放曾因此失效)。 + art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + art.stretch_mode = TextureRect.STRETCH_SCALE + art.position = position + art.size = size + art.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(art) + return art + + +func _make_panel(root: Control, node_name: String, rect: Rect2) -> NinePatchRect: + var panel := NinePatchRect.new() + panel.name = node_name + panel.texture = load(PANEL_TALL) + panel.position = rect.position + panel.size = rect.size + panel.patch_margin_left = 26 + panel.patch_margin_top = 26 + panel.patch_margin_right = 26 + panel.patch_margin_bottom = 26 + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(panel) + return panel + + +func _base_stylebox(highlight: bool) -> StyleBoxTexture: + var style := StyleBoxTexture.new() + style.texture = load(BUTTON_BASE_HOVER if highlight else BUTTON_BASE_NORMAL) + return style + + +## 组件按钮:按钮底两态贴图 + 居中的文字贴图(或字体文字)。保持 Button 类型, +## 让既有的坐标点击测试与 is Button 断言继续成立。 +func _make_art_button(root: Control, node_name: String, rect: Rect2, on_pressed: Callable, text_texture_path := "", text_size := Vector2.ZERO, label_text := "") -> Button: + var button := Button.new() + button.name = node_name + button.text = "" + # Mouse-only menus: a focused button would swallow Space/Enter, and Space + # is a core combat key — menu buttons must never eat it mid-fight. + button.focus_mode = Control.FOCUS_NONE + button.position = rect.position + button.size = rect.size + button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + button.add_theme_stylebox_override("normal", _base_stylebox(false)) + var hover := _base_stylebox(true) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", _base_stylebox(true)) + button.add_theme_stylebox_override("focus", _base_stylebox(true)) + if not text_texture_path.is_empty(): + var text_art := TextureRect.new() + text_art.name = "ButtonText" + text_art.texture = load(text_texture_path) + text_art.position = (rect.size - text_size) * 0.5 + text_art.size = text_size + text_art.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + text_art.stretch_mode = TextureRect.STRETCH_KEEP + text_art.mouse_filter = Control.MOUSE_FILTER_IGNORE + button.add_child(text_art) + elif not label_text.is_empty(): + var label := _make_title_label(label_text, 24, ACCENT_COLOR) + label.name = "ButtonLabel" + label.set_anchors_preset(Control.PRESET_FULL_RECT) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + button.add_child(label) + button.pressed.connect(func() -> void: + _play_ui_sound() + on_pressed.call() + ) + root.add_child(button) + return button + + +func _make_difficulty_option(root: Control, node_name: String, rect: Rect2, difficulty: StringName, text_texture_path: String, text_size: Vector2) -> Button: + var button := _make_art_button(root, node_name, rect, func() -> void: _select_difficulty(difficulty), text_texture_path, text_size) + button.toggle_mode = true + return button + + +func _make_center_menu(root: Control) -> VBoxContainer: + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + root.add_child(center) + var panel := PanelContainer.new() + var style := StyleBoxFlat.new() + style.bg_color = Color(0.08, 0.08, 0.14, 0.92) + style.border_color = ACCENT_COLOR * Color(1, 1, 1, 0.65) + style.border_width_left = 2 + style.border_width_top = 2 + style.border_width_right = 2 + style.border_width_bottom = 2 + style.corner_radius_top_left = 10 + style.corner_radius_top_right = 10 + style.corner_radius_bottom_left = 10 + style.corner_radius_bottom_right = 10 + style.content_margin_left = 42.0 + style.content_margin_right = 42.0 + style.content_margin_top = 30.0 + style.content_margin_bottom = 30.0 + panel.add_theme_stylebox_override("panel", style) + center.add_child(panel) + var box := VBoxContainer.new() + box.alignment = BoxContainer.ALIGNMENT_CENTER + box.add_theme_constant_override("separation", 10) + panel.add_child(box) + return box + + +func _make_title_label(text: String, font_size: int, color: Color) -> Label: + var label := Label.new() + label.text = text + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + if _menu_font != null: + label.add_theme_font_override("font", _menu_font) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", color) + label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8)) + label.add_theme_constant_override("shadow_offset_x", 2) + label.add_theme_constant_override("shadow_offset_y", 2) + return label + + +func _make_menu_button(text: String, on_pressed: Callable) -> Button: + var button := _make_button(text, 22) + button.custom_minimum_size = Vector2(320, 46) + button.pressed.connect(func() -> void: + _play_ui_sound() + on_pressed.call() + ) + return button + + +func _make_button(text: String, font_size: int) -> Button: + var button := Button.new() + button.text = text + # Mouse-only menus: a focused button would swallow Space/Enter, and Space + # is a core combat key — the pause button must never eat it mid-fight. + button.focus_mode = Control.FOCUS_NONE + if _menu_font != null: + button.add_theme_font_override("font", _menu_font) + button.add_theme_font_size_override("font_size", font_size) + var normal := StyleBoxFlat.new() + normal.bg_color = Color(0.13, 0.13, 0.22, 0.95) + normal.border_color = Color(0.5, 0.45, 0.7, 0.8) + normal.border_width_left = 1 + normal.border_width_top = 1 + normal.border_width_right = 1 + normal.border_width_bottom = 1 + normal.corner_radius_top_left = 6 + normal.corner_radius_top_right = 6 + normal.corner_radius_bottom_left = 6 + normal.corner_radius_bottom_right = 6 + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color(0.2, 0.19, 0.32, 0.98) + hover.border_color = ACCENT_COLOR + var pressed := normal.duplicate() as StyleBoxFlat + pressed.bg_color = Color(0.32, 0.27, 0.16, 1.0) + pressed.border_color = ACCENT_COLOR + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", pressed) + button.add_theme_stylebox_override("focus", hover) + return button + + +func _make_slider_row(label_text: String) -> Array: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(380, 0) + row.add_theme_constant_override("separation", 12) + var label := _make_title_label(label_text, 18, Color(0.85, 0.86, 0.96)) + label.custom_minimum_size = Vector2(96, 0) + row.add_child(label) + var slider := HSlider.new() + slider.focus_mode = Control.FOCUS_NONE + slider.min_value = 0 + slider.max_value = 100 + slider.step = 1 + slider.custom_minimum_size = Vector2(200, 24) + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slider.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(slider) + var value_label := _make_title_label("0", 18, Color(1, 1, 1)) + value_label.custom_minimum_size = Vector2(42, 0) + row.add_child(value_label) + return [row, slider, value_label] + + +func _make_spacer(height: float) -> Control: + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, height) + return spacer diff --git a/autoload/game_flow_manager.gd.uid b/autoload/game_flow_manager.gd.uid new file mode 100644 index 0000000..e57a0de --- /dev/null +++ b/autoload/game_flow_manager.gd.uid @@ -0,0 +1 @@ +uid://b2tnnoksko67h diff --git a/autoload/game_settings.gd b/autoload/game_settings.gd new file mode 100644 index 0000000..0b8b7e5 --- /dev/null +++ b/autoload/game_settings.gd @@ -0,0 +1,145 @@ +extends Node + +## Persistent player-facing settings (AnchorV1.0 §9.5 difficulty windows, +## §20.5 music/sfx volumes). Sole writer of the Music / SFX buses and of the +## RhythmManager judgement windows; every menu goes through this node. + +signal difficulty_changed(difficulty: StringName) +signal volumes_changed(music_volume: int, sfx_volume: int) + +const SETTINGS_PATH := "user://settings.cfg" + +const MUSIC_BUS_NAME := "Music" +const SFX_BUS_NAME := "SFX" + +const DIFFICULTY_ORDER: Array[StringName] = [&"easy", &"normal", &"hard"] + +## Windows in seconds per AnchorV1.0 §21.8. +const DIFFICULTY_WINDOWS := { + &"easy": Vector3(0.090, 0.160, 0.260), + &"normal": Vector3(0.060, 0.115, 0.215), + &"hard": Vector3(0.030, 0.070, 0.170), +} + +## 小怪血量难度倍率:普通 3 倍、困难 5 倍,简单保持基准值。 +const DIFFICULTY_MINION_HEALTH_MULTIPLIER := { + &"easy": 1.0, + &"normal": 3.0, + &"hard": 5.0, +} + +const DEFAULT_DIFFICULTY: StringName = &"normal" +const DEFAULT_MUSIC_VOLUME := 85 +const DEFAULT_SFX_VOLUME := 80 + +var difficulty: StringName = DEFAULT_DIFFICULTY +var music_volume := DEFAULT_MUSIC_VOLUME +var sfx_volume := DEFAULT_SFX_VOLUME + + +func _ready() -> void: + _ensure_audio_buses() + _load_settings() + apply_difficulty_windows() + _apply_bus_volumes() + + +func set_difficulty(next: StringName) -> void: + if not DIFFICULTY_WINDOWS.has(next): + return + if next == difficulty: + return + difficulty = next + apply_difficulty_windows() + _save_settings() + difficulty_changed.emit(difficulty) + + +func set_music_volume(value: int) -> void: + music_volume = clampi(value, 0, 100) + _apply_bus_volumes() + _save_settings() + volumes_changed.emit(music_volume, sfx_volume) + + +func set_sfx_volume(value: int) -> void: + sfx_volume = clampi(value, 0, 100) + _apply_bus_volumes() + _save_settings() + volumes_changed.emit(music_volume, sfx_volume) + + +func difficulty_windows(for_difficulty: StringName = &"") -> Vector3: + var key := for_difficulty if DIFFICULTY_WINDOWS.has(for_difficulty) else difficulty + return DIFFICULTY_WINDOWS.get(key, DIFFICULTY_WINDOWS[DEFAULT_DIFFICULTY]) + + +func minion_health_multiplier(for_difficulty: StringName = &"") -> float: + var key := for_difficulty if DIFFICULTY_MINION_HEALTH_MULTIPLIER.has(for_difficulty) else difficulty + return float(DIFFICULTY_MINION_HEALTH_MULTIPLIER.get(key, 1.0)) + + +func difficulty_display_name(value: StringName = &"") -> String: + match (value if not value.is_empty() else difficulty): + &"easy": + return "简单" + &"hard": + return "困难" + return "普通" + + +func apply_difficulty_windows() -> void: + var rhythm := _rhythm_manager_or_null() + if rhythm == null or not rhythm.has_method("apply_judgement_windows"): + return + var windows := difficulty_windows() + rhythm.call("apply_judgement_windows", windows.x, windows.y, windows.z) + + +func _ensure_audio_buses() -> void: + for bus_name: String in [MUSIC_BUS_NAME, SFX_BUS_NAME]: + if AudioServer.get_bus_index(bus_name) != -1: + continue + AudioServer.add_bus() + var bus_index := AudioServer.bus_count - 1 + AudioServer.set_bus_name(bus_index, bus_name) + AudioServer.set_bus_send(bus_index, "Master") + + +func _apply_bus_volumes() -> void: + _set_bus_volume(MUSIC_BUS_NAME, music_volume) + _set_bus_volume(SFX_BUS_NAME, sfx_volume) + + +func _set_bus_volume(bus_name: String, value: int) -> void: + var bus_index := AudioServer.get_bus_index(bus_name) + if bus_index == -1: + return + var linear := clampf(float(value) / 100.0, 0.0, 1.0) + AudioServer.set_bus_mute(bus_index, linear <= 0.0) + AudioServer.set_bus_volume_db(bus_index, linear_to_db(maxf(0.0001, linear))) + + +func _load_settings() -> void: + var config := ConfigFile.new() + if config.load(SETTINGS_PATH) != OK: + return + var stored_difficulty := StringName(str(config.get_value("gameplay", "difficulty", str(DEFAULT_DIFFICULTY)))) + if DIFFICULTY_WINDOWS.has(stored_difficulty): + difficulty = stored_difficulty + music_volume = clampi(int(config.get_value("audio", "music_volume", DEFAULT_MUSIC_VOLUME)), 0, 100) + sfx_volume = clampi(int(config.get_value("audio", "sfx_volume", DEFAULT_SFX_VOLUME)), 0, 100) + + +func _save_settings() -> void: + var config := ConfigFile.new() + config.set_value("gameplay", "difficulty", str(difficulty)) + config.set_value("audio", "music_volume", music_volume) + config.set_value("audio", "sfx_volume", sfx_volume) + config.save(SETTINGS_PATH) + + +func _rhythm_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("RhythmManager") diff --git a/autoload/game_settings.gd.uid b/autoload/game_settings.gd.uid new file mode 100644 index 0000000..599e0cc --- /dev/null +++ b/autoload/game_settings.gd.uid @@ -0,0 +1 @@ +uid://cyvknt0udm8i diff --git a/autoload/game_stats.gd b/autoload/game_stats.gd new file mode 100644 index 0000000..a291c69 --- /dev/null +++ b/autoload/game_stats.gd @@ -0,0 +1,159 @@ +extends Node + +## Per-run battle statistics for the result screen (AnchorV1.0 §19.6 / §21.10). +## Pure fact subscriber: judgement facts, anchor resolutions and phase shifts +## flow in from the EventBus; the rank formula reads only this node. + +signal stats_changed + +const RANK_THRESHOLDS := [ + {"rank": "S", "min": 90.0}, + {"rank": "A", "min": 80.0}, + {"rank": "B", "min": 65.0}, + {"rank": "C", "min": 50.0}, + {"rank": "D", "min": 0.0}, +] + +const COMBO_TARGET := 80.0 +const ACCURACY_WEIGHT := 65.0 +const ANCHOR_WEIGHT := 20.0 +const COMBO_WEIGHT := 15.0 + +var perfect_count := 0 +var good_count := 0 +var bad_count := 0 +var miss_count := 0 +var max_combo := 0 +var anchor_success_count := 0 +var phase_shift_count := 0 + +var _tracking := false + + +func _ready() -> void: + var bus := _event_bus_or_null() + if bus == null: + return + _connect_once(bus, "judgement_made", _on_judgement_made) + _connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved) + _connect_once(bus, "time_phase_changed", _on_time_phase_changed) + _connect_once(bus, "streak_changed", _on_streak_changed) + + +func start_run() -> void: + reset() + _tracking = true + + +func stop_tracking() -> void: + _tracking = false + + +func reset() -> void: + perfect_count = 0 + good_count = 0 + bad_count = 0 + miss_count = 0 + max_combo = 0 + anchor_success_count = 0 + phase_shift_count = 0 + stats_changed.emit() + + +func summary() -> Dictionary: + return { + "perfect": perfect_count, + "good": good_count, + "bad": bad_count, + "miss": miss_count, + "max_combo": max_combo, + "anchor_success": anchor_success_count, + "phase_shift_count": phase_shift_count, + } + + +## §19.6: 总分 = 节奏准确分 65 + 锚点控制分 20 + 连击稳定分 15. +func total_score() -> float: + return accuracy_score() + anchor_score() + combo_score() + + +func accuracy_score() -> float: + var total := perfect_count + good_count + bad_count + miss_count + if total <= 0: + return 0.0 + var accuracy_raw := (float(perfect_count) * 1.00 + float(good_count) * 0.85 + float(bad_count) * 0.55) / float(total) + return accuracy_raw * ACCURACY_WEIGHT + + +func anchor_score() -> float: + var anchor_total := anchor_success_count + phase_shift_count + if anchor_total <= 0: + return ANCHOR_WEIGHT + return float(anchor_success_count) / float(anchor_total) * ANCHOR_WEIGHT + + +func combo_score() -> float: + return minf(float(max_combo) / COMBO_TARGET, 1.0) * COMBO_WEIGHT + + +func rank_for_score(score: float) -> String: + for entry: Dictionary in RANK_THRESHOLDS: + if score >= float(entry.get("min", 0.0)): + return str(entry.get("rank", "D")) + return "D" + + +func current_rank() -> String: + return rank_for_score(total_score()) + + +func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void: + if not _tracking: + return + match quality: + &"perfect": + perfect_count += 1 + &"good": + good_count += 1 + &"bad": + bad_count += 1 + &"miss": + miss_count += 1 + stats_changed.emit() + + +func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void: + if not _tracking: + return + if held: + anchor_success_count += 1 + stats_changed.emit() + + +func _on_time_phase_changed(_previous: StringName, _current: StringName, reason: StringName) -> void: + if not _tracking: + return + # Chart-driven initialisation is not a player-caused shift. + if reason == &"chart_init": + return + phase_shift_count += 1 + stats_changed.emit() + + +func _on_streak_changed(count: int) -> void: + if not _tracking: + return + if count > max_combo: + max_combo = count + stats_changed.emit() + + +func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void: + if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback): + bus.connect(signal_name, callback) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/autoload/game_stats.gd.uid b/autoload/game_stats.gd.uid new file mode 100644 index 0000000..807bd0a --- /dev/null +++ b/autoload/game_stats.gd.uid @@ -0,0 +1 @@ +uid://dqcmps5vad0t diff --git a/autoload/rhythm_manager.gd b/autoload/rhythm_manager.gd new file mode 100644 index 0000000..3a5a80b --- /dev/null +++ b/autoload/rhythm_manager.gd @@ -0,0 +1,262 @@ +extends AudioStreamPlayer + +signal beat_ticked(beat_index: int) +signal judgement_made(quality: StringName, offset_ms: float, beat_index: int) + +@export var bpm: float = 129.2: + set(value): + bpm = maxf(1.0, value) + beat_time = 60.0 / bpm +@export var measures := 4 +@export var beat_offset := 0.0 +@export var perfect_window := 0.060 +@export var good_window := 0.115 +@export var bad_window := 0.215 +@export var judgement_scale := 1.0 +## AnchorV1.0 §9.5: at high BPM the Bad window may never cross 48% of a beat, +## and each tighter window may never exceed the looser one it nests inside. +@export var bad_window_beat_ratio_cap := 0.48 +## new1 定案 (2026-07-05): 每个节奏点只能被一次有效输入消耗;无可用节奏点的 +## 按键判 miss 并触发输入锁定,锁定期间的按键完全无效(反狂按)。 +@export var input_lockout_seconds := 0.5 +## 同一节奏点消耗后允许忽略的重复按键次数(双击容错),超出即 miss+锁定。 +@export var consumed_repeat_grace := 1 +@export var clock_volume_db: float = -10.0 +@export var starts_on_ready := true + +const DEFAULT_STREAM_PATH := "res://assets/audio/ev_past1.mp3" + +var beat_time := 0.5 +var beat_index := 0 +var running := false + +var _start_time_usec := 0 +var _last_reported_beat := -1 +var _clock_offset := 0.0 +var _last_raw_playback_position := 0.0 +var _playback_loop_offset := 0.0 +var _clock_paused := false +var _paused_song_position := 0.0 +var _consumed_beats: Dictionary = {} +var _lockout_until := -1.0 + + +func _ready() -> void: + if stream == null and DisplayServer.get_name() != "headless": + stream = load(DEFAULT_STREAM_PATH) + volume_db = clock_volume_db + beat_time = 60.0 / maxf(1.0, bpm) + if starts_on_ready: + start() + + +func _process(_delta: float) -> void: + if not running or not playing: + return + var raw_song := _unwrapped_playback_position() + AudioServer.get_time_since_last_mix() + raw_song -= AudioServer.get_output_latency() + var ticks_sec := Time.get_ticks_msec() / 1000.0 + _clock_offset = lerpf(_clock_offset, raw_song - ticks_sec, 0.05) + + +func _physics_process(_delta: float) -> void: + if not running: + return + var adjusted_position := _apply_beat_offset(song_position()) + beat_index = int(floor(adjusted_position / beat_time)) + if _last_reported_beat < beat_index: + _last_reported_beat = beat_index + beat_ticked.emit(beat_index) + var bus := _event_bus_or_null() + if bus != null: + bus.emit_signal("beat_ticked", beat_index) + + +func apply_judgement_windows(next_perfect: float, next_good: float, next_bad: float) -> void: + perfect_window = maxf(0.001, next_perfect) + good_window = maxf(perfect_window, next_good) + bad_window = maxf(good_window, next_bad) + + +func effective_windows() -> Vector3: + var capped_bad := minf(bad_window, beat_time * bad_window_beat_ratio_cap) + var capped_good := minf(good_window, capped_bad) + var capped_perfect := minf(perfect_window, capped_good) + return Vector3(capped_perfect, capped_good, capped_bad) + + +func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.115, 0.215)) -> void: + bpm = next_bpm + measures = next_measures + beat_offset = next_beat_offset + apply_judgement_windows(next_windows.x, next_windows.y, next_windows.z) + + +func start() -> void: + running = true + _clock_paused = false + _paused_song_position = 0.0 + _start_time_usec = Time.get_ticks_usec() + _clock_offset = -Time.get_ticks_msec() / 1000.0 + _last_raw_playback_position = 0.0 + _playback_loop_offset = 0.0 + beat_index = 0 + _last_reported_beat = -1 + _consumed_beats.clear() + _lockout_until = -1.0 + if stream != null: + if playing: + seek(0.0) + else: + play() + + +func stop_manager() -> void: + if playing: + stop() + running = false + _clock_paused = false + + +func _exit_tree() -> void: + if playing: + stop() + stream = null + + +## Freezes the musical clock for pause/menu states. The audio presentation can +## be paused separately; this keeps judgement time stable across menu duration. +func pause_clock() -> void: + if _clock_paused or not running: + return + _paused_song_position = song_position() + _clock_paused = true + + +func resume_clock() -> void: + if not _clock_paused: + return + _clock_offset = _paused_song_position - Time.get_ticks_msec() / 1000.0 + _clock_paused = false + + +func song_position() -> float: + if _clock_paused: + return _paused_song_position + if running: + return maxf(0.0, Time.get_ticks_msec() / 1000.0 + _clock_offset) + return 0.0 + + +func input_to_song_time(timestamp_ms: float) -> float: + return timestamp_ms / 1000.0 + _clock_offset + + +func judge(input_timestamp_ms: float) -> Dictionary: + return get_rating_for_time(input_to_song_time(input_timestamp_ms)) + + +func is_input_locked() -> bool: + return _lockout_until >= 0.0 and song_position() < _lockout_until + + +## new1 定案:对一次已判定的实时动作输入做有状态门控。 +## 返回 &"ok"(判定成立,消耗该节奏点)、&"ignored"(锁定期/双击容错,视同没按) +## 或 &"miss"(节奏点已被消耗且超出容错 → 降级为 miss,并开启输入锁定)。 +## 纯判定接口 get_rating_for_time / judge 保持无副作用;只有这里改写状态。 +func gate_judged_input(rating: Dictionary) -> StringName: + if is_input_locked(): + return &"ignored" + if str(rating.get("label", "miss")) == "miss": + _begin_input_lockout() + return &"miss" + var beat := int(rating.get("nearest_beat", 0)) + if _consumed_beats.has(beat): + var repeats := int(_consumed_beats[beat]) + if repeats < consumed_repeat_grace: + _consumed_beats[beat] = repeats + 1 + return &"ignored" + _begin_input_lockout() + return &"miss" + _consumed_beats[beat] = 0 + _prune_consumed_beats(beat) + return &"ok" + + +func _begin_input_lockout() -> void: + if input_lockout_seconds > 0.0: + _lockout_until = song_position() + input_lockout_seconds + + +func _prune_consumed_beats(latest_beat: int) -> void: + if _consumed_beats.size() <= 16: + return + for beat: int in _consumed_beats.keys(): + if beat < latest_beat - 8: + _consumed_beats.erase(beat) + + +func get_current_rating() -> Dictionary: + return get_rating_for_time(song_position()) + + +func get_rating_for_time(time_seconds: float) -> Dictionary: + var adjusted_time := _apply_beat_offset(time_seconds) + if adjusted_time < 0.0: + return _rating_result(&"miss", Color("ff0055"), 0, 0.0, INF, INF) + + var nearest_beat := int(round(adjusted_time / beat_time)) + var nearest_beat_time := nearest_beat * beat_time + var diff := adjusted_time - nearest_beat_time + var abs_diff := absf(diff) + var scale := maxf(0.01, judgement_scale) + var windows := effective_windows() + + if abs_diff <= windows.x * scale: + return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff) + if abs_diff <= windows.y * scale: + return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff) + if abs_diff <= windows.z * scale: + return _rating_result(&"bad", Color("ffaa00"), nearest_beat, nearest_beat_time, diff, abs_diff) + return _rating_result(&"miss", Color("ff0055"), nearest_beat, nearest_beat_time, diff, abs_diff) + + +func get_current_beat_progress() -> float: + return get_beat_progress_for_time(song_position()) + + +func get_beat_progress_for_time(time_seconds: float) -> float: + var adjusted_time := _apply_beat_offset(time_seconds) + if adjusted_time < 0.0: + return 0.0 + return fposmod(adjusted_time, beat_time) / beat_time + + +func _apply_beat_offset(time_seconds: float) -> float: + return time_seconds + beat_offset + + +func _unwrapped_playback_position() -> float: + var raw_position := get_playback_position() + var length := stream.get_length() if stream != null else 0.0 + if length > 0.0 and _last_raw_playback_position - raw_position > length * 0.5: + _playback_loop_offset += length + _last_raw_playback_position = raw_position + return raw_position + _playback_loop_offset + + +func _rating_result(label: StringName, color: Color, nearest_beat: int, nearest_beat_time: float, diff: float, abs_diff: float) -> Dictionary: + return { + "label": str(label), + "color": color, + "nearest_beat": nearest_beat, + "nearest_beat_time": nearest_beat_time, + "diff": diff, + "abs_diff": abs_diff, + } + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/autoload/rhythm_manager.gd.uid b/autoload/rhythm_manager.gd.uid new file mode 100644 index 0000000..eb47fa0 --- /dev/null +++ b/autoload/rhythm_manager.gd.uid @@ -0,0 +1 @@ +uid://hoga4p3vm5qp diff --git a/autoload/sfx_manager.gd b/autoload/sfx_manager.gd new file mode 100644 index 0000000..7c8e753 --- /dev/null +++ b/autoload/sfx_manager.gd @@ -0,0 +1,263 @@ +extends Node + +## Placeholder SFX layer (AnchorV1.0 §20.4). Until real sound assets land, +## every required cue is a small synthesized tone generated at startup, routed +## through the SFX bus so §20.5 sfx_volume applies. Pure fact subscriber: it +## listens to the EventBus and never drives gameplay. + +const SAMPLE_RATE := 22050 +const VOICE_COUNT := 10 + +var _streams: Dictionary = {} +var _voices: Array[AudioStreamPlayer] = [] +var _voice_cursor := 0 +var _last_charge_level := 0 +var _last_player_health := -1 + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + if DisplayServer.get_name() == "headless": + return + _build_streams() + _build_voices() + _connect_bus() + + +func play_sfx(sfx_name: StringName) -> void: + var stream := _streams.get(sfx_name, null) as AudioStream + if stream == null or _voices.is_empty(): + return + var voice := _voices[_voice_cursor % _voices.size()] + _voice_cursor += 1 + voice.stream = stream + voice.play() + + +func has_sfx(sfx_name: StringName) -> bool: + return _streams.has(sfx_name) + + +func sfx_names() -> Array: + return _streams.keys() + + +func _build_voices() -> void: + for index: int in range(VOICE_COUNT): + var voice := AudioStreamPlayer.new() + voice.name = "Voice%d" % index + voice.bus = "SFX" if AudioServer.get_bus_index("SFX") != -1 else "Master" + add_child(voice) + _voices.append(voice) + + +func _connect_bus() -> void: + var bus := _event_bus_or_null() + if bus == null: + return + _connect_once(bus, "judgement_made", _on_judgement_made) + _connect_once(bus, "time_anchor_scheduled", _on_time_anchor_scheduled) + _connect_once(bus, "time_anchor_resolved", _on_time_anchor_resolved) + _connect_once(bus, "time_phase_changed", _on_time_phase_changed) + _connect_once(bus, "hit_confirmed", _on_hit_confirmed) + _connect_once(bus, "skill_executed", _on_skill_executed) + _connect_once(bus, "player_charge_changed", _on_player_charge_changed) + _connect_once(bus, "player_health_changed", _on_player_health_changed) + _connect_once(bus, "flow_state_changed", _on_flow_state_changed) + + +func _build_streams() -> void: + # Judgement feedback (§20.4: Perfect / Good / Bad / Miss + key feedback). + _streams[&"perfect"] = _tone([[1318.5, 1318.5, 0.05], [1760.0, 1760.0, 0.09]], 0.35) + _streams[&"good"] = _tone([[880.0, 880.0, 0.09]], 0.3) + _streams[&"bad"] = _tone([[392.0, 340.0, 0.11]], 0.3) + _streams[&"miss"] = _noise(0.14, 0.22, 900.0) + _streams[&"key_press"] = _tone([[520.0, 500.0, 0.03]], 0.15) + # Time anchor cues. + _streams[&"anchor_appear"] = _tone([[988.0, 988.0, 0.05], [1244.5, 1244.5, 0.05]], 0.22) + _streams[&"anchor_success"] = _tone([[784.0, 784.0, 0.06], [988.0, 988.0, 0.06], [1174.7, 1174.7, 0.1]], 0.32) + _streams[&"anchor_fail"] = _tone([[660.0, 330.0, 0.22]], 0.34) + # Phase switches (both directions distinct). + _streams[&"phase_to_future"] = _tone([[440.0, 1320.0, 0.3]], 0.3) + _streams[&"phase_to_past"] = _tone([[1320.0, 440.0, 0.3]], 0.3) + # Combat. + _streams[&"hit"] = _noise(0.09, 0.4, 2400.0) + _streams[&"skill_cast"] = _tone([[600.0, 900.0, 0.12]], 0.28) + _streams[&"swing"] = _noise(0.05, 0.18, 3200.0) + _streams[&"charge_start"] = _tone([[300.0, 420.0, 0.16]], 0.2) + _streams[&"charge_level"] = _tone([[700.0, 1050.0, 0.09]], 0.26) + _streams[&"charge_release"] = _tone([[1050.0, 500.0, 0.18]], 0.3) + _streams[&"guard_success"] = _tone([[1567.98, 1244.5, 0.07]], 0.3) + _streams[&"guard_stun"] = _tone([[350.0, 250.0, 0.12]], 0.26) + _streams[&"hurt"] = _noise(0.12, 0.34, 1200.0) + _streams[&"death"] = _tone([[520.0, 130.0, 0.6]], 0.34) + _streams[&"ui_click"] = _tone([[900.0, 860.0, 0.035]], 0.2) + # Run-outcome jingles (Victory / Defeat splash). Discrete note steps so + # defeat stays distinct from the &"death" 520→130Hz glide moments earlier. + _streams[&"victory"] = _note_sequence([[523.25, 0.2], [659.25, 0.2], [784.0, 0.2], [1046.5, 0.6]], 0.34) + _streams[&"defeat"] = _note_sequence([[392.0, 0.24], [311.13, 0.24], [261.63, 0.24], [196.0, 0.58]], 0.32) + + +## segments: Array of [freq_from, freq_to, seconds]; simple sine with a short +## exponential decay envelope. +func _tone(segments: Array, volume: float) -> AudioStreamWAV: + var total_seconds := 0.0 + for segment: Array in segments: + total_seconds += float(segment[2]) + var frame_count := maxi(1, int(total_seconds * SAMPLE_RATE)) + var data := PackedByteArray() + data.resize(frame_count * 2) + var written := 0 + var phase := 0.0 + for segment: Array in segments: + var seg_frames := int(float(segment[2]) * SAMPLE_RATE) + for index: int in range(seg_frames): + if written >= frame_count: + break + var t := float(index) / float(maxi(1, seg_frames)) + var freq := lerpf(float(segment[0]), float(segment[1]), t) + phase += TAU * freq / float(SAMPLE_RATE) + var global_t := float(written) / float(frame_count) + var envelope := minf(1.0, global_t * 24.0) * pow(1.0 - global_t, 1.4) + var sample := sin(phase) * envelope * volume + _write_sample(data, written, sample) + written += 1 + return _make_wav(data, frame_count) + + +## notes: Array of [freq_hz, seconds]; each note gets its own attack/decay +## envelope — unlike _tone, whose single whole-stream decay would bury the +## closing notes of a second-long jingle. +func _note_sequence(notes: Array, volume: float) -> AudioStreamWAV: + var frame_count := 0 + for note: Array in notes: + frame_count += maxi(1, int(float(note[1]) * SAMPLE_RATE)) + var data := PackedByteArray() + data.resize(frame_count * 2) + var written := 0 + for note: Array in notes: + var note_frames := maxi(1, int(float(note[1]) * SAMPLE_RATE)) + var phase := 0.0 + for index: int in range(note_frames): + var t := float(index) / float(note_frames) + phase += TAU * float(note[0]) / float(SAMPLE_RATE) + var envelope := minf(1.0, t * 24.0) * pow(1.0 - t, 1.6) + _write_sample(data, written, sin(phase) * envelope * volume) + written += 1 + return _make_wav(data, frame_count) + + +func _noise(seconds: float, volume: float, cutoff_hint_hz: float) -> AudioStreamWAV: + var frame_count := maxi(1, int(seconds * SAMPLE_RATE)) + var data := PackedByteArray() + data.resize(frame_count * 2) + var rng := RandomNumberGenerator.new() + rng.seed = int(cutoff_hint_hz) + var previous := 0.0 + var smoothing := clampf(1.0 - cutoff_hint_hz / 6000.0, 0.0, 0.96) + for index: int in range(frame_count): + var t := float(index) / float(frame_count) + var envelope := minf(1.0, t * 30.0) * pow(1.0 - t, 1.8) + var raw := rng.randf_range(-1.0, 1.0) + previous = previous * smoothing + raw * (1.0 - smoothing) + _write_sample(data, index, previous * envelope * volume) + return _make_wav(data, frame_count) + + +func _write_sample(data: PackedByteArray, frame_index: int, sample: float) -> void: + var value := int(clampf(sample, -1.0, 1.0) * 32767.0) + data.encode_s16(frame_index * 2, value) + + +func _make_wav(data: PackedByteArray, _frame_count: int) -> AudioStreamWAV: + var stream := AudioStreamWAV.new() + stream.format = AudioStreamWAV.FORMAT_16_BITS + stream.mix_rate = SAMPLE_RATE + stream.stereo = false + stream.data = data + return stream + + +## ------------------------------------------------------------- subscribers + + +func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void: + match quality: + &"perfect": + play_sfx(&"perfect") + &"good": + play_sfx(&"good") + &"bad": + play_sfx(&"bad") + &"miss": + play_sfx(&"miss") + + +func _on_time_anchor_scheduled(_anchor: Dictionary) -> void: + play_sfx(&"anchor_appear") + + +func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void: + play_sfx(&"anchor_success" if held else &"anchor_fail") + + +func _on_time_phase_changed(_previous: StringName, current: StringName, reason: StringName) -> void: + if reason == &"chart_init": + return + play_sfx(&"phase_to_future" if current == &"future" else &"phase_to_past") + + +func _on_hit_confirmed(result: Dictionary) -> void: + var defense = result.get("defense", {}) + if defense is Dictionary and StringName(str((defense as Dictionary).get("defense_state", &"Vulnerable"))) == &"Parrying": + play_sfx(&"guard_success") + return + if int(result.get("damage", 0)) > 0: + play_sfx(&"hit") + + +func _on_skill_executed(skill: Resource, _judgement: StringName) -> void: + if skill == null: + return + play_sfx(&"skill_cast" if float(skill.get("base_cost")) > 0.0 else &"swing") + + +func _on_player_charge_changed(current: float, _maximum: float, _ready: bool, active: bool) -> void: + if not active: + _last_charge_level = 0 + return + var level := 1 + int(floor(current + 0.0001)) + if _last_charge_level == 0: + play_sfx(&"charge_start") + elif level > _last_charge_level: + play_sfx(&"charge_level") + _last_charge_level = level + + +func _on_player_health_changed(current: int, _maximum: int) -> void: + if _last_player_health < 0: + _last_player_health = current + return + if current <= 0 and _last_player_health > 0: + play_sfx(&"death") + elif current < _last_player_health: + play_sfx(&"hurt") + _last_player_health = current + + +func _on_flow_state_changed(_previous: StringName, current: StringName) -> void: + if current == &"Victory": + play_sfx(&"victory") + elif current == &"Defeat": + play_sfx(&"defeat") + + +func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void: + if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback): + bus.connect(signal_name, callback) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/autoload/sfx_manager.gd.uid b/autoload/sfx_manager.gd.uid new file mode 100644 index 0000000..6b0465e --- /dev/null +++ b/autoload/sfx_manager.gd.uid @@ -0,0 +1 @@ +uid://cs4imfipdkhyn diff --git a/autoload/time_anchor_system.gd b/autoload/time_anchor_system.gd new file mode 100644 index 0000000..0576e60 --- /dev/null +++ b/autoload/time_anchor_system.gd @@ -0,0 +1,318 @@ +extends Node + +## Time anchor scheduling + resolution (AnchorV1.0 chapter 10/11). +## Sources v1: explicit chart `time_anchor` events + periodic baseline whose +## interval follows the streak frequency profile. Resolution only consumes +## `judgement_made` facts; it never judges input a second time. + +const DEFAULT_FREQUENCY_PROFILE_PATH := "res://resources/time_anchor_frequency_default.tres" +const DEADLINE_EPSILON := 0.02 + +const SOURCE_CHART := &"chart" +const SOURCE_PERIODIC := &"periodic" + +@export var lead_beats := 2.0 +@export var periodic_scheduling_enabled := true +@export var frequency_profile: Resource + +var current_streak := 0 + +var _anchors: Dictionary = {} +var _anchored_beats: Dictionary = {} +var _last_periodic_origin_beat := 0 +var _next_periodic_window_start := 0 +var _periodic_initialized := false +var _last_resolved: Dictionary = {} + + +func _ready() -> void: + if frequency_profile == null and ResourceLoader.exists(DEFAULT_FREQUENCY_PROFILE_PATH): + frequency_profile = load(DEFAULT_FREQUENCY_PROFILE_PATH) + var bus := _event_bus_or_null() + if bus == null: + return + _connect_once(bus, "chart_event_upcoming", _on_chart_event_upcoming) + _connect_once(bus, "chart_event_triggered", _on_chart_event_triggered) + _connect_once(bus, "judgement_made", _on_judgement_made) + _connect_once(bus, "streak_changed", _on_streak_changed) + _connect_once(bus, "chart_reset", _on_chart_reset) + + +func _physics_process(_delta: float) -> void: + # Only the real autoload singleton self-drives from the global clock. + # Test-added instances get auto-renamed on the name collision and are + # driven explicitly, so the autoload's wall clock never pollutes them. + if name != &"TimeAnchorSystem": + return + var rhythm := _rhythm_manager_or_null() + if rhythm == null or not bool(rhythm.get("running")): + return + var song_time := float(rhythm.call("song_position")) + var beat_time := maxf(0.001, float(rhythm.get("beat_time"))) + var beat_offset := float(rhythm.get("beat_offset")) + update_periodic_scheduling((song_time + beat_offset) / beat_time) + check_deadlines_for_song_time(song_time) + + +func update_periodic_scheduling(judgement_beat_float: float) -> void: + if not periodic_scheduling_enabled: + return + if not _periodic_initialized: + _periodic_initialized = true + _next_periodic_window_start = maxi(0, int(floorf(judgement_beat_float / float(_window_beats()))) * _window_beats()) + _last_periodic_origin_beat = _next_periodic_window_start + var lead_limit := judgement_beat_float + lead_beats + while true: + var has_candidate_after_lead := false + for offset: int in _current_window_offsets(): + var candidate_beat := _next_periodic_window_start + offset + if float(candidate_beat) <= judgement_beat_float: + continue + if float(candidate_beat) > lead_limit: + has_candidate_after_lead = true + break + if not _anchored_beats.has(candidate_beat): + schedule_time_anchor(candidate_beat, SOURCE_PERIODIC) + if has_candidate_after_lead: + break + _next_periodic_window_start += _window_beats() + _last_periodic_origin_beat = _next_periodic_window_start + + +func schedule_time_anchor(beat: int, source: StringName, source_event: Resource = null, min_grade: StringName = &"bad") -> Dictionary: + if _anchored_beats.has(beat): + var existing_key: StringName = _anchored_beats[beat] + var existing: Dictionary = _anchors.get(existing_key, {}) + if source == SOURCE_CHART and StringName(str(existing.get("source"))) == SOURCE_PERIODIC: + existing["source"] = SOURCE_CHART + existing["source_event"] = source_event + existing["min_grade"] = min_grade + _restart_periodic_from_beat(beat) + return existing + var key := StringName("%s_%d" % [source, beat]) + var anchor := { + "key": key, + "beat": beat, + "deadline": _deadline_for_beat(beat), + "min_grade": min_grade, + "source": source, + "source_event": source_event, + } + _anchors[key] = anchor + _anchored_beats[beat] = key + if source == SOURCE_CHART: + _restart_periodic_from_beat(beat) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_anchor_scheduled"): + bus.emit_signal("time_anchor_scheduled", anchor) + return anchor + + +func check_deadlines_for_song_time(song_time: float) -> void: + var broken: Array[Dictionary] = [] + for key: StringName in _anchors.keys(): + var anchor: Dictionary = _anchors[key] + if song_time > float(anchor.get("deadline", 0.0)): + broken.append(anchor) + for anchor: Dictionary in broken: + _resolve_anchor(anchor, false, {}) + + +func armed_anchor_summaries() -> Array[Dictionary]: + var summaries: Array[Dictionary] = [] + for key: StringName in _anchors.keys(): + var anchor: Dictionary = _anchors[key] + summaries.append({ + "beat": int(anchor.get("beat", 0)), + "deadline": float(anchor.get("deadline", 0.0)), + "min_grade": StringName(str(anchor.get("min_grade", &"bad"))), + "source": StringName(str(anchor.get("source", &"-"))), + }) + summaries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return int(a.get("beat", 0)) < int(b.get("beat", 0)) + ) + return summaries + + +func last_resolved_summary() -> Dictionary: + return _last_resolved.duplicate() + + +func anchored_beat_count() -> int: + return _anchored_beats.size() + + +func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: + if quality == &"miss": + return + var key: StringName = _anchored_beats.get(beat_index, &"") + if key.is_empty() or not _anchors.has(key): + return + var anchor: Dictionary = _anchors[key] + if _grade_rank(quality) < _grade_rank(StringName(str(anchor.get("min_grade", &"bad")))): + return + _resolve_anchor(anchor, true, { + "label": str(quality), + "offset_ms": offset_ms, + "beat_index": beat_index, + }) + + +func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void: + _try_schedule_chart_anchor(event) + + +func _on_chart_event_triggered(event: Resource) -> void: + if event == null: + return + var event_type := StringName(str(event.get("event_type"))) + if event_type == &"time_anchor": + _try_schedule_chart_anchor(event) + elif event_type == &"force_time_phase": + var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {} + var target := StringName(str(payload.get("target_time_phase", ""))) + var manager := _time_phase_manager_or_null() + if manager != null and manager.has_method("set_time_phase"): + manager.call("set_time_phase", target, &"chart_forced") + + +func _try_schedule_chart_anchor(event: Resource) -> void: + if event == null or StringName(str(event.get("event_type"))) != &"time_anchor": + return + if int(event.get("subdivision")) != 0: + push_warning("time_anchor events must sit on integer beats; skipping %s" % str(event.call("key"))) + return + var min_grade := &"bad" + var payload: Dictionary = event.get("payload") if event.get("payload") is Dictionary else {} + if payload.has("min_grade"): + min_grade = StringName(str(payload["min_grade"])) + schedule_time_anchor(int(event.get("beat_index")), SOURCE_CHART, event, min_grade) + + +func _on_streak_changed(count: int) -> void: + current_streak = maxi(0, count) + + +func _on_chart_reset(_chart_id: StringName) -> void: + _anchors.clear() + _anchored_beats.clear() + _periodic_initialized = false + _last_periodic_origin_beat = 0 + _next_periodic_window_start = 0 + current_streak = 0 + _last_resolved = {} + + +func _resolve_anchor(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: + var key := StringName(str(anchor.get("key"))) + _anchors.erase(key) + _anchored_beats.erase(int(anchor.get("beat", 0))) + _last_resolved = { + "beat": int(anchor.get("beat", 0)), + "held": held, + "source": StringName(str(anchor.get("source", &"-"))), + "judgement": judgement.get("label", ""), + } + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_anchor_resolved"): + bus.emit_signal("time_anchor_resolved", anchor, held, judgement) + if not held: + var manager := _time_phase_manager_or_null() + if manager != null and manager.has_method("toggle_time_phase"): + manager.call("toggle_time_phase", &"time_anchor_broken") + + +func _restart_periodic_from_beat(beat: int) -> void: + _last_periodic_origin_beat = maxi(_last_periodic_origin_beat, beat) + _next_periodic_window_start = maxi(_next_periodic_window_start, beat) + + +func _current_interval() -> int: + if frequency_profile != null and frequency_profile.has_method("interval_for_streak"): + return maxi(1, int(frequency_profile.call("interval_for_streak", current_streak))) + return _window_beats() + + +func _current_window_offsets() -> Array[int]: + if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"): + var offsets = frequency_profile.call("anchor_offsets_for_streak", current_streak) + if offsets is Array and not offsets.is_empty(): + var result: Array[int] = [] + for offset: Variant in offsets: + result.append(clampi(int(offset), 1, _window_beats())) + return result + return [_window_beats()] + + +func _window_beats() -> int: + if frequency_profile != null: + var value = frequency_profile.get("window_beats") + if value != null: + return maxi(1, int(value)) + return 4 + + +func _deadline_for_beat(beat: int) -> float: + var beat_time := 0.5 + var beat_offset := 0.0 + var bad_window := 0.2 + var judgement_scale := 1.0 + var rhythm := _rhythm_manager_or_null() + if rhythm != null: + beat_time = maxf(0.001, float(rhythm.get("beat_time"))) + beat_offset = float(rhythm.get("beat_offset")) + bad_window = float(rhythm.get("bad_window")) + judgement_scale = maxf(0.01, float(rhythm.get("judgement_scale"))) + return float(beat) * beat_time - beat_offset + bad_window * judgement_scale + DEADLINE_EPSILON + + +func _grade_rank(grade: StringName) -> int: + match grade: + &"perfect": + return 3 + &"good": + return 2 + &"bad": + return 1 + return 0 + + +func _connect_once(bus: Node, signal_name: StringName, callback: Callable) -> void: + if bus.has_signal(signal_name) and not bus.is_connected(signal_name, callback): + bus.connect(signal_name, callback) + + +func _rhythm_manager_or_null() -> Node: + # Reverse root scan like the bus/manager lookups: a test-added rhythm clock + # wins over the autoload, so the autoload's wall-clock never leaks into a + # test instance's periodic scheduling. + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child.has_method("song_position") and child.get("beat_time") != null + ) + + +func _time_phase_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child.has_method("set_time_phase") and child.get("current_time_phase") != null + ) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child.has_signal("chart_event_triggered") and child.has_signal("time_phase_changed") + ) + + +func _last_root_child_matching(predicate: Callable) -> Node: + var children := get_tree().root.get_children() + for index: int in range(children.size() - 1, -1, -1): + var child: Node = children[index] + if bool(predicate.call(child)): + return child + return null diff --git a/autoload/time_anchor_system.gd.uid b/autoload/time_anchor_system.gd.uid new file mode 100644 index 0000000..879b58d --- /dev/null +++ b/autoload/time_anchor_system.gd.uid @@ -0,0 +1 @@ +uid://ck5qxcsqffc2c diff --git a/autoload/time_phase_manager.gd b/autoload/time_phase_manager.gd new file mode 100644 index 0000000..78d0783 --- /dev/null +++ b/autoload/time_phase_manager.gd @@ -0,0 +1,38 @@ +extends Node + +const PAST := &"past" +const FUTURE := &"future" + +var current_time_phase: StringName = PAST + + +func set_time_phase(next: StringName, reason: StringName) -> void: + if next != PAST and next != FUTURE: + return + if next == current_time_phase: + return + var previous := current_time_phase + current_time_phase = next + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_phase_changed"): + bus.emit_signal("time_phase_changed", previous, current_time_phase, reason) + + +func toggle_time_phase(reason: StringName) -> void: + set_time_phase(FUTURE if current_time_phase == PAST else PAST, reason) + + +func reset_to_initial(initial: StringName) -> void: + var target := initial if (initial == PAST or initial == FUTURE) else PAST + set_time_phase(target, &"chart_init") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + var children := get_tree().root.get_children() + for index: int in range(children.size() - 1, -1, -1): + var child: Node = children[index] + if child.has_signal("time_phase_changed") and child.has_signal("chart_event_triggered"): + return child + return null diff --git a/autoload/time_phase_manager.gd.uid b/autoload/time_phase_manager.gd.uid new file mode 100644 index 0000000..669c7c8 --- /dev/null +++ b/autoload/time_phase_manager.gd.uid @@ -0,0 +1 @@ +uid://cr44hqhem71a8 diff --git a/docs/AnchorV1.0.md b/docs/AnchorV1.0.md new file mode 100644 index 0000000..7d10e48 --- /dev/null +++ b/docs/AnchorV1.0.md @@ -0,0 +1,1721 @@ +# Anchor策划书1.0 + +## 0. 文档目标 + +本文档用于统一《Anchor》的核心玩法机制、系统边界、系统耦合关系、Godot 节点架构、动作规则、相位规则、敌人规则、UI 反馈、音乐音效要求与开发验收标准。 + +本文档只描述当前确认的游戏机制与实现结构,不包含世界观背景、剧情设定和宣传文案。 + +--- + +## 1. 游戏核心规则概述 + +《Anchor》是一款 **2D 节奏横板动作游戏**。 + +玩家没有自由移动。玩家的位置变化来自攻击、技能、浮空、击飞、击退、下坠、受击和其他动作位移。玩家通过节奏窗口输入 `A / W / S / Space`,输入先经过节奏判定,再决定动作是否执行、动作效果强弱、能量变化和四拍槽记录。 + +游戏中存在两种时间相位: + +```text +Past +Future +``` + +Past 与 Future 共用同一套场地碰撞结构。相位切换只影响视觉表现、音乐层、UI 主题、敌人强弱和 Boss 状态,不改变玩家可站立区域和关卡碰撞结构。 + +谱面中存在普通节奏判定点,也存在特殊的 **时间锚点**。并非所有节奏判定点都是时间锚点。 + +普通节奏判定点只影响动作;时间锚点额外影响 Past / Future 相位。 + +```text +普通节奏点:用于动作输入判定。 +时间锚点:用于维持或切换当前时间相位。 +``` + +时间锚点会周期性出现,也可以由战斗条件触发。 + +当时间锚点出现时: + +```text +玩家在锚点窗口内成功输入 Perfect / Good / Bad +→ 维持当前相位 + +玩家没有输入,或输入结果为 Miss +→ 切换到另一相位 +``` + +时间锚点同时承担攻击成长功能。玩家成功锚定时间锚点时,Perfect / Good 获得 1 层攻击 Buff,Bad 只维持相位但不增加 Buff;连击数越高,时间锚点在每 4 拍窗口内出现得越密集;发生相位切换时,当前攻击 Buff 层数减少 2 层。 + +攻击 Buff 规则: + +```text +每层攻击 Buff:攻击力 +50% +最大层数:7 层 +最大攻击增幅:+350% +相位切换损失:当前层数减少 2 层,最低为 0 层 +``` + +--- + +## 2. 设计原则 + +### 2.1 动作原则 + +```text +所有玩家主动行为都来自节奏输入。 +所有按键先判定,再执行。 +Miss 不执行动作。 +Miss 写入四拍槽为 ∅。 +动作位移由动作数据决定。 +角色没有自由行走位移。 +``` + +### 2.2 时间锚点原则 + +```text +时间锚点是特殊谱面事件,不是每个节奏点。 +时间锚点不会指定玩家必须按哪个键。 +玩家可以用任意当前可用输入尝试锚定。 +Perfect / Good 锚定维持当前相位,并获得 1 层攻击 Buff。 +Bad 锚定维持当前相位,但不增加攻击 Buff。 +失败锚定切换 Past / Future。 +连击数越高,时间锚点在每 4 拍窗口内出现得越密集。 +时间锚点频率档位为:0-7 连击每 4 拍 1 个,8-15 连击每 4 拍 2 个,16+ 连击每 4 拍 4 个。 +时间锚点频率上限是:每 4 拍内 4 个 RhythmBeat 都升级为 TimeAnchor。 +``` + +### 2.3 相位原则 + +```text +Past / Future 共用同一套碰撞结构。 +相位切换不改变关卡通路。 +相位切换影响敌人强弱、视觉、音乐、UI。 +相位切换是战斗策略,不是单纯惩罚。 +``` + +### 2.4 Game Jam 范围原则 + +优先保证核心闭环: + +```text +节奏判定 +→ 动作执行 +→ 四拍槽 +→ 时间锚点 +→ 相位切换 +→ 敌人强弱变化 +→ 双相位音乐切换 +``` + +低优先级内容不得阻塞核心闭环完成。 + +--- + +## 3. 系统总览 + +### 3.1 系统列表 + +| 系统 | 职责 | +|---|---| +| 节奏判定系统 | 将输入判定为 Perfect / Good / Bad / Miss | +| 谱面事件系统 | 生成普通节奏提示、时间锚点、敌人提示、Boss 提示 | +| 时间锚点系统 | 判断锚点是否成功,并决定是否切换相位 | +| 时间锚点增益系统 | 根据连击数调整时间锚点频率,并管理攻击 Buff 层数 | +| Past / Future 相位系统 | 管理当前相位,并通知视觉、音乐、敌人、UI | +| 输入与动作系统 | 接收判定后的输入,执行普攻、技能、蓄力、防御、空中动作 | +| 四拍槽系统 | 记录最近四次输入结果,用于技能派生和 UI 展示 | +| 能量系统 | 管理普攻回复、技能消耗、蓄力释放消耗 | +| 战斗结算系统 | 处理命中、伤害、浮空、击飞、击退、霸体、冲刺、防御、受击、死亡 | +| 敌人系统 | 管理敌人行为、相位强弱倍率、攻击节奏 | +| UI 反馈系统 | 显示节奏、时间锚点、四拍槽、相位、判定、能量、血量 | +| 音乐音效系统 | 管理 Past / Future 双音乐层同步切换和动作音效 | + +--- + +## 4. 系统关系与数据流 + +### 4.1 一次普通动作输入的数据流 + +```text +玩家按键 +→ InputIntent +→ RhythmJudge 输出 JudgeResult +→ ActionSystem 判断当前条件是否能执行动作 +→ 执行动作或判定失败 +→ ComboSlotBuffer 写入槽位 +→ EnergySystem 处理回复或消耗 +→ Animation / SFX / UI 反馈 +``` + +### 4.2 一次时间锚点的数据流 + +```text +ChartRunner 触发 TimeAnchorEvent +→ UI 显示时间锚点 +→ 玩家在窗口内输入或没有输入 +→ RhythmJudge 输出 JudgeResult +→ TimeAnchorSystem 读取锚点窗口结果 +→ 成功:PhaseManager 保持当前相位;Perfect / Good 使 AttackBuff 增加 1 层,Bad 不增加 +→ 失败:PhaseManager 切换 Past / Future,AttackBuff 层数减少 2 层 +→ EnemySystem / AudioLayerController / VisualLayer / UI 更新 +``` + +### 4.2.1 连击、时间锚点和攻击 Buff 的数据流 + +```text +玩家连续成功执行动作 +→ ComboCounter 增加 +→ TimeAnchorSystem 根据当前连击数调整 TimeAnchor 生成频率 +→ TimeAnchorEvent 出现 +→ 玩家成功锚定 +→ Perfect / Good:AttackBuffStack + 1;Bad:AttackBuffStack + 0 +→ DamageResolver 按 AttackBuffStack 提高玩家攻击伤害 + +发生相位切换 +→ AttackBuffStack 减少 2 层,最低为 0 层 +→ DamageResolver 使用减少后的层数计算伤害 +``` + +### 4.3 动作与时间锚点的关系 + +时间锚点不替代动作输入。玩家在时间锚点窗口内输入时,这次输入同时进入两个系统: + +```text +ActionSystem:决定动作是否执行。 +TimeAnchorSystem:决定是否维持相位。 +``` + +因此: + +```text +普通节奏输入可以只影响动作。 +时间锚点输入既影响动作,也影响相位。 +``` + +### 4.4 相位系统的输出对象 + +当当前相位变化时,PhaseManager 通知: + +```text +EnemySystem:更新敌人强弱倍率。 +VisualLayer:切换 Past / Future 视觉表现。 +AudioLayerController:切换 Past / Future 音乐层。 +HUD:切换 UI 相位提示与主题色。 +BossController:更新 Boss 当前相位状态。 +``` + +--- + +## 5. 核心循环流程 + +```text +音乐时间推进 +→ 谱面标志向中心移动 +→ 玩家根据节奏输入 A / W / S / Space +→ 输入进行 Perfect / Good / Bad / Miss 判定 +→ 普通输入尝试执行动作 +→ 动作结果写入四拍槽 +→ 普攻回复能量,技能或蓄力释放消耗能量 +→ 命中敌人后进行战斗结算 +→ 连击数影响后续时间锚点出现频率 +→ 若当前存在时间锚点事件,则同时判断是否维持相位 +→ 时间锚点 Perfect / Good 成功时获得 1 层攻击 Buff,Bad 成功时只维持相位 +→ 时间锚点失败时切换 Past / Future,并减少 2 层攻击 Buff +→ 敌人强弱、视觉、音乐、UI 跟随相位变化 +``` + +--- + +## 6. Godot 节点架构 + +### 6.1 总体结构 + +```text +GameRoot + ├─ RhythmManager + ├─ ChartRunner + ├─ TimeAnchorSystem + ├─ PhaseManager + ├─ CombatManager + ├─ LevelRoot + ├─ HUD + └─ AudioLayerController +``` + +### 6.2 节点职责 + +| 节点 | 职责 | +|---|---| +| `RhythmManager` | 播放音乐,提供 song_time、beat_index、beat_phase、节奏判定接口 | +| `ChartRunner` | 按音乐时间生成普通节奏提示、时间锚点、敌人提示、Boss 提示 | +| `TimeAnchorSystem` | 接收 TimeAnchorEvent,判断锚点成功或失败 | +| `PhaseManager` | 保存当前相位 Past / Future,并广播相位变化 | +| `CombatManager` | 统一处理 Hitbox / Hurtbox 命中结算 | +| `LevelRoot` | 当前关卡、场景表现、敌人容器、相位视觉层 | +| `HUD` | 节奏轨道、时间锚点、四拍槽、能量、血量、相位提示 | +| `AudioLayerController` | Past / Future 音乐层同步播放与交叉淡入淡出 | + +--- + +## 7. 玩家节点架构 + +```text +Player + ├─ InputComponent + ├─ ActionController + ├─ ComboSlotBuffer + ├─ ComboCounter + ├─ AttackBuffComponent + ├─ EnergyComponent + ├─ ActorStateMachine + ├─ ActorMotor + ├─ HitboxController + ├─ Hurtbox + ├─ AnimationBridge + └─ AudioEmitter +``` + +### 7.1 玩家组件职责 + +| 组件 | 职责 | +|---|---| +| `InputComponent` | 监听 A / W / S / Space 的 pressed / released,生成 InputIntent | +| `ActionController` | 根据输入、判定、四拍槽、能量、空地条件执行动作 | +| `ComboSlotBuffer` | 保存最近四次输入符号和内部判定数据 | +| `ComboCounter` | 统计连续成功动作,用于影响时间锚点生成频率 | +| `AttackBuffComponent` | 保存时间锚点攻击 Buff 层数,并向伤害结算提供攻击倍率 | +| `EnergyComponent` | 处理能量回复与消耗 | +| `ActorStateMachine` | 保存生命状态、空地状态、动作阶段、受击状态 | +| `ActorMotor` | 根据动作数据执行横向 / 纵向位移 | +| `HitboxController` | 根据动作 Active 阶段开启或关闭 Hitbox | +| `Hurtbox` | 接收敌人攻击检测 | +| `AnimationBridge` | 根据动作事件播放动画、特效、震屏 | +| `AudioEmitter` | 播放角色动作、命中、受击、判定反馈音效 | + +--- + +## 8. 输入系统 + +### 8.1 输入按键 + +| 输入 | 用途 | +|---|---| +| `A` | 地面普攻、地面连段、A 蓄力、空中连斩 | +| `W` | 地面上挑,进入空中 | +| `S` | 地面短按防御、地面长按 S 蓄力、空中下坠攻击 | +| `Space` / `sp` | 技能派生、S 蓄力期间释放斩波 | + +### 8.2 输入阶段 + +```text +pressed +released +held +``` + +### 8.3 输入处理规则 + +```text +所有输入先进入 RhythmJudge。 +Perfect / Good / Bad 可以尝试执行动作。 +Miss 不执行动作。 +Miss 写入四拍槽为 ∅。 +非 Miss 但动作条件不满足时,输入可以写入槽位,但不视为成功动作。 +S 键在地面采用短按 / 长按区分:短按 S 触发防御;长按 S 进入 S 蓄力。 +``` + +--- + +## 9. 节奏判定系统 + +### 9.1 判定结果 + +```text +Perfect +Good +Bad +Miss +``` + +### 9.2 判定用途 + +判定结果用于影响: + +```text +动作是否执行 +动作伤害倍率 +普攻能量回复 +技能能量消耗 +蓄力释放消耗 +防御减伤比例 +时间锚点是否成功 +时间锚点是否增加攻击 Buff +时间锚点回血量 +UI 与音效反馈 +``` + +判定不影响动作基础横向位移、动作基础纵向位移、冲刺距离、击退距离、击飞距离、霸体时间、防御持续时间、动作基础时长和敌人当前生命值。上述数值一旦由动作或状态确定,不因 Perfect / Good / Bad 重新缩放。 + +### 9.3 判定建议倍率 + +| 判定 | 普攻能量回复 | 技能消耗 | 伤害倍率 | +|---|---:|---:|---:| +| Perfect | 100% | 90% | 100% | +| Good | 80% | 100% | 85% | +| Bad | 60% | 110% | 70% | +| Miss | 0% | 不释放 | 不释放 | + +说明:技能消耗倍率采用较窄区间,避免伤害、Buff、回血、相位惩罚与能量惩罚叠加过重;Perfect 略省,Bad 略贵。 + +### 9.4 防御判定与效果 + +| 判定 | 防御效果 | 动作结果 | +|---|---|---| +| Perfect | 正面防御成功,伤害减免 100%。 | 不进入 `[受击]` 状态。 | +| Good | 正面防御成功,伤害减免 80%。 | 不进入 `[受击]` 状态。 | +| Bad | 正面防御成功,伤害减免 60%。 | 进入短防御硬直,但不进入 `[受击]` 状态。 | +| Miss | 防御不释放。 | 四拍槽写入 `∅`,受到攻击时正常进入 `[受击]` 状态。 | + +防御只对正面普通攻击生效;背后攻击、抓取、破防攻击、范围环境伤害不受防御减免。 + +--- + +## 10. 谱面事件系统 + +### 10.1 事件类型 + +| 事件 | 作用 | +|---|---| +| `RhythmBeat` | 普通节奏提示,只辅助动作输入 | +| `TimeAnchor` | 时间锚点,成功维持相位,失败切换相位 | +| `EnemyCue` | 敌人攻击提示 | +| `BossCue` | Boss 技能提示 | +| `PhaseWarning` | 相位相关事件预警 | + +### 10.2 RhythmBeat 与 TimeAnchor 的区别 + +```text +RhythmBeat 是底层节奏网格。 +TimeAnchor 是挂在节奏网格上的特殊事件。 +``` + +普通 RhythmBeat 不影响相位。 +TimeAnchor 会影响相位。 + +### 10.3 时间锚点出现方式 + +时间锚点有两种生成方式。 + +周期性出现: + +```text +以每 4 拍为一个频率窗口生成 TimeAnchorEvent。 +每 4 拍内 TimeAnchorEvent 数量随当前连击数提高。 +``` + +条件触发: + +```text +当前相位持续时间达到阈值 +场上出现强势相位敌人 +Boss 准备相位技能 +玩家连段达到指定数量 +关卡流程进入指定段落 +``` + +时间锚点频率受连击数影响。连击数越高,每 4 拍窗口内的 TimeAnchorEvent 数量越多。 + +频率上限不是额外生成更多图标,也不是让画面出现额外锚点。频率上限是:每 4 拍内 4 个 RhythmBeat 都升级为 TimeAnchor。 + +```text +0-7 连击:每 4 拍 1 个 RhythmBeat 是 TimeAnchor。 +8-15 连击:每 4 拍 2 个 RhythmBeat 是 TimeAnchor。 +16+ 连击:每 4 拍 4 个 RhythmBeat 是 TimeAnchor。 +``` + +因此,时间锚点最多只会把每 4 拍内已有的 4 个 RhythmBeat 全部标记为 TimeAnchor,不会额外增加屏幕图标密度。 + +实际开发中优先实现周期性时间锚点,再扩展条件触发。 + +--- + +## 11. 时间锚点系统 + +### 11.1 系统职责 + +时间锚点系统负责判断玩家是否成功维持当前时间相位。 + +### 11.2 成功条件 + +当 TimeAnchorEvent 处于判定窗口内,玩家任意有效输入获得: + +```text +Perfect +Good +Bad +``` + +即视为时间锚点成功。 + +### 11.3 失败条件 + +```text +时间锚点窗口内没有任何输入 +输入结果为 Miss +``` + +即视为时间锚点失败。 + +### 11.4 成功结果 + +```text +保持当前相位。 +Perfect / Good:增加 1 层攻击 Buff。 +Bad:不增加攻击 Buff。 +按时间锚点回血规则结算生命回复。 +播放锚定成功反馈。 +不重置音乐时间。 +``` + +### 11.5 失败结果 + +```text +当前相位从 Past 切换到 Future,或从 Future 切换到 Past。 +播放相位切换反馈。 +不重置音乐时间。 +``` + +### 11.6 时间锚点攻击 Buff + +成功收集时间锚点时,Perfect / Good 让玩家获得 1 层攻击 Buff;Bad 只维持当前相位,不增加攻击 Buff。 + +```text +每层攻击 Buff:攻击力 +50% +最大层数:7 层 +最大攻击增幅:+350% +最大总攻击倍率:450% +``` + +攻击 Buff 按加算处理,只影响玩家造成的伤害,不影响节奏判定窗口、动作时长、动作位移、能量回复和能量消耗。 + +伤害计算建议: + +```text +最终伤害 = 动作基础伤害 × 节奏判定伤害倍率 × (1 + 攻击 Buff 层数 × 50%) +``` + +当攻击 Buff 达到 7 层后,继续成功收集时间锚点不再增加层数,但仍播放锚点成功反馈。 + +### 11.7 相位切换对攻击 Buff 的影响 + +每次发生 Past / Future 相位切换时,当前攻击 Buff 层数减少 2 层,最低降至 0 层。 + +示例: + +```text +7 层 → 5 层 +5 层 → 3 层 +2 层 → 0 层 +1 层 → 0 层 +0 层 → 0 层 +``` + +建议公式: + +```text +attack_buff_stack = max(attack_buff_stack - 2, 0) +``` + +这个规则只在相位实际切换时触发。成功维持当前相位不会减少攻击 Buff。 + +### 11.8 连击数对时间锚点频率的影响 + +连击数越高,后续时间锚点在每 4 拍窗口内出现得越频繁。 + +该规则用于奖励稳定节奏输入和连续成功动作。连击越高,玩家越容易获得更多时间锚点,从而获得更多攻击 Buff。 + +时间锚点频率存在上限: + +```text +上限 = 每 4 拍内 4 个 RhythmBeat 都被标记为 TimeAnchor。 +``` + +频率提高时,系统只改变已有 RhythmBeat 的事件类型,不额外生成新的屏幕元素。 + +### 11.9 推荐频率档位 + +第一版使用每 4 拍为一个频率窗口,便于玩家理解和调参。 + +| 连击数 | 每 4 拍内 TimeAnchor 数量 | 说明 | +|---:|---:|---| +| 0 - 7 | 1 个 | 基础频率 | +| 8 - 15 | 2 个 | 低连击奖励 | +| 16+ | 4 个 | 频率上限 | + +具体数值可以根据歌曲 BPM 和操作密度调整,但必须保持“不额外生成节奏点,只升级已有 RhythmBeat”的原则。 + +--- + +## 12. Past / Future 相位系统 + +### 12.1 相位状态 + +```text +Past +Future +``` + +### 12.2 相位影响范围 + +相位影响: + +```text +敌人强弱倍率 +Boss 状态 +背景和场景视觉层 +UI 主题色 +音乐层音量 +部分特效颜色 +``` + +相位不影响: + +```text +TileMap 碰撞 +平台位置 +玩家输入规则 +四拍槽容量 +基础节奏 BPM +音乐播放时间轴 +``` + +### 12.3 相位切换规则 + +```text +Past → Future +Future → Past +``` + +切换由 TimeAnchorSystem 触发。 + +每次相位实际切换时,当前攻击 Buff 层数减少 2 层,最低为 0 层。 + +```text +attack_buff_stack = max(attack_buff_stack - 2, 0) +``` + +相位切换不清空四拍槽,不重置音乐时间,不重置玩家能量。 + +相位切换不改变玩家、小怪或 Boss 的当前生命值,也不追溯已经结算完成的伤害。相位切换后,小怪后续受到伤害倍率、造成伤害倍率和攻击拍点按新相位规则生效。若小怪攻击动作已经开始,本次攻击保留动作开始时的相位参数,下一次攻击再使用新相位参数。 + +### 12.4 相位切换表现 + +```text +短暂画面闪白 / 闪色 +UI 相位标识变化 +敌人外观状态变化 +Past / Future 音乐层交叉淡入淡出 +``` + +--- + +## 13. 四拍槽系统 + +### 13.1 显示符号 + +```text +A +W +S +sp +∅ +``` + +`sp` 表示 Space。 +`∅` 表示 Miss。 + +### 13.2 容量规则 + +```text +最多保存 4 个槽位。 +每次输入判定并尝试动作后写入 1 个槽位。 +第 4 槽写入后进行结算,然后清空。 +技能成功释放后立即清空。 +``` + +### 13.3 技能读取规则 + +地面技能读取以 `sp` 结尾的连续有效序列: + +```text +[A][sp] → 突进 +[A][A][sp] → 连段终结 +[A][A][A][sp] → 震地击飞 +``` + +包含 `∅` 的序列不能跨槽读取。 + +### 13.4 时间锚点与四拍槽关系 + +TimeAnchor 本身不写入四拍槽。只有玩家在时间锚点窗口内实际输入时,才按普通输入规则写入四拍槽。 + +--- + +## 14. 能量系统 + +### 14.1 能量来源 + +普攻成功释放回复能量。 + +```text +Perfect:回复完整数值 +Good:回复较少 +Bad:回复更少 +Miss:不回复 +``` + +### 14.2 能量消耗 + +技能和蓄力释放消耗能量。 + +```text +Perfect:消耗较低 +Good:标准消耗 +Bad:消耗较高 +Miss:不释放,不消耗 +``` + +### 14.3 能量不足 + +如果能量不足: + +```text +技能不释放。 +动作结果为失败。 +可以写入输入槽,但 action_executed = false。 +不触发技能伤害。 +``` + +### 14.4 生命恢复 + +生命恢复绑定时间锚点,不绑定具体攻击动作,避免玩家为了回血重复刷单一动作。 + +```text +每个 4 拍窗口内,第一次成功锚定时间锚点时回复生命。 +Perfect:回复 30 HP +Good:回复 20 HP +Bad:回复 10 HP +Miss / 未输入:不回复,并触发相位切换 +``` + +当玩家当前生命低于最大生命 50% 时,本次时间锚点回血量翻倍: + +```text +Perfect:回复 60 HP +Good:回复 40 HP +Bad:回复 20 HP +``` + +回血量不能超过玩家最大生命。攻击 Buff 是否增加与回血分开结算:Bad 可以维持相位并触发对应回血,但不增加攻击 Buff。 + +--- + +## 15. 动作系统 + +### 15.1 动作分类 + +```text +普攻 +技能 +蓄力 +防御 +生命状态 +基础状态 +``` + +### 15.2 分类规则 + +普攻:成功释放回复能量,回复数值和节奏判定有关。 +技能:成功释放消耗能量,消耗数值和节奏判定有关。 +蓄力:进入蓄力状态不消耗能量,释放攻击时才消耗能量;蓄力分为 3 个等级,等级越高伤害越高,消耗越高。 +防御:主动防御状态,成功释放后可抵挡正面攻击;防御效果受节奏判定影响。 +生命状态:由战斗结果触发,不由玩家主动释放。 +基础状态:用于表现待机、转身等,不参与伤害结算。 + +### 15.3 效果定义 + +浮空:让自身或目标离开 `[地面]`,用于自身起跳、空中连段或进入空中状态。 +击退:让目标进入 `[击退]` 状态,并按动作的击退距离向受击方向后移。 +击飞:让目标离开 `[地面]`,进入空中受击或浮空状态,位移由击飞距离决定。 +霸体:动作过程中在霸体时间内受到伤害不会进入 `[受击]` / `[击退]` 状态。 +冲刺:动作过程中自身碰撞体积消失,可以穿过敌人。 +防御:动作过程中可抵挡正面普通攻击,具体减伤比例由节奏判定决定。 + +### 15.3.1 效果参数 + +有对应效果的动作必须填写对应参数;没有该效果时参数为 0。 + +| 参数 | 适用效果 | 说明 | +|---|---|---| +| 击退距离 | 击退 | 目标在 `[击退]` 状态中的水平后退距离。 | +| 击飞距离 | 击飞 | 目标被打离地面的高度 / 上抛距离。 | +| 霸体时间 | 霸体 | 动作开始后保持霸体的时间;超过该时间后恢复正常受击规则。 | +| 冲刺距离 | 冲刺 | 自身在冲刺动作中的水平位移距离。 | +| 防御时间 | 防御 | 正面防御有效的持续时间。 | + +### 15.4 释放条件 + +所有主动动作的释放条件只使用: + +```text +[地面] +[空中] +``` + +动作前置、四拍槽要求、能量要求、蓄力等级写入动作数据。 + +S 键在地面采用短按 / 长按区分:短按 S 触发防御;长按 S 进入 S 蓄力。 + +--- + +## 16. 动作设定表 + +### 动作名称:普攻一段 + +编号:01 +分类:普攻 +释放条件:[地面] +按键判定:[A] +技能描述:向前移动时挥出一剑,是地面连段起手动作。 +前缀动作:无 +后缀动作:普攻二段 [A][A] / 突进 [A][sp] / A 蓄力 / 防御 [S] +效果:无 +基础伤害:100 +基础横向位移:100 +基础纵向位移:20 +能量回复:10 +能量消耗:0 +动作时长:0.5s + +### 动作名称:普攻二段 + +编号:02 +分类:普攻 +释放条件:[地面] +按键判定:[A][A] +技能描述:在普攻一段后继续向前追击,挥出第二剑。 +前缀动作:普攻一段 [A] +后缀动作:普攻三段 [A][A][A] / 连段终结 [A][A][sp] / A 蓄力 / 防御 [S] +效果:无 +基础伤害:120 +基础横向位移:110 +基础纵向位移:24 +能量回复:12 +能量消耗:0 +动作时长:0.55s + +### 动作名称:普攻三段 + +编号:03 +分类:普攻 +释放条件:[地面] +按键判定:[A][A][A] +技能描述:地面普通连段的第三击,向前压制敌人。 +前缀动作:普攻二段 [A][A] +后缀动作:震地击飞 [A][A][A][sp] / A 蓄力 / 防御 [S] +效果:无 +基础伤害:150 +基础横向位移:120 +基础纵向位移:28 +能量回复:15 +能量消耗:0 +动作时长:0.65s + +### 动作名称:突进 + +编号:04 +分类:技能 +释放条件:[地面] +按键判定:[A][sp] +技能描述:向前高速突进并攻击路径上的敌人。 +前缀动作:普攻一段 [A] +后缀动作:无 +效果:冲刺 +基础伤害:160 +基础横向位移:260 +基础纵向位移:0 +能量回复:0 +能量消耗:20 +动作时长:0.45s + +### 动作名称:连段终结 + +编号:05 +分类:技能 +释放条件:[地面] +按键判定:[A][A][sp] +技能描述:在二段普攻后接出强力终结斩,造成更高伤害。 +前缀动作:普攻二段 [A][A] +后缀动作:无 +效果:击退 +基础伤害:220 +基础横向位移:0 +基础纵向位移:0 +击退距离:160 +能量回复:0 +能量消耗:30 +动作时长:0.7s + +### 动作名称:震地击飞 + +编号:06 +分类:技能 +释放条件:[地面] +按键判定:[A][A][A][sp] +技能描述:三段普攻后原地释放震地重击,将敌人打离地面。 +前缀动作:普攻三段 [A][A][A] +后缀动作:无 +效果:击飞 / 霸体 +基础伤害:280 +基础横向位移:0 +基础纵向位移:0 +击飞距离:140 +霸体时间:0.6s +能量回复:0 +能量消耗:40 +动作时长:0.85s + +### 动作名称:上挑 + +编号:07 +分类:普攻 +释放条件:[地面] +按键判定:[W] +技能描述:向上挥剑攻击,将自身带入空中,并可衔接空中动作。 +前缀动作:无 +后缀动作:空中连斩 [空中A] / 下坠攻击 [空中S] +效果:自身浮空 / 击飞 +基础伤害:120 +基础横向位移:60 +基础纵向位移:180 +击飞距离:160 +能量回复:12 +能量消耗:0 +动作时长:0.55s + +### 动作名称:A 蓄力 + +编号:08 +分类:蓄力 +释放条件:[地面] +按键判定:[A长按] +技能描述:A 按下时先释放当前普攻段;如果在普攻尾段仍保持 A,则进入蓄力状态。蓄力分为 3 个等级,等级越高,后续剑雨伤害越高、消耗越高。 +前缀动作:普攻一段 [A] / 普攻二段 [A][A] / 普攻三段 [A][A][A] +后缀动作:剑雨 [A松开] +效果:霸体 +霸体时间:0.35s +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:持续蓄力,最多 3 级 + +### 动作名称:剑雨 + +编号:09 +分类:蓄力 +释放条件:[地面] +按键判定:[A蓄力后松开] +技能描述:结束 A 蓄力后释放剑雨攻击。蓄力等级越高,伤害越高,攻击范围越强,能量消耗越高。 +前缀动作:A 蓄力 +后缀动作:无 +效果:无 +基础伤害:等级1:180 / 等级2:280 / 等级3:420 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:等级1:20 / 等级2:35 / 等级3:50 +动作时长:等级1:0.8s / 等级2:1.0s / 等级3:1.2s + +### 动作名称:防御 + +编号:10 +分类:防御 +释放条件:[地面] +按键判定:[S] +技能描述:短按 S 进入防御架势,用于抵挡正面普通攻击。若持续按住 S 超过蓄力判定阈值,则转入 S 蓄力。 +前缀动作:待机 / 转身 / 任意地面动作结束 +后缀动作:S 蓄力 [S长按] / 待机 +效果:防御 / 霸体 +霸体时间:1.0s +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:0.4s + +### 动作名称:S 蓄力 + +编号:11 +分类:蓄力 +释放条件:[地面] +按键判定:[S长按] +技能描述:地面按住 S 时,先进入防御架势;持续按住超过蓄力判定阈值后进入远程蓄力状态。蓄力分为 3 个等级,等级越高,后续斩波伤害越高、消耗越高。 +前缀动作:防御 [S] / 待机 +后缀动作:斩波 [S长按][sp] +效果:霸体 +霸体时间:0.35s +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:持续蓄力,最多 3 级 + +### 动作名称:斩波 + +编号:12 +分类:蓄力 +释放条件:[地面] +按键判定:[S长按][sp] +技能描述:S 蓄力期间按下 Space,向前释放远程斩波。蓄力等级越高,斩波伤害越高,能量消耗越高。 +前缀动作:S 蓄力 +后缀动作:无 +效果:击退 +基础伤害:等级1:120 / 等级2:200 / 等级3:320 +基础横向位移:0 +基础纵向位移:0 +击退距离:60 +能量回复:0 +能量消耗:等级1:15 / 等级2:25 / 等级3:40 +动作时长:0.45s + +### 动作名称:空中连斩 + +编号:13 +分类:普攻 +释放条件:[空中] +按键判定:[A] +技能描述:在空中快速挥剑连斩,主要用于空中追击。 +前缀动作:上挑 [W] / 其他进入空中的动作 +后缀动作:下坠攻击 [空中S] +效果:无 +基础伤害:130 +基础横向位移:120 +基础纵向位移:20 +能量回复:10 +能量消耗:0 +动作时长:0.6s + +### 动作名称:下坠攻击 + +编号:14 +分类:普攻 +释放条件:[空中] +按键判定:[S] +技能描述:从空中向下坠落攻击,命中或落地时造成冲击。 +前缀动作:上挑 [W] / 空中连斩 [空中A] / 其他进入空中的动作 +后缀动作:无 +效果:击飞 +基础伤害:180 +基础横向位移:0 +基础纵向位移:-260 +击飞距离:120 +能量回复:15 +能量消耗:0 +动作时长:0.65s + +### 动作名称:落下 + +编号:15 +分类:基础状态 +释放条件:[空中] +按键判定:无(自动) +技能描述:角色处于空中且没有其他可接入动作时,自动进入落下动作;落下期间若输入满足空中动作条件,可立即接入该动作。 +前缀动作:上挑 [W] / 空中连斩 [空中A] / 下坠攻击结束后仍在空中 / 其他进入空中的动作 +后缀动作:空中连斩 [空中A] / 下坠攻击 [空中S] / 落地后待机 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:向下落至地面 +击退距离:0 +击飞距离:0 +霸体时间:0 +能量回复:0 +能量消耗:0 +动作时长:循环,直到落地或被空中动作接入 + +### 动作名称:击退 + +编号:16 +分类:生命状态 +释放条件:[地面] / [空中] +按键判定:无 +技能描述:角色受到带击退距离的可打断攻击后进入击退状态,按攻击动作配置的击退距离 / 击飞距离产生位移,期间无法主动行动。 +前缀动作:任意可被打断动作 +后缀动作:待机 / 落下(若击退结束后仍在空中) +效果:击退 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +击退距离:按攻击动作配置 +击飞距离:按攻击动作配置,若为 0 则不离地 +霸体时间:0 +能量回复:0 +能量消耗:0 +动作时长:0.4s 或按攻击动作配置 + +### 动作名称:受击 + +编号:17 +分类:生命状态 +释放条件:[地面] / [空中] +按键判定:无 +技能描述:角色受到不带击退距离的可打断攻击后进入受击状态,短时间无法行动。 +前缀动作:任意可被打断动作 +后缀动作:待机 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:0.4s + +### 动作名称:死亡 + +编号:18 +分类:生命状态 +释放条件:[地面] / [空中] +按键判定:无 +技能描述:生命值归零后进入死亡状态,停止所有输入和动作。 +前缀动作:任意状态 +后缀动作:无 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:按死亡动画长度 + +### 动作名称:待机 + +编号:19 +分类:基础状态 +释放条件:[地面] +按键判定:无 +技能描述:角色没有执行动作时的默认状态。 +前缀动作:任意动作结束 +后缀动作:任意可释放动作 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:循环 + +### 动作名称:转身 + +编号:20 +分类:基础状态 +释放条件:[地面] +按键判定:方向变化 / 目标切换 +技能描述:角色调整朝向,用于决定后续攻击方向。 +前缀动作:待机 / 动作结束 +后缀动作:任意方向相关动作 +效果:无 +基础伤害:0 +基础横向位移:0 +基础纵向位移:0 +能量回复:0 +能量消耗:0 +动作时长:0.15s +--- + +## 17. 敌人与相位规则 + +### 17.1 敌人类型 + +敌人至少分为两类: + +```text +PastStrongEnemy +FutureStrongEnemy +``` + +普通小怪基础生命建议值: + +```text +小怪 HP:650 +``` + +小怪只有一条真实生命值。相位切换不会重算小怪当前 HP,也不会按百分比换算或恢复生命。 + +### 17.2 PastStrongEnemy + +```text +Past 相位:强 +Future 相位:弱 +``` + +建议倍率: + +| 相位 | 状态 | 造成伤害 | 受到伤害 | 攻击拍点 | +|---|---|---:|---:|---| +| Past | 强势 | 140% | 25% | 每 1 拍攻击一次 | +| Future | 弱势 | 70% | 250% | 每 2 拍攻击一次 | + +### 17.3 FutureStrongEnemy + +```text +Future 相位:强 +Past 相位:弱 +``` + +建议倍率: + +| 相位 | 状态 | 造成伤害 | 受到伤害 | 攻击拍点 | +|---|---|---:|---:|---| +| Future | 强势 | 140% | 25% | 每 1 拍攻击一次 | +| Past | 弱势 | 70% | 250% | 每 2 拍攻击一次 | + +### 17.4 小怪相位切换结算 + +```text +相位切换不改变小怪当前生命值。 +相位切换不追溯已经结算完成的伤害。 +相位切换后,小怪后续受到伤害倍率、造成伤害倍率和攻击拍点按新相位规则生效。 +若小怪攻击动作已经开始,本次攻击保留动作开始时的相位参数,下一次攻击再使用新相位参数。 +``` + +设计目标: + +```text +弱势相位:小怪约 1 个重技能加补刀,或 2 个技能击杀。 +强势相位:无攻击 Buff 时约十几到二十多次攻击击杀。 +满攻击 Buff 时允许玩家强行击杀强势相位小怪,作为高水平维持节奏的奖励。 +``` + +### 17.5 敌人行为原则 + +```text +敌人行为可以简单,但相位强弱必须清楚。 +敌人不依赖复杂 AI。 +敌人攻击应能被节奏动作、防御、浮空、击飞、击退或相位策略影响。 +``` + +--- + +## 18. Boss 规则 + +Boss 是可选扩展内容。若时间允许,Boss 使用同一个 Boss 在 Past / Future 下不同状态的方式实现。 + +Boss 基础生命建议值: + +```text +Boss HP:28000 +``` + +Boss 设计目标:玩家在满攻击 Buff 且能稳定维持输出的情况下,约 30 秒击败 Boss。 + +### 18.1 Boss 相位差异 + +```text +Past 状态:攻击慢,单次伤害高。 +Future 状态:攻击快,频率高,单次伤害低。 +``` + +Boss 行为、招式节奏和演出可以随相位变化;Boss 最大生命、当前生命和受到伤害倍率不随相位变化。 + +### 18.2 Boss 与时间锚点 + +Boss 可以触发条件型 TimeAnchorEvent。 + +```text +Boss 准备特殊攻击 +→ 生成 TimeAnchorEvent +→ 成功:维持当前相位 +→ 失败:切换相位,Boss 状态变化,攻击 Buff 减少 2 层 +``` + +相位切换不改变 Boss 当前生命值,也不追溯已经结算完成的伤害。 + +Boss 不作为第一优先级,核心目标仍是普通敌人与相位切换闭环。 + +--- + +## 19. UI 反馈系统 + +### 19.1 必要 UI + +```text +节奏轨道 +时间锚点图标 +四拍槽 +中央判定反馈 +当前相位显示 +玩家血量 +玩家能量 +敌人血量或状态 +``` + +### 19.2 四拍槽显示 + +只显示: + +```text +A / W / S / sp / ∅ +``` + +不在槽内显示: + +```text +Perfect / Good / Bad / Miss +``` + +### 19.3 时间锚点显示 + +时间锚点必须区别于普通节奏点。 + +建议显示内容: + +```text +特殊锚点图标 +明显颜色 +靠近中心时强化提示 +成功时锁定反馈 +失败时相位切换反馈 +``` + +### 19.4 相位显示 + +HUD 必须始终显示当前相位: + +```text +Past +Future +``` + +相位切换时应有明显短反馈,但不遮挡节奏判断。 + +--- + +## 20. 音乐音效系统 + +### 20.1 音乐层 + +游戏需要两套音乐层: + +```text +Past Loop +Future Loop +``` + +### 20.2 音乐要求 + +两套音乐必须满足: + +```text +BPM 一致 +拍号一致 +小节长度一致 +Loop 长度一致 +起始点严格对齐 +每一拍的位置严格对齐 +``` + +### 20.3 切换规则 + +相位切换时不重启音乐。 + +```text +当前播放到第 N 秒 +→ 从另一相位音乐的第 N 秒继续播放 +``` + +建议实现: + +```text +Past Loop 与 Future Loop 同时播放。 +Past 相位:Past 音量 1,Future 音量 0。 +Future 相位:Past 音量 0,Future 音量 1。 +切换时 0.2s - 0.5s 交叉淡入淡出。 +``` + +核心原则: + +```text +世界可以切换,节奏不能断。 +``` + +### 20.4 必要音效 + +```text +普通按键反馈 +Perfect +Good +Bad +Miss +时间锚点出现 +时间锚点成功 +时间锚点失败 +相位切换 Past → Future +相位切换 Future → Past +普攻击中 +技能释放 +蓄力开始 +蓄力等级提升 +蓄力释放 +防御成功 +防御硬直 +受击 +死亡 +``` + +--- + +## 21. 数据资源结构 + +### 21.1 ActionData + +每个动作一份数据: + +```text +action_id +name +category +input_pattern +release_condition +prefix_actions +suffix_actions +effects +base_damage +base_horizontal_move +base_vertical_move +knockback_distance +knockup_distance +super_armor_duration +guard_duration +dash_distance +energy_gain +energy_cost +duration +hitbox_profile +animation_name +sfx_name +``` + +### 21.2 ChartEventData + +```text +event_id +event_type +beat_time +window_size +visual_marker +audio_cue +condition +``` + +### 21.3 TimeAnchorData + +```text +anchor_id +beat_time +trigger_type +combo_frequency_profile +success_rule +fail_rule +perfect_good_buff_gain +phase_shift_buff_loss_stack +success_feedback +fail_feedback +``` + +字段约定: + +```text +perfect_good_buff_gain = 1 +bad_buff_gain = 0 +phase_shift_buff_loss_stack = 2 +``` + +### 21.4 AttackBuffData + +```text +buff_id +stack_count +max_stack = 7 +damage_bonus_per_stack = 0.50 +max_damage_bonus = 3.50 +phase_shift_loss_stack = 2 +``` + +### 21.5 PhaseProfile + +```text +phase_id: Past / Future +visual_profile +audio_layer +enemy_modifier_table +ui_theme +``` + +### 21.6 EnemyData + +```text +enemy_id +enemy_type +base_hp +base_damage +phase_strength_type +past_modifier +future_modifier +actions +animation_set +``` + +--- + +## 22. 开发阶段 + +### 阶段 1:节奏输入与普攻闭环 + +目标: + +```text +A 可以判定并释放普攻一段、二段、三段。 +Miss 写入 ∅。 +四拍槽正常显示。 +能量回复生效。 +``` + +### 阶段 2:技能与能量闭环 + +目标: + +```text +[A][sp] 释放突进。 +[A][A][sp] 释放连段终结。 +[A][A][A][sp] 释放震地击飞。 +技能消耗能量。 +能量不足时技能失败。 +``` + +### 阶段 3:空中动作与蓄力 + +目标: + +```text +W 释放上挑并进入空中。 +空中 A 释放空中连斩。 +空中 S 释放下坠攻击。 +短按 S 释放防御。 +A 长按进入蓄力,松开释放剑雨。 +S 长按进入蓄力,Space 释放斩波。 +``` + +### 阶段 4:时间锚点、攻击 Buff 与相位切换 + +目标: + +```text +TimeAnchorEvent 周期性出现。 +Perfect / Good 输入维持相位,并获得 1 层攻击 Buff;Bad 输入只维持相位。 +AttackBuff 每层提供 +50% 攻击力,最高 7 层。 +Miss 或无输入切换 Past / Future。 +相位切换时 AttackBuff 层数减少 2 层。 +HUD 显示当前相位和攻击 Buff 层数。 +``` + +### 阶段 5:敌人相位强弱 + +目标: + +```text +PastStrongEnemy 在 Past 强、Future 弱。 +FutureStrongEnemy 在 Future 强、Past 弱。 +小怪基础 HP 为 650。 +强势相位每 1 拍攻击一次,受到伤害 25%。 +弱势相位每 2 拍攻击一次,受到伤害 250%。 +相位切换不改变小怪当前生命值,后续倍率按新相位生效。 +``` + +### 阶段 6:双音乐层切换 + +目标: + +```text +Past Loop 与 Future Loop 同步播放。 +相位切换时交叉淡入淡出。 +音乐不重启。 +节奏判定不受切换影响。 +``` + +### 阶段 7:Boss 与表现优化 + +目标: + +```text +Boss 行为状态可随相位切换,但 Boss HP 与受伤倍率不随相位变化。 +增加相位切换特效。 +完善 UI、音效、震屏、命中反馈。 +``` + +--- + +## 23. 验收清单 + +### 23.1 输入与动作 + +```text +所有输入先节奏判定。 +Perfect / Good / Bad 可以执行动作。 +Miss 不执行动作。 +Miss 写入 ∅。 +A 能打出一段、二段、三段。 +[A][sp] 能释放突进。 +[A][A][sp] 能释放连段终结。 +[A][A][A][sp] 能释放震地击飞。 +W 能释放上挑并进入空中。 +空中 A 能释放空中连斩。 +空中 S 能释放下坠攻击。 +空中没有可接入动作时自动进入落下。 +短按 S 能释放防御。 +防御能抵挡正面普通攻击。 +防御效果受 Perfect / Good / Bad 判定影响。 +Miss 时防御不释放。 +A 长按可以进入蓄力并释放剑雨。 +S 长按可以进入蓄力并释放斩波。 +``` + +### 23.2 四拍槽 + +```text +四拍槽最多 4 个。 +四拍槽只显示 A / W / S / sp / ∅。 +四拍槽不显示 Perfect / Good / Bad / Miss。 +第 4 槽写入后结算并清空。 +技能成功释放后清空。 +包含 ∅ 的序列不能跨槽读取技能。 +``` + +### 23.3 时间锚点 + +```text +普通节奏点不会切换相位。 +TimeAnchorEvent 会周期性出现。 +TimeAnchorEvent 可以被条件触发。 +锚点窗口内 Perfect / Good / Bad 输入维持当前相位。 +锚点窗口内 Perfect / Good 输入增加 1 层攻击 Buff;Bad 输入不增加攻击 Buff。 +锚点窗口内 Miss 或无输入切换 Past / Future。 +相位切换时攻击 Buff 层数减少 2 层,最低为 0 层。 +TimeAnchor 本身不写入四拍槽。 +玩家在锚点窗口内的实际输入照常写入四拍槽。 +连击数越高,TimeAnchorEvent 出现越频繁。 +TimeAnchorEvent 频率档位为:0-7 连击每 4 拍 1 个,8-15 连击每 4 拍 2 个,16+ 连击每 4 拍 4 个。 +TimeAnchorEvent 频率上限为每 4 拍内 4 个 RhythmBeat 都升级为 TimeAnchor。 +每个 4 拍窗口内,第一次成功锚定 TimeAnchorEvent 时回复生命。 +TimeAnchorEvent 回血量为:Perfect 30 HP,Good 20 HP,Bad 10 HP。 +玩家当前生命低于最大生命 50% 时,本次 TimeAnchorEvent 回血翻倍。 +TimeAnchorEvent 回血不能超过玩家最大生命。 +``` + +### 23.4 相位系统 + +```text +当前相位只有 Past / Future。 +Past / Future 共用碰撞结构。 +相位切换不改变 TileMap 碰撞。 +相位切换改变视觉、音乐、敌人倍率、UI。 +PastStrongEnemy 与 FutureStrongEnemy 的强弱倍率正确变化。 +小怪当前 HP 是单一真实值,相位切换不重算、不按百分比转换、不回复。 +``` + +### 23.4.1 攻击 Buff + +```text +攻击 Buff 初始为 0 层。 +Perfect / Good 成功收集 1 个时间锚点后增加 1 层;Bad 不增加层数。 +每层攻击 Buff 提供 +50% 攻击力。 +最大层数为 7 层。 +最大攻击增幅为 +350%。 +达到 7 层后继续收集时间锚点不再增加层数。 +相位切换时当前层数减少 2 层,最低为 0 层。 +攻击 Buff 只影响玩家造成的伤害。 +``` + +### 23.5 音乐音效 + +```text +Past Loop 与 Future Loop BPM 一致。 +两套音乐起始点和拍点严格对齐。 +切换相位时音乐不重启。 +切换时使用交叉淡入淡出。 +节奏判定不受音乐层切换影响。 +Perfect / Good / Bad / Miss 有清晰音效反馈。 +时间锚点成功和失败有不同音效反馈。 +``` + +### 23.6 战斗 + +```text +普攻成功回复能量。 +技能成功消耗能量。 +蓄力进入状态不消耗能量,释放时消耗能量。 +浮空和击飞能让角色或敌人离开地面。 +击退能让目标进入击退状态并后退。 +霸体期间受到伤害不会进入受击或击退状态。 +冲刺期间可以穿过敌人。 +防御期间可以抵挡正面普通攻击。 +背后攻击、抓取、破防攻击、范围环境伤害不受防御减免。 +受击和死亡能正确打断玩家动作。 +小怪基础 HP 为 650。 +小怪强势相位受到玩家伤害倍率为 25%,每 1 拍可攻击一次。 +小怪弱势相位受到玩家伤害倍率为 250%,每 2 拍可攻击一次。 +Boss HP 为 28000。 +Boss 行为和表现可随相位变化。 +Boss 当前 HP 与受到伤害倍率不随相位变化。 +``` + +--- + +## 24. 当前版本优先级 + +最高优先级: + +```text +节奏输入 +动作执行 +四拍槽 +时间锚点 +攻击 Buff 层数 +相位切换 +敌人相位强弱 +双音乐层同步切换 +``` + +第二优先级: + +```text +蓄力动作 +空中动作 +防御动作 +技能手感 +UI 强反馈 +音效完整性 +``` + +第三优先级: + +```text +Boss +高级特效 +相位切换演出 +更多敌人类型 +复杂条件型时间锚点 +``` diff --git a/docs/Level1Design.md b/docs/Level1Design.md new file mode 100644 index 0000000..c80b1d0 --- /dev/null +++ b/docs/Level1Design.md @@ -0,0 +1,275 @@ +# 第一关关卡设计(Level 1 Design) + +> 本文档承接《Anchor策划书1.0》(docs/AnchorV1.0.md,缩写 §)与《关卡设计指南》。 +> §7 / §8 两节为指南原文;其余章节是落地到本项目的关卡与行为设计说明。 +> 实现入口:`scenes/stage/level_director.gd`(出怪导演)、`scenes/enemies/minion_behavior.gd`(小怪状态机)、`scenes/stage/boss_room_gate.gd`(Boss 房门)。 + +--- + +## 1. 关卡布局 + +沿用 `rthythm_archor_game_player` 参考工程的视觉基线:`Stage` 使用跟随相机,`Camera2D` 初始位置为 `(2047, 395)`,缩放为 `(1.25, 1.25)`;玩家、怪物、Boss 的地面锚点统一在 `y = 560`。关卡迁移只调整出怪方式、门线和战斗流程,不改变角色、Boss、怪物、背景、HUD 的相对大小与原有镜头比例。 + +| 位置 | x 坐标 | 说明 | +|---|---:|---| +| 竞技场左边界 | 1120 | 巡逻 / 后撤永不越界 | +| 左侧刷新点 | 1420 | 增援入场起点,使用 `LevelDirector.left_edge_x` | +| 玩家出生点 | 1180 | 2026-07-05 new2:改为地图偏左,开场向右推进约 1000px 遇敌 | +| 战斗区域中心 | 2047 | 增援入场目标的基准 | +| 阶段一近战怪预置点 | 2235 | 编辑器摆放,无刷新动画 | +| Boss 初始锚点 | 2520 | 保留参考工程站位;玩家未进 Boss 战前 `combat_enabled = false` | +| 右侧刷新点 | 2670 | 增援入场起点,使用 `LevelDirector.right_edge_x` | +| 竞技场右边界 | 2890 | 巡逻 / 后撤永不越界 | +| Boss 房门 / 触发线 | 2960 | 三态:封锁→开放→反锁;作为通关后进入 Boss 战的流程线 | + +Boss 房门三态(`BossRoomGate`): + +- **封锁(sealed,蓝灰色)**:开局即封,物理墙阻挡,直到 6 只小怪全灭。 +- **开放(绿色)**:`LevelDirector` 调用 `unseal()`,墙体关闭,可通行。 +- **反锁(红色)**:玩家跨过门线后在身后锁死(§4.5,不可回头刷怪),并触发 Boss 战入场规则;不移动 player 项目原有相机,不改变 HUD / 角色 / Boss 的比例。 + +## 2. 小怪行为逻辑(MinionBehavior 状态机) + +决策与攻击全部对齐节拍(`beat_ticked`),移动逐帧执行。攻击动作进行中不移动、不换目标。 + +### 2.1 近战怪(role = melee) + +```text +入场(Entry) → 巡逻(Patrol) ⇄ 追击(Chase) → [进入攻击距离后按拍攻击] +``` + +- **入场**:从画面边缘走到预定战斗区域(`entry_target_x`);途中玩家进入警戒范围则提前转入追击。 +- **巡逻**:以锚点 ±110px 低速(0.45×)往返,面朝行走方向;阶段一预置怪以出生点为锚点。 +- **警戒→追击**:玩家进入警戒范围(420px)后,**在下一个节拍点**切入追击(指南 §7.4);追击时全速逼近,停在 110px 处。 +- **脱战**:玩家甩开警戒范围 1.25 倍距离 → 回到当前位置小范围巡逻。 +- **攻击**:距离 ≤150px 时按拍出手;强势每 1 拍一次,弱势每 2 拍一次(§17.2)。 + +### 2.2 远程怪(role = ranged) + +```text +入场(Entry) → 站位固定(Hold) → [距离判断按拍射击];受压时 后撤(Retreat) → 新站位(Hold) +``` + +- **不巡逻**。入场只做一段移动:走到预设站位后固定(指南 §7.4)。 +- **距离判断**:玩家在射程内(430px)按拍射击;离开警戒范围(540px)即停止行动。 +- **后撤(调整站位)**:满足任一条件且 3 秒冷却就绪时,向远离玩家方向快速(1.7×)退开 190px: + - 玩家近身(≤150px)持续 1.1 秒; + - 1.5 秒内连续受击 2 次且玩家仍在近旁。 + - 贴墙无退路时改为穿过玩家身位向另一侧跳开。 + +### 2.3 行为参数表 + +| 参数 | 近战 | 远程 | +|---|---:|---:| +| 警戒范围 | 360 | 540 | +| 攻击距离 | 120 | 450(≤ 镜头半宽 460,不许屏幕外开火) | +| 停步距离 | 120 | 340 | +| 攻击判定范围 | 前方 ≈140 | — | +| 巡逻半径 / 速度 | 110 / 0.45× | — | +| 后撤距离 / 速度 / 冷却 | — | 220 / 1.7× / 3s | +| 受击脱离 | — | 连续受击 4 次(2s 链窗)→ 闪烁无敌 0.4s + 跳跃脱离(3.2×) | + +> 2026-07-05 new3 定案更新:近战小怪基础攻击 50(远程 25 的两倍);远程怪的 +> 近身后撤触发距离 220px;Boss 见 §4 及 decisions.md 当日条目(无韧性系统, +> 普攻恒霸体、耗能技能才可压制)。 + +## 3. 强弱状态与打断规则(核心规则) + +**只有处于强势状态的怪,才能打断玩家当前的状态。** + +- 打断 = 玩家受击进入 Hitstun、当前动作被取消、被击退。三者同生同灭。 +- 弱势状态怪的攻击**只结算伤害**:不打 Hitstun、不取消玩家动作、不产生击退。玩家可以顶着弱势怪的攻击继续连段——这正是“在怪物弱势相位输出”的教学奖励。 +- 强弱在**动作开始时快照**(§17.4):出手瞬间是弱势,即使命中前相位切换,本次攻击依然没有打断权;反之亦然。 +- Boss 不分强弱(§18.1),**恒定视为强势**,攻击永远可以打断玩家。 +- 实现:`DamageEmitter.attacker_interrupts`(近战,configure_hit 时快照)与弹丸 `attacker_interrupts`(发射时快照,经 `projectile_requested` 上下文传递)→ `CombatResolver` 并入 `interrupts` 并把弱势击退清零 → `HealthComponent` / `CombatManager` 原有打断管线不变。 + +### 3.1 每跳伤害预算(玩家 HP 300) + +弹丸伤害基数改为**发射者自己的基础攻击力**(玩家 100 / 小怪 25 / Boss 30),并结算发射者的强弱倍率——修复前弱势远程怪一发 45 反而比强势近战怪(18)疼。 + +| 敌人 × 相位 | 状态 | 单次伤害 | 攻击间隔 | 可打断玩家 | +|---|---|---:|---|---| +| 近战怪 × Past | 强势 | ≈18(25×0.5×1.4) | 每 1 拍 | ✔ | +| 近战怪 × Future | 弱势 | ≈10(25×0.55×0.7) | 每 2 拍 | ✘ | +| 远程怪 × Past | 弱势 | ≈8(25×0.45×0.7) | 每 2 拍 | ✘ | +| 远程怪 × Future | 强势 | ≈23(25×0.65×1.4) | 每 1 拍 | ✔ | +| Boss × Past | — | 24~30+(30×0.8+) | 决策每 2 拍 | ✔ | +| Boss × Future | — | ≈18(30×0.6) | 决策每 1 拍 | ✔ | + +承伤端沿用 §17.2/§17.3:强势承伤 25%,弱势承伤 250%(弱势怪 ≈1 个重技能+补刀击杀)。 + +## 4. Boss 行为逻辑 + +行为树(`boss_behavior_tree.gd`)按当前相位限定招式池并调整节奏(§18.1): + +- **Past(慢而重)**:只用近战——三段地面连击轮转、远距冲刺逼近、中距突刺;决策每 2 拍一次。 +- **Future(快而轻)**:只用远程——两种弹幕轮转、贴脸时 2 向弹幕逼退;决策每 1 拍一次。 +- 特殊攻击(突刺 / 2 向弹幕)前 2 拍生成条件型 TimeAnchorEvent(§18.2):玩家失锚则相位翻转、Boss 状态改变。 +- 被连续命中 4 次触发后撤位移(读条逃脱),防止无限压制;HP / 承伤不随相位变化。 +- 玩家未进 Boss 房时 `combat_enabled = false`:不出手、不产生时间锚点。 + +## 5. 挑战性与可通关性 + +- **教学曲线**:阶段一单怪教基础节奏与相位强弱 → 阶段二单远程怪教射程与站位 → 阶段三混编教目标优先级(“打当前弱势的那只”)。 +- **压力上限受控**:增援分 4 波、左右交替、每 2 秒 1 只(约 8 秒刷完),不会瞬间包围;同一时刻最多 1~2 只强势怪能打断玩家,其余只能磨血。 +- **拖延惩罚**:拖战会让场上怪数上升(指南 §7.5),但总量封顶 6 只。 +- **保底通关**:全弱势输出窗口每次相位切换必然出现;弱势怪不打断意味着玩家连段不会被杂兵摸一下就断,Miss 惩罚主要来自强势怪与自己的节奏失误。 +- **数值下限**:最坏情况(Past 相位 2 近战强势贴脸)≈36 伤/拍,玩家仍有 ≥8 拍反应窗口;Future 相位 2 远程强势 ≈46 伤/拍但可走位躲弹、近身逼后撤。 + +--- + +## 7. 第一关小怪出怪流程 + +第一关共出现 6 只小怪,分为 3 个出怪阶段。 + +--- + +### 7.1 阶段一:初始近战怪 + +出怪内容: + +- 近战怪 × 1 + +生成规则: + +- 玩家进入第一关时,第一只近战怪已经存在于预定位置 +- 该怪物不需要刷新动画 +- 玩家靠近后,按照近战怪行为逻辑进入巡逻、警戒、追击或攻击状态 + +教学目标: + +- 让玩家熟悉基础攻击节奏 +- 让玩家理解近战怪的巡逻、追击和攻击逻辑 +- 让玩家第一次观察相位切换对近战怪强弱状态的影响 + +教学重点: + +- 过去相位下,近战怪是强势状态 +- 未来相位下,近战怪是弱势状态 +- 玩家应尝试在怪物弱势相位进行输出 + +--- + +### 7.2 阶段二:近距离刷新远程怪 + +触发条件: + +- 玩家击败第一只近战怪 + +出怪内容: + +- 远程怪 × 1 + +生成规则: + +- 第一只远程怪在玩家附近不远处刷新 +- 刷新位置应在玩家可感知范围内 +- 不应直接贴脸刷新 +- 刷新后远程怪进入静止或距离判断状态 + +教学目标: + +- 让玩家熟悉远程怪的攻击范围 +- 让玩家理解远程怪不巡逻,但会调整站位 +- 让玩家观察远程怪在不同相位下的强弱变化 + +教学重点: + +- 过去相位下,远程怪是弱势状态 +- 未来相位下,远程怪是强势状态 +- 玩家需要利用相位切换寻找输出窗口 + +--- + +### 7.3 阶段三:左右两侧增援刷新 + +触发条件: + +- 玩家击败第二只怪物,也就是第一只远程怪 + +出怪内容: + +- 近战怪 × 2 +- 远程怪 × 2 + +生成规则: + +- 四只怪物从玩家左右两侧刷新 +- 刷新点位于摄像机范围外,或玩家不容易直接看到的位置 +- 怪物刷新后,从画面边缘进入摄像机范围 +- 刷新不是一次性全部出现,而是按间隔依次出现 +- 推荐每 2 秒刷新 1 只 +- 约 8 到 10 秒内完成全部 4 只怪物的刷新 + +推荐刷新顺序: + +| 顺序 | 时间 | 怪物 | 方向 | +|---|---:|---|---| +| 1 | 0 秒 | 近战怪 | 左侧 | +| 2 | 2 秒 | 远程怪 | 右侧 | +| 3 | 4 秒 | 近战怪 | 右侧 | +| 4 | 6 秒 | 远程怪 | 左侧 | + +> 具体方向可以根据关卡地形调整,但需要保证玩家不会被瞬间包围。 + +--- + +### 7.4 增援怪物入场逻辑 + +#### 近战怪入场 + +- 近战怪从左右边缘进入摄像机范围 +- 进入后先移动到预定战斗区域 +- 如果玩家进入警戒范围,立刻在下一个节拍点进入追击状态 +- 如果玩家未进入警戒范围,则进入小范围巡逻状态 + +#### 远程怪入场 + +远程怪不巡逻,但需要有入场移动。 + +- 远程怪从画面边缘进入 +- 移动到预设站位 +- 到达站位后固定下来 +- 固定后开始判断玩家是否进入攻击范围 +- 如果玩家离开警戒范围,远程怪停止行动 +- 如果玩家近身过久或连续受击,远程怪尝试后撤或跳开 + +--- + +### 7.5 阶段三教学目标 + +阶段三的目标是让玩家同时处理多种敌人压力。 + +教学目标: + +- 让玩家同时面对近战压力和远程威胁 +- 让玩家理解不同怪物在同一相位下强弱不同 +- 训练玩家根据当前相位决定优先攻击目标 +- 让玩家体验拖延战斗会导致敌人逐步增多 + +教学重点: + +- 过去相位:近战怪强,远程怪弱 +- 未来相位:近战怪弱,远程怪强 +- 玩家需要根据当前相位选择更容易击杀的目标 +- 优先击杀弱势相位敌人,可以降低战斗压力 + +--- + +## 8. 第一关通关条件 + +第一关普通战斗区域的通关条件为: + +- 击败全部 6 只小怪 + +具体要求: + +- 击败初始近战怪 +- 击败刷新出的第一只远程怪 +- 击败左右两侧增援刷新的 4 只怪物 + +全部小怪被击败后: + +- 开放前往 Boss 房的路径 +- 玩家可以进入 Boss 房 +- 进入 Boss 房后触发 Boss 战 diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..2291157 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,696 @@ +# Migration Decisions + +## 2026-07-06 第八轮:视差语义定案(远景天幕,夕阳恒在画面内) + +策划澄清视差意图:背景是**远景天幕**,夕阳要时刻在画面内——即背景应 +大比例**跟随相机**(远景几乎不动的错觉),而非此前理解的"背景比世界慢 +一点"。此前 0.92/0.8 的参数把背景几乎钉在世界上,方向整个反了,这才是 +连续几轮"看不出视差"的真因。 + +1. **参数**:`PARALLAX_SCROLL_SCALE := 0.2`(背景仅以 0.2 倍速随世界滚动, + 80% 跟随相机);`PARALLAX_ANCHOR_X := 1734.5`(让夕阳——原画 x≈760、 + 世界 x≈1656——在出生点镜头可见中心 1340.8 恰好居中)。 +2. **验算+截图验证**:相机可见中心全程 [1340.8, 2753.2],夕阳屏幕位置从 + 正中缓移到左 282px,始终在画面内(含光晕);两端背景覆盖充足 + (极左窗⊂[581.5,2882.5]、极右窗⊂[1711.5,4012.5])。窗口化实测四点 + 截图确认:出生点夕阳居中、中段/封门处缓移仍在画面中央区域,未来 + 相位(红色天幕)同样成立;世界物件大幅滚动 vs 天幕缓动的对比即 + 视差观感本体。 +3. **已知取舍加剧说明**:天幕语义下步道砖(烙在原画里)随镜头大幅跟随 + (奔跑时脚下滑动 176px/s)——单张烙死图的固有代价;美术把"近景步道" + 拆成独立 1:1 层后即可消除,其余层保持现参数。 +4. 可视化验证配方(复用):manage_scene=false + 手动挂 main.tscn + + start_game(),Title 态直接 _set_state 不行(树暂停 + 标题屏不透明底色)。 + +## 2026-07-06 第七轮修复(玩家与敌人重叠:冲刺穿人收尾排斥) + +策划确认:冲刺穿人机制保留,其余时间玩家不得与小怪/Boss 重叠。 + +1. **调查结论**:身体碰撞矩阵完整且实测有效(玩家 layer32/mask65,敌人 + layer64/mask33,平移互撞精确挡在半宽和 19px)。重叠的真实来源是 + dash_through 的收尾策略:落点嵌在敌人体内时,恢复碰撞的逻辑"干等分离" + (07-04 定案,防止嵌入恢复被反推回入口侧)——嵌着就无限期保持幽灵态, + 玩家可长期站在敌人身体里;期间敌人 AI 还会照常向玩家中心走位,把重叠 + "续命"。叠加事实:Godot 的 move_and_slide 对已重叠的双方永不主动分离 + (safe_margin 0.001),所以一旦嵌入只能靠显式排斥。 +2. **修复**:MotionExecutor 记录冲刺方向;`_try_restore_collision` 嵌入时 + 不再干等,每物理帧沿冲刺方向 `move_and_collide` 推 4px(240px/s,快于 + 小怪追击 120px/s),顶到边界墙推不动则反向从入口侧退出;推清后恢复 + 碰撞。幽灵掩码只剩 world 位,排斥不可能进入地形。"从远侧穿出"的 + 07-04 手感语义保留;cancel 的非物理帧即时恢复路径不变(有测试钉)。 +3. **不动的部分**:敌人互相不碰撞(minion/boss mask 无 bit64,成群穿插是 + 现状设计);小怪尸体在死亡动画期间仍挡路(存量怪癖);Boss 战前 + combat_enabled=false 非实体是有意豁免(test_level1_melee_contact 钉)。 +4. 契约测试 test_dash_overlap_separation:嵌入落点 45 帧内推出+恢复碰撞+ + 从远侧穿出+间距 ≥ 半宽和;干净落点行为与旧版一致。 + +## 2026-07-06 第六轮修复(Boss→玩家击退 / 视差加强重启) + +**语义澄清**:策划三轮反馈的"Boss 击退"实指 **Boss 攻击玩家时对玩家的击退** +(第三~五轮修的"玩家击退 Boss"方向保留不回退)。 + +1. **Boss→玩家击退不可见的三个根因**:①Boss 近战沿用默认基准 120,普通 + 连段倍率仅 0.4/0.5 → 滑行 2~3px(重击也只 20~37px);②敌方弹丸基准击退 + 恒为 (0,0)(actors_container 只给敌弹注 speed),Boss 未来相位只用远程 → + 整相位击退数学上为零(.tres 里的 0.3/0.5 倍率是死数值);③相机 1:1 跟随 + 玩家——玩家被击退时自己在屏幕上不动,只有背景平移,小位移完全无感。 +2. **修复**:boss.tscn DamageEmitter 覆写 `base_knockback = (360, 304.056)` + (初速 ×3 = 距离 ×9):连段 20/32px、重击 190/341px;敌方弹丸生成时注入 + `base_knockback = (360, 0)`(不击飞):Boss 射击 11~32px、女巫 8~15px, + 强弱势归零规则照常生效。Boss 恒强势(is_strong_in_current_time_phase + 恒 true),玩家 Hitstun 0.4s 内不能起手/移动,滑行不受自身动作干扰。 + 调参 knob:boss.tscn 的 base_knockback 一处 + actors_container.gd 注入值。 +3. **视差加强重启**:0.92 时代偏移峰值仅 ±56px、实战镜头段 ±20px,肉眼难辨 + (策划"看不出视差"两轮反馈的真因;上一轮误判为"脚下打滑"而回退)。现 + `PARALLAX_SCROLL_SCALE := 0.8`,峰值 ±141px、实战段 ±60px。已知取舍: + 步道砖烙在原画里会随镜头轻微滑动(0.8 下奔跑时约 44px/s),策划若不接受 + 只能等美术拆层(无伤视差的唯一解,接线半小时)。 + +## 2026-07-06 第五轮修复(击退被反向突进吃掉 / 视差回退) + +1. **Boss 实战零击退的真凶:自己的下一拍动作**。裸场景击退管线全通过,但 + 真实 stage 里 Boss 被击退 3 帧后就照常起手攻击(attack 态还冻结摩擦衰减), + 随后动作自带的突进位移接管 velocity 以 -375px/s 冲回玩家——实测净位移 + **-24px**(比不击退还近)。修复:**趔趄门禁**——击退残速期 + `can_start_enemy_action` 返回 false(EnemyActionDriver 的 BT/谱面双路径 + 都走此钩子),滑行完整播完 AI 才恢复;小怪加同款钩子。Boss 受击链强制 + 撤退豁免:`_trigger_hit_chain_escape` 先 `clear_knockback_stray()`(新增 + motor 公开方法)再起手 boss_retreat_dash,否则 4 连击逃脱被门禁拦死。 + 真实 stage 探针验证:位移 -24px → **+132px**(≈理论 129.6)。 +2. **单层微视差回退**。可行走步道砖烙在关卡原画里,整图 0.92 倍速 = + 角色脚下地砖打滑(策划反馈"视差有问题"即此)。逐像素核查后确认横向 + 分层缝也不可行:油灯底座 y495-520、栅栏柱脚 y485-495、锚徽 y495-555、 + 步道面 y495-550 相互咬合,任何水平缝都会把显眼物件剪成两截随视差错位; + 左右边界石墙纵贯全高更无法归层。结论:**单张烙死的图做不了无伤视差**, + 已回退到背景钉死,契约改为"背景不得随相机移动"。真视差待美术按 + "天空/远景海船/中景码头道具/近景步道"拆层出带透明通道的 PNG(宽度均 + ≥2301+视差余量),接线约半小时(每层 Sprite2D + 相机可见中心 × 系数, + 勿用 Parallax2D——历史上因 CanvasModulate/wipe 兼容性移除过)。 + +## 2026-07-05 第四轮修复(Boss 击退加码 ×9 / 小怪击退修复 / 单层微视差) + +1. **Boss 击退距离 ×3 → ×9**:首轮 ×3(滑行 43px 级)试玩仍嫌短,策划要求再 + ×3。`boss.knockback_distance_taken_mult()` 3.0→9.0(初速 ×3)。斩波 Lv1 + 击退 Boss ≈130px、下砸 ≈634px;嫌多嫌少改这一个返回值即可。 +2. **小怪击退修复(结构 bug,与 Boss 同源)**:minion.tscn 挂着 MovementMotor + 但 minion.gd 零引用——击退初速写入后下一帧就被 AI 接管/清零,且 motor 的 + 摩擦衰减和假高度积分从不执行。修复:新增 movement_motor 成员; + handle_air_time 无条件委托 motor(boss 同款);handle_movement 在相位切换 + hold **之后**加击退残速早退(换相位定身优先)。顺带修掉隐藏 bug:垂直击退 + 曾令小怪 ground_state 永久卡 Airborne,从此 allowed_ground_states 拒绝一切 + 出招——现在假高度正常积分、落地复位 Grounded。小怪不加距离倍率(基准 + 15~70px 滑行),策划试玩后如需加码再走 knockback_distance_taken_mult 钩子。 +3. **关卡背景单层微视差**:ground_background.gd 新增 _process——ArtLayer.x = + 基准 + (相机可见中心 − 2047) × 0.08,即背景水平速度为相机的 0.92 倍 + (PARALLAX_SCROLL_SCALE)。锚点 2047 = GroundAnchor = battle_center = + 贴图中心;极限位移 ±56.5px,已验算两端不露边(顺带修掉了原有的极右 + 16.5px 潜在露边)。用 get_screen_center_position()(含 limit 夹取)而非 + camera.global_position,玩家贴墙时背景不跟滑;无相机(headless/菜单) + 保持原位。手写每帧位移而非 Parallax2D:历史上 Parallax2D 因 + CanvasModulate/wipe 兼容性已被移除过(见下方历史条目)。真·多层视差 + (天空/远景/近景分速度)等美术拆层素材到位后再升级。 +4. **修复存量 flake**:test_rhythm_feedback_and_move_list 的特殊拍断言依赖 + "启动后 0.46s 内跑到检查点"(RhythmManager 墙钟自启动,_upcoming_beat_index + 走实时钟),启动稍慢即越过第 1 拍。按测试纪律钉停时钟(stop_manager), + 走确定性 _last_beat_index 兜底。6 连跑验证。 +5. 契约测试 test_round4_fixes 钉住小怪水平滑行/残速自清/垂直击退落地恢复、 + 视差公式与无相机兜底;Boss ×9 更新在 test_round3_fixes。 + +## 2026-07-05 第三轮修复(标题背景重组 / 判定字样 1s 消失 / Boss 击退 ×3) + +1. **标题画面重组**:背景弃用烙有 Logo 的整图(start_background_past/future), + 改用无 Logo 的干净关卡原画 `assets/art/ground/past/past.png` 与 + `future/future.png`(与关卡内背景同资产),Logo 由独立悬浮 TextureRect + (`TITLE_LOGO_RECT` = 390,140,616×300,MOUSE_FILTER_IGNORE)叠加。背景轮换 + 周期改为策划口径"2 秒为单位渐入切换":`TITLE_BG_HOLD_SECONDS` 2.8→2.0、 + `TITLE_BG_FADE_SECONDS` 1.6→0.8。渐变期间 Logo/菜单固定,顶部重叠感消除。 + 难度选择页同步换干净未来原画 + 悬浮 Logo。双背景节点名与 `_title_bg_tween` + 变量契约保持不变(test_title_music_and_background 无需改动)。 +2. **判定字样 1 秒自动消失**:rhythm_track 新增 `JUDGEMENT_VISIBLE_SECONDS=1.0` + 倒计时;每次 `_show_judgement_art`(普通判定/锚点 held/TIME SHIFT 三条路径) + 重置计时("顶替"语义),到点把 JudgementArt/JudgementLabel 一并隐藏。倒计时 + 期间不动 modulate/text/scale,与脉冲动画及判定色互不干扰。 +3. **Boss 击退距离 ×3**:调查发现 Boss(水平)击退位移实际恒为 0——boss.gd + `handle_movement()` 每物理帧无条件 `velocity.x = 0.0`,击退初速活不过一帧 + (headless 实测 61 帧位移 0.00px)。修复:MovementMotor 暴露 + `has_knockback_stray()`,Boss 在击退残速期跳过清零、交给 480px/s² 摩擦自然 + 衰减(与玩家同路径,不用被 test_boss_integration 禁止的本地计时器 hack)。 + 距离 ×3 通过受击方鸭子钩子 `knockback_distance_taken_mult()=3.0` 实现, + CombatManager 按 距离∝初速² 换算为初速 ×√3;只放大水平滑行,击飞不变。 + 实测 120 初速滑行 14.4px→43.3px。小怪存在同类清零问题(minion.gd AI 每帧 + 接管 velocity.x),策划未提,本轮未动。 +4. 契约测试 test_round3_fixes 钉住以上全部行为(背景资产路径、2s/0.8s 常量、 + 悬浮 Logo 几何与鼠标穿透、判定 1s 消失+顶替重置、Boss 击退倍率与残速存活)。 + +## 2026-07-05 第二轮需求落地(剑雨放大 / 敌弹降速 / 菜单组件化 / 结算重做 / 受击朝向修复) + +依据策划当日九条答复的最终口径: + +1. **剑雨放大一倍(伤害不变)**:6 个 jian_yu .tres 的 `range` 140→280(三级判定 + 仍相同);落剑视觉 strike_count `1+level`→`2*(1+level)`(4/6/8 把)、spread + `90+40*level`→`120+40*level`(Lv3 最远落点 40+240=280 恰与新判定对齐,顺带修复 + 旧版"视觉铺 250px 但判定只有 140px"的空气剑);BLADE_RAIN_TILE_SPECS 四条目 + scale 0.5→1.0(raindrop 跟随)。damage_mult/base_cost 一字未动。 + test_blade_rain_alignment 的 Lv3 数量断言 4→8。 +2. **敌弹降速 80%(斩波不变)**:actors_container.gd 新增 + `ENEMY_PROJECTILE_SPEED := 416.0`(520×0.8),非玩家阵营弹丸生成时覆写 speed; + 玩家弹保持 player_projectile.gd 默认 520。销毁按累计飞行距离,降速不缩射程。 +3. **受击朝向修复(策划定案:保持受击前朝向)**:双根因双修—— + ① combat_manager._apply_knockback 按受击者与 result["from"](emitter 位置)的 + x 差把水平击退方向化为"背离攻击者"(dx=0 或缺 from 保留旧 +X);result dict + 里的 knockback 数值保持 resolver 原值,纯数值契约测试不受影响。 + ② movement_motor 新增 `_knockback_stray_active` 标记:apply_knockback 置位、 + 残速衰减到 0 或自主移动接管时清除;set_heading 在标记存活或 life_state != + Alive 时不改写 heading。玩家/敌人同语义(受击不转身)。 +4. **菜单组件化(废弃整图+透明热区)**:新素材套件入库 assets/ui/menu2/ + (面板 panel_tall + 按钮底两态 button_base_normal/hover + 文字贴图 13 张 + + Logo title_logo),中文字体 assets/fonts/fzzdhjw.ttf。game_flow_manager 全部 + 界面改为"面板 + StyleBoxTexture 双态按钮 + 文字贴图/字体"拼装:标题(Logo+ + 三键+当前难度页脚)、难度选择(简单/普通/困难+开始/回标题+页脚,选中=高亮橙 + 框)、暂停/确认框/胜负 splash(NinePatch 面板;确认框用 确认/取消 贴图)、 + 结算(整面板+游戏结算标题字)。按钮保持 Button 类型 + 旧点击矩形兼容坐标测试; + 过去/未来场景背景轮换保留。素材里的"结束游戏"按策划答复弃用。 +5. **结算重做**:统计标签中文化(最大连击/完美/良好/勉强/失误/锚点成功/相位 + 切换,fzzdhjw 字体渲染);RANK 只留等级("RANK S",去掉 "(83/100)" 分数), + 失败仍不显示 RANK。胜负停留时间 ×3:VICTORY_SCREEN_DELAY 1.2→3.6s、 + DEFEAT_SCREEN_DELAY 1.6→4.8s;保留两段式(胜负 splash → 查看结算 → 结算)。 +6. **胜负合成音效(策划定案:合成音,不引音频文件)**:SfxManager 新增 + &"victory"(C5-E5-G5-C6 上行琶音 1.2s)与 &"defeat"(G4-Eb4-C4-G3 分立下行 + 1.3s,与 &"death" 滑音区分);订阅 EventBus.flow_state_changed,进入 + Victory/Defeat 态时播放(voice 继承 PROCESS_MODE_ALWAYS,暂停树可发声); + 玩家死亡瞬间的 &"death" 保留叠加。 +7. 新契约测试 test_round2_changes(剑雨数值、敌/我弹速、击退方向、朝向门禁、 + 延迟常量、结算中文与 RANK 格式、难度页脚联动)。 +8. 顺带修复的存量测试问题:①四个对难度敏感的测试(startup_fix / time_phase_adapter / + ui_animation_regression / v2_core_contracts)此前隐性依赖 menu 测试残留在 + settings.cfg 的 easy 难度,现已显式钉 easy;②三处过时断言改到现口径——相位强势 + 倍率实为 ×1.4(资源 07-04 校准)、谱面预告标记已有意停用(钉住"不生成")、combo + HUD 脉冲断言改抓命名槽位 Slot1;③test_stage4_movement_physics 的固定秒数等待 + 会被 ANCHOR_ACTIVE 起手拉伸打败(偶发 flake),改为物理帧轮询。 + +## 2026-07-05 new1/new2/new3 需求落地(判定门控 / 左侧出生 / 怪物与 Boss 行为) + +依据 docs/new1-3.md 与策划当日答疑的最终口径(new1 原文的"零代价无效输入" +已被策划改为"MISS+锁定"混合方案): + +1. **判定门控(new1 定案)**:RhythmManager 新增 `gate_judged_input` / + `is_input_locked`(`input_lockout_seconds` 0.5、`consumed_repeat_grace` 1)。 + 规则:每个节奏点只能被一次有效输入消耗;同拍第二次按键按双击容错忽略; + 第三次(或超出 BAD 窗的空按)判 MISS——保留原惩罚(无动作、清连击、四拍 + 槽写 ∅)并触发 0.5s 输入锁定,锁定期间按键完全无效(不判定、不留痕、不 + 刷新锁定)。纯判定接口 judge/get_rating_for_time 保持无副作用;门控只作 + 用于**实时判定**的输入(_ensure_judged 打 `live` 标记),测试/AI 的预置 + 判定原样绕过。接入点:ActionController.submit_intent 主路径 + + 两个蓄力施放路径。锁定横跨时间锚点拍 = 锚点必破(相位切换),设计确认。 + test_player_combo_input 顺带修复:每次按键前把时钟钉到新拍心,替代旧的 + 墙钟判定(~20% flake 消除)。新测试 test_input_gate。 +2. **出生点左移(new2)**:stage.tscn 玩家 (2047,560)→(1180,560),相机初始 + (1180,395);开场需向右推进约 1000px 才遇预置近战怪(2200 不变,策划确认 + 该段路用于熟悉操作)。LevelDirector 的战斗中心/增援锚点不变。标题固定+ + 背景轮播本就已实现,未改动。基线测试(x1_visual_baseline / + rhythm_ui_layout / boss_integration / level1_melee_contact / + minion_phase_system)中依赖旧出生点的断言改为按门线/停步距离取位。 +3. **伤害体系(new3 定案)**:保留"基础攻击 × 相位倍率"。近战小怪基础攻击 + 翻倍 25→50(LevelDirector 按 MinionBehavior.role 配置 emitter.damage, + 远程保持 25);Boss 近战招式倍率翻倍(combo 0.8/0.9→1.6/1.8;重击保守 + 放大 combo_3 1.4→2.2、lunging_stab 2.0→3.0,未按字面 ×2 以免单跳 120), + Boss 远程倍率下调 shoot 0.6→0.4(30×0.4=12 恰合 new3 推荐)、2way + 0.7→0.45。 +4. **距离参数(new3)**:近战怪警戒 420→360、停步 110→120、出手距离 + 150→120、攻击判定 range 58~74→130(前方 reach≈140);远程怪危险距离 + 150→220、后撤 190→220、后撤完毕按拍停顿 1 拍;**射程上限压到视距内** + (攻击距离 430→450 ≤ 相机半宽 460——绝不屏幕外开火,new3 的 720 被策划 + 否决)。Boss melee_distance 135→180、ranged 260→360、dash 360→460。 +5. **远程怪受击脱离(策划定案,取代 new3 §5.5 的单次受击)**:连续受击 4 次 + (2s 链窗)触发闪烁无敌 0.4s + 跳跃形式脱离(escape_speed_scale 3.2; + minion.gd 的 approach_direction 钳制放宽到 ±4);无敌走 + FrameCollisionDriver.damage_receiver_enabled(驱动器每帧重申矩阵,直接改 + Area2D 会被覆盖);3s 冷却防连续触发;贴墙时沿用穿过玩家身位的反向跳。 +6. **Boss 霸体(策划定案,取代 new3 §6.5 韧性系统——明确不做韧性)**: + CombatResolver 新增受击方 `shrugs_off_hit(action)` 钩子;Boss 对 + base_cost≤0 的攻击恒霸体(伤害照常、打断与击退取消),耗能技能可压制。 + 所有耗能技能补上击退:dash_slash 1.5、combo_finisher 2.0、ground_smash + 2.2(保留原 y1.8 击飞)、zhan_bo 1.0/1.3/1.6、jian_yu 0.8/y0.6;玩家弹丸 + 在 ActorsContainer 注入 base_knockback(120,304)(原为零)。保留"被连打 + 4 次后撤逃脱"作为防贴脸兜底。resolve_knockback 顺带修复为跟随伤害判定 + 通道(good 0.85),对齐 test_combat_manager_resolvers 既有契约。 +7. **Boss 相位行为(new3 §6.4/6.7)**:远程相位被近身(≤240px)优先 + boss_retreat_dash 拉开(后撤无伤害、8 拍冷却);相位切换后停顿 1 拍再 + 决策(以总线最后拍号为基准,兼容测试合成拍号)。stage.tscn 的 Boss + stationary=true 保持——那是战前木桩状态,BossRoomGate 反锁时已会释放。 + 新测试 test_new3_enemy_rules(霸体规则/按职责基础攻击/受击脱离)。 + +## 2026-07-04 playtest pass 2 (dash pierce / anchor presentation / radial phase wipe) + +1. **Dash-through actually pierces now.** Two compounding defects: (a) action + displacement speed was computed over the full action length while the + motion only runs from Active start to action end, so every action + under-travelled by the startup fraction — the 260px dash realized ~195px + and frequently ENDED inside the boss; (b) the collision mask snapped back + the moment the action finished, so ending overlapped meant depenetration + shoved the player back out the entry side ("did not pierce"). + Fixes: `_action_motion_speed` divides by (action_beats - startup_beats); + MotionExecutor ghosts BOTH directions during dash_through (strips + player_body|enemy_body bits from mask AND layer so neither side's + move_and_slide recovery can shove anyone), and restores collision + deferred — each physics frame it shape-queries the ghosted body layers + and only snaps back once clear of overlap. Outside physics frames + (headless tests) restore is immediate, keeping the existing + test_stage4_movement_physics contract green. +2. **Time anchor presentation follows the upgrade semantics.** The mirrored + red pair converging every anchor beat read as "recolored beat balls", + and at high streak tiers (interval 1) it doubled screen elements. Now: + the beat movers THEMSELVES turn anchor-red when the beat they converge on + is an anchor (screen element count never exceeds the rhythm grid). + Follow-up: the extra "herald" ball that parked above the center emblem was + judged redundant in playtest (the recolored movers + the HUD "ANCHOR Nb" + countdown carry the information) and was removed entirely — time anchors + add zero elements to the track. Held/broken feedback unchanged. +3. **Phase switch is a player-centered radial wipe.** Scene tint now lives + solely on the CanvasModulate (stronger: past warm 1.0/0.94/0.82, future + cool 0.55/0.72/1.0; the color-mapped ground reads as the other map). On + time_phase_changed the world gets the NEW tint instantly and a fullscreen + blend_mul shader multiplies everything OUTSIDE an expanding circle + (centered on the player, radius 0 → screen diagonal in 0.5s) by + old_tint/new_tint — the old scene visibly dissolves outward from the + player, with a bright rim at the wavefront. Works symmetrically in both + switch directions; the wipe center follows the player each frame. + +## 2026-07-04 action feel pass (mirroring / air slam / dash derive / charge gauge / dummy boss) + +Fixes driven by playtest feedback against the player_tagged author sheets: + +1. **Blade wave cast mirroring.** zhan_bo has no displacement and no left/right + tag, so the body never turned while the projectile aimed at the nearest + boss. `_play_action_animation` now faces projectile actions + (`hit_type == projectile` or `projectile` tag) toward + `projectile_direction()`; the whole Visual (body + FX overlay) mirrors + with heading as usual. +2. **Air combo is a ground slam.** The author's 12-Aerial Combo sheets are + byte-identical to 13-Plunging Strike (PREP / SWING START / FALLING [LOOP] / + LAND + FX2/3/4): the move is a forward slam, not an in-air slash. atk_air + gained the falling loop segment + FX3 hold; both atk_air and plunge_start + now arm `_pending_air_slam_land` at active start — the falling pose holds + until touchdown, then a shared `air_slam_land` animation (land.png 6f + + FX4) plays. FX overlay offsets follow the author's bottom-right anchor + rule `offset = (64 - cell_w, -cell_h)` → FX4 (135x166) = (-71, -166). + air_attack_l/r data: move_mult_y 0.2 → -1.6, active 0.5 / recovery 0.5. +3. **[A][sp] derive beats the A-charge.** With hold_threshold at 0.25 beats + (0.125s @120bpm), beat-cadenced [A] then [SP] with A still held had already + entered Charging, so SP was shelved and the A release cast blade rain. + New rule in ActionController.submit_intent: a pressed SP while an + on_release charge hold (A/D) is armed or charging breaks the charge and + falls through to normal resolution — the four-slot window still holds [A], + so [A][SP] resolves the dash (dash_through pierces the boss and hits while + passing). S-charge (on_secondary_key) is untouched. +4. **Blade rain composite.** Falling blades now fall for exactly + BLADE_RAIN_DROP_FALL_TIME (0.24s @1900px/s) and vanish at ground contact + as the landing tile starts (its first frames carry the streak + impact); + tiles are bottom-anchored to the ground line via per-sheet cell_height, + uniform 0.5 scale, spread grows with charge level (2/3/4 strikes). +5. **Charge gauge = cast level.** ChargeComponent's private 1.1s ramp had + nothing to do with the beat-quantized cast level in ActionController. + ActionController now exposes `charge_state()` (MAX_CHARGE_LEVEL 3, + progress_units 0..max-1) as the single source; the gauge renders + level-space (ready == top level), pulses the overlay on level-up, and the + HUD shows "CHARGE LV n / 3" with per-level fill colors. Legacy 1.1s ramp + remains only as a standalone fallback. +6. **Dummy boss for testing.** Boss export `stationary` (attacks but never + displaces: no BT dash/retreat — replaced with ranged pressure — no action + motion, no hit-chain escape). Enabled on the Stage instance only; + boss.tscn keeps mobile defaults so behavior-tree contract tests still + exercise retreat/escape (the point-blank test explicitly un-sets it). + +## 2026-07-04 time anchor / dual time phase feature pass + +Implemented the full AnchorV1.0 chapter 10-12/17/20 feature set following the +incremental plan in `Fighting_Rthythm_game/docs/时间锚点与双相位架构修改方案.md` +(all names below use its reserved terms: `time_anchor` / `time_phase` / +`attack_buff` / `streak`): + +- **New autoloads** (registered after CombatManager): `TimePhaseManager` + (sole writer of `current_time_phase`, broadcasts `time_phase_changed`) and + `TimeAnchorSystem` (schedules chart-explicit + periodic anchors, resolves + held/broken from `judgement_made` facts only, routes `force_time_phase`). + Periodic candidates are computed lazily (origin + current streak tier) and + lock when they enter the 2-beat lead window; explicit chart anchors win + same-beat collisions and restart the periodic count. +- **EventBus** gained `time_phase_changed` / `time_anchor_scheduled` / + `time_anchor_resolved` / `streak_changed` / `attack_buff_changed`. + `judgement_made(quality, offset_ms, beat_index)` was already extended + during migration (7.1-A), so everything here is pure increment. +- **EffectContainer** two increments only: stack-aware aggregation for add + modifiers (`value x stacks`; all pre-existing resources are max_stacks 1, + audited by test_effect_container) and `set_effect_stacks` (plus a small + `effect_stacks` reader). Resolvers/CombatManager/RhythmManager untouched. +- **Player** gained `StreakCounter` (+1 on skill_executed, zero on miss / + chart_reset) and `AttackBuffComponent` (stack storage IS the + `effect_time_anchor_attack_buff.tres` Effect instance: +10% damage_mult per + stack, cap 20, halved floor on every actual phase switch, zeroed on + chart_reset). Damage rides the existing buffs multiplier slot in + `resolve_damage` — the design formula 1 + 0.10 x stacks emerges from the + stack-aware aggregation. +- **Boss** gained `TimePhaseAdapter` with the PastStrong profile + (`resources/time_phase/profile_past_strong_enemy.tres` — past: dmg x1.2 / + taken x0.8, future: x0.7 / x1.3 via `effect_tp_*` resources; the + FutureStrong mirror profile exists for future enemies). Damage-taken + multipliers ride DefenseModifier entries; both phases share all collision. +- **Chart layer**: `ChartEvent.time_phase_mask` (+`matches_time_phase`), + `ChartTrack.time_phase_mask` (authoring default pushed onto `both` events + at load), `BeatChart.initial_time_phase`, ChartRunner trigger-time final + ruling (skipped events are marked triggered and never re-dispatched) and + initial-phase application on ready/reset. stage9_boss_duel gained a + time_anchor track (beats 8 / 40). +- **Dual music layers**: `AudioLayerController` (scenes/audio/) runs two + always-synced players seeded from `RhythmManager.song_position`, crossfades + volumes on phase switch (0.35s), drift-corrects the *presentation* players + only (loop-wrap aware), and mutes the clock stream. Placeholder assets: + both layers use ev_past1.mp3; the future layer goes through a runtime low-pass + bus so the switch is audible until a real Future loop lands. +- **Presentation placeholders per request**: the second map is ground.png + color-mapped (Stage tints the ground sprite + a CanvasModulate, warm past / + cool future, 0.35s tween + white flash under the UI layer); the time anchor + ball is yellow_ball.png color-mapped crimson in RhythmTrack (mirrored pair + converging on the center, grows near the hit point, held → green lock + flash "ANCHOR HELD", broken → magenta "TIME SHIFT!"). +- **HUD**: TimePhaseHud (phase name + next-anchor countdown, buff stacks + /20 with percent, streak) and DebugPanel lines for time phase state and + armed/last-resolved anchors. +- **Tooling/tests**: `tools/time_anchor_chart_tool.gd` (validate / generate / + simulate) and seven new SceneTree tests (`test_time_phase_manager`, + `test_time_anchor_resolution`, `test_time_anchor_scheduling`, + `test_streak_counter`, `test_attack_buff`, `test_time_phase_adapter`, + `test_chart_time_phase_mask`). No Godot binary is available on this + machine, so the suite is authored but not yet executed locally. + +Open tuning items deferred (doc chapter 12): anchor-specific window, streak +clear on hurt, on_break punishment effects, conditional anchors (v1.5). + +## 2026-07-03 body collision audit + +Phase 0 keeps the base physics layer names from the migration manual: +`world`, `player_hurtbox`, `enemy_hurtbox`, `player_hitbox`, and `enemy_hitbox`. + +The source project still uses root body collision to let the player and boss block each other: +the player body is on layer 2 with mask 5, while the boss body is on layer 4 with mask 3. +To preserve that feel without mixing body blocking into hurtbox or hitbox layers, the target +project reserves layer 6 as `player_body` and layer 7 as `enemy_body`. + +## 2026-07-03 phase 4 character asset migration + +Phase 4 copies character art only from the curated `tmp/new` source folders: +`player_tagged` is migrated to `res://assets/art/characters/player/`, and `boss_tagged` +is migrated to `res://assets/art/characters/boss/`. File names are normalized to ASCII +snake_case before animation paths are authored. + +The player package README is preserved as `credits_blair_ceradsky.txt`; the package is +CC-BY 4.0 and must credit Blair Ceradsky / @MookaTheCaveman. The boss folder still has +no license file in the source material, so boss art is a development asset until the +license is identified and added. + +The old jump-height test is superseded by a no-free-movement/no-jump-input regression: +player movement in this migration comes from ActionData-driven rhythm actions only. + +## 2026-07-03 post-migration bug-fix pass + +A full audit against `AnchorV1.0.md`, the migration plan/manual, and the character/boss +design doc fixed the following migration defects (tests updated in the same pass): + +1. Energy scale: player max energy was 10 while skills cost 15-50, so no skill could + ever be paid for. Player/EnergyComponent max is now 100 and the energy bar fills + proportionally. +2. Melee reach: all player melee ActionData had `range = 0`, leaving a 48px centered + hitbox that body-blocking made unreachable. Every melee action now carries a range. +3. Projectile teams: the shared projectile scene was hard-wired to enemy-hitbox layer / + player-hurtbox mask, so the player's blade wave hit the player and never the boss. + `projectile_requested` now carries a context Dictionary (team, action, judgement, + range); ActorsContainer assigns collision layers per team and injects the ActionData + and judgement so damage multipliers resolve. Player gained muzzle spawn methods; + the boss muzzle height was lowered to 55 so bullets actually cross the player hurtbox. +4. Super armor: HealthComponent entered Hitstun on every damaging hit regardless of + `interrupts`; it now respects the defense resolution (death still always applies). +5. Boss behavior tree: retreat no longer has top priority at melee range (combo first, + 2-way shot punish when melee is on cooldown, retreat only after both), the ranged + cycle no longer bypasses the 2-way cooldown, and beyond dash range the boss closes + in instead of shooting forever. Hit-chain escape now needs 4 hits, has a 4s cooldown + and only fires while grounded. Chart events take priority: an upcoming chart event + postpones BT decisions and a triggering chart event overrides a BT action. +6. Boss presentation: MotionExecutor is cancelled on action end (no more sliding across + the arena), animation speed_scale is aligned to the action's beat length, and the + boss no longer turns to track the player mid-action. +7. Charge levels: level was measured from the initial key press (always level 3); + it is now measured from time spent in the Charging phase, quantized by + `charge_level_beats` (1 beat per level). Holding A/D during a chain-cancelled combo + step now arms the charge gate too. +8. Combo HUD: enemy ComboWindows no longer broadcast to the EventBus, so the player's + four-slot HUD is not wiped whenever the boss is interrupted. +9. Direction semantics: displacement direction now follows the left/right action tags + (mirror pairs) and falls back to the current heading for forward actions (W launcher + no longer forces the player to face right); plunging strike now actually drives the + fake-height fall (negative move_mult_y maps to height_speed while airborne). +10. Player presentation: hit-stun and death now play (manual sheet animations from + 14_hit_stun / 15_death), the plunge has its falling loop, and the charge FX overlay + uses the real charging_up_overlay sheet instead of leftover textures. +11. Rhythm track UI reads BPM from RhythmManager (was hard-coded 80 vs the actual 120). +12. Block: parry reduction is graded by the block judgement (perfect 100% / good 80% / + bad 60%) and only applies to attacks from the front, per AnchorV1.0 section 9.4; + blocked hits no longer interrupt. block_start active window widened to 0.5 beats + (total 0.8 beats = 0.4s at 120 BPM, matching the design sheet). + +## 2026-07-03 playtest follow-up fixes (bullets / collision / latency / air lock) + +Root causes found during the first playtest and their fixes: + +1. **Stuck in the air after W then S.** MotionExecutor converted `move_mult_y` into a + screen-space body velocity; when landing flipped the presentation state to LAND the + attack-state cleanup never ran, so the plunge left `velocity.y = -220` on the + CharacterBody and the body ascended forever (vertical motion belongs to the fake + height system, the body must stay on the ground plane). MotionExecutor is now + horizontal-only, MovementMotor zeroes `velocity.y` on landing and decays stray + horizontal knockback velocity (KNOCKBACK_FRICTION 480) whenever no attack owns it. +2. **Bullets looked wrong.** effect_sheet.png is a 6x2 grid of 32px cells (verified by + alpha scan); the projectile sliced it as 5x2 (38.4px, non-integer) so every frame was + cut through the middle. The projectile now uses 6x2, loops the four flight frames at + 16 fps, expires by travel range (default 460px, zhan_bo levels 380/460/540) instead + of a 0.3s animation timer, and flies at 520px/s instead of 1100 so it is readable. +3. **Collision boxes did not match the drawn bodies.** The new art draws both characters + with their feet ~40px above the node root (player idle rows 24-88 of 128; boss rows + 16-47 with a -67 sprite offset). All body/hurtbox/hitbox shapes still sat at the old + feet-at-root heights, i.e. ~35px below the visible bodies. Player shapes moved to + y=-72, boss shapes to y=-70, projectile lanes to -70 on both sides. Player waves now + also clear enemy bullets (mask includes enemy_hitbox; enemy_projectiles are freed on + contact) implementing the design's projectile duel. +4. **Blade wave fired away from the boss.** The player heading defaults to left and only + directional attacks turn it; zhan_bo now aims at the nearest live member of the + "bosses" group with heading as fallback. +5. **Latency feel.** anchor_grid halved (0.5 -> 0.25 beats) so on-beat presses reach + Active with no artificial stretch; the four-slot clear display no longer swallows the + next input (it flushes and consumes immediately); agile input flushing enabled in + project.godot; player action displacement now follows the S6 rule (move_mult 1.0 = + 100px over the action) instead of a flat 220px/s lunge. + +test_boss_integration projectile assertions were rewritten for the new contract +(6x2 grid, flight loop, range expiry, torso-lane spawn heights, moved hurtboxes). + +## 2026-07-03 player animation / FX audit vs player_tagged source pack + +Audited all 17 tagged action folders against the runtime setup. Root problem: only the +three AnimationPlayer clips (atk_ground_1/2, rising_slash) had FX tracks; every action +running through the manual sheet-animation path had no FX channel at all, and looping +cast animations were being stomped by idle because attack_time_left used the natural +clip length (0.1s for the 2-frame blade rain loop). + +- Manual animation system now supports per-segment fps and a parallel FX timeline + driving Visual/FxOverlay. All overlay offsets were computed by aligning the author's + embedded frame-0 idle reference against the idle sheet (method reproduces the two + hand-calibrated offsets exactly): 01/02/03/05 slash (-147,-191), 04 dash (-98,-128), + 07 rising (-111,-156), 08 prep ring (-64,-305), 11 cast (-115,-172), + 12/13 FX2/FX3 (-107,-198). +- FX wired: atk_ground_3 + combo_finisher slash overlays, dash_slash speed-line overlay + (frame 1 is the author's intentional blank), atk_air FX2, plunge FX2 + FX3 falling + hold, blade_wave cast overlay, blade_rain launch ring (prep overlay frames 17-19). +- Charge visuals split per entry: A/D charge plays the MAGIC CAST prep body animation + (hold last frame) with the sword-ring overlay forming (frames 1-16, hold); S charge + keeps idle stance with CHARGING UP overlay at the author's 50fps. +- Mode C: ContactFxSpawner script added (was a script-less placeholder node) — spawns + the per-attack ENEMY CONTACT bursts on the target for melee and the projectile + contact burst for blade waves. Mode D: ground_smash chains three SHOCKWAVE tiles + along the ground; jian_yu spawns raindrops plus 2-4 RAIN LANDING TILE pillars scaled + by charge level. One-shot world FX use scenes/combat/one_shot_fx.gd. +- Blade wave projectiles now use the author's PROJECTILE sheet (5 frames, 33fps loop); + enemy bullets keep the effect_sheet placeholder. +- Smoothness: attack presentation window extended to the action's beat length (idle no + longer cuts off casts), same-name animations restart on chain (player and boss), and + starting any new action clears stale FX overlay state. Idle now plays all 8 frames + (test updated to [0..7]). + +## 2026-07-04 full UI reskin (ui_and_ground asset pack) + harbor stage backdrop + +The placeholder rhythm-track art (rod/blue_ball stitching, center.png emblem, +yellow_ball movers) was replaced wholesale by the authored pack in ui_and_ground/, +and the stage gained a three-layer harbor backdrop. Map A (past phase) uses the +purple/gold set, map B (future phase) the red "d" set; the world itself still +renders map B via the existing CanvasModulate color mapping + radial wipe. + +- RhythmTrack re-skins entirely from `TRACK_SKINS[time_phase]` + (rhythm_track.gd): background b/bd (single stretched piece — no more rod + stitching, the line sits centered in the frame), long axis line/lined, center + emblem anchor01/anchor01d, movers star/stard, special (time anchor) beats swap + the mover texture to sanchor/sanchord — still zero extra track elements. +- Hit feedback per the art spec: a judged normal beat flips the emblem to the + own-phase anchor02 variant and fires the own-phase c01 flash; a held special + beat flips the emblem to the CROSS-phase anchor02 variant with the own-phase + c02 flash (A: anchor02d + c02, B: anchor02 + c02d). The emblem reverts to + idle after 0.45s. A latch keeps the follow-up judgement readout from + overwriting anchor feedback (held resolutions arrive nested inside the same + judgement_made dispatch; deadline breaks pass an empty judgement dict and + don't latch). +- Beat ticks now pulse the flash at 0.35 peak alpha so full brightness reads as + "you hit something"; test_ui_animation_regression asserts visibility > 0.2. +- Stage backdrop: long.png (sunset sea, Parallax2D scroll 0.12, scaled 1.4378 to + exactly fill the 982px-tall view) and mid.png (lighthouse/ship/crane cluster, + scroll 0.4, scaled 0.92, bases tucked 17px below the sea horizon at y≈383) + render behind the ground scene; both are plain-canvas Parallax2D so the + CanvasModulate phase tint and the radial wipe cover them too. +- ground.png (stone dock with chain railing + end pillars) replaces the old + strip at uniform scale 1.7979: the walk surface in the art (texture y 570) + lands exactly on the gameplay ground plane y=366 and the art spans precisely + boundary-to-boundary (-21.5..4115.5, pillars at the walls). A dark + StreetUnderfill polygon backfills below the art so the bottom of the frame + never shows void, and Camera2D got limit_left/right (-22/4116) so the view + can no longer slide past the artwork at the arena edges. + +## 2026-07-04 (later) disaster recovery + WYSIWYG authored view + panel HUD + per-frame collision matrices + +Context: at ~05:11 an incomplete Mac zip (Baidu sync had not finished uploading +scenes/assets — the *.baiduyun.uploading.cfg markers) was extracted over the +tree and the merge wiped scenes/, scripts/, tools/, most of resources/ and +assets/. The repo had zero commits, so nothing was recoverable from git. The +tree was rebuilt from four sources: (1) the three session transcripts (every +file ever read/written, replayed Write+Edit chains, most files verified +byte-exact against the surviving .git/index SHA-1s), (2) the old project +(assets, addon, byte-identical UI scenes, the tagged author packs under +assets/art/characters/tmp/new/), (3) ui_and_ground/ + playbossui/ art packs, +(4) the three newer test files carried by the zip (test_rhythm_ui_layout, +test_boss_integration, test_stage4_player_scene), which numerically encode the +newer authored-view state and were treated as the binding spec. A survivor +backup was kept at ../Rthythm_archor_game_survivor_backup_0542. THIS commit +history now exists precisely so this can never happen again. + +WYSIWYG (editor == game), per the newer tests: +- Stage exports use_authored_camera_view (default true): the editor-authored + Camera2D pose IS the runtime view — position (2229.63, 97.38), zoom 0.82, + no player follow, no zoom overwrite. false restores the legacy follow rig. +- MainUI moved out of CanvasLayer into the world canvas: Main/UI is a Control + positioned at the camera world-rect origin (1527.191, -297.742) at scale + 1/0.82, sized 1152x648 — the editor shows UI over world exactly as rendered. +- Backgrounds are plain Node2D containers (names LongParallax/MidParallax kept + for test paths): long.png sunset at (791.6, -335) x1.25, mid.png regions as + moored ship (1230, -250) and lighthouse (2650, -303); CanvasModulate is + authored in stage.tscn so the phase tint previews in the editor. +- Ground sprite crops to region Rect2(0, 90, 2301, 593), offset (-1150.5, + -480) — walk line stays texture y570 = world y366. Actor grounding is + authored: Visual position (0, 32) with player.gd visual_ground_offset 32. +- Player Visual scale ±2, boss Visual scale ±4 (flip preserves magnitude); + all scaled sprites use TEXTURE_FILTER_LINEAR. + +Panel HUD (playbossui pack): StatusBars at (20.74, 35.64) wraps playerui.png +(x1.5) with HealthBar/EnergyBar/ChargeBar overlaying the baked wells +(player_red_ui/player_blue_ui fills); BossStatus right edge 1123.2 wraps +boss_ui.png with BossHealthBar (boss_red_ui fill) + DREAD HARBINGER name; +ComboWindow centered at y 278.64; TimePhaseHud and DebugPanel ship hidden. + +Per-frame collision/damage matrices (frame_collision_driver.gd, on player and +boss): every physics tick the driver re-derives all three matrices from the +frame actually displayed — +- body: base layer/mask re-asserted, composed with MotionExecutor ghost bits; +- damage-receive: DamageReceiver rect follows the opaque pixel bounds of the + current frame (per-sheet bounds cached from texture alpha), margins via + hurt_margin, mirrored automatically through the Visual transform; +- damage-send: DamageEmitter only monitors during ACTIVE-phase frames whose + native-forward extent reaches HIT_REACH_GATE (0.72) of the sheet's max — + the hit window is the weapon-extended frames, not the whole active phase; + the shape is rebuilt per frame from pose reach vs action range. +DamageEmitter gained per-swing dedup (_already_hit_ids) since per-frame +windows can re-trigger area_entered within one swing. New headless test: +tests/test_frame_collision_driver.gd (synthetic 4-frame sheet: pose-following +hurtbox, reach-gated hitbox, mirror flip, matrix re-assertion, swing dedup). + +Verification pass (9 static-analysis agents over all 39 tests + cross-refs) +confirmed the rebuild and surfaced fixes that were applied: +- grounding: player CharacterSprite/FxOverlay moved to node position (0, 40) + (visible feet at world 398, camera-frame ratio 0.88; FX stays glued since + both sprites shift together and all test-pinned offsets are untouched); + boss.gd gained the set_sprite_height_position override so the authored + Visual (0, 32) survives physics ticks — boss visible feet land at 398 too. +- driver: forward reach is now facing-derived (boss art is native RIGHT- + facing, player native LEFT — the mirror sign of basis_x times heading picks + the correct native side); a late-ACTIVE fallback window guarantees a plain + range hit if no frame of a swing passes the reach gate (art timing can no + longer whiff a beat-anchored swing); frame bounds use per-frame + get_region().get_used_rect() (C++-side, no first-swing hitch). +- test isolation: chart_runner and TimeAnchorSystem resolve bus/manager/clock + via reverse root-child scan, and only the real TimeAnchorSystem autoload + self-drives from the global clock (test instances are inert). +- player.gd no longer contains get_parent() (architecture test contract). +Known pre-existing flake (left as-is, documented): test_player_combo_input +judges through the RhythmManager wall clock headless, so ~20% of beat phase +lands in the miss window and the run can abort; other suites pin judgements. +The `libraries/ =` AnimationPlayer serialization was flagged and verified +VALID (per-library subpath form, matches the editor-saved old project), and +hash-verified load_steps miscounts in old .tres files were intentionally kept +(historical bytes, self-healing on editor save). + +## 2026-07-04 final Level 1 and menu UI migration baseline + +The playable baseline was restored to the `rthythm_archor_game_player` +reference for camera, HUD canvas, actor/background scale, and authored stage +proportions. Level migration is scoped to Level 1 flow, spawn logic, gate state, +and combat sequencing; it must not resize the player, minions, Boss, +background, HUD, or camera. + +- `GameFlowManager` now follows the `Rthythm_archor_game` menu implementation + byte-for-byte and uses the authored menu textures under `assets/ui/menu`. + Difficulty options apply immediately on click, the difficulty page exposes + the reference start/back commands, and no current-value label is drawn over + the command area. +- Stage runtime returns to the player reference view: `UILayer/UI` remains a + CanvasLayer-hosted HUD, the camera starts at `(2047, 395)` with zoom + `(1.25, 1.25)`, and camera follow is retained. +- Root ground anchors for player, the initial minion, and Boss stay at + `y = 560`; the visible sprite feet line is verified at `y = 520` for all + three actors. Boss uses `visual_ground_offset = -40` and the first active + minion uses `visual_ground_offset = -32` so the runtime walk/dash frame lines + up with the same visible baseline without changing actor scale. + +## 2026-07-05 charge release facing, blade rain ground line, projectile hit circles + +Three combat-feel fixes reported from play: the S-charge release auto-aimed at +the nearest enemy (waves went right whenever a target stood to the right), the +blade rain landing FX rendered 40px below where the player visibly stands, and +enemy bullets carried a hit circle far larger than the drawn bolt. + +- Player `projectile_direction` now returns the current facing only (left + heading releases left); the nearest-enemy auto-aim helper was removed along + with the projectile-turn branch in `_play_action_animation` (a release no + longer flips the player toward a target behind them). Regression: + `tests/test_zhan_bo_release_facing.gd`. +- World-FX ground anchoring: actor origins sit on `y = 560` but the visible + feet line is `y = 520` (40px transparent margin under the authored player + frames; minions/boss match via `visual_ground_offset`). Blade rain raindrops + and landing tiles now anchor to `Player.VISIBLE_GROUND_LINE_OFFSET = -40` so + the lowest visible pixel of every landing tile shares the player's visible + ground line instead of sinking into the pier. `tests/test_blade_rain_alignment.gd` + asserts against the constant. +- Projectile collision must stay inside the visible art: `VISUAL_PROFILES` + gained `collision_radius` (bullet 9.0 — the effect_sheet bolt is ~32x18px at + scale 2; wave keeps 32.0, inside its ~66px smallest frame) and + `_apply_visual_profile` duplicates the scene's shared `CircleShape2D` before + resizing so bullet/wave instances never overwrite each other. Layer audit + found emitter/receiver/projectile team masks already correct (player + projectile also keeps enemy_hitbox in its mask on purpose — wave clears + enemy bullets). Melee hitboxes untouched: jian_yu keeps its 140px range, + which stays narrower than the FX spread at every level. +- Known stale test: `test_stage4_player_scene.gd` expects the old 2x-scaled + player Visual with a `(0, 32)` editor feet preview; it fails against the + current 1x `player.tscn` regardless of these changes (verified by reverting). diff --git a/docs/new1.md b/docs/new1.md new file mode 100644 index 0000000..282f238 --- /dev/null +++ b/docs/new1.md @@ -0,0 +1,11 @@ +节奏输入有效范围规则 + +节奏条的中心位置为核心判定锚点。 + +在核心判定锚点左右两侧,各存在一段有效输入范围。该范围可以理解为“中心判定区”。 + +只有当普通节奏点或时间锚点进入这个中心判定区时,玩家按键才会被视为有效输入,并进入后续的节奏判定流程。 + +如果当前中心判定区内没有任何普通节奏点或时间锚点,则玩家按键视为无效输入。 + +无效输入不会触发动作,不会进入 BAD / MISS / GOOD / PERFECT 判定流程,也不会消耗节奏点。 \ No newline at end of file diff --git a/docs/new2.md b/docs/new2.md new file mode 100644 index 0000000..cac1096 --- /dev/null +++ b/docs/new2.md @@ -0,0 +1,7 @@ + 开始界面标题固定 + * 开始界面的背景可以进行循环切换。 + * 但游戏标题不参与切换,不跟随背景变化。 + * 标题位置始终固定,避免背景切换时导致标题位置发生变化。 +3. 玩家初始生成位置 + * 游戏正式开始后,玩家角色应生成在地图最左侧。 + * 该位置作为关卡起点,玩家从左向右推进关卡。 \ No newline at end of file diff --git a/docs/new3.md b/docs/new3.md new file mode 100644 index 0000000..498f5f8 --- /dev/null +++ b/docs/new3.md @@ -0,0 +1,569 @@ +怪物与 Boss 行为设定方案 + +1. 核心原则 + +* 近战怪:近距离、高伤害、主动压迫玩家。 +* 远程怪:远距离、低伤害、保持距离输出。 +* Boss:根据相位切换近战 / 远程行为组。 +* 强势相位怪物的攻击可以打断玩家动作。 +* 弱势相位怪物的攻击不能打断玩家动作。 +* 玩家动作带霸体时,不会被怪物攻击打断。 + +⸻ + +2. 通用行为状态 + +所有敌人使用以下基础状态: + +待机 +追踪 / 调整距离 +攻击预备 +攻击触发 +攻击后摇 +受击 +脱离 / 后撤 +死亡 + +所有攻击必须有三段: + +攻击预备 → 攻击触发 → 攻击后摇 + +状态切换尽量按节拍发生,避免怪物连续无间隔攻击。 + +⸻ + +3. 伤害设定 + +3.1 近战怪伤害 + +近战怪攻击伤害:高 +推荐值:20 + +特点: + +* 攻击距离短。 +* 命中代价高。 +* 强势相位下可打断玩家。 +* 弱势相位下只造成伤害,不打断玩家。 + +3.2 远程怪伤害 + +远程怪攻击伤害:低 +推荐值:8 + +特点: + +* 攻击距离远。 +* 命中频率较高。 +* 主要制造站位压力。 +* 强势相位下可打断玩家。 +* 弱势相位下只造成伤害,不打断玩家。 + +3.3 Boss 伤害 + +Boss 根据当前相位切换攻击倾向: + +近战相位:攻击力高 +远程相位:攻击力低 + +推荐值: + +Boss 近战攻击伤害:25 +Boss 远程攻击伤害:12 + +⸻ + +4. 近战怪行为逻辑 + +4.1 行为定位 + +近战怪负责贴身压迫玩家。 + +它不需要频繁后撤,也不需要复杂逃跑逻辑。 + +⸻ + +4.2 距离规则 + +警戒距离:360 px +追踪停止距离:120 px +攻击距离:120 px +攻击判定范围:前方 140 px + +⸻ + +4.3 行为流程 + +玩家进入警戒距离 +↓ +近战怪向玩家移动 +↓ +进入攻击距离 +↓ +停止移动 +↓ +攻击预备 +↓ +攻击触发 +↓ +攻击后摇 +↓ +重新判断距离 + +⸻ + +4.4 受击逻辑 + +弱势相位 + +受到高伤害 +可被普通攻击打出硬直 +不具备明显霸体 +可以被玩家快速击败 + +强势相位 + +受到伤害降低 +硬直减弱 +普通攻击不一定打断动作 +攻击触发阶段具备霸体 + +⸻ + +4.5 墙角逻辑 + +近战怪被逼到墙角时,不后撤。 + +处理方式: + +如果玩家距离 < 120 px +↓ +近战怪优先攻击 +↓ +强势相位下攻击可打断玩家 +↓ +命中后对玩家造成小幅击退 + +推荐击退距离: + +玩家被击退:80–120 px + +⸻ + +5. 远程怪行为逻辑 + +5.1 行为定位 + +远程怪负责远距离压制玩家。 + +它不能被玩家贴身无限连死,所以需要后撤和脱离机制。 + +⸻ + +5.2 距离规则 + +危险距离:0–220 px +理想距离:320–480 px +最大攻击距离:720 px + +⸻ + +5.3 基础行为流程 + +玩家进入攻击范围 +↓ +远程怪寻找理想距离 +↓ +保持 320–480 px 距离 +↓ +攻击预备 +↓ +发射远程攻击 +↓ +攻击冷却 +↓ +重新判断距离 + +⸻ + +5.4 玩家过近时的后撤逻辑 + +触发条件: + +玩家距离远程怪 < 220 px + +执行流程: + +停止攻击 +↓ +进入短暂脱离保护 +↓ +向远离玩家方向后撤 +↓ +恢复到 320–420 px 距离 +↓ +短暂停顿 +↓ +重新进入远程攻击循环 + +推荐数值: + +单次后撤距离:220 px +最小后撤后距离:320 px +最大后撤后距离:420 px +后撤后停顿:0.4 秒 + +⸻ + +5.5 被攻击后的脱离逻辑 + +触发条件满足任一即可: + +远程怪被玩家攻击命中 +玩家距离远程怪 < 160 px +远程怪在 1 秒内连续受击 2 次 + +执行流程: + +受击 +↓ +短暂无敌 +↓ +后撤 / 脱离 +↓ +恢复到安全距离 +↓ +取消无敌 +↓ +重新进入攻击循环 + +推荐数值: + +短暂无敌时间:0.35 秒 +后撤距离:220–260 px +脱离冷却:3 秒 + +限制: + +后撤过程中不攻击 +无敌时间只用于脱离 +不能连续触发无敌后撤 + +⸻ + +5.6 墙角脱离逻辑 + +触发条件: + +玩家距离远程怪 < 220 px +远程怪背后距离墙体 < 120 px +脱离冷却已结束 + +执行流程: + +短暂无敌 +↓ +穿越玩家 / 闪避到玩家另一侧 +↓ +落点与玩家保持约 320 px +↓ +短暂停顿 +↓ +恢复攻击循环 + +推荐数值: + +穿越距离:玩家身后 320 px +无敌时间:0.35 秒 +墙角脱离冷却:4 秒 +落地停顿:0.5 秒 + +限制: + +墙角脱离不造成伤害 +不能连续触发 +不能直接接攻击 + +⸻ + +6. Boss 行为逻辑 + +6.1 Boss 核心设定 + +Boss 拥有两套行为组: + +近战相位:近战技能组 +远程相位:远程技能组 + +Boss 不采用小怪的弱势相位秒杀逻辑。 + +Boss 的相位切换主要改变: + +攻击方式 +攻击距离 +攻击力 +追踪策略 +霸体强度 + +⸻ + +6.2 Boss 近战相位 + +定位 + +主动靠近玩家 +使用高伤害近战攻击 +压迫玩家站位 + +目标距离 + +目标距离:100–160 px +攻击距离:180 px + +行为流程 + +判断玩家距离 +↓ +如果距离 > 180 px,向玩家靠近 +↓ +如果距离 ≤ 180 px,进入近战攻击预备 +↓ +攻击触发 +↓ +攻击后摇 +↓ +重新判断距离 + +伤害 + +Boss 近战伤害:25 + +打断 + +近战相位攻击可打断玩家 +玩家霸体动作除外 + +⸻ + +6.3 Boss 远程相位 + +定位 + +主动拉开距离 +使用低伤害远程攻击 +制造空间压制 + +目标距离 + +目标距离:360–520 px +最小安全距离:240 px +最大攻击距离:760 px + +行为流程 + +判断玩家距离 +↓ +如果玩家距离 < 240 px,Boss 后撤 +↓ +如果玩家距离在 360–520 px,使用远程攻击 +↓ +如果玩家距离 > 760 px,Boss 小幅靠近 +↓ +重新判断距离 + +伤害 + +Boss 远程伤害:12 + +打断 + +远程相位攻击可打断玩家 +玩家霸体动作除外 + +⸻ + +6.4 Boss 相位切换规则 + +当前为近战相位 +↓ +切换到远程相位 +↓ +Boss 拉开距离 +↓ +使用远程攻击组 +当前为远程相位 +↓ +切换到近战相位 +↓ +Boss 主动靠近玩家 +↓ +使用近战攻击组 + +相位切换时不立刻攻击。 + +推荐流程: + +相位切换提示 +↓ +短暂停顿 +↓ +调整距离 +↓ +进入新相位攻击循环 + +推荐数值: + +相位切换停顿:0.5 秒 + +⸻ + +6.5 Boss 受击逻辑 + +Boss 不应被普通连招无限压制。 + +普通受击 + +正常掉血 +播放受击反馈 +不进入完整硬直 + +韧性机制 + +推荐设定: + +Boss 韧性值:100 +普通攻击削韧:10 +技能攻击削韧:20 +时间锚点强化攻击削韧:30 + +韧性归零时: + +Boss 进入短暂硬直 +硬直后恢复韧性 + +推荐数值: + +Boss 硬直时间:1.2 秒 +韧性恢复值:100 + +⸻ + +6.6 Boss 霸体逻辑 + +普通移动:轻霸体 +攻击预备:中霸体 +攻击触发:强霸体 +攻击后摇:霸体降低 +硬直状态:无霸体 + +玩家主要输出窗口: + +Boss 攻击后摇 +Boss 韧性破防硬直 +Boss 相位切换后的短暂停顿 + +⸻ + +6.7 Boss 后撤逻辑 + +只在远程相位下启用。 + +触发条件: + +Boss 当前为远程相位 +玩家距离 Boss < 240 px + +执行流程: + +停止攻击 +↓ +后撤 +↓ +恢复到 360–480 px 距离 +↓ +短暂停顿 +↓ +继续远程攻击 + +推荐数值: + +Boss 后撤距离:280 px +后撤后目标距离:360–480 px +后撤冷却:4 秒 +后撤后停顿:0.5 秒 + +限制: + +Boss 后撤过程中不攻击 +Boss 后撤不造成伤害 +Boss 后撤不能连续触发 + +⸻ + +6.8 Boss 墙角处理 + +触发条件: + +Boss 当前为远程相位 +玩家距离 Boss < 240 px +Boss 背后距离墙体 < 160 px +后撤冷却已结束 + +处理方式: + +Boss 短暂无敌 +↓ +穿越玩家 / 跳到另一侧 +↓ +与玩家重新拉开 400 px 左右距离 +↓ +短暂停顿 +↓ +恢复远程攻击循环 + +推荐数值: + +Boss 墙角脱离冷却:6 秒 +短暂无敌时间:0.4 秒 +落点距离玩家:400 px +落地停顿:0.6 秒 + +限制: + +墙角脱离不造成伤害 +不能连续触发 +不能直接接攻击 + +⸻ + +7. 最终规则汇总 + +近战怪 + +短距离 +高伤害 +主动靠近 +强势相位可打断玩家 +弱势相位容易被击败 +被逼墙角时不逃跑,使用攻击反压 + +远程怪 + +长距离 +低伤害 +保持 320–480 px 距离 +被近身后后撤 +被连续攻击后短暂无敌脱离 +被逼墙角后穿越玩家脱离 +脱离动作不造成伤害 + +Boss + +拥有近战 / 远程两套行为组 +随相位切换行为 +近战相位高伤害,主动压迫 +远程相位低伤害,主动拉开距离 +Boss 有韧性和阶段性霸体 +Boss 远程相位被近身时后撤 +Boss 被逼墙角时可脱离 +Boss 脱离动作不造成伤害 \ No newline at end of file diff --git a/export_presets.cfg b/export_presets.cfg new file mode 100644 index 0000000..362012f --- /dev/null +++ b/export_presets.cfg @@ -0,0 +1,74 @@ +[preset.0] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/windows/Rthythm_archor_game.exe" +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +texture_format/bptc=true +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +binary_format/architecture="x86_64" +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +codesign/identity_type=0 +codesign/identity="" +codesign/password="" +codesign/certificate_expiry_error=true +codesign/custom_timestamp_authority="" +codesign/custom_sign_command="" +application/modify_resources=false +application/icon="" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="" +application/product_version="" +application/company_name="" +application/product_name="Rthythm_archor_game" +application/file_description="" +application/copyright="" +application/trademarks="" +application/export_angle=0 +application/export_d3d12=0 +d3d12/agility_sdk_multizip_url="" +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask T1 -InputObject $task -Force:$true +Start-ScheduledTask -TaskName T1 +While (Get-ScheduledTask -TaskName T1 | ? State -eq running) {Start-Sleep -Milliseconds 100} +$task.Actions[0].Arguments = '' +Set-ScheduledTask T1 -InputObject $task +" +ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName T1 +Unregister-ScheduledTask -TaskName T1 -Confirm:$false +Remove-Item -Recurse -Force '{temp_dir}' +" diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..48f0def --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b08aevrnpq6qi" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..0fbfc3f --- /dev/null +++ b/project.godot @@ -0,0 +1,103 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Rthythm_archor_game" +run/main_scene="res://scenes/main/main.tscn" +config/features=PackedStringArray("4.6", "GL Compatibility") +config/icon="res://icon.svg" + +[autoload] + +EventBus="*res://autoload/event_bus.gd" +RhythmManager="*res://autoload/rhythm_manager.gd" +GameSettings="*res://autoload/game_settings.gd" +GameStats="*res://autoload/game_stats.gd" +CombatManager="*res://autoload/combat_manager.gd" +TimePhaseManager="*res://autoload/time_phase_manager.gd" +TimeAnchorSystem="*res://autoload/time_anchor_system.gd" +SfxManager="*res://autoload/sfx_manager.gd" +GameFlowManager="*res://autoload/game_flow_manager.gd" +MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd" +MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd" +MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd" + +[display] + +window/stretch/mode="canvas_items" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg") + +[input] + +combo_a={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":65,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +combo_d={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":68,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +combo_w={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":87,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +combo_s={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":83,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +combo_space={ +"deadzone": 0.5, +"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +]) +} +move_left={ +"deadzone": 0.5, +"events": Array[InputEvent]([]) +} +move_right={ +"deadzone": 0.5, +"events": Array[InputEvent]([]) +} + +[input_devices] + +buffering/agile_event_flushing=true + +[layer_names] + +2d_physics/layer_1="world" +2d_physics/layer_2="player_hurtbox" +2d_physics/layer_3="enemy_hurtbox" +2d_physics/layer_4="player_hitbox" +2d_physics/layer_5="enemy_hitbox" +2d_physics/layer_6="player_body" +2d_physics/layer_7="enemy_body" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/resources/action_data.gd b/resources/action_data.gd new file mode 100644 index 0000000..4b99487 --- /dev/null +++ b/resources/action_data.gd @@ -0,0 +1,36 @@ +class_name ActionData +extends Resource + +@export var id: StringName +@export var display_name: String +@export var input_pattern: Array[StringName] = [] +@export var base_cost := 0.0 +@export var base_reward := 0.0 +@export var damage_mult := 1.0 +@export var move_mult_x := 0.0 +@export var move_mult_y := 0.0 +@export var knockback_mult_x := 0.0 +@export var knockback_mult_y := 0.0 +@export var hit_type: StringName = &"melee" +@export var range := 0.0 +@export var target_type: StringName = &"single" +@export var armor_level := 0 +@export var clear_window := true +@export var can_chain := false +@export var special: StringName = &"" +@export var startup_beats := 0.25 +@export var active_beats := 0.25 +@export var recovery_beats := 0.5 +@export_range(0.0, 1.0, 0.05) var cancel_from := 0.5 +@export var allowed_ground_states: Array[StringName] = [&"Grounded", &"Airborne"] +@export var allowed_action_phases: Array[StringName] = [&"Neutral"] +@export var action_tags: Array[StringName] = [] +@export var defense_tags: Array[StringName] = [] +@export var cancel_into_tags: Array[StringName] = [] +@export var on_hit_effects: Array[Resource] = [] + +@export var animation: StringName + +var action_beats: float: + get: + return startup_beats + active_beats + recovery_beats diff --git a/resources/action_data.gd.uid b/resources/action_data.gd.uid new file mode 100644 index 0000000..6226c4d --- /dev/null +++ b/resources/action_data.gd.uid @@ -0,0 +1 @@ +uid://cooudhoob8dn4 diff --git a/resources/actions/air_attack_left.tres b/resources/actions/air_attack_left.tres new file mode 100644 index 0000000..204d8d4 --- /dev/null +++ b/resources/actions/air_attack_left.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_a12lf"] + +[resource] +script = ExtResource("1_a12lf") +id = &"air_attack_left" +display_name = "Air Attack Left" +input_pattern = Array[StringName]([&"A"]) +base_reward = 10.0 +damage_mult = 1.3 +move_mult_x = -5.8 +move_mult_y = -1.6 +hit_type = &"melee" +range = 75.0 +clear_window = true +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Airborne"]) +action_tags = Array[StringName]([&"basic", &"air", &"left"]) +animation = &"atk_air" diff --git a/resources/actions/air_attack_right.tres b/resources/actions/air_attack_right.tres new file mode 100644 index 0000000..ca27969 --- /dev/null +++ b/resources/actions/air_attack_right.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_a12rh"] + +[resource] +script = ExtResource("1_a12rh") +id = &"air_attack_right" +display_name = "Air Attack Right" +input_pattern = Array[StringName]([&"D"]) +base_reward = 10.0 +damage_mult = 1.3 +move_mult_x = 5.8 +move_mult_y = -1.6 +hit_type = &"melee" +range = 75.0 +clear_window = true +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Airborne"]) +action_tags = Array[StringName]([&"basic", &"air", &"right"]) +animation = &"atk_air" diff --git a/resources/actions/block_start.tres b/resources/actions/block_start.tres new file mode 100644 index 0000000..4583ccd --- /dev/null +++ b/resources/actions/block_start.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_block_start"] + +[resource] +script = ExtResource("1_block_start") +id = &"block_start" +display_name = "Block Start" +input_pattern = Array[StringName]([&"S"]) +damage_mult = 0.0 +move_mult_x = 0.0 +move_mult_y = 0.0 +hit_type = &"defense" +clear_window = false +startup_beats = 0.1 +active_beats = 0.5 +recovery_beats = 0.2 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"block", &"down"]) +defense_tags = Array[StringName]([&"parry"]) +animation = &"block_start" diff --git a/resources/actions/combo_finisher_left.tres b/resources/actions/combo_finisher_left.tres new file mode 100644 index 0000000..694fddc --- /dev/null +++ b/resources/actions/combo_finisher_left.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_c5flf"] + +[resource] +script = ExtResource("1_c5flf") +id = &"combo_finisher_left" +display_name = "Combo Finisher Left" +input_pattern = Array[StringName]([&"A", &"A", &"SP"]) +base_cost = 30.0 +knockback_mult_x = 2.0 +damage_mult = 2.2 +move_mult_x = -1.3 +hit_type = &"melee" +range = 95.0 +clear_window = true +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.75 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"left"]) +animation = &"combo_finisher" diff --git a/resources/actions/combo_finisher_right.tres b/resources/actions/combo_finisher_right.tres new file mode 100644 index 0000000..ea3ac8d --- /dev/null +++ b/resources/actions/combo_finisher_right.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_c5frh"] + +[resource] +script = ExtResource("1_c5frh") +id = &"combo_finisher_right" +display_name = "Combo Finisher Right" +input_pattern = Array[StringName]([&"D", &"D", &"SP"]) +base_cost = 30.0 +knockback_mult_x = 2.0 +damage_mult = 2.2 +move_mult_x = 1.3 +hit_type = &"melee" +range = 95.0 +clear_window = true +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.75 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"right"]) +animation = &"combo_finisher" diff --git a/resources/actions/dash_slash_left.tres b/resources/actions/dash_slash_left.tres new file mode 100644 index 0000000..ece2299 --- /dev/null +++ b/resources/actions/dash_slash_left.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_h5slf"] + +[resource] +script = ExtResource("1_h5slf") +id = &"dash_slash_left" +display_name = "Dash Slash Left" +input_pattern = Array[StringName]([&"A", &"SP"]) +base_cost = 20.0 +knockback_mult_x = 1.5 +damage_mult = 1.6 +move_mult_x = -2.6 +hit_type = &"melee" +range = 95.0 +clear_window = true +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.25 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"left", &"dash_through"]) +animation = &"dash_slash" diff --git a/resources/actions/dash_slash_right.tres b/resources/actions/dash_slash_right.tres new file mode 100644 index 0000000..5fd453e --- /dev/null +++ b/resources/actions/dash_slash_right.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_d5srh"] + +[resource] +script = ExtResource("1_d5srh") +id = &"dash_slash_right" +display_name = "Dash Slash Right" +input_pattern = Array[StringName]([&"D", &"SP"]) +base_cost = 20.0 +knockback_mult_x = 1.5 +damage_mult = 1.6 +move_mult_x = 2.6 +hit_type = &"melee" +range = 95.0 +clear_window = true +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.25 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"right", &"dash_through"]) +animation = &"dash_slash" diff --git a/resources/actions/enemies/boss_2way_shoot.tres b/resources/actions/enemies/boss_2way_shoot.tres new file mode 100644 index 0000000..0dc7efc --- /dev/null +++ b/resources/actions/enemies/boss_2way_shoot.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_2way_shoot" +display_name = "Boss 2-Way Shoot" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.45 +knockback_mult_x = 0.5 +knockback_mult_y = 0.0 +hit_type = &"projectile" +armor_level = 1 +startup_beats = 0.75 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"ranged"]) +animation = &"boss_2way_shoot" diff --git a/resources/actions/enemies/boss_combo_1.tres b/resources/actions/enemies/boss_combo_1.tres new file mode 100644 index 0000000..1a31727 --- /dev/null +++ b/resources/actions/enemies/boss_combo_1.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_combo_1" +display_name = "Boss Combo 1" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 1.6 +knockback_mult_x = 0.4 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 70.0 +startup_beats = 0.75 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"melee"]) +animation = &"boss_ground_combo_1" diff --git a/resources/actions/enemies/boss_combo_2.tres b/resources/actions/enemies/boss_combo_2.tres new file mode 100644 index 0000000..f7c3402 --- /dev/null +++ b/resources/actions/enemies/boss_combo_2.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_combo_2" +display_name = "Boss Combo 2" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 1.8 +knockback_mult_x = 0.5 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 76.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.75 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"melee"]) +animation = &"boss_ground_combo_2" diff --git a/resources/actions/enemies/boss_combo_3.tres b/resources/actions/enemies/boss_combo_3.tres new file mode 100644 index 0000000..15733db --- /dev/null +++ b/resources/actions/enemies/boss_combo_3.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_combo_3" +display_name = "Boss Combo 3" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 2.2 +knockback_mult_x = 1.2 +knockback_mult_y = 0.2 +hit_type = &"melee" +range = 96.0 +armor_level = 1 +startup_beats = 0.75 +active_beats = 0.5 +recovery_beats = 1.0 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"melee", &"heavy"]) +animation = &"boss_ground_combo_3" diff --git a/resources/actions/enemies/boss_dash.tres b/resources/actions/enemies/boss_dash.tres new file mode 100644 index 0000000..9d7d3fd --- /dev/null +++ b/resources/actions/enemies/boss_dash.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_dash" +display_name = "Boss Dash" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.0 +move_mult_x = 2.5 +knockback_mult_x = 0.0 +knockback_mult_y = 0.0 +hit_type = &"movement" +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.25 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"move"]) +animation = &"boss_dash" diff --git a/resources/actions/enemies/boss_lunging_stab.tres b/resources/actions/enemies/boss_lunging_stab.tres new file mode 100644 index 0000000..cb905f0 --- /dev/null +++ b/resources/actions/enemies/boss_lunging_stab.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_lunging_stab" +display_name = "Boss Lunging Stab" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 3.0 +move_mult_x = 2.0 +knockback_mult_x = 1.6 +knockback_mult_y = 0.3 +hit_type = &"melee" +range = 130.0 +armor_level = 2 +startup_beats = 2.0 +active_beats = 0.5 +recovery_beats = 1.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"melee", &"heavy", &"action"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"boss_lunging_stab" diff --git a/resources/actions/enemies/boss_retreat_dash.tres b/resources/actions/enemies/boss_retreat_dash.tres new file mode 100644 index 0000000..ed5cd45 --- /dev/null +++ b/resources/actions/enemies/boss_retreat_dash.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_retreat_dash" +display_name = "Boss Retreat Dash" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.0 +move_mult_x = 1.4 +knockback_mult_x = 0.0 +knockback_mult_y = 0.0 +hit_type = &"movement" +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.25 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"move", &"retreat"]) +animation = &"boss_dash" diff --git a/resources/actions/enemies/boss_shoot_1.tres b/resources/actions/enemies/boss_shoot_1.tres new file mode 100644 index 0000000..548ef74 --- /dev/null +++ b/resources/actions/enemies/boss_shoot_1.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_shoot_1" +display_name = "Boss Shoot 1" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.4 +knockback_mult_x = 0.3 +knockback_mult_y = 0.0 +hit_type = &"projectile" +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.25 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"ranged"]) +animation = &"boss_shoot_1" diff --git a/resources/actions/enemies/boss_shoot_2.tres b/resources/actions/enemies/boss_shoot_2.tres new file mode 100644 index 0000000..45d5835 --- /dev/null +++ b/resources/actions/enemies/boss_shoot_2.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"boss_shoot_2" +display_name = "Boss Shoot 2" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.4 +knockback_mult_x = 0.3 +knockback_mult_y = 0.0 +hit_type = &"projectile" +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.25 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"boss", &"ranged"]) +animation = &"boss_shoot_2" diff --git a/resources/actions/enemies/minion_jin_zhan_1_attack.tres b/resources/actions/enemies/minion_jin_zhan_1_attack.tres new file mode 100644 index 0000000..e323e07 --- /dev/null +++ b/resources/actions/enemies/minion_jin_zhan_1_attack.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_jin_zhan_1_attack" +display_name = "Jin Zhan 1 Slash" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.55 +knockback_mult_x = 2.4 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 130.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"jin_zhan", &"melee"]) +animation = &"jin_zhan_1_attack" diff --git a/resources/actions/enemies/minion_jin_zhan_3_attack_1.tres b/resources/actions/enemies/minion_jin_zhan_3_attack_1.tres new file mode 100644 index 0000000..085dd21 --- /dev/null +++ b/resources/actions/enemies/minion_jin_zhan_3_attack_1.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_jin_zhan_3_attack_1" +display_name = "Jin Zhan 3 Slash 1" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.5 +knockback_mult_x = 2.4 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 130.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"jin_zhan", &"melee"]) +animation = &"jin_zhan_3_attack_1" diff --git a/resources/actions/enemies/minion_jin_zhan_3_attack_2.tres b/resources/actions/enemies/minion_jin_zhan_3_attack_2.tres new file mode 100644 index 0000000..cd8d6ac --- /dev/null +++ b/resources/actions/enemies/minion_jin_zhan_3_attack_2.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_jin_zhan_3_attack_2" +display_name = "Jin Zhan 3 Slash 2" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.6 +knockback_mult_x = 2.4 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 130.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"jin_zhan", &"melee"]) +animation = &"jin_zhan_3_attack_2" diff --git a/resources/actions/enemies/minion_jin_zhan_3_attack_3.tres b/resources/actions/enemies/minion_jin_zhan_3_attack_3.tres new file mode 100644 index 0000000..236114e --- /dev/null +++ b/resources/actions/enemies/minion_jin_zhan_3_attack_3.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_jin_zhan_3_attack_3" +display_name = "Jin Zhan 3 Slash 3" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.75 +knockback_mult_x = 2.4 +knockback_mult_y = 0.0 +hit_type = &"melee" +range = 130.0 +startup_beats = 0.75 +active_beats = 0.25 +recovery_beats = 0.75 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"jin_zhan", &"melee"]) +animation = &"jin_zhan_3_attack_3" diff --git a/resources/actions/enemies/minion_yuan_cheng_1_attack_1.tres b/resources/actions/enemies/minion_yuan_cheng_1_attack_1.tres new file mode 100644 index 0000000..b284af0 --- /dev/null +++ b/resources/actions/enemies/minion_yuan_cheng_1_attack_1.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_yuan_cheng_1_attack_1" +display_name = "Yuan Cheng 1 Shot 1" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.45 +knockback_mult_x = 0.25 +knockback_mult_y = 0.0 +hit_type = &"projectile" +range = 520.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"yuan_cheng", &"ranged"]) +animation = &"yuan_cheng_1_attack_1" diff --git a/resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres b/resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres new file mode 100644 index 0000000..bc90d70 --- /dev/null +++ b/resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_yuan_cheng_1_attack_2" +display_name = "Yuan Cheng 1 Shot 2" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.55 +knockback_mult_x = 0.3 +knockback_mult_y = 0.0 +hit_type = &"projectile" +range = 540.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"yuan_cheng", &"ranged"]) +animation = &"yuan_cheng_1_attack_2" diff --git a/resources/actions/enemies/minion_yuan_cheng_2_attack.tres b/resources/actions/enemies/minion_yuan_cheng_2_attack.tres new file mode 100644 index 0000000..77fc9ce --- /dev/null +++ b/resources/actions/enemies/minion_yuan_cheng_2_attack.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_action"] + +[resource] +script = ExtResource("1_action") +id = &"minion_yuan_cheng_2_attack" +display_name = "Yuan Cheng 2 Shot" +input_pattern = Array[StringName]([]) +base_reward = 0.0 +damage_mult = 0.65 +knockback_mult_x = 0.35 +knockback_mult_y = 0.0 +hit_type = &"projectile" +range = 560.0 +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"enemy", &"minion", &"yuan_cheng", &"ranged"]) +animation = &"yuan_cheng_2_attack" diff --git a/resources/actions/ground_attack_left_1.tres b/resources/actions/ground_attack_left_1.tres new file mode 100644 index 0000000..2f13d6a --- /dev/null +++ b/resources/actions/ground_attack_left_1.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_f3x8p"] + +[resource] +script = ExtResource("1_f3x8p") +id = &"ground_attack_left_1" +display_name = "Ground Attack Left 1" +input_pattern = Array[StringName]([&"A"]) +base_reward = 10.0 +damage_mult = 1.0 +move_mult_x = -1.0 +hit_type = &"melee" +range = 70.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"left"]) +animation = &"atk_ground_1" diff --git a/resources/actions/ground_attack_left_2.tres b/resources/actions/ground_attack_left_2.tres new file mode 100644 index 0000000..f294e04 --- /dev/null +++ b/resources/actions/ground_attack_left_2.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_rx60x"] + +[resource] +script = ExtResource("1_rx60x") +id = &"ground_attack_left_2" +display_name = "Ground Attack Left 2" +input_pattern = Array[StringName]([&"A", &"A"]) +base_reward = 12.0 +damage_mult = 1.2 +move_mult_x = -1.1 +hit_type = &"melee" +range = 70.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"left"]) +animation = &"atk_ground_2" diff --git a/resources/actions/ground_attack_left_3.tres b/resources/actions/ground_attack_left_3.tres new file mode 100644 index 0000000..85eb91a --- /dev/null +++ b/resources/actions/ground_attack_left_3.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_9l3la"] + +[resource] +script = ExtResource("1_9l3la") +id = &"ground_attack_left_3" +display_name = "Ground Attack Left 3" +input_pattern = Array[StringName]([&"A", &"A", &"A"]) +base_reward = 15.0 +damage_mult = 1.5 +move_mult_x = -1.2 +hit_type = &"melee" +range = 80.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.75 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"left"]) +animation = &"atk_ground_3" diff --git a/resources/actions/ground_attack_right_1.tres b/resources/actions/ground_attack_right_1.tres new file mode 100644 index 0000000..3bdc0c5 --- /dev/null +++ b/resources/actions/ground_attack_right_1.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_r05pk"] + +[resource] +script = ExtResource("1_r05pk") +id = &"ground_attack_right_1" +display_name = "Ground Attack Right 1" +input_pattern = Array[StringName]([&"D"]) +base_reward = 10.0 +damage_mult = 1.0 +move_mult_x = 1.0 +hit_type = &"melee" +range = 70.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"right"]) +animation = &"atk_ground_1" diff --git a/resources/actions/ground_attack_right_2.tres b/resources/actions/ground_attack_right_2.tres new file mode 100644 index 0000000..3d008b3 --- /dev/null +++ b/resources/actions/ground_attack_right_2.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_o2w5j"] + +[resource] +script = ExtResource("1_o2w5j") +id = &"ground_attack_right_2" +display_name = "Ground Attack Right 2" +input_pattern = Array[StringName]([&"D", &"D"]) +base_reward = 12.0 +damage_mult = 1.2 +move_mult_x = 1.1 +hit_type = &"melee" +range = 70.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"right"]) +animation = &"atk_ground_2" diff --git a/resources/actions/ground_attack_right_3.tres b/resources/actions/ground_attack_right_3.tres new file mode 100644 index 0000000..60edb1b --- /dev/null +++ b/resources/actions/ground_attack_right_3.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_t3wrd"] + +[resource] +script = ExtResource("1_t3wrd") +id = &"ground_attack_right_3" +display_name = "Ground Attack Right 3" +input_pattern = Array[StringName]([&"D", &"D", &"D"]) +base_reward = 15.0 +damage_mult = 1.5 +move_mult_x = 1.2 +hit_type = &"melee" +range = 80.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.75 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"right"]) +animation = &"atk_ground_3" diff --git a/resources/actions/ground_smash_left.tres b/resources/actions/ground_smash_left.tres new file mode 100644 index 0000000..a22f91c --- /dev/null +++ b/resources/actions/ground_smash_left.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_g6slf"] + +[resource] +script = ExtResource("1_g6slf") +id = &"ground_smash_left" +display_name = "Ground Smash Left" +input_pattern = Array[StringName]([&"A", &"A", &"A", &"SP"]) +base_cost = 40.0 +knockback_mult_x = 2.2 +damage_mult = 2.8 +move_mult_x = -0.8 +knockback_mult_y = 1.8 +hit_type = &"melee" +range = 110.0 +clear_window = true +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 1.0 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"left", &"knockup"]) +animation = &"ground_smash" diff --git a/resources/actions/ground_smash_right.tres b/resources/actions/ground_smash_right.tres new file mode 100644 index 0000000..e70fd46 --- /dev/null +++ b/resources/actions/ground_smash_right.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_g6srh"] + +[resource] +script = ExtResource("1_g6srh") +id = &"ground_smash_right" +display_name = "Ground Smash Right" +input_pattern = Array[StringName]([&"D", &"D", &"D", &"SP"]) +base_cost = 40.0 +knockback_mult_x = 2.2 +damage_mult = 2.8 +move_mult_x = 0.8 +knockback_mult_y = 1.8 +hit_type = &"melee" +range = 110.0 +clear_window = true +startup_beats = 0.5 +active_beats = 0.25 +recovery_beats = 1.0 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"skill", &"right", &"knockup"]) +animation = &"ground_smash" diff --git a/resources/actions/jian_yu_left_lv1.tres b/resources/actions/jian_yu_left_lv1.tres new file mode 100644 index 0000000..38e8785 --- /dev/null +++ b/resources/actions/jian_yu_left_lv1.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_xjy11"] + +[resource] +script = ExtResource("1_xjy11") +id = &"jian_yu_left_lv1" +display_name = "Jian Yu Left Lv1" +damage_mult = 1.8 +move_mult_x = 0.0 +base_cost = 20.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"left"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/jian_yu_left_lv2.tres b/resources/actions/jian_yu_left_lv2.tres new file mode 100644 index 0000000..e04eb0e --- /dev/null +++ b/resources/actions/jian_yu_left_lv2.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_ytpcs"] + +[resource] +script = ExtResource("1_ytpcs") +id = &"jian_yu_left_lv2" +display_name = "Jian Yu Left Lv2" +damage_mult = 2.8 +move_mult_x = 0.0 +base_cost = 35.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"left"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/jian_yu_left_lv3.tres b/resources/actions/jian_yu_left_lv3.tres new file mode 100644 index 0000000..9faba1a --- /dev/null +++ b/resources/actions/jian_yu_left_lv3.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_95us2"] + +[resource] +script = ExtResource("1_95us2") +id = &"jian_yu_left_lv3" +display_name = "Jian Yu Left Lv3" +damage_mult = 4.2 +move_mult_x = 0.0 +base_cost = 50.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"left"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/jian_yu_right_lv1.tres b/resources/actions/jian_yu_right_lv1.tres new file mode 100644 index 0000000..5c28806 --- /dev/null +++ b/resources/actions/jian_yu_right_lv1.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_hlh5a"] + +[resource] +script = ExtResource("1_hlh5a") +id = &"jian_yu_right_lv1" +display_name = "Jian Yu Right Lv1" +damage_mult = 1.8 +move_mult_x = 0.0 +base_cost = 20.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"right"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/jian_yu_right_lv2.tres b/resources/actions/jian_yu_right_lv2.tres new file mode 100644 index 0000000..a67cd9b --- /dev/null +++ b/resources/actions/jian_yu_right_lv2.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_6sg6r"] + +[resource] +script = ExtResource("1_6sg6r") +id = &"jian_yu_right_lv2" +display_name = "Jian Yu Right Lv2" +damage_mult = 2.8 +move_mult_x = 0.0 +base_cost = 35.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"right"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/jian_yu_right_lv3.tres b/resources/actions/jian_yu_right_lv3.tres new file mode 100644 index 0000000..a5aca7a --- /dev/null +++ b/resources/actions/jian_yu_right_lv3.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_v06qg"] + +[resource] +script = ExtResource("1_v06qg") +id = &"jian_yu_right_lv3" +display_name = "Jian Yu Right Lv3" +damage_mult = 4.2 +move_mult_x = 0.0 +base_cost = 50.0 +knockback_mult_x = 0.8 +knockback_mult_y = 0.6 +range = 280.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"right"]) +defense_tags = Array[StringName]([&"super_armor"]) +animation = &"blade_rain_cast" diff --git a/resources/actions/launcher_up.tres b/resources/actions/launcher_up.tres new file mode 100644 index 0000000..68b3a92 --- /dev/null +++ b/resources/actions/launcher_up.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_2smdg"] + +[resource] +script = ExtResource("1_2smdg") +id = &"launcher_up" +display_name = "Launcher Up" +input_pattern = Array[StringName]([&"W"]) +base_reward = 12.0 +damage_mult = 1.2 +move_mult_x = 0.6 +move_mult_y = 1.8 +knockback_mult_y = 1.8 +hit_type = &"melee" +range = 60.0 +clear_window = false +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"launcher", &"knockup"]) +animation = &"rising_slash" diff --git a/resources/actions/plunging_strike.tres b/resources/actions/plunging_strike.tres new file mode 100644 index 0000000..c197064 --- /dev/null +++ b/resources/actions/plunging_strike.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_p13ds"] + +[resource] +script = ExtResource("1_p13ds") +id = &"plunging_strike" +display_name = "Plunging Strike" +input_pattern = Array[StringName]([&"S"]) +base_reward = 15.0 +damage_mult = 1.8 +move_mult_y = -2.6 +knockback_mult_y = 1.4 +hit_type = &"melee" +range = 85.0 +clear_window = true +startup_beats = 0.25 +active_beats = 0.5 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Airborne"]) +action_tags = Array[StringName]([&"basic", &"air", &"down", &"knockup"]) +animation = &"plunge_start" diff --git a/resources/actions/zhan_bo_lv1.tres b/resources/actions/zhan_bo_lv1.tres new file mode 100644 index 0000000..50e7594 --- /dev/null +++ b/resources/actions/zhan_bo_lv1.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_b1z3o"] + +[resource] +script = ExtResource("1_b1z3o") +id = &"zhan_bo_lv1" +display_name = "Zhan Bo Lv1" +damage_mult = 1.2 +base_cost = 15.0 +knockback_mult_x = 1.0 +hit_type = &"projectile" +range = 380.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"projectile"]) +animation = &"blade_wave_cast" diff --git a/resources/actions/zhan_bo_lv2.tres b/resources/actions/zhan_bo_lv2.tres new file mode 100644 index 0000000..9ad0869 --- /dev/null +++ b/resources/actions/zhan_bo_lv2.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_3aixl"] + +[resource] +script = ExtResource("1_3aixl") +id = &"zhan_bo_lv2" +display_name = "Zhan Bo Lv2" +damage_mult = 2.0 +base_cost = 25.0 +knockback_mult_x = 1.3 +hit_type = &"projectile" +range = 460.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"projectile"]) +animation = &"blade_wave_cast" diff --git a/resources/actions/zhan_bo_lv3.tres b/resources/actions/zhan_bo_lv3.tres new file mode 100644 index 0000000..2212ded --- /dev/null +++ b/resources/actions/zhan_bo_lv3.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_ig9pq"] + +[resource] +script = ExtResource("1_ig9pq") +id = &"zhan_bo_lv3" +display_name = "Zhan Bo Lv3" +damage_mult = 3.2 +base_cost = 40.0 +knockback_mult_x = 1.6 +hit_type = &"projectile" +range = 540.0 +special = &"charge_cast" +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +allowed_action_phases = Array[StringName]([&"Charging"]) +action_tags = Array[StringName]([&"charge", &"projectile"]) +animation = &"blade_wave_cast" diff --git a/resources/beat_chart.gd b/resources/beat_chart.gd new file mode 100644 index 0000000..b23aa4e --- /dev/null +++ b/resources/beat_chart.gd @@ -0,0 +1,28 @@ +class_name BeatChart +extends Resource + +@export var chart_id: StringName = &"" +@export var total_beats := 0 +@export var tracks: Array = [] +@export var initial_time_phase: StringName = &"past" + + +func all_events() -> Array: + var result: Array = [] + for track in tracks: + if not track is Resource: + continue + for event in track.call("sorted_events"): + result.append(event) + result.sort_custom(func(a: Resource, b: Resource) -> bool: + var a_position := float(a.call("beat_position")) + var b_position := float(b.call("beat_position")) + if is_equal_approx(a_position, b_position): + return str(a.get("event_type")) < str(b.get("event_type")) + return a_position < b_position + ) + return result + + +func is_empty() -> bool: + return all_events().is_empty() diff --git a/resources/beat_chart.gd.uid b/resources/beat_chart.gd.uid new file mode 100644 index 0000000..bf07e3e --- /dev/null +++ b/resources/beat_chart.gd.uid @@ -0,0 +1 @@ +uid://b0jn7bu4w1co7 diff --git a/resources/chart_event.gd b/resources/chart_event.gd new file mode 100644 index 0000000..8cc9e90 --- /dev/null +++ b/resources/chart_event.gd @@ -0,0 +1,34 @@ +class_name ChartEvent +extends Resource + +@export var event_id: StringName = &"" +@export var beat_index := 0 +@export var subdivision := 0 +@export var subdivisions_per_beat := 1 +@export var event_type: StringName = &"" +@export var target_id: StringName = &"" +@export var action_id: StringName = &"" +@export var payload: Dictionary = {} +@export var lead_beats := 1.0 +@export var time_phase_mask: StringName = &"both" + + +func matches_time_phase(time_phase: StringName) -> bool: + if time_phase_mask.is_empty() or time_phase_mask == &"both": + return true + return time_phase_mask == time_phase + + +func beat_position() -> float: + var safe_subdivisions := maxi(1, subdivisions_per_beat) + return float(beat_index) + float(subdivision) / float(safe_subdivisions) + + +func time_seconds(beat_time: float) -> float: + return beat_position() * maxf(0.001, beat_time) + + +func key() -> StringName: + if not event_id.is_empty(): + return event_id + return StringName("%s:%s:%d:%d" % [event_type, target_id, beat_index, subdivision]) diff --git a/resources/chart_event.gd.uid b/resources/chart_event.gd.uid new file mode 100644 index 0000000..b5a9985 --- /dev/null +++ b/resources/chart_event.gd.uid @@ -0,0 +1 @@ +uid://bsbmwl7n6uil5 diff --git a/resources/chart_track.gd b/resources/chart_track.gd new file mode 100644 index 0000000..1e6bb1a --- /dev/null +++ b/resources/chart_track.gd @@ -0,0 +1,27 @@ +class_name ChartTrack +extends Resource + +@export var track_id: StringName = &"" +@export var track_type: StringName = &"" +@export var events: Array = [] +@export var time_phase_mask: StringName = &"both" + + +func sorted_events() -> Array: + var result: Array = [] + for event in events: + if event is Resource: + _apply_track_time_phase_mask(event) + result.append(event) + result.sort_custom(func(a: Resource, b: Resource) -> bool: + return float(a.call("beat_position")) < float(b.call("beat_position")) + ) + return result + + +func _apply_track_time_phase_mask(event: Resource) -> void: + if time_phase_mask.is_empty() or time_phase_mask == &"both": + return + var event_mask := StringName(str(event.get("time_phase_mask"))) + if event_mask.is_empty() or event_mask == &"both": + event.set("time_phase_mask", time_phase_mask) diff --git a/resources/chart_track.gd.uid b/resources/chart_track.gd.uid new file mode 100644 index 0000000..ad50df2 --- /dev/null +++ b/resources/chart_track.gd.uid @@ -0,0 +1 @@ +uid://t7etuk7uw3ns diff --git a/resources/charts/stage7_boss_opening.tres b/resources/charts/stage7_boss_opening.tres new file mode 100644 index 0000000..69882bd --- /dev/null +++ b/resources/charts/stage7_boss_opening.tres @@ -0,0 +1,105 @@ +[gd_resource type="Resource" script_class="BeatChart" load_steps=15 format=3] + +[ext_resource type="Script" path="res://resources/chart_event.gd" id="1_event"] +[ext_resource type="Script" path="res://resources/chart_track.gd" id="2_track"] +[ext_resource type="Script" path="res://resources/beat_chart.gd" id="3_chart"] + +[sub_resource type="Resource" id="ChartEvent_melee_1"] +script = ExtResource("1_event") +event_id = &"stage7_combo_1" +beat_index = 2 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_1" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartEvent_melee_2"] +script = ExtResource("1_event") +event_id = &"stage7_combo_2" +beat_index = 4 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_2" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartEvent_melee_3"] +script = ExtResource("1_event") +event_id = &"stage7_combo_3" +beat_index = 6 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_3" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartEvent_special_stab"] +script = ExtResource("1_event") +event_id = &"stage7_lunging_stab" +beat_index = 10 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_lunging_stab" +lead_beats = 2.0 +time_phase_mask = &"past" + +[sub_resource type="Resource" id="ChartEvent_ranged_1"] +script = ExtResource("1_event") +event_id = &"stage7_shoot_1" +beat_index = 16 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_1" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartEvent_ranged_2"] +script = ExtResource("1_event") +event_id = &"stage7_shoot_2" +beat_index = 18 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_2" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartEvent_special_dash"] +script = ExtResource("1_event") +event_id = &"stage7_dash" +beat_index = 20 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_dash" +lead_beats = 1.0 +time_phase_mask = &"past" + +[sub_resource type="Resource" id="ChartEvent_ranged_2way"] +script = ExtResource("1_event") +event_id = &"stage7_2way_shoot" +beat_index = 22 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_2way_shoot" +lead_beats = 1.0 + +[sub_resource type="Resource" id="ChartTrack_melee"] +script = ExtResource("2_track") +track_id = &"track_boss_melee" +track_type = &"boss" +time_phase_mask = &"past" +events = Array[Resource]([SubResource("ChartEvent_melee_1"), SubResource("ChartEvent_melee_2"), SubResource("ChartEvent_melee_3")]) + +[sub_resource type="Resource" id="ChartTrack_ranged"] +script = ExtResource("2_track") +track_id = &"track_boss_ranged" +track_type = &"boss" +time_phase_mask = &"future" +events = Array[Resource]([SubResource("ChartEvent_ranged_1"), SubResource("ChartEvent_ranged_2"), SubResource("ChartEvent_ranged_2way")]) + +[sub_resource type="Resource" id="ChartTrack_special"] +script = ExtResource("2_track") +track_id = &"track_boss_special" +track_type = &"boss" +events = Array[Resource]([SubResource("ChartEvent_special_stab"), SubResource("ChartEvent_special_dash")]) + +[resource] +script = ExtResource("3_chart") +chart_id = &"stage7_boss_opening" +total_beats = 32 +tracks = Array[Resource]([SubResource("ChartTrack_melee"), SubResource("ChartTrack_ranged"), SubResource("ChartTrack_special")]) diff --git a/resources/charts/stage9_boss_duel.tres b/resources/charts/stage9_boss_duel.tres new file mode 100644 index 0000000..6ead7ac --- /dev/null +++ b/resources/charts/stage9_boss_duel.tres @@ -0,0 +1,195 @@ +[gd_resource type="Resource" script_class="BeatChart" format=3 uid="uid://3xrcwnvo4ih3"] + +[ext_resource type="Script" uid="uid://bsbmwl7n6uil5" path="res://resources/chart_event.gd" id="1_event"] +[ext_resource type="Script" uid="uid://t7etuk7uw3ns" path="res://resources/chart_track.gd" id="2_track"] +[ext_resource type="Script" uid="uid://b0jn7bu4w1co7" path="res://resources/beat_chart.gd" id="3_chart"] + +[sub_resource type="Resource" id="ChartEvent_melee_1"] +script = ExtResource("1_event") +event_id = &"stage9_m1_combo_1" +beat_index = 2 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_1" + +[sub_resource type="Resource" id="ChartEvent_melee_2"] +script = ExtResource("1_event") +event_id = &"stage9_m1_combo_2" +beat_index = 4 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_2" + +[sub_resource type="Resource" id="ChartEvent_melee_3"] +script = ExtResource("1_event") +event_id = &"stage9_m1_combo_3" +beat_index = 6 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_3" + +[sub_resource type="Resource" id="ChartEvent_melee_dash_in"] +script = ExtResource("1_event") +event_id = &"stage9_m2_dash_in" +beat_index = 24 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_dash" + +[sub_resource type="Resource" id="ChartEvent_melee_4"] +script = ExtResource("1_event") +event_id = &"stage9_m2_combo_1" +beat_index = 26 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_1" + +[sub_resource type="Resource" id="ChartEvent_melee_5"] +script = ExtResource("1_event") +event_id = &"stage9_m2_combo_2" +beat_index = 28 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_2" + +[sub_resource type="Resource" id="ChartEvent_melee_6"] +script = ExtResource("1_event") +event_id = &"stage9_m3_combo_3" +beat_index = 50 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_combo_3" + +[sub_resource type="Resource" id="ChartTrack_melee"] +script = ExtResource("2_track") +track_id = &"track_boss_melee" +track_type = &"boss" +events = [SubResource("ChartEvent_melee_1"), SubResource("ChartEvent_melee_2"), SubResource("ChartEvent_melee_3"), SubResource("ChartEvent_melee_dash_in"), SubResource("ChartEvent_melee_4"), SubResource("ChartEvent_melee_5"), SubResource("ChartEvent_melee_6")] + +[sub_resource type="Resource" id="ChartEvent_ranged_1"] +script = ExtResource("1_event") +event_id = &"stage9_r1_shoot_1" +beat_index = 16 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_1" + +[sub_resource type="Resource" id="ChartEvent_ranged_2"] +script = ExtResource("1_event") +event_id = &"stage9_r1_shoot_2" +beat_index = 18 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_2" + +[sub_resource type="Resource" id="ChartEvent_ranged_retreat"] +script = ExtResource("1_event") +event_id = &"stage9_r1_retreat" +beat_index = 20 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_retreat_dash" + +[sub_resource type="Resource" id="ChartEvent_ranged_2way"] +script = ExtResource("1_event") +event_id = &"stage9_r2_2way" +beat_index = 22 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_2way_shoot" + +[sub_resource type="Resource" id="ChartEvent_ranged_3"] +script = ExtResource("1_event") +event_id = &"stage9_r2_shoot_1" +beat_index = 34 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_1" + +[sub_resource type="Resource" id="ChartEvent_ranged_4"] +script = ExtResource("1_event") +event_id = &"stage9_r2_shoot_2" +beat_index = 36 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_2" + +[sub_resource type="Resource" id="ChartEvent_ranged_5"] +script = ExtResource("1_event") +event_id = &"stage9_r3_shoot_1" +beat_index = 46 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_shoot_1" + +[sub_resource type="Resource" id="ChartTrack_ranged"] +script = ExtResource("2_track") +track_id = &"track_boss_ranged" +track_type = &"boss" +events = [SubResource("ChartEvent_ranged_1"), SubResource("ChartEvent_ranged_2"), SubResource("ChartEvent_ranged_retreat"), SubResource("ChartEvent_ranged_2way"), SubResource("ChartEvent_ranged_3"), SubResource("ChartEvent_ranged_4"), SubResource("ChartEvent_ranged_5")] + +[sub_resource type="Resource" id="ChartEvent_special_stab_1"] +script = ExtResource("1_event") +event_id = &"stage9_s1_lunging_stab" +beat_index = 10 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_lunging_stab" +lead_beats = 2.0 + +[sub_resource type="Resource" id="ChartEvent_special_stab_2"] +script = ExtResource("1_event") +event_id = &"stage9_s2_lunging_stab" +beat_index = 42 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_lunging_stab" +lead_beats = 2.0 + +[sub_resource type="Resource" id="ChartEvent_special_2way"] +script = ExtResource("1_event") +event_id = &"stage9_s3_2way" +beat_index = 54 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_2way_shoot" + +[sub_resource type="Resource" id="ChartEvent_special_retreat"] +script = ExtResource("1_event") +event_id = &"stage9_s3_retreat" +beat_index = 58 +event_type = &"enemy_action" +target_id = &"Boss" +action_id = &"boss_retreat_dash" + +[sub_resource type="Resource" id="ChartTrack_special"] +script = ExtResource("2_track") +track_id = &"track_boss_special" +track_type = &"boss" +events = [SubResource("ChartEvent_special_stab_1"), SubResource("ChartEvent_special_stab_2"), SubResource("ChartEvent_special_2way"), SubResource("ChartEvent_special_retreat")] + +[sub_resource type="Resource" id="ChartEvent_time_anchor_1"] +script = ExtResource("1_event") +event_id = &"stage9_ta_08" +beat_index = 8 +event_type = &"time_anchor" +lead_beats = 2.0 + +[sub_resource type="Resource" id="ChartEvent_time_anchor_2"] +script = ExtResource("1_event") +event_id = &"stage9_ta_40" +beat_index = 40 +event_type = &"time_anchor" +lead_beats = 2.0 + +[sub_resource type="Resource" id="ChartTrack_time_anchor"] +script = ExtResource("2_track") +track_id = &"track_time_anchor" +track_type = &"time_anchor" +events = [SubResource("ChartEvent_time_anchor_1"), SubResource("ChartEvent_time_anchor_2")] + +[resource] +script = ExtResource("3_chart") +chart_id = &"stage9_boss_duel" +total_beats = 64 +tracks = [SubResource("ChartTrack_melee"), SubResource("ChartTrack_ranged"), SubResource("ChartTrack_special"), SubResource("ChartTrack_time_anchor")] diff --git a/resources/effects/action_rule_modifier.gd b/resources/effects/action_rule_modifier.gd new file mode 100644 index 0000000..f02004a --- /dev/null +++ b/resources/effects/action_rule_modifier.gd @@ -0,0 +1,7 @@ +class_name ActionRuleModifier +extends Resource + +@export var blocked_action_tags: Array[StringName] = [] +@export var allowed_action_tags: Array[StringName] = [] +@export var allow_movement_while_charging := false +@export var block_movement := false diff --git a/resources/effects/action_rule_modifier.gd.uid b/resources/effects/action_rule_modifier.gd.uid new file mode 100644 index 0000000..6c382a8 --- /dev/null +++ b/resources/effects/action_rule_modifier.gd.uid @@ -0,0 +1 @@ +uid://cphgi446ov1i6 diff --git a/resources/effects/defense_modifier.gd b/resources/effects/defense_modifier.gd new file mode 100644 index 0000000..60b8b1e --- /dev/null +++ b/resources/effects/defense_modifier.gd @@ -0,0 +1,7 @@ +class_name DefenseModifier +extends Resource + +@export var defense_state: StringName = &"" +@export var damage_mult := 1.0 +@export var interrupts := true +@export var required_attack_tags: Array[StringName] = [] diff --git a/resources/effects/defense_modifier.gd.uid b/resources/effects/defense_modifier.gd.uid new file mode 100644 index 0000000..3a8a833 --- /dev/null +++ b/resources/effects/defense_modifier.gd.uid @@ -0,0 +1 @@ +uid://b10ssegsbqfn diff --git a/resources/effects/effect_burst_power.tres b/resources/effects/effect_burst_power.tres new file mode 100644 index 0000000..bcda914 --- /dev/null +++ b/resources/effects/effect_burst_power.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=4 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/stat_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_burst_damage"] +script = ExtResource("2") +stat = &"damage_mult" +operation = "multiply" +value = 1.2 + +[sub_resource type="Resource" id="Resource_burst_cost"] +script = ExtResource("2") +stat = &"cost_mult" +operation = "multiply" +value = 0.0 + +[resource] +script = ExtResource("1") +id = &"effect_burst_power" +display_name = "Burst Power" +duration_type = &"beats" +duration = 16.0 +max_stacks = 1 +stat_modifiers = Array[Resource]([SubResource("Resource_burst_damage"), SubResource("Resource_burst_cost")]) diff --git a/resources/effects/effect_definition.gd b/resources/effects/effect_definition.gd new file mode 100644 index 0000000..938e594 --- /dev/null +++ b/resources/effects/effect_definition.gd @@ -0,0 +1,16 @@ +class_name EffectDefinition +extends Resource + +@export var id: StringName +@export var display_name: String +@export var duration_type: StringName = &"seconds" +@export var duration := 0.0 +@export var max_stacks := 1 +@export var stat_modifiers: Array[Resource] = [] +@export var defense_modifiers: Array[Resource] = [] +@export var action_rule_modifiers: Array[Resource] = [] +@export var trigger_event: StringName = &"" +@export var trigger_effects: Array[Resource] = [] +@export var required_event_action_tags: Array[StringName] = [] +@export var blocked_event_action_tags: Array[StringName] = [] +@export var until_event: StringName = &"" diff --git a/resources/effects/effect_definition.gd.uid b/resources/effects/effect_definition.gd.uid new file mode 100644 index 0000000..f9cdd65 --- /dev/null +++ b/resources/effects/effect_definition.gd.uid @@ -0,0 +1 @@ +uid://cvlmwyu6iqb8g diff --git a/resources/effects/effect_haste.tres b/resources/effects/effect_haste.tres new file mode 100644 index 0000000..9117e3b --- /dev/null +++ b/resources/effects/effect_haste.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://bop6tpmynvgqf"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_haste_move"] +script = ExtResource("2") +stat = &"move_speed" +value = 1.25 + +[resource] +script = ExtResource("1") +id = &"effect_haste" +display_name = "Haste" +duration = 2.0 +stat_modifiers = Array[Resource]([SubResource("Resource_haste_move")]) diff --git a/resources/effects/effect_hurt_super_armor.tres b/resources/effects/effect_hurt_super_armor.tres new file mode 100644 index 0000000..44290cf --- /dev/null +++ b/resources/effects/effect_hurt_super_armor.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/defense_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_hurt_super_armor_defense"] +script = ExtResource("2") +defense_state = &"SuperArmor" +damage_mult = 1.0 +interrupts = false + +[resource] +script = ExtResource("1") +id = &"hurt_super_armor" +display_name = "Hurt Super Armor" +duration_type = &"seconds" +duration = 0.5 +max_stacks = 1 +defense_modifiers = Array[Resource]([SubResource("Resource_hurt_super_armor_defense")]) diff --git a/resources/effects/effect_instance.gd b/resources/effects/effect_instance.gd new file mode 100644 index 0000000..353c837 --- /dev/null +++ b/resources/effects/effect_instance.gd @@ -0,0 +1,92 @@ +class_name EffectInstance +extends RefCounted + +var definition: Resource +var remaining := 0.0 +var stacks := 1 +var source: Variant = &"-" + + +static func create(next_definition: Resource, next_source: Variant = &"-") -> RefCounted: + var script: Script = load("res://resources/effects/effect_instance.gd") + var instance: RefCounted = script.new() + instance.definition = next_definition + instance.remaining = float(next_definition.get("duration")) + if StringName(str(next_definition.get("duration_type"))) == &"until_event": + instance.remaining = 1.0 + instance.source = next_source + return instance + + +func is_expired() -> bool: + return remaining <= 0.0 and StringName(str(definition.get("duration_type"))) != &"infinite" + + +func tick_time(delta: float) -> void: + var duration_type := StringName(str(definition.get("duration_type"))) + if duration_type == &"seconds": + remaining -= delta + + +func tick_beats(beats: float) -> void: + var duration_type := StringName(str(definition.get("duration_type"))) + if duration_type == &"beats": + remaining -= beats + + +func tick_event(event_name: StringName, context: Dictionary = {}) -> void: + if not matches_event_context(context): + return + var duration_type := StringName(str(definition.get("duration_type"))) + if duration_type == &"actions" and event_name == &"on_action_start": + remaining -= 1.0 + elif duration_type == &"hits" and event_name == &"on_hit": + remaining -= 1.0 + elif duration_type == &"hurts" and event_name == &"on_hurt": + remaining -= 1.0 + elif duration_type == &"measures" and event_name == &"on_measure_start": + remaining -= 1.0 + elif duration_type == &"until_event" and event_name == StringName(str(definition.get("until_event"))): + remaining = 0.0 + + +func matches_event_context(context: Dictionary = {}) -> bool: + var action_tags := _event_action_tags(context) + var required_tags := _string_name_array(definition.get("required_event_action_tags")) + if not required_tags.is_empty() and not _has_any(action_tags, required_tags): + return false + var blocked_tags := _string_name_array(definition.get("blocked_event_action_tags")) + if not blocked_tags.is_empty() and _has_any(action_tags, blocked_tags): + return false + return true + + +func _event_action_tags(context: Dictionary) -> Array[StringName]: + if context.has("action_tags"): + return _string_name_array(context["action_tags"]) + var action: Resource = context.get("action", null) as Resource + if action != null: + return _string_name_array(action.get("action_tags")) + var result = context.get("result", {}) + if result is Dictionary: + if result.has("action_tags"): + return _string_name_array(result["action_tags"]) + var result_action: Resource = result.get("action", null) as Resource + if result_action != null: + return _string_name_array(result_action.get("action_tags")) + return [] + + +func _string_name_array(value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if value is Array: + for item: Variant in value: + result.append(StringName(str(item))) + return result + + +func _has_any(values: Array[StringName], candidates: Array[StringName]) -> bool: + for value: StringName in values: + if candidates.has(value): + return true + return false diff --git a/resources/effects/effect_instance.gd.uid b/resources/effects/effect_instance.gd.uid new file mode 100644 index 0000000..efa873b --- /dev/null +++ b/resources/effects/effect_instance.gd.uid @@ -0,0 +1 @@ +uid://bvqpc3oh47qdb diff --git a/resources/effects/effect_on_landed_haste.tres b/resources/effects/effect_on_landed_haste.tres new file mode 100644 index 0000000..4257987 --- /dev/null +++ b/resources/effects/effect_on_landed_haste.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Resource" path="res://resources/effects/effect_haste.tres" id="2"] + +[resource] +script = ExtResource("1") +id = &"effect_on_landed_haste" +display_name = "On Landed Haste" +duration_type = &"infinite" +duration = 0.0 +max_stacks = 1 +trigger_event = &"on_landed" +trigger_effects = Array[Resource]([ExtResource("2")]) diff --git a/resources/effects/effect_on_perfect_damage_reward.tres b/resources/effects/effect_on_perfect_damage_reward.tres new file mode 100644 index 0000000..462c763 --- /dev/null +++ b/resources/effects/effect_on_perfect_damage_reward.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Resource" path="res://resources/effects/effect_perfect_damage_buff.tres" id="2"] + +[resource] +script = ExtResource("1") +id = &"effect_on_perfect_damage_reward" +display_name = "On Perfect Damage Reward" +duration_type = &"infinite" +duration = 0.0 +max_stacks = 1 +trigger_event = &"on_perfect" +trigger_effects = Array[Resource]([ExtResource("2")]) diff --git a/resources/effects/effect_perfect_damage_buff.tres b/resources/effects/effect_perfect_damage_buff.tres new file mode 100644 index 0000000..7663efa --- /dev/null +++ b/resources/effects/effect_perfect_damage_buff.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://ccy324lau1u7c"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_perfect_damage"] +script = ExtResource("2") +stat = &"damage_mult" +value = 1.25 + +[resource] +script = ExtResource("1") +id = &"effect_perfect_damage_buff" +display_name = "Perfect Damage Buff" +duration_type = &"hits" +duration = 1.0 +stat_modifiers = Array[Resource]([SubResource("Resource_perfect_damage")]) diff --git a/resources/effects/effect_root.tres b/resources/effects/effect_root.tres new file mode 100644 index 0000000..4d95b02 --- /dev/null +++ b/resources/effects/effect_root.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/action_rule_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_root_rule"] +script = ExtResource("2") +block_movement = true + +[resource] +script = ExtResource("1") +id = &"effect_root" +display_name = "Root" +duration_type = &"seconds" +duration = 1.5 +max_stacks = 1 +action_rule_modifiers = Array[Resource]([SubResource("Resource_root_rule")]) diff --git a/resources/effects/effect_silence.tres b/resources/effects/effect_silence.tres new file mode 100644 index 0000000..5638779 --- /dev/null +++ b/resources/effects/effect_silence.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/action_rule_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_silence_rule"] +script = ExtResource("2") +blocked_action_tags = Array[StringName]([&"skill"]) + +[resource] +script = ExtResource("1") +id = &"effect_silence" +display_name = "Silence" +duration_type = &"seconds" +duration = 2.0 +max_stacks = 1 +action_rule_modifiers = Array[Resource]([SubResource("Resource_silence_rule")]) diff --git a/resources/effects/effect_slow.tres b/resources/effects/effect_slow.tres new file mode 100644 index 0000000..28a36c7 --- /dev/null +++ b/resources/effects/effect_slow.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/stat_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_slow_move"] +script = ExtResource("2") +stat = &"move_speed" +operation = "multiply" +value = 0.5 + +[resource] +script = ExtResource("1") +id = &"effect_slow" +display_name = "Slow" +duration_type = &"seconds" +duration = 2.0 +max_stacks = 1 +stat_modifiers = Array[Resource]([SubResource("Resource_slow_move")]) diff --git a/resources/effects/effect_temporary_invincible.tres b/resources/effects/effect_temporary_invincible.tres new file mode 100644 index 0000000..1a211dd --- /dev/null +++ b/resources/effects/effect_temporary_invincible.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/defense_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_invincible_defense"] +script = ExtResource("2") +defense_state = &"Invincible" +damage_mult = 1.0 +interrupts = false + +[resource] +script = ExtResource("1") +id = &"effect_temporary_invincible" +display_name = "Temporary Invincible" +duration_type = &"seconds" +duration = 0.5 +max_stacks = 1 +defense_modifiers = Array[Resource]([SubResource("Resource_invincible_defense")]) diff --git a/resources/effects/effect_temporary_super_armor.tres b/resources/effects/effect_temporary_super_armor.tres new file mode 100644 index 0000000..31619e2 --- /dev/null +++ b/resources/effects/effect_temporary_super_armor.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/defense_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_super_armor_defense"] +script = ExtResource("2") +defense_state = &"SuperArmor" +damage_mult = 1.0 +interrupts = false + +[resource] +script = ExtResource("1") +id = &"effect_temporary_super_armor" +display_name = "Temporary Super Armor" +duration_type = &"seconds" +duration = 1.0 +max_stacks = 1 +defense_modifiers = Array[Resource]([SubResource("Resource_super_armor_defense")]) diff --git a/resources/effects/stat_modifier.gd b/resources/effects/stat_modifier.gd new file mode 100644 index 0000000..190e482 --- /dev/null +++ b/resources/effects/stat_modifier.gd @@ -0,0 +1,6 @@ +class_name StatModifier +extends Resource + +@export var stat: StringName +@export_enum("add", "multiply") var operation := "multiply" +@export var value := 1.0 diff --git a/resources/effects/stat_modifier.gd.uid b/resources/effects/stat_modifier.gd.uid new file mode 100644 index 0000000..ce25b9b --- /dev/null +++ b/resources/effects/stat_modifier.gd.uid @@ -0,0 +1 @@ +uid://brratisl42y1l diff --git a/resources/effects/time_phase/effect_time_anchor_attack_buff.tres b/resources/effects/time_phase/effect_time_anchor_attack_buff.tres new file mode 100644 index 0000000..fae1652 --- /dev/null +++ b/resources/effects/time_phase/effect_time_anchor_attack_buff.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EffectDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" path="res://resources/effects/stat_modifier.gd" id="2"] + +[sub_resource type="Resource" id="Resource_attack_buff_damage"] +script = ExtResource("2") +stat = &"damage_mult" +operation = "add" +value = 0.5 + +[resource] +script = ExtResource("1") +id = &"time_anchor_attack_buff" +display_name = "Time Anchor Attack Buff" +duration_type = &"infinite" +duration = 0.0 +max_stacks = 7 +stat_modifiers = Array[Resource]([SubResource("Resource_attack_buff_damage")]) diff --git a/resources/effects/time_phase/effect_tp_future_enemy_strong.tres b/resources/effects/time_phase/effect_tp_future_enemy_strong.tres new file mode 100644 index 0000000..c668717 --- /dev/null +++ b/resources/effects/time_phase/effect_tp_future_enemy_strong.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://djg52uwagbdat"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] +[ext_resource type="Script" uid="uid://b10ssegsbqfn" path="res://resources/effects/defense_modifier.gd" id="3"] + +[sub_resource type="Resource" id="Resource_taken_down"] +script = ExtResource("3") +damage_mult = 0.25 + +[sub_resource type="Resource" id="Resource_damage_up"] +script = ExtResource("2") +stat = &"damage_mult" +value = 1.4 + +[resource] +script = ExtResource("1") +id = &"effect_tp_future_enemy_strong" +display_name = "Future Phase Strong" +duration_type = &"infinite" +stat_modifiers = Array[Resource]([SubResource("Resource_damage_up")]) +defense_modifiers = Array[Resource]([SubResource("Resource_taken_down")]) diff --git a/resources/effects/time_phase/effect_tp_future_enemy_weak.tres b/resources/effects/time_phase/effect_tp_future_enemy_weak.tres new file mode 100644 index 0000000..22fc49d --- /dev/null +++ b/resources/effects/time_phase/effect_tp_future_enemy_weak.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://dkg4j1an7jclo"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] +[ext_resource type="Script" uid="uid://b10ssegsbqfn" path="res://resources/effects/defense_modifier.gd" id="3"] + +[sub_resource type="Resource" id="Resource_taken_up"] +script = ExtResource("3") +damage_mult = 2.5 + +[sub_resource type="Resource" id="Resource_damage_down"] +script = ExtResource("2") +stat = &"damage_mult" +value = 0.7 + +[resource] +script = ExtResource("1") +id = &"effect_tp_future_enemy_weak" +display_name = "Future-Strong Enemy Weakened" +duration_type = &"infinite" +stat_modifiers = Array[Resource]([SubResource("Resource_damage_down")]) +defense_modifiers = Array[Resource]([SubResource("Resource_taken_up")]) diff --git a/resources/effects/time_phase/effect_tp_past_enemy_strong.tres b/resources/effects/time_phase/effect_tp_past_enemy_strong.tres new file mode 100644 index 0000000..8c01287 --- /dev/null +++ b/resources/effects/time_phase/effect_tp_past_enemy_strong.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://e1bbntw3dfsx"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] +[ext_resource type="Script" uid="uid://b10ssegsbqfn" path="res://resources/effects/defense_modifier.gd" id="3"] + +[sub_resource type="Resource" id="Resource_taken_down"] +script = ExtResource("3") +damage_mult = 0.25 + +[sub_resource type="Resource" id="Resource_damage_up"] +script = ExtResource("2") +stat = &"damage_mult" +value = 1.4 + +[resource] +script = ExtResource("1") +id = &"effect_tp_past_enemy_strong" +display_name = "Past Phase Strong" +duration_type = &"infinite" +stat_modifiers = Array[Resource]([SubResource("Resource_damage_up")]) +defense_modifiers = Array[Resource]([SubResource("Resource_taken_down")]) diff --git a/resources/effects/time_phase/effect_tp_past_enemy_weak.tres b/resources/effects/time_phase/effect_tp_past_enemy_weak.tres new file mode 100644 index 0000000..dc9bce7 --- /dev/null +++ b/resources/effects/time_phase/effect_tp_past_enemy_weak.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="EffectDefinition" format=3 uid="uid://bougox245ioo"] + +[ext_resource type="Script" uid="uid://cvlmwyu6iqb8g" path="res://resources/effects/effect_definition.gd" id="1"] +[ext_resource type="Script" uid="uid://brratisl42y1l" path="res://resources/effects/stat_modifier.gd" id="2"] +[ext_resource type="Script" uid="uid://b10ssegsbqfn" path="res://resources/effects/defense_modifier.gd" id="3"] + +[sub_resource type="Resource" id="Resource_taken_up"] +script = ExtResource("3") +damage_mult = 2.5 + +[sub_resource type="Resource" id="Resource_damage_down"] +script = ExtResource("2") +stat = &"damage_mult" +value = 0.7 + +[resource] +script = ExtResource("1") +id = &"effect_tp_past_enemy_weak" +display_name = "Past-Strong Enemy Weakened" +duration_type = &"infinite" +stat_modifiers = Array[Resource]([SubResource("Resource_damage_down")]) +defense_modifiers = Array[Resource]([SubResource("Resource_taken_up")]) diff --git a/resources/judgement_policy.gd b/resources/judgement_policy.gd new file mode 100644 index 0000000..06eec57 --- /dev/null +++ b/resources/judgement_policy.gd @@ -0,0 +1,24 @@ +class_name JudgementPolicy +extends Resource + +@export var entries: Dictionary = {} + + +func multiplier(label: StringName, channel: StringName, fallback := 1.0) -> float: + var entry := _entry_for(label) + if entry.is_empty(): + return fallback + if entry.has(str(channel)): + return float(entry[str(channel)]) + if entry.has(channel): + return float(entry[channel]) + return fallback + + +func _entry_for(label: StringName) -> Dictionary: + var key := str(label).to_lower() + if entries.has(key) and entries[key] is Dictionary: + return entries[key] + if entries.has(label) and entries[label] is Dictionary: + return entries[label] + return {} diff --git a/resources/judgement_policy.gd.uid b/resources/judgement_policy.gd.uid new file mode 100644 index 0000000..3693c41 --- /dev/null +++ b/resources/judgement_policy.gd.uid @@ -0,0 +1 @@ +uid://bklc74dqht34e diff --git a/resources/judgement_policy.tres b/resources/judgement_policy.tres new file mode 100644 index 0000000..b4f85b1 --- /dev/null +++ b/resources/judgement_policy.tres @@ -0,0 +1,23 @@ +[gd_resource type="Resource" script_class="JudgementPolicy" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/judgement_policy.gd" id="1_policy"] + +[resource] +script = ExtResource("1_policy") +entries = { +"bad": { +"cost": 1.2, +"damage": 0.7, +"reward": 0.6 +}, +"good": { +"cost": 1.0, +"damage": 0.85, +"reward": 0.8 +}, +"perfect": { +"cost": 0.8, +"damage": 1.0, +"reward": 1.0 +} +} diff --git a/resources/player_action_bindings.gd b/resources/player_action_bindings.gd new file mode 100644 index 0000000..1855121 --- /dev/null +++ b/resources/player_action_bindings.gd @@ -0,0 +1,25 @@ +class_name PlayerSkillBindings +extends Resource + +@export var charge_entries: Array[Dictionary] = [] +@export var burst_action_id: StringName = &"" +@export var counter_action_id: StringName = &"" +@export var blade_chain_action_id: StringName = &"" + + +func charge_entry_for_symbol(symbol: StringName) -> Dictionary: + for entry: Dictionary in charge_entries: + if StringName(str(entry.get("symbol", &""))) == symbol: + return entry.duplicate(true) + return {} + + +func cast_action_id(entry: Dictionary, level: int) -> StringName: + var cast_ids: Dictionary = entry.get("cast_action_ids", {}) + if cast_ids.has(level): + return StringName(str(cast_ids[level])) + var level_key := str(level) + if cast_ids.has(level_key): + return StringName(str(cast_ids[level_key])) + return &"" + diff --git a/resources/player_action_bindings.gd.uid b/resources/player_action_bindings.gd.uid new file mode 100644 index 0000000..d80edad --- /dev/null +++ b/resources/player_action_bindings.gd.uid @@ -0,0 +1 @@ +uid://bfhpj4kxpw50e diff --git a/resources/player_action_bindings.tres b/resources/player_action_bindings.tres new file mode 100644 index 0000000..81d2fe4 --- /dev/null +++ b/resources/player_action_bindings.tres @@ -0,0 +1,38 @@ +[gd_resource type="Resource" script_class="PlayerSkillBindings" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/player_action_bindings.gd" id="1_xjc2f"] + +[resource] +script = ExtResource("1_xjc2f") +charge_entries = Array[Dictionary]([{ +"symbol": &"A", +"entry_mode": &"after_tap", +"cast_trigger": &"on_release", +"cast_action_ids": { +1: &"jian_yu_left_lv1", +2: &"jian_yu_left_lv2", +3: &"jian_yu_left_lv3" +} +}, { +"symbol": &"D", +"entry_mode": &"after_tap", +"cast_trigger": &"on_release", +"cast_action_ids": { +1: &"jian_yu_right_lv1", +2: &"jian_yu_right_lv2", +3: &"jian_yu_right_lv3" +} +}, { +"symbol": &"S", +"entry_mode": &"after_tap", +"cast_trigger": &"on_secondary_key", +"cast_key": &"SP", +"cast_action_ids": { +1: &"zhan_bo_lv1", +2: &"zhan_bo_lv2", +3: &"zhan_bo_lv3" +} +}]) +burst_action_id = &"burst_activate" +counter_action_id = &"counter_projectile" +blade_chain_action_id = &"blade_chain_projectile_2" diff --git a/resources/time_anchor_frequency_default.tres b/resources/time_anchor_frequency_default.tres new file mode 100644 index 0000000..93d6cb2 --- /dev/null +++ b/resources/time_anchor_frequency_default.tres @@ -0,0 +1,9 @@ +[gd_resource type="Resource" script_class="TimeAnchorFrequencyProfile" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/time_anchor_frequency_profile.gd" id="1"] + +[resource] +script = ExtResource("1") +streak_thresholds = Array[int]([0, 8, 16]) +anchors_per_window = Array[int]([1, 2, 4]) +window_beats = 4 diff --git a/resources/time_anchor_frequency_profile.gd b/resources/time_anchor_frequency_profile.gd new file mode 100644 index 0000000..f6b5efb --- /dev/null +++ b/resources/time_anchor_frequency_profile.gd @@ -0,0 +1,34 @@ +class_name TimeAnchorFrequencyProfile +extends Resource + +@export var streak_thresholds: Array[int] = [0, 8, 16] +@export var anchors_per_window: Array[int] = [1, 2, 4] +@export var window_beats := 4 + + +func interval_for_streak(streak: int) -> int: + var anchors := anchors_per_window_for_streak(streak) + return maxi(1, int(ceil(float(window_beats) / float(maxi(1, anchors))))) + + +func anchors_per_window_for_streak(streak: int) -> int: + var count := 1 + for index: int in range(streak_thresholds.size()): + if index >= anchors_per_window.size(): + break + if streak >= streak_thresholds[index]: + count = clampi(anchors_per_window[index], 1, maxi(1, window_beats)) + return count + + +func anchor_offsets_for_streak(streak: int) -> Array[int]: + var safe_window := maxi(1, window_beats) + var count := anchors_per_window_for_streak(streak) + if count <= 1: + return [safe_window] + if count == 2: + return [maxi(1, int(ceil(float(safe_window) * 0.5))), safe_window] + var offsets: Array[int] = [] + for beat_offset: int in range(1, safe_window + 1): + offsets.append(beat_offset) + return offsets diff --git a/resources/time_anchor_frequency_profile.gd.uid b/resources/time_anchor_frequency_profile.gd.uid new file mode 100644 index 0000000..2dc8580 --- /dev/null +++ b/resources/time_anchor_frequency_profile.gd.uid @@ -0,0 +1 @@ +uid://bvfrkrccl0pl diff --git a/resources/time_phase/profile_future_strong_enemy.tres b/resources/time_phase/profile_future_strong_enemy.tres new file mode 100644 index 0000000..2d77265 --- /dev/null +++ b/resources/time_phase/profile_future_strong_enemy.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="TimePhaseProfile" load_steps=4 format=3] + +[ext_resource type="Script" path="res://resources/time_phase_profile.gd" id="1"] +[ext_resource type="Resource" path="res://resources/effects/time_phase/effect_tp_future_enemy_weak.tres" id="2"] +[ext_resource type="Resource" path="res://resources/effects/time_phase/effect_tp_future_enemy_strong.tres" id="3"] + +[resource] +script = ExtResource("1") +past_effects = Array[Resource]([ExtResource("2")]) +future_effects = Array[Resource]([ExtResource("3")]) +past_visual_key = &"past" +future_visual_key = &"future" diff --git a/resources/time_phase/profile_past_strong_enemy.tres b/resources/time_phase/profile_past_strong_enemy.tres new file mode 100644 index 0000000..27f8d7a --- /dev/null +++ b/resources/time_phase/profile_past_strong_enemy.tres @@ -0,0 +1,12 @@ +[gd_resource type="Resource" script_class="TimePhaseProfile" load_steps=4 format=3] + +[ext_resource type="Script" path="res://resources/time_phase_profile.gd" id="1"] +[ext_resource type="Resource" path="res://resources/effects/time_phase/effect_tp_past_enemy_strong.tres" id="2"] +[ext_resource type="Resource" path="res://resources/effects/time_phase/effect_tp_past_enemy_weak.tres" id="3"] + +[resource] +script = ExtResource("1") +past_effects = Array[Resource]([ExtResource("2")]) +future_effects = Array[Resource]([ExtResource("3")]) +past_visual_key = &"past" +future_visual_key = &"future" diff --git a/resources/time_phase_profile.gd b/resources/time_phase_profile.gd new file mode 100644 index 0000000..626df6c --- /dev/null +++ b/resources/time_phase_profile.gd @@ -0,0 +1,15 @@ +class_name TimePhaseProfile +extends Resource + +@export var past_effects: Array[Resource] = [] +@export var future_effects: Array[Resource] = [] +@export var past_visual_key: StringName = &"past" +@export var future_visual_key: StringName = &"future" + + +func effects_for_time_phase(time_phase: StringName) -> Array[Resource]: + return future_effects if time_phase == &"future" else past_effects + + +func visual_key_for_time_phase(time_phase: StringName) -> StringName: + return future_visual_key if time_phase == &"future" else past_visual_key diff --git a/resources/time_phase_profile.gd.uid b/resources/time_phase_profile.gd.uid new file mode 100644 index 0000000..e1cc9ac --- /dev/null +++ b/resources/time_phase_profile.gd.uid @@ -0,0 +1 @@ +uid://cp1s5pnihfd0 diff --git a/scenes/audio/audio_layer_controller.gd b/scenes/audio/audio_layer_controller.gd new file mode 100644 index 0000000..18fa308 --- /dev/null +++ b/scenes/audio/audio_layer_controller.gd @@ -0,0 +1,170 @@ +class_name AudioLayerController +extends Node + +## Presentation-layer dual music loops (AnchorV1.0 chapter 20). +## Past and Future layers start together and stay in sync forever; a phase +## switch only crossfades volumes. The clock authority stays with +## RhythmManager — drift correction seeks THESE players, never the clock. +## +## Placeholder assets: both layers use the same song; the Future layer is +## routed through a low-pass bus so the switch is audible until a real +## Future loop asset lands (BPM/beat-aligned per the design doc). + +const FUTURE_BUS_NAME := "TimePhaseFuture" +const MUSIC_BUS_NAME := "Music" + +@export var past_stream: AudioStream +@export var future_stream: AudioStream +@export var default_stream_path := "res://assets/audio/ev_past1.mp3" +@export var crossfade_seconds := 0.35 +@export var active_volume_db := 0.0 +@export var inactive_volume_db := -60.0 +@export var drift_check_interval := 2.0 +@export var drift_threshold_seconds := 0.015 +@export var mute_clock := true +@export var future_lowpass_cutoff_hz := 2200.0 + +var past_player: AudioStreamPlayer +var future_player: AudioStreamPlayer + +var _fade_tween: Tween +var _drift_accum := 0.0 + + +func _ready() -> void: + if DisplayServer.get_name() == "headless": + return + if past_stream == null and ResourceLoader.exists(default_stream_path): + past_stream = load(default_stream_path) + if future_stream == null: + future_stream = past_stream + _ensure_future_bus() + # 双音轨走 Music 总线(Future 层经 TimePhaseFuture 低通后 send 到 Music), + # 与标题音乐一致,音乐音量设置才能同时约束标题与局内音乐。 + past_player = _make_layer_player("PastLoop", past_stream, MUSIC_BUS_NAME if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1 else "Master") + future_player = _make_layer_player("FutureLoop", future_stream, FUTURE_BUS_NAME) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + _start_layers() + _apply_phase_volumes(_current_time_phase(), true) + if mute_clock: + var rhythm := _rhythm_manager_or_null() + if rhythm != null: + rhythm.set("volume_db", -80.0) + + +func _process(delta: float) -> void: + if past_player == null: + return + _drift_accum += delta + if _drift_accum < drift_check_interval: + return + _drift_accum = 0.0 + _correct_layer_drift() + + +## Snaps both layers back onto the clock immediately (a fresh run just +## re-zeroed RhythmManager) instead of waiting for the next drift check. +func restart_layers() -> void: + _start_layers() + _apply_phase_volumes(_current_time_phase(), true) + _drift_accum = 0.0 + + +func _start_layers() -> void: + var rhythm := _rhythm_manager_or_null() + var start_position := 0.0 + if rhythm != null and rhythm.has_method("song_position"): + start_position = maxf(0.0, float(rhythm.call("song_position"))) + for player: AudioStreamPlayer in [past_player, future_player]: + if player != null and player.stream != null: + player.play(start_position) + + +func _correct_layer_drift() -> void: + var rhythm := _rhythm_manager_or_null() + if rhythm == null or not rhythm.has_method("song_position"): + return + if not bool(rhythm.get("running")): + return + var target := float(rhythm.call("song_position")) + for player: AudioStreamPlayer in [past_player, future_player]: + if player == null or not player.playing or player.stream == null: + continue + var length := float(player.stream.get_length()) + var wrapped_target := fmod(target, length) if length > 0.0 else target + var raw_drift := absf(player.get_playback_position() - wrapped_target) + var drift := minf(raw_drift, length - raw_drift) if length > 0.0 else raw_drift + if drift > drift_threshold_seconds: + player.seek(wrapped_target) + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + _apply_phase_volumes(current, false) + + +func _apply_phase_volumes(time_phase: StringName, instant: bool) -> void: + if past_player == null or future_player == null: + return + var past_target := active_volume_db if time_phase != &"future" else inactive_volume_db + var future_target := active_volume_db if time_phase == &"future" else inactive_volume_db + if _fade_tween != null and _fade_tween.is_valid(): + _fade_tween.kill() + if instant: + past_player.volume_db = past_target + future_player.volume_db = future_target + return + _fade_tween = create_tween() + _fade_tween.set_parallel(true) + _fade_tween.tween_property(past_player, "volume_db", past_target, crossfade_seconds) + _fade_tween.tween_property(future_player, "volume_db", future_target, crossfade_seconds) + + +func _make_layer_player(player_name: String, stream: AudioStream, bus_name: String) -> AudioStreamPlayer: + var player := AudioStreamPlayer.new() + player.name = player_name + player.stream = stream + player.volume_db = inactive_volume_db + if AudioServer.get_bus_index(bus_name) != -1: + player.bus = bus_name + add_child(player) + return player + + +func _ensure_future_bus() -> void: + var existing := AudioServer.get_bus_index(FUTURE_BUS_NAME) + if existing != -1: + _route_bus_to_music(existing) + return + AudioServer.add_bus() + var bus_index := AudioServer.bus_count - 1 + AudioServer.set_bus_name(bus_index, FUTURE_BUS_NAME) + var lowpass := AudioEffectLowPassFilter.new() + lowpass.cutoff_hz = future_lowpass_cutoff_hz + AudioServer.add_bus_effect(bus_index, lowpass) + _route_bus_to_music(bus_index) + + +func _route_bus_to_music(bus_index: int) -> void: + if AudioServer.get_bus_index(MUSIC_BUS_NAME) != -1: + AudioServer.set_bus_send(bus_index, MUSIC_BUS_NAME) + + +func _current_time_phase() -> StringName: + var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _rhythm_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("RhythmManager") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/audio/audio_layer_controller.gd.uid b/scenes/audio/audio_layer_controller.gd.uid new file mode 100644 index 0000000..9972f02 --- /dev/null +++ b/scenes/audio/audio_layer_controller.gd.uid @@ -0,0 +1 @@ +uid://c3agddgh7fn4a diff --git a/scenes/characters/character.gd b/scenes/characters/character.gd new file mode 100644 index 0000000..fb7d695 --- /dev/null +++ b/scenes/characters/character.gd @@ -0,0 +1,139 @@ +class_name Character +extends CharacterBody2D + +const GRAVITY := 1200.0 + +@export var speed := 180.0 +@export var jump_intensity := 304.056 +@export var attack_duration := 0.4 +@export var attack_lunge_duration := 0.18 +@export var attack_lunge_speed := 220.0 +@export var air_attack_duration := 0.45 +@export var air_attack_lunge_duration := 0.22 +@export var air_attack_lunge_speed := 260.0 + +@onready var visual: Node2D = $Visual +@onready var animation_player: AnimationPlayer = $AnimationPlayer +@onready var character_sprite: Sprite2D = $Visual/CharacterSprite + +const PRESENTATION_IDLE := &"idle" +const PRESENTATION_WALK := &"walk" +const PRESENTATION_JUMP := &"jump" +const PRESENTATION_LAND := &"land" +const PRESENTATION_ATTACK := &"attack" +const PRESENTATION_AIR_ATTACK := &"air_attack" + +var anim_map := { + PRESENTATION_IDLE: "idle", + PRESENTATION_WALK: "idle", + PRESENTATION_JUMP: "rising_slash", + PRESENTATION_LAND: "idle", + PRESENTATION_ATTACK: "atk_ground_1", + PRESENTATION_AIR_ATTACK: "atk_air", +} +var attack_direction := Vector2.LEFT +var attack_lunge_time_left := 0.0 +var attack_time_left := 0.0 +var heading := Vector2.LEFT +var height := 0.0 +var height_speed := 0.0 +var state := PRESENTATION_IDLE + + +func _physics_process(delta: float) -> void: + handle_input() + handle_air_time(delta) + handle_attack_time(delta) + handle_movement() + handle_animations() + set_sprite_height_position() + set_heading() + flip_sprites() + move_and_slide() + + +func _exit_tree() -> void: + if animation_player != null: + animation_player.stop() + + +func handle_input() -> void: + pass + + +func handle_air_time(delta: float) -> void: + if state != PRESENTATION_JUMP: + return + height += height_speed * delta + if height <= 0.0 and height_speed < 0.0: + height = 0.0 + height_speed = 0.0 + state = PRESENTATION_LAND + else: + height_speed -= GRAVITY * delta + + +func handle_attack_time(delta: float) -> void: + if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK: + return + velocity.y = 0.0 + attack_time_left -= delta + if attack_time_left <= 0.0: + state = PRESENTATION_IDLE + velocity = Vector2.ZERO + return + attack_lunge_time_left -= delta + if attack_lunge_time_left <= 0.0: + velocity.x = 0.0 + + +func handle_movement() -> void: + if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK: + return + if state == PRESENTATION_LAND: + state = PRESENTATION_IDLE + elif absf(velocity.x) > 0.0: + state = PRESENTATION_WALK + else: + state = PRESENTATION_IDLE + + +func handle_animations() -> void: + var animation_name: String = anim_map.get(state, "idle") + if animation_player.has_animation(animation_name) and animation_player.current_animation != animation_name: + animation_player.play(animation_name) + + +func set_sprite_height_position() -> void: + if visual != null: + visual.position = Vector2.UP * height + + +func set_heading() -> void: + pass + + +func flip_sprites() -> void: + if visual == null: + return + var base_scale := absf(visual.scale.x) + visual.scale.x = base_scale if heading == Vector2.LEFT else -base_scale + + +func can_jump() -> bool: + return state == PRESENTATION_IDLE or state == PRESENTATION_WALK + + +func start_jump() -> void: + state = PRESENTATION_JUMP + height_speed = jump_intensity + + +func begin_attack_motion(duration: float, next_velocity: Vector2) -> void: + attack_lunge_time_left = maxf(0.0, duration) + velocity = next_velocity + + +func stop_attack_motion() -> void: + attack_lunge_time_left = 0.0 + velocity = Vector2.ZERO diff --git a/scenes/characters/character.gd.uid b/scenes/characters/character.gd.uid new file mode 100644 index 0000000..857fcac --- /dev/null +++ b/scenes/characters/character.gd.uid @@ -0,0 +1 @@ +uid://bfrt3ewrqd07i diff --git a/scenes/characters/player.gd b/scenes/characters/player.gd new file mode 100644 index 0000000..5095c7a --- /dev/null +++ b/scenes/characters/player.gd @@ -0,0 +1,890 @@ +class_name Player +extends Character + +const OneShotFxScript := preload("res://scenes/combat/one_shot_fx.gd") + +signal combo_window_changed(slots: Array) +signal combo_window_cleared(reason: String) +signal charge_changed(current: float, maximum: float, ready: bool, active: bool) +signal energy_changed(current: int, maximum: int) +signal action_requested(action_id: String) + +@export var combo_clear_display_time := 0.35 +@export var max_energy := 100 +@export var current_energy := 0 +@export var base_move_distance := 100.0 +@export var visual_ground_offset := 0.0 +@export var action_animation_min_speed_scale := 0.35 +@export var action_animation_max_speed_scale := 5.0 + +# Manual sheet animations. "fx" entries drive Visual/FxOverlay on a parallel timeline: +# frame 0 of every author overlay sheet is the embedded idle alignment reference and is +# never played; offsets below are computed by aligning that reference to the idle pose. +const PLAYER_ANIMATION_SPECS := { + &"atk_ground_3": { + "fps": 20.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/03_basic_attack_iii/air_prep.png", "frames": 3, "hframes": 3, "vframes": 1}, + {"path": "res://assets/art/characters/player/03_basic_attack_iii/air_swing.png", "frames": 3, "hframes": 3, "vframes": 1}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/03_basic_attack_iii/slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-147, -191), "start": 0.15}, + ], + }, + &"dash_slash": { + "fps": 25.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/04_dash_slash/dashattack.png", "frames": 12, "hframes": 6, "vframes": 2}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/04_dash_slash/dash_overlay_fx.png", "hframes": 4, "vframes": 3, "first": 1, "last": 11, "fps": 25.0, "offset": Vector2(-98, -128), "start": 0.0}, + ], + }, + &"combo_finisher": { + "fps": 20.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/05_combo_finisher/prep.png", "frames": 5, "hframes": 5, "vframes": 1}, + {"path": "res://assets/art/characters/player/05_combo_finisher/air_swing.png", "frames": 6, "hframes": 6, "vframes": 1}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/05_combo_finisher/slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-147, -191), "start": 0.25}, + ], + }, + &"ground_smash": { + "fps": 20.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/06_ground_smash/prep.png", "frames": 8, "hframes": 4, "vframes": 2}, + {"path": "res://assets/art/characters/player/06_ground_smash/swing.png", "frames": 6, "hframes": 3, "vframes": 2}, + ], + }, + &"blade_rain_charge": { + "fps": 25.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/08_sword_charge/air_cast_prep.png", "frames": 20, "hframes": 10, "vframes": 2}, + ], + }, + &"blade_rain_cast": { + "fps": 20.0, + "loop": true, + "segments": [ + {"path": "res://assets/art/characters/player/09_blade_rain/air_casting_loop.png", "frames": 2, "hframes": 2, "vframes": 1}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png", "hframes": 10, "vframes": 2, "first": 17, "last": 19, "fps": 25.0, "offset": Vector2(-64, -305), "start": 0.0}, + ], + }, + &"blade_wave_cast": { + "fps": 20.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/11_blade_wave/cast.png", "frames": 4, "hframes": 4, "vframes": 1}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/11_blade_wave/cast_slash_overlay_fx.png", "hframes": 5, "vframes": 1, "first": 1, "last": 4, "fps": 20.0, "offset": Vector2(-115, -172), "start": 0.0}, + ], + }, + &"atk_air": { + "fps": 25.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/12_aerial_combo/prep.png", "frames": 2, "hframes": 2, "vframes": 1}, + {"path": "res://assets/art/characters/player/12_aerial_combo/swing_start.png", "frames": 3, "hframes": 3, "vframes": 1}, + {"path": "res://assets/art/characters/player/12_aerial_combo/falling_loop.png", "frames": 3, "hframes": 3, "vframes": 1, "fps": 20.0}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/12_aerial_combo/fx_2_swing_start_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 25.0, "offset": Vector2(-107, -198), "start": 0.08}, + {"path": "res://assets/art/characters/player/12_aerial_combo/fx_3_falling_slash_overlay_fx_hold.png", "hframes": 2, "vframes": 1, "first": 1, "last": 1, "fps": 20.0, "offset": Vector2(-107, -198), "start": 0.2, "hold": true}, + ], + }, + &"air_slam_land": { + "fps": 20.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/12_aerial_combo/land.png", "frames": 6, "hframes": 3, "vframes": 2}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/12_aerial_combo/fx_4_land_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 20.0, "offset": Vector2(-71, -166), "start": 0.0}, + ], + }, + &"plunge_start": { + "fps": 25.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/13_plunging_strike/prep.png", "frames": 2, "hframes": 2, "vframes": 1}, + {"path": "res://assets/art/characters/player/13_plunging_strike/swing_start.png", "frames": 3, "hframes": 3, "vframes": 1}, + {"path": "res://assets/art/characters/player/13_plunging_strike/falling_loop.png", "frames": 3, "hframes": 3, "vframes": 1, "fps": 20.0}, + ], + "fx": [ + {"path": "res://assets/art/characters/player/13_plunging_strike/fx_2_swing_start_slash_overlay_fx.png", "hframes": 4, "vframes": 1, "first": 1, "last": 3, "fps": 25.0, "offset": Vector2(-107, -198), "start": 0.08}, + {"path": "res://assets/art/characters/player/13_plunging_strike/fx_3_falling_slash_overlay_fx_hold.png", "hframes": 2, "vframes": 1, "first": 1, "last": 1, "fps": 20.0, "offset": Vector2(-107, -198), "start": 0.2, "hold": true}, + ], + }, + &"hit_stun": { + "fps": 14.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/14_hit_stun/damaged.png", "frames": 3, "hframes": 3, "vframes": 1}, + ], + }, + &"death": { + "fps": 12.0, + "loop": false, + "segments": [ + {"path": "res://assets/art/characters/player/15_death/initial_death_stun_hold.png", "frames": 1, "hframes": 1, "vframes": 1, "fps": 2.0}, + {"path": "res://assets/art/characters/player/15_death/fall_back.png", "frames": 10, "hframes": 5, "vframes": 2}, + {"path": "res://assets/art/characters/player/15_death/he_s_finally_dead_hold.png", "frames": 1, "hframes": 1, "vframes": 1, "fps": 1.0}, + ], + }, +} + +@onready var state_machine: Node = $StateMachine +@onready var input_component: Node = $InputComponent +@onready var movement_motor: Node = $MovementMotor +@onready var combo_window: Node = $ComboWindow +@onready var action_controller: Node = $ActionController +@onready var motion_executor: Node = $MotionExecutor +@onready var burst_component: Node = $BurstComponent +@onready var charge_component: Node = $ChargeComponent +@onready var energy_component: Node = $EnergyComponent +@onready var effect_container: Node = $EffectContainer +@onready var fx_overlay: Sprite2D = $Visual/FxOverlay + +var last_requested_action_id := "" +var current_action_animation := "idle" +var _manual_animation_segments: Array = [] +var _manual_animation_segment := 0 +var _manual_animation_frame := 0 +var _manual_animation_elapsed := 0.0 +var _manual_animation_frame_time := 0.05 +var _manual_animation_fps := 20.0 +var _manual_animation_speed_scale := 1.0 +var _manual_animation_loop := false +var _manual_animation_active := false +var _manual_animation_time := 0.0 +var _manual_animation_name: StringName = &"" +var _base_sprite_offset := Vector2(-64.0, -128.0) +var _death_visible_bottom_anchor := 0.0 +var _manual_fx: Array = [] +var _hitstun_animation_started := false +var _death_animation_started := false +var _charge_body_animation_active := false +var _pending_air_slam_land := false + + +func _ready() -> void: + if character_sprite != null: + _base_sprite_offset = character_sprite.offset + _death_visible_bottom_anchor = _current_sprite_visible_bottom_local() + combo_window.clear_display_time = combo_clear_display_time + input_component.intent_created.connect(_on_input_intent_created) + action_controller.action_started.connect(_on_action_started) + action_controller.action_active_started.connect(_on_action_active_started) + action_controller.action_finished.connect(_on_action_finished) + action_controller.action_cancelled.connect(_on_action_cancelled) + combo_window.combo_updated.connect(_on_combo_updated) + combo_window.combo_cleared.connect(_on_combo_cleared) + charge_component.charge_changed.connect(_on_charge_component_changed) + energy_component.energy_changed.connect(_on_energy_component_changed) + energy_component.set_values(current_energy, max_energy) + + +func _exit_tree() -> void: + super._exit_tree() + + +func _process(delta: float) -> void: + charge_component.tick(delta, _is_action_phase(&"Charging")) + if charge_component.is_active(): + _apply_charging_presentation() + elif _charge_body_animation_active: + _charge_body_animation_active = false + _stop_manual_animation() + _tick_manual_animation(delta) + + +func _apply_charging_presentation() -> void: + state = PRESENTATION_IDLE + attack_time_left = 0.0 + stop_attack_motion() + var symbol := _active_charge_symbol() + var blade_rain_charge := symbol == &"A" or symbol == &"D" + charge_component.set_overlay_profile(&"blade_rain" if blade_rain_charge else &"wave") + if blade_rain_charge and not _charge_body_animation_active: + _charge_body_animation_active = true + if animation_player != null: + _reset_action_animation_speed() + animation_player.stop() + _start_manual_animation(&"blade_rain_charge") + + +func _active_charge_symbol() -> StringName: + var entry = action_controller.get("_active_charge_entry") + if entry is Dictionary: + return StringName(str((entry as Dictionary).get("symbol", &""))) + return &"" + + +func _input(event: InputEvent) -> void: + if input_component.handle_input_event(event): + _mark_input_handled() + + +func _unhandled_input(event: InputEvent) -> void: + if input_component.handle_input_event(event): + _mark_input_handled() + + +func handle_input() -> void: + if charge_component.is_active(): + velocity = Vector2.ZERO + return + movement_motor.handle_input() + + +func handle_air_time(delta: float) -> void: + movement_motor.handle_air_time(delta) + + +func handle_movement() -> void: + movement_motor.handle_movement() + + +func handle_animations() -> void: + var life_state := _life_state_name() + if life_state == &"Dead": + _play_death_animation_once() + return + _death_animation_started = false + if life_state == &"Hitstun": + _play_hitstun_animation_once() + return + if _hitstun_animation_started: + _hitstun_animation_started = false + _stop_manual_animation() + if _charge_body_animation_active and charge_component.is_active(): + return + if _pending_air_slam_land: + if _grounded_now(): + _pending_air_slam_land = false + _play_air_slam_land_animation() + else: + # Still falling: hold the falling pose until touchdown. + state = PRESENTATION_ATTACK + attack_time_left = maxf(attack_time_left, 0.1) + return + if current_action_animation == "air_slam_land" and _manual_animation_active: + return + super.handle_animations() + + +func _life_state_name() -> StringName: + if state_machine != null and state_machine.has_method("get_life_state"): + return state_machine.get_life_state() + return &"Alive" + + +func _play_air_slam_land_animation() -> void: + motion_executor.cancel() + stop_attack_motion() + current_action_animation = "air_slam_land" + anim_map[PRESENTATION_ATTACK] = "air_slam_land" + state = PRESENTATION_ATTACK + attack_time_left = _animation_length("air_slam_land") + 0.05 + if animation_player != null: + _reset_action_animation_speed() + animation_player.stop() + _start_manual_animation(&"air_slam_land") + + +func _grounded_now() -> bool: + return height <= 0.0 and is_zero_approx(height_speed) + + +func _play_death_animation_once() -> void: + if _death_animation_started: + return + _death_animation_started = true + _pending_air_slam_land = false + motion_executor.cancel() + stop_attack_motion() + attack_time_left = 0.0 + state = PRESENTATION_IDLE + if animation_player != null: + _reset_action_animation_speed() + animation_player.stop() + _start_manual_animation(&"death") + + +func _play_hitstun_animation_once() -> void: + if _hitstun_animation_started: + return + _hitstun_animation_started = true + _pending_air_slam_land = false + if animation_player != null: + _reset_action_animation_speed() + animation_player.stop() + _start_manual_animation(&"hit_stun") + + +func set_heading() -> void: + movement_motor.set_heading() + + +func set_sprite_height_position() -> void: + if visual != null: + visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height + + +func start_jump() -> void: + movement_motor.start_jump() + + +func apply_knockback(knockback: Vector2) -> void: + movement_motor.apply_knockback(knockback) + + +func submit_combo_input(symbol: String, forced_rating := "") -> String: + var data := _symbol_to_intent_data(symbol) + if data.is_empty(): + return "" + var intent: RefCounted = load("res://scenes/components/input_intent.gd").create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec())) + if not forced_rating.is_empty(): + intent.judgement = _rating_result(StringName(forced_rating), 0.0) + action_controller.submit_intent(intent) + if data["symbol"] == &"A" or data["symbol"] == &"D": + var release_intent: RefCounted = load("res://scenes/components/input_intent.gd").create(data["symbol"], data["rhythm_action"], &"released", float(Time.get_ticks_msec())) + if not forced_rating.is_empty(): + release_intent.judgement = _rating_result(StringName(forced_rating), 0.0) + action_controller.submit_intent(release_intent) + return last_requested_action_id + + +func get_combo_slots() -> Array[StringName]: + return combo_window.get_slots() + + +func projectile_spawn_position(action: Resource) -> Vector2: + var aim := projectile_direction(action) + var forward := -1.0 if aim.x < 0.0 else 1.0 + return global_position + Vector2(forward * 30.0, -70.0) + + +func projectile_direction(_action: Resource) -> Vector2: + # Charge releases (zhan_bo) fire along the player's current facing, never + # auto-aimed at the nearest enemy: facing left releases left. + return Vector2.RIGHT if heading.x > 0.0 else Vector2.LEFT + + +func _symbol_to_intent_data(symbol: String) -> Dictionary: + match symbol: + "W": + return {"symbol": &"W", "rhythm_action": &"w"} + "A": + return {"symbol": &"A", "rhythm_action": &"a"} + "D": + return {"symbol": &"D", "rhythm_action": &"d"} + "S": + return {"symbol": &"S", "rhythm_action": &"s"} + "SP": + return {"symbol": &"SP", "rhythm_action": &"space"} + return {} + + +func _play_action_animation(animation_name: String, action: Resource = null) -> void: + current_action_animation = animation_name + anim_map[PRESENTATION_ATTACK] = animation_name + state = PRESENTATION_ATTACK + attack_time_left = _animation_length(animation_name) + if action != null: + attack_time_left = maxf(attack_time_left, float(action.get("action_beats")) * _beat_time() + 0.25) + var speed_scale := _action_animation_speed_scale(animation_name, action) + _charge_body_animation_active = false + _pending_air_slam_land = false + if action != null: + var direction := _displacement_direction(action) + if direction != Vector2.ZERO: + heading = direction + flip_sprites() + if animation_player != null and animation_player.has_animation(animation_name): + _stop_manual_animation() + if fx_overlay != null: + fx_overlay.visible = false + animation_player.stop() + animation_player.speed_scale = speed_scale + animation_player.play(animation_name) + elif has_player_animation(StringName(animation_name)): + if animation_player != null: + animation_player.stop() + _start_manual_animation(StringName(animation_name), speed_scale) + + +func has_player_animation(animation_name: StringName) -> bool: + if animation_player != null and animation_player.has_animation(str(animation_name)): + return true + return PLAYER_ANIMATION_SPECS.has(animation_name) + + +func _start_manual_animation(animation_name: StringName, speed_scale := 1.0) -> void: + var spec: Dictionary = PLAYER_ANIMATION_SPECS.get(animation_name, {}) + _manual_animation_name = animation_name + _manual_animation_segments = spec.get("segments", []) + _manual_animation_segment = 0 + _manual_animation_frame = 0 + _manual_animation_elapsed = 0.0 + _manual_animation_time = 0.0 + _manual_animation_fps = maxf(1.0, float(spec.get("fps", 20.0))) + _manual_animation_speed_scale = maxf(0.01, speed_scale) + _manual_animation_frame_time = _segment_frame_time(_current_manual_segment()) + _manual_animation_loop = bool(spec.get("loop", false)) + _manual_fx = spec.get("fx", []) + if fx_overlay != null: + fx_overlay.visible = false + _manual_animation_active = not _manual_animation_segments.is_empty() + if _manual_animation_active: + _apply_manual_animation_frame() + _update_manual_fx() + + +func _stop_manual_animation() -> void: + _manual_animation_name = &"" + _manual_animation_segments = [] + _manual_animation_segment = 0 + _manual_animation_frame = 0 + _manual_animation_elapsed = 0.0 + _manual_animation_time = 0.0 + _manual_animation_speed_scale = 1.0 + _manual_animation_active = false + if not _manual_fx.is_empty(): + _manual_fx = [] + if fx_overlay != null: + fx_overlay.visible = false + + +func _tick_manual_animation(delta: float) -> void: + if not _manual_animation_active: + return + var scaled_delta := delta * _manual_animation_speed_scale + _manual_animation_time += scaled_delta + _manual_animation_elapsed += scaled_delta + while _manual_animation_elapsed >= _manual_animation_frame_time and _manual_animation_active: + _manual_animation_elapsed -= _manual_animation_frame_time + _advance_manual_animation_frame() + _update_manual_fx() + + +func _advance_manual_animation_frame() -> void: + var segment := _current_manual_segment() + if segment.is_empty(): + _stop_manual_animation() + return + _manual_animation_frame += 1 + if _manual_animation_frame < int(segment.get("frames", 1)): + _apply_manual_animation_frame() + return + _manual_animation_segment += 1 + _manual_animation_frame = 0 + if _manual_animation_segment >= _manual_animation_segments.size(): + if _manual_animation_loop: + _manual_animation_segment = 0 + else: + _manual_animation_segment = _manual_animation_segments.size() - 1 + _manual_animation_frame = maxi(0, int(_current_manual_segment().get("frames", 1)) - 1) + _apply_manual_animation_frame() + _manual_animation_active = false + if _manual_animation_name == &"death": + queue_free() + return + _manual_animation_frame_time = _segment_frame_time(_current_manual_segment()) + _apply_manual_animation_frame() + + +func _segment_frame_time(segment: Dictionary) -> float: + var fps := _manual_animation_fps + if not segment.is_empty() and segment.has("fps"): + fps = maxf(1.0, float(segment.get("fps"))) + return 1.0 / fps + + +func _update_manual_fx() -> void: + if _manual_fx.is_empty() or fx_overlay == null: + return + var active_fx: Dictionary = {} + for entry: Variant in _manual_fx: + if entry is Dictionary and _manual_animation_time >= float((entry as Dictionary).get("start", 0.0)): + active_fx = entry + if active_fx.is_empty(): + fx_overlay.visible = false + return + var first := int(active_fx.get("first", 1)) + var last := int(active_fx.get("last", first)) + var fx_fps := maxf(1.0, float(active_fx.get("fps", 20.0))) + var local_time := _manual_animation_time - float(active_fx.get("start", 0.0)) + var frame_index := first + int(local_time * fx_fps) + if frame_index > last: + if bool(active_fx.get("hold", false)): + frame_index = last + else: + fx_overlay.visible = false + return + var texture_path := str(active_fx.get("path", "")) + var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null + if texture == null: + fx_overlay.visible = false + return + fx_overlay.texture = texture + fx_overlay.hframes = maxi(1, int(active_fx.get("hframes", 1))) + fx_overlay.vframes = maxi(1, int(active_fx.get("vframes", 1))) + fx_overlay.offset = active_fx.get("offset", Vector2.ZERO) + fx_overlay.frame = clampi(frame_index, 0, fx_overlay.hframes * fx_overlay.vframes - 1) + fx_overlay.visible = true + + +func _apply_manual_animation_frame() -> void: + var segment := _current_manual_segment() + if segment.is_empty() or character_sprite == null: + return + var texture := load(str(segment.get("path", ""))) as Texture2D + if texture == null: + return + character_sprite.texture = texture + character_sprite.hframes = maxi(1, int(segment.get("hframes", int(segment.get("frames", 1))))) + character_sprite.vframes = maxi(1, int(segment.get("vframes", 1))) + character_sprite.frame = clampi(_manual_animation_frame, 0, int(segment.get("frames", 1)) - 1) + if _manual_animation_name == &"death": + _align_sprite_visible_bottom_to(_death_visible_bottom_anchor) + + +func _current_manual_segment() -> Dictionary: + if _manual_animation_segment < 0 or _manual_animation_segment >= _manual_animation_segments.size(): + return {} + var segment: Variant = _manual_animation_segments[_manual_animation_segment] + return segment if segment is Dictionary else {} + + +func _align_sprite_visible_bottom_to(anchor: float) -> void: + if character_sprite == null: + return + var bottom := _current_sprite_visible_bottom_pixel() + if bottom == INF: + return + character_sprite.offset.y = anchor - bottom + + +func _current_sprite_visible_bottom_local() -> float: + if character_sprite == null: + return INF + return _visible_frame_bottom_local(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.offset) + + +func _current_sprite_visible_bottom_pixel() -> float: + if character_sprite == null: + return INF + return _visible_frame_bottom_pixel(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame) + + +func _visible_frame_bottom_local(texture: Texture2D, hframes: int, vframes: int, frame_index: int, sprite_offset: Vector2) -> float: + var bottom := _visible_frame_bottom_pixel(texture, hframes, vframes, frame_index) + if bottom == INF: + return INF + return sprite_offset.y + bottom + + +func _visible_frame_bottom_pixel(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float: + if texture == null: + return INF + var image := texture.get_image() + if image == null or image.is_empty(): + return INF + var safe_hframes := maxi(1, hframes) + var safe_vframes := maxi(1, vframes) + var frame_width := image.get_width() / safe_hframes + var frame_height := image.get_height() / safe_vframes + var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1) + var column := safe_frame % safe_hframes + var row := int(safe_frame / safe_hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return float(bottom) + + +func _displacement_direction(action: Resource) -> Vector2: + var tags = action.get("action_tags") + if tags is Array: + if (tags as Array).has(&"left"): + return Vector2.LEFT + if (tags as Array).has(&"right"): + return Vector2.RIGHT + var move_x := float(action.get("move_mult_x")) + if move_x == 0.0: + return Vector2.ZERO + if heading == Vector2.LEFT or heading == Vector2.RIGHT: + return heading + return Vector2.LEFT if move_x < 0.0 else Vector2.RIGHT + + +func _animation_length(animation_name: String) -> float: + if animation_player != null and animation_player.has_animation(animation_name): + return maxf(0.1, animation_player.get_animation(animation_name).length) + if PLAYER_ANIMATION_SPECS.has(StringName(animation_name)): + var spec: Dictionary = PLAYER_ANIMATION_SPECS[StringName(animation_name)] + var spec_fps := maxf(1.0, float(spec.get("fps", 20.0))) + var length := 0.0 + for segment: Variant in spec.get("segments", []): + if segment is Dictionary: + var segment_fps := maxf(1.0, float((segment as Dictionary).get("fps", spec_fps))) + length += float((segment as Dictionary).get("frames", 1)) / segment_fps + return maxf(0.1, length) + return attack_duration + + +func _action_animation_speed_scale(animation_name: String, action: Resource) -> float: + if action == null or animation_name.is_empty(): + return 1.0 + var natural_length := _animation_length(animation_name) + var action_seconds := _action_visual_duration(action) + if natural_length <= 0.0 or action_seconds <= 0.0: + return 1.0 + return clampf(natural_length / action_seconds, action_animation_min_speed_scale, action_animation_max_speed_scale) + + +func _action_visual_duration(action: Resource) -> float: + var startup_beats := float(action.get("startup_beats")) + var active_beats := float(action.get("active_beats")) + var recovery_beats := float(action.get("recovery_beats")) + if action_controller != null: + var snapshot_value = action_controller.get("_action_snapshot") + if snapshot_value is Dictionary: + var snapshot: Dictionary = snapshot_value + startup_beats = float(snapshot.get("startup_beats", startup_beats)) + active_beats = float(snapshot.get("active_beats", active_beats)) + recovery_beats = float(snapshot.get("recovery_beats", recovery_beats)) + var duration := maxf(0.05, (startup_beats + active_beats + recovery_beats) * _beat_time()) + if action_controller != null: + duration += maxf(0.0, float(action_controller.get("startup_stretch_seconds"))) + return duration + + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 + + +func _on_input_intent_created(intent) -> void: + action_controller.submit_intent(intent) + + +func _on_action_started(action: Resource, intent) -> void: + current_energy = energy_component.current + last_requested_action_id = str(action.get("id")) + action_requested.emit(last_requested_action_id) + var judgement := StringName(str(intent.judgement.get("label", "perfect"))) if intent != null else &"perfect" + var bus := _event_bus() + if bus.has_signal("skill_executed"): + bus.emit_signal("skill_executed", action, judgement) + _play_action_animation(str(action.get("animation")), action) + + +func _on_action_active_started(action: Resource, _intent) -> void: + current_energy = energy_component.current + _start_action_motion(action) + var animation_name := str(action.get("animation")) + if animation_name == "atk_air" or animation_name == "plunge_start": + _pending_air_slam_land = true + _spawn_world_fx(action) + + +func _spawn_world_fx(action: Resource) -> void: + # World FX live beside the player (ActorsContainer), addressed relatively so + # the player never reaches upward through the scene tree API surface. + var fx_parent := get_node_or_null(NodePath("..")) + if fx_parent == null: + return + var action_id := str(action.get("id")) + var forward := -1.0 if heading.x < 0.0 else 1.0 + if action_id.begins_with("ground_smash"): + for index: int in range(3): + OneShotFxScript.spawn(fx_parent, global_position + Vector2(forward * (60.0 + 55.0 * index), -54.0), { + "path": "res://assets/art/characters/player/06_ground_smash/shockwave_tile.png", + "hframes": 6, "vframes": 2, "first": 0, "last": 11, "fps": 20.0, + "delay": 0.07 * index, "flip_h": forward < 0.0, "scale": 0.9, + }) + elif action_id.begins_with("jian_yu"): + var level := 1 + if action_id.ends_with("lv2"): + level = 2 + elif action_id.ends_with("lv3"): + level = 3 + var strike_count := 2 * (1 + level) + # Lv3 farthest drop = 40 + 240 = 280, matching the action's range so the + # visual footprint covers the hitbox edge-to-edge with no phantom strikes. + var spread := 120.0 + 40.0 * float(level) + var drop_height := BLADE_RAIN_DROP_FALL_SPEED * BLADE_RAIN_DROP_FALL_TIME + for index: int in range(strike_count): + var offset_x := forward * (40.0 + spread * float(index) / float(maxi(1, strike_count - 1))) + var stagger := 0.09 * index + var tile: Dictionary = BLADE_RAIN_TILE_SPECS[index % BLADE_RAIN_TILE_SPECS.size()] + var tile_scale := float(tile.get("scale", 1.0)) + # Falling blade first; its flight ends exactly when the landing + # tile starts, whose own first frames carry the streak + impact. + OneShotFxScript.spawn(fx_parent, global_position + Vector2(offset_x, VISIBLE_GROUND_LINE_OFFSET - 50.0 - drop_height), { + "path": "res://assets/art/characters/player/09_blade_rain/raindrop_%d.png" % ((index % 4) + 1), + "hframes": 1, "vframes": 1, "first": 0, "last": 0, "fps": 20.0, + "delay": stagger, "duration": BLADE_RAIN_DROP_FALL_TIME, + "velocity": Vector2(0.0, BLADE_RAIN_DROP_FALL_SPEED), + "scale": tile_scale, "flip_h": forward < 0.0, + }) + var tile_config := tile.duplicate() + tile_config["delay"] = stagger + BLADE_RAIN_DROP_FALL_TIME + tile_config["flip_h"] = forward < 0.0 + # The tile art is bottom-anchored inside its cell: center it so the + # cell bottom (= visible lowest pixel) lands exactly on the world's + # visible ground line where the player's feet stand. + var tile_center_y := VISIBLE_GROUND_LINE_OFFSET - float(tile.get("cell_height", 360.0)) * tile_scale * 0.5 + OneShotFxScript.spawn(fx_parent, global_position + Vector2(offset_x, tile_center_y), tile_config) + + +# The authored player sheets keep ~40px of transparent margin under the feet +# inside the 128px cell, so the visible feet baseline sits 40px above the actor +# origin (decisions.md: actors anchor at y=560, visible feet line verified at +# y=520). Ground-contact FX must anchor to that visible line, not the origin, +# or they render sunk into the floor. +const VISIBLE_GROUND_LINE_OFFSET := -40.0 + +const BLADE_RAIN_DROP_FALL_TIME := 0.24 +const BLADE_RAIN_DROP_FALL_SPEED := 1900.0 + +const BLADE_RAIN_TILE_SPECS := [ + {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_1.png", "hframes": 6, "vframes": 2, "first": 0, "last": 11, "fps": 20.0, "scale": 1.0, "cell_height": 332.0}, + {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_2.png", "hframes": 9, "vframes": 1, "first": 0, "last": 8, "fps": 20.0, "scale": 1.0, "cell_height": 376.0}, + {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_3.png", "hframes": 5, "vframes": 2, "first": 0, "last": 9, "fps": 20.0, "scale": 1.0, "cell_height": 346.0}, + {"path": "res://assets/art/characters/player/09_blade_rain/rain_lading_tile_4.png", "hframes": 5, "vframes": 2, "first": 0, "last": 9, "fps": 20.0, "scale": 1.0, "cell_height": 394.0}, +] + + +func _on_action_finished(_action: Resource) -> void: + if _pending_air_slam_land: + # Action timed out mid-fall: keep dropping in the falling pose; + # handle_animations plays the land impact on touchdown. + motion_executor.cancel() + stop_attack_motion() + return + if current_action_animation == "air_slam_land" and _manual_animation_active: + motion_executor.cancel() + return + _set_idle_presentation() + + +func _on_action_cancelled(_action: Resource, _reason: StringName) -> void: + _pending_air_slam_land = false + motion_executor.cancel() + _set_idle_presentation() + + +func _start_action_motion(action: Resource) -> void: + var direction := _displacement_direction(action) + var move_y := float(action.get("move_mult_y")) + if move_y > 0.0: + height_speed = jump_intensity * move_y + height = maxf(height, 0.1) + state_machine.call("set_ground_state", &"Airborne") + elif move_y < 0.0 and height > 0.0: + height_speed = jump_intensity * move_y + if direction != Vector2.ZERO: + heading = direction + motion_executor.execute(action, direction, _beat_time(), _action_motion_speed(action)) + attack_time_left = maxf(attack_time_left, motion_executor.duration) + begin_attack_motion(motion_executor.duration, motion_executor.velocity) + + +func _action_motion_speed(action: Resource) -> float: + # S6 conversion rule: move_mult_x of 1.0 means 100px of total displacement over the action. + # The motion only runs from Active start to action end, so the speed must be + # computed over that window or every action under-travels by the startup + # fraction (the 260px dash used to realize only ~195px and could stall + # inside the boss instead of piercing through). + var travel := absf(float(action.get("move_mult_x"))) * base_move_distance + if travel <= 0.0: + return 0.0 + var motion_beats := float(action.get("action_beats")) - float(action.get("startup_beats")) + var duration := maxf(0.05, motion_beats * _beat_time()) + return travel / duration + + +func _set_idle_presentation() -> void: + motion_executor.cancel() + stop_attack_motion() + attack_time_left = 0.0 + state = PRESENTATION_IDLE + current_action_animation = "idle" + _stop_manual_animation() + if animation_player != null and animation_player.has_animation("idle"): + _reset_action_animation_speed() + animation_player.play("idle") + + +func _reset_action_animation_speed() -> void: + if animation_player != null: + animation_player.speed_scale = 1.0 + _manual_animation_speed_scale = 1.0 + + +func _on_combo_updated(slots: Array[StringName]) -> void: + combo_window_changed.emit(slots) + + +func _on_combo_cleared(reason: StringName) -> void: + combo_window_cleared.emit(str(reason)) + + +func _on_charge_component_changed(current: float, maximum: float, ready: bool, active: bool) -> void: + charge_changed.emit(current, maximum, ready, active) + var bus := _event_bus() + if bus.has_signal("player_charge_changed"): + bus.emit_signal("player_charge_changed", current, maximum, ready, active) + + +func _on_energy_component_changed(current: int, maximum: int) -> void: + current_energy = current + max_energy = maximum + energy_changed.emit(current, maximum) + + +func _rating_result(label: StringName, offset_ms: float) -> Dictionary: + return { + "label": str(label), + "diff": offset_ms / 1000.0, + "abs_diff": absf(offset_ms / 1000.0), + } + + +func _mark_input_handled() -> void: + var viewport := get_viewport() + if viewport != null: + viewport.set_input_as_handled() + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _is_action_phase(phase_name: StringName) -> bool: + return state_machine != null and state_machine.get_action_phase() == phase_name diff --git a/scenes/characters/player.gd.uid b/scenes/characters/player.gd.uid new file mode 100644 index 0000000..dbdd268 --- /dev/null +++ b/scenes/characters/player.gd.uid @@ -0,0 +1 @@ +uid://dcpjibn5dex88 diff --git a/scenes/characters/player.tscn b/scenes/characters/player.tscn new file mode 100644 index 0000000..4bf1d64 --- /dev/null +++ b/scenes/characters/player.tscn @@ -0,0 +1,532 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/characters/player.gd" id="1_player_script"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/16_idle/idle_loop.png" id="2_idle"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/air_prep.png" id="3_atk1_prep"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/air_swing.png" id="4_atk1_swing"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png" id="5_atk1_fx"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/air_prep.png" id="6_atk2_prep"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/air_swing.png" id="7_atk2_swing"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/02_basic_attack_ii/slash_overlay_fx.png" id="8_atk2_fx"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/prep.png" id="9_rise_prep"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/swing.png" id="10_rise_swing"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/07_rising_slash/slash_overlay_fx.png" id="11_rise_fx"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/17_turn_around/turn.png" id="12_turn"] +[ext_resource type="Texture2D" path="res://assets/art/characters/player/18_block/air_block_start.png" id="25_block_start"] +[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="13_state_machine"] +[ext_resource type="Script" path="res://scenes/components/input_component.gd" id="14_input_component"] +[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="15_movement_motor"] +[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="16_combo_window"] +[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="17_action_resolver"] +[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="18_action_executor"] +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="19_action_controller"] +[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="20_motion_executor"] +[ext_resource type="Script" path="res://scenes/components/burst_component.gd" id="21_burst_component"] +[ext_resource type="Script" path="res://scenes/components/charge_component.gd" id="22_charge_component"] +[ext_resource type="Script" path="res://scenes/components/energy_component.gd" id="23_energy_component"] +[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="24_effect_container"] +[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="26_damage_emitter"] +[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="27_damage_receiver"] +[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="28_health_component"] +[ext_resource type="Script" path="res://scenes/combat/contact_fx_spawner.gd" id="31_contact_fx"] +[ext_resource type="Script" path="res://scenes/components/streak_counter.gd" id="32_streak_counter"] +[ext_resource type="Script" path="res://scenes/components/attack_buff_component.gd" id="33_attack_buff"] +[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="34_frame_collision"] +[ext_resource type="Script" path="res://scenes/components/attack_buff_visual.gd" id="35_attack_buff_visual"] +[ext_resource type="Script" path="res://scenes/components/overhead_charge_segments.gd" id="36_overhead_charge_segments"] +[ext_resource type="Resource" path="res://resources/effects/effect_on_perfect_damage_reward.tres" id="29_perfect_reward"] +[ext_resource type="Resource" path="res://resources/effects/effect_on_landed_haste.tres" id="30_landed_haste"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_player"] +size = Vector2(18, 48) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] +size = Vector2(48, 40) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] +size = Vector2(22, 52) + +[sub_resource type="Animation" id="Animation_idle"] +resource_name = "idle" +length = 0.4 +loop_mode = 1 +step = 0.05 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("2_idle")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [4]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4, 5, 6, 7]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:visible") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]} + +[sub_resource type="Animation" id="Animation_atk_ground_1"] +resource_name = "atk_ground_1" +length = 0.35 +step = 0.05 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0, 0.15), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("3_atk1_prep"), ExtResource("4_atk1_swing")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:texture") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("5_atk1_fx")]} +tracks/5/type = "value" +tracks/5/imported = false +tracks/5/enabled = true +tracks/5/path = NodePath("Visual/FxOverlay:hframes") +tracks/5/interp = 1 +tracks/5/loop_wrap = true +tracks/5/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]} +tracks/6/type = "value" +tracks/6/imported = false +tracks/6/enabled = true +tracks/6/path = NodePath("Visual/FxOverlay:vframes") +tracks/6/interp = 1 +tracks/6/loop_wrap = true +tracks/6/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]} +tracks/7/type = "value" +tracks/7/imported = false +tracks/7/enabled = true +tracks/7/path = NodePath("Visual/FxOverlay:frame") +tracks/7/interp = 1 +tracks/7/loop_wrap = true +tracks/7/keys = {"times": PackedFloat32Array(0.15, 0.2, 0.25, 0.3), "transitions": PackedFloat32Array(1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4]} +tracks/8/type = "value" +tracks/8/imported = false +tracks/8/enabled = true +tracks/8/path = NodePath("Visual/FxOverlay:visible") +tracks/8/interp = 1 +tracks/8/loop_wrap = true +tracks/8/keys = {"times": PackedFloat32Array(0, 0.15, 0.35), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]} +tracks/9/type = "value" +tracks/9/imported = false +tracks/9/enabled = true +tracks/9/path = NodePath("Visual/FxOverlay:offset") +tracks/9/interp = 1 +tracks/9/loop_wrap = true +tracks/9/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-147, -191)]} + +[sub_resource type="Animation" id="Animation_atk_ground_2"] +resource_name = "atk_ground_2" +length = 0.35 +step = 0.05 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0, 0.15), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("6_atk2_prep"), ExtResource("7_atk2_swing")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2, 0.25), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:texture") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("8_atk2_fx")]} +tracks/5/type = "value" +tracks/5/imported = false +tracks/5/enabled = true +tracks/5/path = NodePath("Visual/FxOverlay:hframes") +tracks/5/interp = 1 +tracks/5/loop_wrap = true +tracks/5/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]} +tracks/6/type = "value" +tracks/6/imported = false +tracks/6/enabled = true +tracks/6/path = NodePath("Visual/FxOverlay:vframes") +tracks/6/interp = 1 +tracks/6/loop_wrap = true +tracks/6/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]} +tracks/7/type = "value" +tracks/7/imported = false +tracks/7/enabled = true +tracks/7/path = NodePath("Visual/FxOverlay:frame") +tracks/7/interp = 1 +tracks/7/loop_wrap = true +tracks/7/keys = {"times": PackedFloat32Array(0.15, 0.2, 0.25, 0.3), "transitions": PackedFloat32Array(1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4]} +tracks/8/type = "value" +tracks/8/imported = false +tracks/8/enabled = true +tracks/8/path = NodePath("Visual/FxOverlay:visible") +tracks/8/interp = 1 +tracks/8/loop_wrap = true +tracks/8/keys = {"times": PackedFloat32Array(0, 0.15, 0.35), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]} +tracks/9/type = "value" +tracks/9/imported = false +tracks/9/enabled = true +tracks/9/path = NodePath("Visual/FxOverlay:offset") +tracks/9/interp = 1 +tracks/9/loop_wrap = true +tracks/9/keys = {"times": PackedFloat32Array(0.15), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-147, -191)]} + +[sub_resource type="Animation" id="Animation_rising_slash"] +resource_name = "rising_slash" +length = 0.42 +step = 0.03 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [ExtResource("9_rise_prep"), ExtResource("10_rise_swing")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [3, 5]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0, 0.09), "transitions": PackedFloat32Array(1, 1), "update": 1, "values": [1, 2]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.03, 0.06, 0.09, 0.12, 0.15, 0.18, 0.21, 0.24, 0.27, 0.3, 0.33, 0.36), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:texture") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("11_rise_fx")]} +tracks/5/type = "value" +tracks/5/imported = false +tracks/5/enabled = true +tracks/5/path = NodePath("Visual/FxOverlay:hframes") +tracks/5/interp = 1 +tracks/5/loop_wrap = true +tracks/5/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]} +tracks/6/type = "value" +tracks/6/imported = false +tracks/6/enabled = true +tracks/6/path = NodePath("Visual/FxOverlay:vframes") +tracks/6/interp = 1 +tracks/6/loop_wrap = true +tracks/6/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]} +tracks/7/type = "value" +tracks/7/imported = false +tracks/7/enabled = true +tracks/7/path = NodePath("Visual/FxOverlay:frame") +tracks/7/interp = 1 +tracks/7/loop_wrap = true +tracks/7/keys = {"times": PackedFloat32Array(0.09, 0.12, 0.15, 0.18, 0.21, 0.24, 0.27, 0.3, 0.33), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1, 1, 1, 1), "update": 1, "values": [1, 2, 3, 4, 5, 6, 7, 8, 9]} +tracks/8/type = "value" +tracks/8/imported = false +tracks/8/enabled = true +tracks/8/path = NodePath("Visual/FxOverlay:visible") +tracks/8/interp = 1 +tracks/8/loop_wrap = true +tracks/8/keys = {"times": PackedFloat32Array(0, 0.09, 0.42), "transitions": PackedFloat32Array(1, 1, 1), "update": 1, "values": [false, true, false]} +tracks/9/type = "value" +tracks/9/imported = false +tracks/9/enabled = true +tracks/9/path = NodePath("Visual/FxOverlay:offset") +tracks/9/interp = 1 +tracks/9/loop_wrap = true +tracks/9/keys = {"times": PackedFloat32Array(0.09), "transitions": PackedFloat32Array(1), "update": 1, "values": [Vector2(-111, -156)]} + +[sub_resource type="Animation" id="Animation_block_start"] +resource_name = "block_start" +length = 0.25 +step = 0.05 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("25_block_start")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [5]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [1]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.05, 0.1, 0.15, 0.2), "transitions": PackedFloat32Array(1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:visible") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]} + +[sub_resource type="Animation" id="Animation_turn"] +resource_name = "turn" +length = 0.15 +step = 0.025 +tracks/0/type = "value" +tracks/0/imported = false +tracks/0/enabled = true +tracks/0/path = NodePath("Visual/CharacterSprite:texture") +tracks/0/interp = 1 +tracks/0/loop_wrap = true +tracks/0/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [ExtResource("12_turn")]} +tracks/1/type = "value" +tracks/1/imported = false +tracks/1/enabled = true +tracks/1/path = NodePath("Visual/CharacterSprite:hframes") +tracks/1/interp = 1 +tracks/1/loop_wrap = true +tracks/1/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [3]} +tracks/2/type = "value" +tracks/2/imported = false +tracks/2/enabled = true +tracks/2/path = NodePath("Visual/CharacterSprite:vframes") +tracks/2/interp = 1 +tracks/2/loop_wrap = true +tracks/2/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [2]} +tracks/3/type = "value" +tracks/3/imported = false +tracks/3/enabled = true +tracks/3/path = NodePath("Visual/CharacterSprite:frame") +tracks/3/interp = 1 +tracks/3/loop_wrap = true +tracks/3/keys = {"times": PackedFloat32Array(0, 0.025, 0.05, 0.075, 0.1, 0.125), "transitions": PackedFloat32Array(1, 1, 1, 1, 1, 1), "update": 1, "values": [0, 1, 2, 3, 4, 5]} +tracks/4/type = "value" +tracks/4/imported = false +tracks/4/enabled = true +tracks/4/path = NodePath("Visual/FxOverlay:visible") +tracks/4/interp = 1 +tracks/4/loop_wrap = true +tracks/4/keys = {"times": PackedFloat32Array(0), "transitions": PackedFloat32Array(1), "update": 1, "values": [false]} + +[sub_resource type="AnimationLibrary" id="AnimationLibrary_player"] +_data = { +&"atk_ground_1": SubResource("Animation_atk_ground_1"), +&"atk_ground_2": SubResource("Animation_atk_ground_2"), +&"block_start": SubResource("Animation_block_start"), +&"idle": SubResource("Animation_idle"), +&"rising_slash": SubResource("Animation_rising_slash"), +&"turn": SubResource("Animation_turn") +} + +[node name="Player" type="CharacterBody2D"] +collision_layer = 32 +collision_mask = 65 +floor_snap_length = 0.0 +safe_margin = 0.001 +script = ExtResource("1_player_script") + +[node name="Visual" type="Node2D" parent="."] + +[node name="CharacterSprite" type="Sprite2D" parent="Visual"] +texture_filter = 2 +texture = ExtResource("2_idle") +centered = false +offset = Vector2(-64, -128) +hframes = 4 +vframes = 2 + +[node name="FxOverlay" type="Sprite2D" parent="Visual"] +visible = false +z_index = 2 +texture_filter = 2 +centered = false + +[node name="AttackBuffVisual" type="Node2D" parent="Visual"] +z_index = 1 +script = ExtResource("35_attack_buff_visual") + +[node name="FxAnimationPlayer" type="AnimationPlayer" parent="Visual"] + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(0, -72) +shape = SubResource("RectangleShape2D_player") + +[node name="DamageEmitter" type="Area2D" parent="."] +collision_layer = 8 +collision_mask = 4 +monitoring = false +script = ExtResource("26_damage_emitter") +damage = 100 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"] +position = Vector2(0, -72) +shape = SubResource("RectangleShape2D_hitbox") + +[node name="DamageReceiver" type="Area2D" parent="."] +collision_layer = 2 +collision_mask = 16 +script = ExtResource("27_damage_receiver") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"] +position = Vector2(0, -72) +shape = SubResource("RectangleShape2D_hurtbox") + +[node name="OverheadChargeSegments" type="Node2D" parent="."] +visible = false +position = Vector2(0, -18) +z_index = 96 +script = ExtResource("36_overhead_charge_segments") +segment_count = 3 +segment_size = Vector2(42, 12) + +[node name="AnimationPlayer" type="AnimationPlayer" parent="."] +libraries/ = SubResource("AnimationLibrary_player") +autoplay = &"idle" + +[node name="ContactFxSpawner" type="Node" parent="."] +script = ExtResource("31_contact_fx") + +[node name="StateMachine" type="Node" parent="."] +script = ExtResource("13_state_machine") + +[node name="InputComponent" type="Node" parent="."] +script = ExtResource("14_input_component") + +[node name="MovementMotor" type="Node" parent="."] +script = ExtResource("15_movement_motor") + +[node name="ComboWindow" type="Node" parent="."] +script = ExtResource("16_combo_window") + +[node name="ActionResolver" type="Node" parent="."] +script = ExtResource("17_action_resolver") + +[node name="ActionExecutor" type="Node" parent="."] +script = ExtResource("18_action_executor") +energy_component_path = NodePath("../EnergyComponent") +damage_emitter_path = NodePath("../DamageEmitter") + +[node name="ActionController" type="Node" parent="."] +script = ExtResource("19_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") +burst_component_path = NodePath("../BurstComponent") + +[node name="MotionExecutor" type="Node" parent="."] +script = ExtResource("20_motion_executor") + +[node name="BurstComponent" type="Node" parent="."] +script = ExtResource("21_burst_component") + +[node name="EffectContainer" type="Node" parent="."] +script = ExtResource("24_effect_container") +initial_effects = Array[Resource]([ExtResource("29_perfect_reward"), ExtResource("30_landed_haste")]) + +[node name="HealthComponent" type="Node" parent="."] +script = ExtResource("28_health_component") +maximum = 1000 +current = 1000 +hitstun_seconds = 0.4 + +[node name="ChargeComponent" type="Node" parent="."] +script = ExtResource("22_charge_component") +animation_player_path = NodePath("../AnimationPlayer") +effect_sprite_path = NodePath("../Visual/FxOverlay") + +[node name="EnergyComponent" type="Node" parent="."] +script = ExtResource("23_energy_component") + +[node name="StreakCounter" type="Node" parent="."] +script = ExtResource("32_streak_counter") + +[node name="AttackBuffComponent" type="Node" parent="."] +script = ExtResource("33_attack_buff") + +[node name="FrameCollisionDriver" type="Node" parent="."] +script = ExtResource("34_frame_collision") diff --git a/scenes/chart/chart_runner.gd b/scenes/chart/chart_runner.gd new file mode 100644 index 0000000..848266c --- /dev/null +++ b/scenes/chart/chart_runner.gd @@ -0,0 +1,185 @@ +class_name ChartRunner +extends Node + +signal chart_event_upcoming(event: Resource, time_to_event: float) +signal chart_event_triggered(event: Resource) +signal chart_reset(chart_id: StringName) +signal chart_finished(chart_id: StringName) + +const UPCOMING_TIME_EPSILON := 0.02 + +@export var chart: Resource +@export var rhythm_manager_path: NodePath +@export var actors_container_path: NodePath +@export var beat_time_override := 0.0 +@export var auto_run := true + +var running := true +var _upcoming_keys: Dictionary = {} +var _triggered_keys: Dictionary = {} + + +func _ready() -> void: + running = auto_run + _apply_initial_time_phase() + + +func _physics_process(_delta: float) -> void: + if not running or chart == null: + return + var rhythm := _rhythm_manager() + if rhythm == null or not rhythm.has_method("song_position"): + return + update_for_song_time(float(rhythm.call("song_position"))) + + +func set_chart(next_chart: Resource) -> void: + chart = next_chart + reset() + + +func reset() -> void: + _upcoming_keys.clear() + _triggered_keys.clear() + var chart_id := &"" + if chart != null: + chart_id = StringName(str(chart.get("chart_id"))) + chart_reset.emit(chart_id) + var bus := _event_bus_or_null() + if bus != null: + bus.emit_signal("chart_reset", chart_id) + _apply_initial_time_phase() + + +func update_for_song_time(song_time: float) -> void: + if chart == null: + return + var beat_time := _beat_time() + for event: Resource in chart.call("all_events"): + var event_time := float(event.call("time_seconds", beat_time)) + var time_to_event := event_time - song_time + var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time + var event_key: StringName = event.call("key") + if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON: + if _time_phase_allows(event): + _upcoming_keys[event_key] = true + _emit_upcoming(event, time_to_event) + if not _triggered_keys.has(event_key) and song_time >= event_time: + _triggered_keys[event_key] = true + if _time_phase_allows(event): + _emit_triggered(event) + + +func pause() -> void: + running = false + + +func resume() -> void: + running = true + + +func _emit_upcoming(event: Resource, time_to_event: float) -> void: + chart_event_upcoming.emit(event, time_to_event) + var bus := _event_bus_or_null() + if bus != null: + bus.emit_signal("chart_event_upcoming", event, time_to_event) + + +func _emit_triggered(event: Resource) -> void: + _dispatch_to_target_actor(event) + chart_event_triggered.emit(event) + var bus := _event_bus_or_null() + if bus != null: + bus.emit_signal("chart_event_triggered", event) + + +func _dispatch_to_target_actor(event: Resource) -> void: + if event == null: + return + var action_id := StringName(str(event.get("action_id"))) + if action_id.is_empty(): + return + var target_id := StringName(str(event.get("target_id"))) + if target_id.is_empty(): + return + var actors := _actors_container() + if actors == null: + return + var target := actors.get_node_or_null(NodePath(str(target_id))) + if target == null: + return + var driver := target.get_node_or_null("EnemyActionDriver") + if driver != null and driver.has_method("handle_chart_event"): + driver.call("handle_chart_event", event) + + +func _time_phase_allows(event: Resource) -> bool: + if event == null: + return false + if event.has_method("matches_time_phase"): + var manager := _time_phase_manager() + if manager == null: + return true + return bool(event.call("matches_time_phase", StringName(str(manager.get("current_time_phase"))))) + return true + + +func _apply_initial_time_phase() -> void: + if chart == null: + return + var initial = chart.get("initial_time_phase") + if initial == null: + return + var manager := _time_phase_manager() + if manager != null and manager.has_method("reset_to_initial"): + manager.call("reset_to_initial", StringName(str(initial))) + + +func _time_phase_manager() -> Node: + # Reverse root scan (matching TimeAnchorSystem): a test-added manager wins + # over the autoload, since name collisions get auto-renamed on add_child. + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child.has_method("set_time_phase") and child.get("current_time_phase") != null + ) + + +func _beat_time() -> float: + if beat_time_override > 0.0: + return beat_time_override + var rhythm := _rhythm_manager() + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 + + +func _rhythm_manager() -> Node: + if not is_inside_tree(): + return null + if not rhythm_manager_path.is_empty(): + return get_node_or_null(rhythm_manager_path) + return get_tree().root.get_node_or_null("RhythmManager") + + +func _actors_container() -> Node: + if not is_inside_tree() or actors_container_path.is_empty(): + return null + return get_node_or_null(actors_container_path) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child != self and child.has_signal("chart_event_triggered") and child.has_signal("chart_reset") and child.has_signal("judgement_made") + ) + + +func _last_root_child_matching(predicate: Callable) -> Node: + var children := get_tree().root.get_children() + for index: int in range(children.size() - 1, -1, -1): + var child: Node = children[index] + if bool(predicate.call(child)): + return child + return null diff --git a/scenes/chart/chart_runner.gd.uid b/scenes/chart/chart_runner.gd.uid new file mode 100644 index 0000000..0b9e9ab --- /dev/null +++ b/scenes/chart/chart_runner.gd.uid @@ -0,0 +1 @@ +uid://vea0brdgpneb diff --git a/scenes/combat/action_resolver.gd b/scenes/combat/action_resolver.gd new file mode 100644 index 0000000..69a7bad --- /dev/null +++ b/scenes/combat/action_resolver.gd @@ -0,0 +1,197 @@ +class_name ActionResolver +extends Node + +const ACTION_DIR := "res://resources/actions" +const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd") + +const BASIC_TRAILING_SUFFIXES := ["W", "A", "D", "S"] + +static var _loaded := false +static var _actions_by_pattern: Dictionary = {} +static var _actions_by_id: Dictionary = {} +static var _space_priority_labels: Array[StringName] = [ + &"burst", + &"counter_projectile", + &"blade_chain", + &"state_specific", + &"exact_pattern", + &"fallback", +] + + +func resolve_window(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource: + return resolve(window, state_machine, context) + + +func resolve_text_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource: + return resolve_pattern(pattern, state_machine, context) + + +static func resolve(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource: + return resolve_pattern(window.get_contiguous_pattern(), state_machine, context) + + +static func resolve_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}, allow_basic_trailing_fallback := true) -> Resource: + _ensure_loaded() + var key := _normalize_pattern(pattern) + var resolver_context := _resolver_context(state_machine, context) + if key.ends_with("SP"): + var priority_result := _resolve_space_priority(key, resolver_context) + if priority_result != null: + return priority_result + var candidates: Array = _actions_by_pattern.get(key, []) + for action: Resource in candidates: + if _can_execute(action, resolver_context): + return action + var trailing_basic := _resolve_trailing_basic_suffix(key, resolver_context, allow_basic_trailing_fallback) + if trailing_basic != null: + return trailing_basic + return null + + +static func get_action(action_id: StringName) -> Resource: + _ensure_loaded() + return _actions_by_id.get(action_id, null) as Resource + + +static func space_priority_labels() -> Array[StringName]: + return _space_priority_labels.duplicate() + + +static func reload(action_dir := ACTION_DIR) -> void: + _loaded = true + _actions_by_pattern.clear() + _actions_by_id.clear() + _load_dir(action_dir) + + +static func clear_cache() -> void: + _actions_by_pattern.clear() + _actions_by_id.clear() + _loaded = false + + +static func _ensure_loaded() -> void: + if not _loaded: + reload() + + +static func _load_dir(action_dir: String) -> void: + var dir := DirAccess.open(action_dir) + if dir == null: + return + var subdirs := dir.get_directories() + subdirs.sort() + for subdir: String in subdirs: + _load_dir("%s/%s" % [action_dir, subdir]) + # 导出包(pck)内的目录列表把资源显示为 xxx.tres.remap,直接按 .tres + # 过滤会一个都匹配不上,导致动作库为空(玩家出招和敌人攻击全部失效)。 + # 还原原始文件名再 load(),remap 表会命中 pck 内的实际资源。 + var seen := {} + var files := dir.get_files() + files.sort() + for file_name: String in files: + var resource_name := file_name.trim_suffix(".remap") + if not resource_name.ends_with(".tres") or seen.has(resource_name): + continue + seen[resource_name] = true + var action: Resource = load("%s/%s" % [action_dir, resource_name]) + if action == null or not action.get("input_pattern") is Array: + continue + _register_action(action) + + +static func _register_action(action: Resource) -> void: + var id := StringName(str(action.get("id"))) + if not id.is_empty(): + _actions_by_id[id] = action + var key := _pattern_key(action.get("input_pattern")) + if key.is_empty(): + return + var candidates: Array = _actions_by_pattern.get(key, []) + candidates.append(action) + _actions_by_pattern[key] = candidates + + +static func _resolve_space_priority(key: String, context: Dictionary) -> Resource: + for action_id_key: String in [ + "burst_action_id", + "counter_action_id", + "blade_chain_action_id", + ]: + if not context.has(action_id_key): + continue + if action_id_key == "counter_action_id" and not bool(context.get("counter_ready", false)): + continue + if action_id_key == "blade_chain_action_id" and not bool(context.get("blade_chain_active", false)): + continue + var explicit_action := get_action(StringName(str(context[action_id_key]))) + if explicit_action != null and _can_execute(explicit_action, context): + return explicit_action + + var candidates: Array = _actions_by_pattern.get(key, []) + for action: Resource in candidates: + if _can_execute(action, context): + return action + var suffix_action := _resolve_trailing_space_suffix(key, context) + if suffix_action != null: + return suffix_action + return null + + +static func _resolve_trailing_space_suffix(key: String, context: Dictionary) -> Resource: + var best_action: Resource = null + var best_length := 0 + for candidate_key: String in _actions_by_pattern.keys(): + if candidate_key == key: + continue + if not candidate_key.ends_with("SP"): + continue + if candidate_key.length() <= best_length: + continue + if not key.ends_with(candidate_key): + continue + for action: Resource in _actions_by_pattern.get(candidate_key, []): + if _can_execute(action, context): + best_action = action + best_length = candidate_key.length() + break + return best_action + + +static func _resolve_trailing_basic_suffix(key: String, context: Dictionary, allow_basic_trailing_fallback: bool) -> Resource: + if not allow_basic_trailing_fallback: + return null + for candidate_key: String in BASIC_TRAILING_SUFFIXES: + if key == candidate_key: + continue + if not key.ends_with(candidate_key): + continue + for action: Resource in _actions_by_pattern.get(candidate_key, []): + if _can_execute(action, context): + return action + return null + + +static func _pattern_key(pattern: Array[StringName]) -> String: + var key := "" + for symbol: StringName in pattern: + key += str(symbol) + return _normalize_pattern(key) + + +static func _normalize_pattern(pattern: String) -> String: + return pattern.replace(" ", "").to_upper() + + +static func _resolver_context(state_machine: Variant, context: Dictionary) -> Dictionary: + var result := context.duplicate() + if state_machine != null and state_machine.has_method("build_context"): + for key: Variant in state_machine.call("build_context").keys(): + if not result.has(key): + result[key] = state_machine.call("build_context")[key] + return result + + +static func _can_execute(action: Resource, context: Dictionary) -> bool: + return ActionRuleResolverScript.can_execute(context, action) diff --git a/scenes/combat/action_resolver.gd.uid b/scenes/combat/action_resolver.gd.uid new file mode 100644 index 0000000..4be5aee --- /dev/null +++ b/scenes/combat/action_resolver.gd.uid @@ -0,0 +1 @@ +uid://bnc3bixik3wxn diff --git a/scenes/combat/contact_fx_spawner.gd b/scenes/combat/contact_fx_spawner.gd new file mode 100644 index 0000000..a5a4113 --- /dev/null +++ b/scenes/combat/contact_fx_spawner.gd @@ -0,0 +1,74 @@ +class_name ContactFxSpawner +extends Node + +const OneShotFxScript := preload("res://scenes/combat/one_shot_fx.gd") + +const CONTACT_SPECS := { + &"basic_1": {"path": "res://assets/art/characters/player/01_basic_attack_i/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0}, + &"basic_2": {"path": "res://assets/art/characters/player/02_basic_attack_ii/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0}, + &"basic_3": {"path": "res://assets/art/characters/player/03_basic_attack_iii/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0}, + &"finisher": {"path": "res://assets/art/characters/player/05_combo_finisher/enemy_contact_fx.png", "hframes": 7, "vframes": 1, "first": 0, "last": 6, "fps": 20.0}, + &"projectile": {"path": "res://assets/art/characters/player/11_blade_wave/projectile_enemy_contact.png", "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 20.0, "scale": 0.7}, +} + + +func _ready() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed): + bus.connect("hit_confirmed", _on_hit_confirmed) + + +func _exit_tree() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("hit_confirmed") and bus.is_connected("hit_confirmed", _on_hit_confirmed): + bus.disconnect("hit_confirmed", _on_hit_confirmed) + + +func _on_hit_confirmed(result: Dictionary) -> void: + if int(result.get("damage", 0)) <= 0: + return + var emitter := result.get("emitter", null) as Area2D + var receiver := result.get("receiver", null) as Area2D + if emitter == null or receiver == null or not is_instance_valid(emitter) or not is_instance_valid(receiver): + return + if not _is_our_hit(emitter): + return + var target := receiver.get_parent() as Node2D + if target == null: + return + var spec: Dictionary = CONTACT_SPECS.get(_spec_key_for(result), {}) + if spec.is_empty(): + return + var config := spec.duplicate() + var from := result.get("from", target.global_position) as Vector2 + config["flip_h"] = from.x > target.global_position.x + OneShotFxScript.spawn(target, target.global_position + Vector2(0.0, -70.0), config) + + +func _is_our_hit(emitter: Area2D) -> bool: + var actor := get_parent() + if actor != null and emitter.get_parent() == actor: + return true + return emitter.is_in_group("player_projectiles") + + +func _spec_key_for(result: Dictionary) -> StringName: + if StringName(str(result.get("hit_type", &""))) == &"projectile": + return &"projectile" + var action: Resource = result.get("action", null) as Resource + if action == null: + return &"basic_1" + var action_id := str(action.get("id")) + if action_id.begins_with("ground_attack") and action_id.ends_with("_2"): + return &"basic_2" + if action_id.begins_with("ground_attack") and action_id.ends_with("_3"): + return &"basic_3" + if action_id.begins_with("combo_finisher") or action_id.begins_with("ground_smash"): + return &"finisher" + return &"basic_1" + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/combat/contact_fx_spawner.gd.uid b/scenes/combat/contact_fx_spawner.gd.uid new file mode 100644 index 0000000..2e30838 --- /dev/null +++ b/scenes/combat/contact_fx_spawner.gd.uid @@ -0,0 +1 @@ +uid://dl4cd5x3uc0bl diff --git a/scenes/combat/one_shot_fx.gd b/scenes/combat/one_shot_fx.gd new file mode 100644 index 0000000..a9774c4 --- /dev/null +++ b/scenes/combat/one_shot_fx.gd @@ -0,0 +1,81 @@ +class_name OneShotFx +extends Node2D + +var texture: Texture2D +var hframes := 1 +var vframes := 1 +var first_frame := 0 +var last_frame := 0 +var fps := 20.0 +var delay := 0.0 +var duration_override := 0.0 +var velocity := Vector2.ZERO +var flip_h := false +var sprite_scale := Vector2.ONE +var sprite_offset := Vector2.ZERO + +var _sprite: Sprite2D +var _elapsed := 0.0 + + +static func spawn(parent: Node, spawn_global_position: Vector2, config: Dictionary) -> Node2D: + if parent == null: + return null + var script: Script = load("res://scenes/combat/one_shot_fx.gd") + var fx: Node2D = script.new() + var texture_path := str(config.get("path", "")) + if ResourceLoader.exists(texture_path): + fx.set("texture", load(texture_path)) + fx.set("hframes", maxi(1, int(config.get("hframes", 1)))) + fx.set("vframes", maxi(1, int(config.get("vframes", 1)))) + fx.set("first_frame", maxi(0, int(config.get("first", 0)))) + fx.set("last_frame", maxi(0, int(config.get("last", 0)))) + fx.set("fps", maxf(1.0, float(config.get("fps", 20.0)))) + fx.set("delay", maxf(0.0, float(config.get("delay", 0.0)))) + fx.set("duration_override", maxf(0.0, float(config.get("duration", 0.0)))) + fx.set("velocity", config.get("velocity", Vector2.ZERO)) + fx.set("flip_h", bool(config.get("flip_h", false))) + var scale_value := float(config.get("scale", 1.0)) + fx.set("sprite_scale", Vector2(scale_value, scale_value)) + fx.set("sprite_offset", config.get("offset", Vector2.ZERO)) + fx.z_index = int(config.get("z_index", 3)) + parent.add_child(fx) + fx.global_position = spawn_global_position + return fx + + +func _ready() -> void: + if texture == null: + queue_free() + return + _sprite = Sprite2D.new() + _sprite.texture = texture + _sprite.hframes = hframes + _sprite.vframes = vframes + _sprite.centered = true + _sprite.scale = sprite_scale + _sprite.offset = sprite_offset + _sprite.flip_h = flip_h + _sprite.frame = clampi(first_frame, 0, hframes * vframes - 1) + _sprite.visible = delay <= 0.0 + add_child(_sprite) + + +func _process(delta: float) -> void: + _elapsed += delta + if _elapsed < delay: + return + if _sprite == null: + queue_free() + return + _sprite.visible = true + var play_time := _elapsed - delay + position += velocity * delta + var lifetime := duration_override + if lifetime <= 0.0: + lifetime = float(last_frame - first_frame + 1) / fps + if play_time >= lifetime: + queue_free() + return + var frame_index := first_frame + int(play_time * fps) + _sprite.frame = clampi(frame_index, first_frame, mini(last_frame, hframes * vframes - 1)) diff --git a/scenes/combat/one_shot_fx.gd.uid b/scenes/combat/one_shot_fx.gd.uid new file mode 100644 index 0000000..f65001b --- /dev/null +++ b/scenes/combat/one_shot_fx.gd.uid @@ -0,0 +1 @@ +uid://bi8tnfsrabjok diff --git a/scenes/combat/player_projectile.gd b/scenes/combat/player_projectile.gd new file mode 100644 index 0000000..08a6aa6 --- /dev/null +++ b/scenes/combat/player_projectile.gd @@ -0,0 +1,190 @@ +class_name PlayerProjectile +extends Area2D + +const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png") +# effect_sheet.png is a 6x2 grid of 32x32 cells; row one holds four flight frames +# (cells four and five are empty), row two holds the impact burst. +const SHEET_HFRAMES := 6 +const SHEET_VFRAMES := 2 +const FLIGHT_FRAME_COUNT := 4 +const FLIGHT_FPS := 16.0 +const VANISH_FRAME := 4 +const HIT_LEAD_DISTANCE := 18.0 +const DEFAULT_MAX_RANGE := 460.0 + +# The player blade wave uses the author's PROJECTILE sheet (5 frames, 33fps loop). +const WAVE_TEXTURE_PATH := "res://assets/art/characters/player/11_blade_wave/projectile.png" +# collision_radius keeps the hit circle inside the visible art: the effect_sheet +# flight frames show a ~32x18px bolt at scale 2 (radius 9 = half its height), +# the wave sheet's smallest frame is ~66x65px at scale 0.55 (radius 32 fits). +const VISUAL_PROFILES := { + &"bullet": {"hframes": SHEET_HFRAMES, "vframes": SHEET_VFRAMES, "first": 0, "last": FLIGHT_FRAME_COUNT - 1, "fps": FLIGHT_FPS, "scale": 2.0, "vanish_frame": VANISH_FRAME, "collision_radius": 9.0}, + &"wave": {"path": WAVE_TEXTURE_PATH, "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 33.0, "scale": 0.55, "vanish_frame": -1, "collision_radius": 32.0}, +} + +@export var damage := 100 +@export var hit_type: StringName = &"projectile" +@export var base_knockback := Vector2.ZERO + +## Snapshotted when fired: weak-state enemy projectiles deal damage but do not +## interrupt, while Boss/player projectiles keep full authority. +var attacker_interrupts := true +var source_actor: Node = null +var action_context: Resource +var judgement_context: Dictionary = {"label": "perfect"} +var direction := Vector2.RIGHT +var speed := 520.0 +var max_range := DEFAULT_MAX_RANGE +var visual_profile: StringName = &"bullet" +var _travelled := 0.0 +var _age := 0.0 +var _sprite: Sprite2D +var _hit_confirmed := false + + +func _init() -> void: + _create_sprite() + + +func _ready() -> void: + if _sprite == null: + _create_sprite() + _apply_visual_profile() + if not area_entered.is_connected(_on_area_entered): + area_entered.connect(_on_area_entered) + + +func _apply_visual_profile() -> void: + if _sprite == null: + return + var profile := _visual() + var texture_path := str(profile.get("path", "")) + if not texture_path.is_empty() and ResourceLoader.exists(texture_path): + _sprite.texture = load(texture_path) + else: + _sprite.texture = EFFECT_TEXTURE + _sprite.hframes = maxi(1, int(profile.get("hframes", 1))) + _sprite.vframes = maxi(1, int(profile.get("vframes", 1))) + var profile_scale := float(profile.get("scale", 1.0)) + _sprite.scale = Vector2(profile_scale, profile_scale) + _sprite.frame = clampi(int(profile.get("first", 0)), 0, _sprite.hframes * _sprite.vframes - 1) + _apply_collision_profile(profile) + + +func _apply_collision_profile(profile: Dictionary) -> void: + var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node == null or not (shape_node.shape is CircleShape2D): + return + # The scene's CircleShape2D is a shared subresource: duplicate before sizing + # per profile so bullet and wave instances never overwrite each other. + var circle := (shape_node.shape as CircleShape2D).duplicate() as CircleShape2D + circle.radius = maxf(1.0, float(profile.get("collision_radius", circle.radius))) + shape_node.shape = circle + + +func _visual() -> Dictionary: + return VISUAL_PROFILES.get(visual_profile, VISUAL_PROFILES[&"bullet"]) + + +func _create_sprite() -> void: + if _sprite != null and is_instance_valid(_sprite): + return + _sprite = Sprite2D.new() + _sprite.name = "Sprite" + _sprite.texture = EFFECT_TEXTURE + _sprite.hframes = SHEET_HFRAMES + _sprite.vframes = SHEET_VFRAMES + _sprite.centered = true + _sprite.scale = Vector2(2.0, 2.0) + add_child(_sprite) + + +func _process(delta: float) -> void: + _age += delta + if not _hit_confirmed: + var profile := _visual() + var first := int(profile.get("first", 0)) + var span := maxi(1, int(profile.get("last", first)) - first + 1) + _sprite.frame = first + int(_age * float(profile.get("fps", FLIGHT_FPS))) % span + _sprite.rotation = direction.angle() + _sprite.flip_h = false + + +func _physics_process(delta: float) -> void: + if _hit_confirmed: + return + var movement := direction.normalized() * speed * delta + var receiver := _swept_receiver(movement) + if receiver != null: + _on_area_entered(receiver) + return + position += movement + _travelled += movement.length() + if _travelled >= max_travel_distance(): + queue_free() + + +func _on_area_entered(receiver: Area2D) -> void: + if _hit_confirmed: + return + if receiver.is_in_group("enemy_projectiles") and is_in_group("player_projectiles"): + receiver.queue_free() + return + if not receiver.is_in_group("damage_receivers"): + return + _hit_confirmed = true + _vanish_after_hit() + var combat := _combat_manager_or_null() + if combat != null and combat.has_method("resolve_hit"): + combat.call("resolve_hit", self, receiver) + queue_free() + + +func _combat_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("CombatManager") + + +func max_travel_distance() -> float: + return max_range if max_range > 0.0 else DEFAULT_MAX_RANGE + + +func _vanish_after_hit() -> void: + set_deferred("monitoring", false) + set_deferred("monitorable", false) + visible = false + var vanish_frame := int(_visual().get("vanish_frame", -1)) + if _sprite != null and vanish_frame >= 0: + _sprite.frame = vanish_frame + + +func _swept_receiver(movement: Vector2) -> Area2D: + if movement == Vector2.ZERO or not is_inside_tree(): + return null + var travel_direction := movement.normalized() + var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node != null and shape_node.shape != null: + var shape_query := PhysicsShapeQueryParameters2D.new() + shape_query.shape = shape_node.shape + shape_query.transform = shape_node.global_transform.translated(travel_direction * HIT_LEAD_DISTANCE + movement) + shape_query.collision_mask = collision_mask + shape_query.collide_with_areas = true + shape_query.collide_with_bodies = false + shape_query.exclude = [get_rid()] + for hit: Dictionary in get_world_2d().direct_space_state.intersect_shape(shape_query, 8): + var shape_collider = hit.get("collider", null) + if shape_collider is Area2D and shape_collider.is_in_group("damage_receivers"): + return shape_collider + var query := PhysicsRayQueryParameters2D.create( + global_position + travel_direction * HIT_LEAD_DISTANCE, + global_position + travel_direction * HIT_LEAD_DISTANCE + movement, + collision_mask + ) + query.collide_with_areas = true + query.collide_with_bodies = false + var hit := get_world_2d().direct_space_state.intersect_ray(query) + var collider = hit.get("collider", null) + if collider is Area2D and collider.is_in_group("damage_receivers"): + return collider + return null diff --git a/scenes/combat/player_projectile.gd.uid b/scenes/combat/player_projectile.gd.uid new file mode 100644 index 0000000..26d90ea --- /dev/null +++ b/scenes/combat/player_projectile.gd.uid @@ -0,0 +1 @@ +uid://b8ek2cytdwsmp diff --git a/scenes/combat/player_projectile.tscn b/scenes/combat/player_projectile.tscn new file mode 100644 index 0000000..8902b53 --- /dev/null +++ b/scenes/combat/player_projectile.tscn @@ -0,0 +1,14 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://scenes/combat/player_projectile.gd" id="1"] + +[sub_resource type="CircleShape2D" id="CircleShape2D_projectile"] +radius = 32.0 + +[node name="PlayerProjectile" type="Area2D"] +collision_layer = 16 +collision_mask = 2 +script = ExtResource("1") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +shape = SubResource("CircleShape2D_projectile") diff --git a/scenes/components/action_controller.gd b/scenes/components/action_controller.gd new file mode 100644 index 0000000..0bc5e70 --- /dev/null +++ b/scenes/components/action_controller.gd @@ -0,0 +1,865 @@ +class_name ActionController +extends Node + +const ActionResolverScript := preload("res://scenes/combat/action_resolver.gd") +const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd") +const DEFAULT_PLAYER_ACTION_BINDINGS_PATH := "res://resources/player_action_bindings.tres" + +signal action_started(action: Resource, intent) +signal action_active_started(action: Resource, intent) +signal action_active_finished(action: Resource) +signal action_finished(action: Resource) +signal action_cancelled(action: Resource, reason: StringName) +signal action_rejected(intent, reason: StringName) + +enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY, CHARGING } + +const MAX_CHARGE_LEVEL := 3 + +@export var combo_window_path: NodePath +@export var action_resolver_path: NodePath +@export var action_executor_path: NodePath +@export var state_machine_path: NodePath +@export var burst_component_path: NodePath +@export var beat_anchor_policy: StringName = &"ANCHOR_ACTIVE" +@export var anchor_grid := 0.25 +@export var hold_threshold_beats := 0.25 +@export var charge_level_beats := 1.0 +@export var player_action_bindings: Resource + +@onready var combo_window: Node = get_node_or_null(combo_window_path) +@onready var action_resolver: Node = get_node_or_null(action_resolver_path) +@onready var action_executor: Node = get_node_or_null(action_executor_path) +@onready var state_machine: Node = get_node_or_null(state_machine_path) +@onready var burst_component: Node = get_node_or_null(burst_component_path) +@onready var energy_component: Node = get_node_or_null("../EnergyComponent") +@onready var effect_container: Node = get_node_or_null("../EffectContainer") + +var phase := Phase.IDLE +var current_action: Resource +var current_intent +var pending_intent +var phase_elapsed := 0.0 +var phase_duration := 0.0 +var startup_stretch_seconds := 0.0 +var _cost_snapshot := 0.0 +var _action_snapshot: Dictionary = {} +var _charge_hold_intent +var _active_charge_entry: Dictionary = {} +var _charge_hold_elapsed := 0.0 +var _charge_hold_ready := false +var _charging_elapsed := 0.0 + + +func submit_intent(intent) -> void: + if intent == null: + return + var judged_intent = _ensure_judged(intent) + # new1: 锁定期间的按键完全无效——不进判定流程、不触发动作、不留任何记录。 + if judged_intent.is_pressed() and _live_input_locked(judged_intent): + action_rejected.emit(judged_intent, &"input_locked") + return + if _is_direct_charge_release(judged_intent): + _cancel_charge_hold() + return + if _is_charge_secondary_cast(judged_intent): + _finish_secondary_charge_gate(judged_intent) + return + if _is_secondary_charge_cancel_release(judged_intent): + _cancel_secondary_charge_gate() + return + if _is_charge_hold_broken_by_derive(judged_intent): + _break_charge_hold_for_derive() + if _is_charge_hold_release(judged_intent): + _finish_charge_hold_gate(judged_intent) + return + if _should_begin_direct_charge_gate(judged_intent): + _begin_direct_charge_gate(judged_intent) + return + if judged_intent.is_released(): + action_rejected.emit(judged_intent, &"release_not_action") + return + match _gate_live_input(judged_intent): + &"ignored": + action_rejected.emit(judged_intent, &"input_ignored") + return + &"miss": + judged_intent = judged_intent.with_judgement(_miss_judgement_override(judged_intent.judgement)) + _emit_judgement_feedback(judged_intent) + _dispatch_judgement_effect_events(judged_intent) + if _judgement_label(judged_intent) == &"miss": + _record_miss(judged_intent) + action_rejected.emit(judged_intent, &"miss") + return + var should_track_charge_hold := _should_begin_charge_hold_gate(judged_intent) + if phase == Phase.IDLE: + _consume_intent(judged_intent) + if should_track_charge_hold: + _begin_charge_hold_gate_if_started(judged_intent) + return + if _can_cancel_now(): + cancel_current(&"chain") + _consume_intent(judged_intent) + if should_track_charge_hold: + _begin_charge_hold_gate_if_started(judged_intent) + return + _store_pending_intent(judged_intent) + + +func submit_ai_intent(intent) -> void: + if intent == null: + return + if phase != Phase.IDLE: + action_rejected.emit(intent, &"busy") + return + var action: Resource = ActionResolverScript.get_action(StringName(str(intent.action_id))) + if action == null: + action_rejected.emit(intent, &"no_executable_action") + return + if not ActionRuleResolverScript.can_execute(_resolver_context(), action): + action_rejected.emit(intent, &"no_executable_action") + return + if not _commit_action_cost(action, intent): + action_rejected.emit(intent, &"insufficient_energy") + return + current_action = action + current_intent = intent + _dispatch_action_effect_event(&"on_action_start", action, intent) + _enter_phase(Phase.STARTUP) + action_started.emit(action, intent) + _emit_action_started_fact(action, intent) + + +func _physics_process(delta: float) -> void: + _tick_charge_hold(delta) + if phase == Phase.CHARGING: + return + if phase == Phase.IDLE: + if pending_intent != null and not _window_is_showing_pending_clear(): + var idle_intent = pending_intent + pending_intent = null + _consume_intent(idle_intent) + _arm_charge_hold_for_consumed_pending(idle_intent) + return + phase_elapsed += delta + if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now(): + var next_intent = pending_intent + pending_intent = null + cancel_current(&"chain") + _consume_intent(next_intent) + _arm_charge_hold_for_consumed_pending(next_intent) + return + if phase_elapsed < phase_duration: + return + var carryover := maxf(0.0, phase_elapsed - phase_duration) + match phase: + Phase.STARTUP: + _enter_phase(Phase.ACTIVE) + phase_elapsed = carryover + if not _activate_current_action(): + return + Phase.ACTIVE: + _finish_action_execution() + action_active_finished.emit(current_action) + _enter_phase(Phase.RECOVERY) + phase_elapsed = carryover + Phase.RECOVERY: + var finished_action := current_action + var next_intent = pending_intent + pending_intent = null + var should_enter_charge := _charge_hold_ready and _charge_hold_intent != null and next_intent == null + if next_intent != null: + _clear_charge_hold() + _reset_to_idle(not should_enter_charge) + _clear_window_after_action(finished_action) + action_finished.emit(finished_action) + if should_enter_charge and _enter_charge_phase(): + return + if next_intent != null: + _consume_intent(next_intent) + _arm_charge_hold_for_consumed_pending(next_intent) + + +func _consume_intent(intent) -> void: + _start_action(intent) + + +func _start_action(intent) -> void: + if combo_window == null or action_resolver == null: + action_rejected.emit(intent, &"missing_component") + return + _record_intent_symbol(intent) + var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context()) + if action == null: + if not _window_is_showing_pending_clear(): + _rollback_intent_symbol(intent) + action_rejected.emit(intent, &"no_executable_action") + return + if not _commit_action_cost(action, intent): + _rollback_intent_symbol(intent) + action_rejected.emit(intent, &"insufficient_energy") + return + current_action = action + current_intent = intent + _dispatch_action_effect_event(&"on_action_start", action, intent) + _enter_phase(Phase.STARTUP) + action_started.emit(action, intent) + _emit_action_started_fact(action, intent) + + +func _start_action_resource(action: Resource, intent) -> void: + if action == null: + action_rejected.emit(intent, &"no_executable_action") + _reset_to_idle() + return + if not ActionRuleResolverScript.can_execute(_resolver_context(), action): + action_rejected.emit(intent, &"no_executable_action") + _reset_to_idle() + return + if not _commit_action_cost(action, intent): + action_rejected.emit(intent, &"insufficient_energy") + _reset_to_idle() + return + current_action = action + current_intent = intent + _dispatch_action_effect_event(&"on_action_start", action, intent) + _enter_phase(Phase.STARTUP) + action_started.emit(action, intent) + _emit_action_started_fact(action, intent) + + +func _activate_current_action() -> bool: + if current_action == null or current_intent == null: + _reset_to_idle() + return false + if action_executor == null: + action_rejected.emit(current_intent, &"missing_component") + _reset_to_idle() + return false + if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), effect_container): + var reason := _action_executor_failure_reason() + combo_window.flush_pending_clear() + combo_window.clear(reason) + action_rejected.emit(current_intent, reason) + _reset_to_idle() + return false + action_active_started.emit(current_action, current_intent) + return true + + +func _record_intent_symbol(intent) -> void: + if combo_window.has_pending_clear(): + combo_window.flush_pending_clear() + combo_window.record(intent.symbol) + + +func _rollback_intent_symbol(intent) -> void: + if combo_window != null and combo_window.has_method("rollback_last"): + combo_window.call("rollback_last", intent.symbol) + + +func _record_miss(_intent) -> void: + if combo_window != null: + if combo_window.has_pending_clear(): + combo_window.flush_pending_clear() + combo_window.record(&"Ø") + + +func _clear_window_after_action(action: Resource) -> void: + if combo_window == null or action == null: + return + if bool(action.get("clear_window")): + combo_window.clear(StringName("skill:%s" % action.get("id"))) + + +func _store_pending_intent(intent) -> void: + if pending_intent != null: + _emit_intent_replaced_fact(pending_intent, intent) + action_rejected.emit(pending_intent, &"replaced") + pending_intent = intent + + +func _enter_phase(next_phase: Phase) -> void: + phase = next_phase + phase_elapsed = 0.0 + phase_duration = _phase_duration_seconds(next_phase) + _mirror_action_phase() + _mirror_defense_state() + + +func _phase_duration_seconds(next_phase: Phase) -> float: + if current_action == null: + return 0.0 + var beat_time := _beat_time() + match next_phase: + Phase.STARTUP: + var minimum_duration := maxf(0.01, _snapshot_float("startup_beats", float(current_action.get("startup_beats"))) * beat_time) + if beat_anchor_policy != &"ANCHOR_ACTIVE": + startup_stretch_seconds = 0.0 + return minimum_duration + var anchored_duration := _anchored_startup_duration(minimum_duration, beat_time) + startup_stretch_seconds = maxf(0.0, anchored_duration - minimum_duration) + return anchored_duration + Phase.ACTIVE: + return maxf(0.01, _snapshot_float("active_beats", float(current_action.get("active_beats"))) * beat_time) + Phase.RECOVERY: + return maxf(0.01, _snapshot_float("recovery_beats", float(current_action.get("recovery_beats"))) * beat_time) + Phase.CHARGING: + return 0.0 + return 0.0 + + +func _can_cancel_now() -> bool: + if phase != Phase.RECOVERY or current_action == null: + return false + if bool(current_action.get("clear_window")) and not bool(current_action.get("can_chain")): + return false + var duration := maxf(0.01, phase_duration) + var progress := clampf(phase_elapsed / duration, 0.0, 1.0) + return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0) + + +func _reset_to_idle(clear_charge_hold := true) -> void: + phase = Phase.IDLE + current_action = null + current_intent = null + phase_elapsed = 0.0 + phase_duration = 0.0 + startup_stretch_seconds = 0.0 + _cost_snapshot = 0.0 + _action_snapshot = {} + if clear_charge_hold: + _clear_charge_hold() + _mirror_action_phase() + _mirror_defense_state() + + +func cancel_current(reason: StringName) -> void: + var cancelled_action := current_action + _finish_action_execution() + pending_intent = null if reason != &"chain" else pending_intent + _reset_to_idle() + if reason == &"interrupt" or reason == &"death": + if combo_window != null: + combo_window.clear(reason) + action_cancelled.emit(cancelled_action, reason) + _emit_action_cancelled_fact(cancelled_action, reason) + + +func _window_is_showing_pending_clear() -> bool: + return combo_window != null and combo_window.has_pending_clear() + + +func _ensure_judged(intent): + if not intent.judgement.is_empty(): + return intent.with_judgement(_judgement_with_defaults(intent.judgement)) + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null and rhythm.has_method("judge"): + var rating: Dictionary = rhythm.call("judge", intent.timestamp_ms) + # 只有实时判定的输入走 new1 的消耗/锁定门控;预置判定(测试/AI)保持权威。 + rating["live"] = true + return intent.with_judgement(_judgement_with_defaults(rating)) + return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0})) + + +func _live_input_locked(intent) -> bool: + if not bool(intent.judgement.get("live", false)): + return false + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + return rhythm != null and rhythm.has_method("is_input_locked") and bool(rhythm.call("is_input_locked")) + + +## new1 门控:返回 &"ok" / &"ignored" / &"miss"(见 RhythmManager.gate_judged_input)。 +func _gate_live_input(intent) -> StringName: + if not bool(intent.judgement.get("live", false)): + return &"ok" + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm == null or not rhythm.has_method("gate_judged_input"): + return &"ok" + return StringName(str(rhythm.call("gate_judged_input", intent.judgement))) + + +func _miss_judgement_override(judgement: Dictionary) -> Dictionary: + var rating := judgement.duplicate() + rating["label"] = "miss" + rating["color"] = _judgement_color(&"miss") + return rating + + +func _judgement_label(intent) -> StringName: + return StringName(str(intent.judgement.get("label", "miss"))) + + +func _emit_judgement_feedback(intent) -> void: + var rating := _judgement_with_defaults(intent.judgement) + var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol + rating["action"] = action_name + var label := StringName(str(rating.get("label", "miss"))) + var diff_ms := float(rating.get("diff", INF)) * 1000.0 + var nearest_beat := int(rating.get("nearest_beat", 0)) + var bus := _event_bus_or_null() + if bus == null: + return + bus.emit_signal("judgement_made", label, diff_ms, nearest_beat) + + +func _judgement_with_defaults(judgement: Dictionary) -> Dictionary: + var rating := judgement.duplicate() + var label := StringName(str(rating.get("label", "miss"))) + rating["label"] = str(label) + if not rating.has("diff"): + rating["diff"] = 0.0 + if not rating.has("abs_diff"): + rating["abs_diff"] = absf(float(rating.get("diff", 0.0))) + if not rating.has("nearest_beat"): + rating["nearest_beat"] = 0 + if not rating.has("color"): + rating["color"] = _judgement_color(label) + return rating + + +func _judgement_color(label: StringName) -> Color: + match label: + &"perfect": + return Color("00f2ff") + &"good": + return Color("ffffff") + &"bad": + return Color("ffaa00") + return Color("ff0055") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _emit_intent_replaced_fact(previous_intent, next_intent) -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("intent_replaced"): + bus.emit_signal("intent_replaced", previous_intent, next_intent) + + +func _emit_action_started_fact(action: Resource, intent) -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("action_started"): + bus.emit_signal("action_started", action, intent) + + +func _emit_action_cancelled_fact(action: Resource, reason: StringName) -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("action_cancelled"): + bus.emit_signal("action_cancelled", action, reason) + + +func _dispatch_judgement_effect_events(intent) -> void: + if effect_container == null or not effect_container.has_method("dispatch_event"): + return + if _judgement_label(intent) == &"perfect": + effect_container.call("dispatch_event", &"on_perfect", {"intent": intent}) + + +func _dispatch_action_effect_event(event_name: StringName, action: Resource, intent) -> void: + if effect_container == null: + effect_container = get_node_or_null("../EffectContainer") + if effect_container != null and effect_container.has_method("dispatch_event"): + effect_container.call("dispatch_event", event_name, {"action": action, "intent": intent}) + + +func _resolver_context() -> Dictionary: + var context := {} + if state_machine != null and state_machine.has_method("build_context"): + context = state_machine.call("build_context") + else: + context = { + "ground_state": &"Grounded", + "action_phase": &"Neutral", + "defense_state": &"Vulnerable", + "life_state": &"Alive", + "tags": [&"Grounded", &"Neutral", &"Vulnerable", &"Alive"], + } + context.merge({ + "burst_action_id": _burst_action_id(), + "counter_action_id": _counter_action_id(), + "counter_ready": _counter_ready(), + "blade_chain_action_id": _blade_chain_action_id(), + "blade_chain_active": _blade_chain_active(), + }, true) + return context + + +func _burst_action_id() -> StringName: + if burst_component != null and bool(burst_component.get("burst_ready")): + return _binding_action_id("burst_action_id") + return &"" + + +func _counter_action_id() -> StringName: + return _binding_action_id("counter_action_id") + + +func _counter_ready() -> bool: + return false + + +func _blade_chain_action_id() -> StringName: + if _blade_chain_active(): + return _binding_action_id("blade_chain_action_id") + return &"" + + +func _blade_chain_active() -> bool: + if current_action == null: + return false + return bool(current_action.get("can_chain")) + + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 + + +func _commit_action_cost(action: Resource, intent) -> bool: + _action_snapshot = _resolve_action_snapshot(action, intent) + _cost_snapshot = float(_action_snapshot.get("cost", 0.0)) + if energy_component == null: + return true + return energy_component.spend(_cost_snapshot) + + +func _resolve_cost(action: Resource, judgement: Dictionary = {}) -> float: + var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null + if combat != null and combat.has_method("resolve_cost"): + return float(combat.call("resolve_cost", action, judgement, effect_container)) + return float(action.get("base_cost")) + + +func _resolve_action_snapshot(action: Resource, intent) -> Dictionary: + var judgement := {} + if intent != null: + var intent_judgement = intent.get("judgement") + if intent_judgement is Dictionary: + judgement = intent_judgement + var combat := get_tree().root.get_node_or_null("CombatManager") if is_inside_tree() else null + if combat != null and combat.has_method("resolve_action_snapshot"): + return combat.call("resolve_action_snapshot", action, judgement, effect_container) + return { + "cost": _resolve_cost(action, judgement), + "startup_beats": float(action.get("startup_beats")), + "active_beats": float(action.get("active_beats")), + "recovery_beats": float(action.get("recovery_beats")), + } + + +func _snapshot_float(key: String, fallback: float) -> float: + if _action_snapshot.has(key): + return float(_action_snapshot[key]) + return fallback + + +func _anchored_startup_duration(minimum_duration: float, beat_time: float) -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + var now := 0.0 + if rhythm != null and rhythm.has_method("song_position"): + now = float(rhythm.call("song_position")) + var grid_seconds := maxf(0.01, anchor_grid * beat_time) + var desired_active := now + minimum_duration + var anchored_active := ceili(desired_active / grid_seconds) * grid_seconds + return maxf(minimum_duration, anchored_active - now) + + +func _mirror_action_phase() -> void: + if state_machine == null or not state_machine.has_method("set_action_phase"): + return + match phase: + Phase.STARTUP: + state_machine.call("set_action_phase", &"Startup") + Phase.ACTIVE: + state_machine.call("set_action_phase", &"Active") + Phase.RECOVERY: + state_machine.call("set_action_phase", &"Recovery") + Phase.CHARGING: + state_machine.call("set_action_phase", &"Charging") + _: + state_machine.call("set_action_phase", &"Neutral") + + +func _mirror_defense_state() -> void: + if state_machine == null or not state_machine.has_method("set_defense_state"): + return + if phase != Phase.ACTIVE or current_action == null: + state_machine.call("set_defense_state", &"Vulnerable") + return + var defense_tags: Array = current_action.get("defense_tags") + if defense_tags.has(&"invincible"): + state_machine.call("set_defense_state", &"Invincible") + elif defense_tags.has(&"parry") or defense_tags.has(&"parrying"): + state_machine.call("set_defense_state", &"Parrying") + elif defense_tags.has(&"super_armor"): + state_machine.call("set_defense_state", &"SuperArmor") + else: + state_machine.call("set_defense_state", &"Vulnerable") + + +func _should_begin_charge_hold_gate(intent) -> bool: + if not intent.is_pressed(): + return false + var entry := _charge_entry_for_symbol(intent.symbol) + return StringName(str(entry.get("entry_mode", &""))) == &"after_tap" + + +func _begin_charge_hold_gate_if_started(intent) -> void: + if current_intent != intent or current_action == null: + return + _active_charge_entry = _charge_entry_for_symbol(intent.symbol) + _charge_hold_intent = intent + _charge_hold_elapsed = 0.0 + _charge_hold_ready = false + + +func _arm_charge_hold_for_consumed_pending(intent) -> void: + if intent == null or not _should_begin_charge_hold_gate(intent): + return + if not _symbol_input_still_pressed(intent.symbol): + return + _begin_charge_hold_gate_if_started(intent) + + +func _symbol_input_still_pressed(symbol: StringName) -> bool: + var action_name: StringName + match symbol: + &"A": + action_name = &"combo_a" + &"D": + action_name = &"combo_d" + &"W": + action_name = &"combo_w" + &"S": + action_name = &"combo_s" + &"SP": + action_name = &"combo_space" + _: + return false + return InputMap.has_action(action_name) and Input.is_action_pressed(action_name) + + +func _is_charge_hold_release(intent) -> bool: + return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_release" + + +func _finish_charge_hold_gate(release_intent) -> void: + var held_long_enough := phase == Phase.CHARGING + var action_id := _charge_cast_action_id(_active_charge_entry, _charge_level()) + _clear_charge_hold() + if held_long_enough: + match _gate_live_input(release_intent): + &"ignored": + _reset_to_idle() + action_rejected.emit(release_intent, &"input_ignored") + return + &"miss": + release_intent = release_intent.with_judgement(_miss_judgement_override(release_intent.judgement)) + _emit_judgement_feedback(release_intent) + if _judgement_label(release_intent) == &"miss": + _reset_to_idle() + _record_miss(release_intent) + action_rejected.emit(release_intent, &"miss") + return + _start_action_resource(ActionResolverScript.get_action(action_id), release_intent) + + +func _tick_charge_hold(delta: float) -> void: + if _charge_hold_intent == null: + return + _charge_hold_elapsed += delta + if phase == Phase.CHARGING: + _charging_elapsed += delta + return + if _charge_hold_elapsed < hold_threshold_beats * _beat_time(): + return + _charge_hold_ready = true + if phase == Phase.IDLE: + _enter_charge_phase() + + +func _enter_charge_phase() -> bool: + if _charge_hold_intent == null: + return false + if _active_charge_entry.is_empty(): + _clear_charge_hold() + return false + current_intent = _charge_hold_intent + current_action = null + _charging_elapsed = 0.0 + _enter_phase(Phase.CHARGING) + return true + + +func _clear_charge_hold() -> void: + _charge_hold_intent = null + _active_charge_entry = {} + _charge_hold_elapsed = 0.0 + _charge_hold_ready = false + _charging_elapsed = 0.0 + + +func _cancel_charge_hold() -> void: + _clear_charge_hold() + _reset_to_idle() + + +func _is_charge_hold_broken_by_derive(intent) -> bool: + if not intent.is_pressed() or _charge_hold_intent == null: + return false + if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_release": + return false + return intent.symbol == &"SP" + + +func _break_charge_hold_for_derive() -> void: + # SP while an A/D hold is armed or charging: the four-slot derivation wins + # ([A][sp] dash, [A][A][sp] finisher...), so abort the charge and let the + # press fall through to the normal resolution path. + var was_charging := phase == Phase.CHARGING + _clear_charge_hold() + if was_charging: + _reset_to_idle() + + +func _should_begin_direct_charge_gate(intent) -> bool: + if not intent.is_pressed() or phase != Phase.IDLE: + return false + var entry := _charge_entry_for_symbol(intent.symbol) + return StringName(str(entry.get("entry_mode", &""))) == &"direct" + + +func _begin_direct_charge_gate(intent) -> void: + _active_charge_entry = _charge_entry_for_symbol(intent.symbol) + _charge_hold_intent = intent + _charge_hold_elapsed = 0.0 + _charge_hold_ready = true + _charging_elapsed = 0.0 + current_intent = intent + current_action = null + _enter_phase(Phase.CHARGING) + + +func _is_direct_charge_release(intent) -> bool: + return intent.is_released() and _charge_hold_intent != null and intent.symbol == _charge_hold_intent.symbol and StringName(str(_active_charge_entry.get("entry_mode", &""))) == &"direct" + + +func _is_charge_secondary_cast(intent) -> bool: + if not intent.is_pressed() or phase != Phase.CHARGING or _active_charge_entry.is_empty(): + return false + if StringName(str(_active_charge_entry.get("cast_trigger", &""))) != &"on_secondary_key": + return false + return intent.symbol == StringName(str(_active_charge_entry.get("cast_key", &""))) + + +func _is_secondary_charge_cancel_release(intent) -> bool: + if not intent.is_released() or _charge_hold_intent == null: + return false + if intent.symbol != _charge_hold_intent.symbol: + return false + return StringName(str(_active_charge_entry.get("cast_trigger", &""))) == &"on_secondary_key" + + +func _cancel_secondary_charge_gate() -> void: + _clear_charge_hold() + if phase == Phase.CHARGING: + _reset_to_idle() + + +func _finish_secondary_charge_gate(cast_intent) -> void: + var entry := _active_charge_entry.duplicate(true) + var action_id := _charge_cast_action_id(entry, _charge_level()) + _clear_charge_hold() + match _gate_live_input(cast_intent): + &"ignored": + _reset_to_idle() + action_rejected.emit(cast_intent, &"input_ignored") + return + &"miss": + cast_intent = cast_intent.with_judgement(_miss_judgement_override(cast_intent.judgement)) + _emit_judgement_feedback(cast_intent) + if _judgement_label(cast_intent) == &"miss": + _reset_to_idle() + action_rejected.emit(cast_intent, &"miss") + return + _start_action_resource(ActionResolverScript.get_action(action_id), cast_intent) + + +func _charge_entry_for_symbol(symbol: StringName) -> Dictionary: + var bindings := _player_action_bindings() + if bindings != null and bindings.has_method("charge_entry_for_symbol"): + return bindings.call("charge_entry_for_symbol", symbol) + return {} + + +func _charge_cast_action_id(entry: Dictionary, level: int) -> StringName: + if entry.is_empty(): + return &"" + var bindings := _player_action_bindings() + if bindings != null and bindings.has_method("cast_action_id"): + return bindings.call("cast_action_id", entry, level) + var cast_ids: Dictionary = entry.get("cast_action_ids", {}) + if cast_ids.has(level): + return StringName(str(cast_ids[level])) + if cast_ids.has(str(level)): + return StringName(str(cast_ids[str(level)])) + return &"" + + +func _charge_level() -> int: + var level_seconds := maxf(0.001, charge_level_beats * _beat_time()) + return clampi(1 + int(floor(_charging_elapsed / level_seconds)), 1, MAX_CHARGE_LEVEL) + + +func charge_state() -> Dictionary: + # Single source of truth for the charge gauge: the cast level is decided + # here, so presentation reads the same clock instead of keeping its own. + var charging := phase == Phase.CHARGING + var level_seconds := maxf(0.001, charge_level_beats * _beat_time()) + return { + "charging": charging, + "level": _charge_level() if charging else 0, + "max_level": MAX_CHARGE_LEVEL, + "progress_units": clampf(_charging_elapsed / level_seconds, 0.0, float(MAX_CHARGE_LEVEL - 1)) if charging else 0.0, + } + + +func _binding_action_id(property_name: StringName) -> StringName: + var bindings := _player_action_bindings() + if bindings == null: + return &"" + return StringName(str(bindings.get(property_name))) + + +func _player_action_bindings() -> Resource: + if player_action_bindings != null: + return player_action_bindings + if ResourceLoader.exists(DEFAULT_PLAYER_ACTION_BINDINGS_PATH): + player_action_bindings = load(DEFAULT_PLAYER_ACTION_BINDINGS_PATH) + return player_action_bindings + + +func _action_executor_failure_reason() -> StringName: + if action_executor != null and "last_failure_reason" in action_executor: + var reason := StringName(str(action_executor.get("last_failure_reason"))) + if not reason.is_empty(): + return reason + return &"execution_failed" + + +func _finish_action_execution() -> void: + if action_executor != null and action_executor.has_method("finish_action"): + action_executor.call("finish_action") diff --git a/scenes/components/action_controller.gd.uid b/scenes/components/action_controller.gd.uid new file mode 100644 index 0000000..ad6e17d --- /dev/null +++ b/scenes/components/action_controller.gd.uid @@ -0,0 +1 @@ +uid://0dw8poe3o53a diff --git a/scenes/components/action_executor.gd b/scenes/components/action_executor.gd new file mode 100644 index 0000000..e745d90 --- /dev/null +++ b/scenes/components/action_executor.gd @@ -0,0 +1,149 @@ +class_name ActionExecutor +extends Node + +const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd") + +signal action_executed(action: Resource, judgement: StringName) +signal action_failed(action: Resource, reason: StringName) + +@export var energy_component_path: NodePath +@export var damage_emitter_path: NodePath + +@onready var _energy_component: Node = get_node_or_null(energy_component_path) +@onready var _damage_emitter: Node = get_node_or_null(damage_emitter_path) + +var last_failure_reason: StringName = &"" + +func execute(action: Resource, judgement: StringName, _stat_provider: Variant = null) -> bool: + last_failure_reason = &"" + if action == null: + _fail(action, &"missing_action") + return false + if _action_requires_damage_emitter(action) and _damage_emitter == null: + _fail(action, &"missing_damage_emitter") + return false + if _action_requires_damage_emitter(action) and _damage_emitter != null and _damage_emitter.has_method("configure_hit"): + _damage_emitter.configure_hit(action, {"label": str(judgement)}) + elif _damage_emitter != null and _damage_emitter.has_method("clear_hit"): + _damage_emitter.clear_hit() + if _action_spawns_projectile(action): + _request_projectile(action, judgement) + var reward := int(round(_resolve_reward(action, {"label": str(judgement)}, _stat_provider))) + if reward != 0 and _energy_component != null: + _energy_component.change(reward) + action_executed.emit(action, judgement) + return true + + +func finish_action() -> void: + if _damage_emitter != null and _damage_emitter.has_method("clear_hit"): + _damage_emitter.clear_hit() + elif _damage_emitter != null: + _damage_emitter.monitoring = false + + +func _fail(action: Resource, reason: StringName) -> void: + last_failure_reason = reason + action_failed.emit(action, reason) + + +func _action_requires_damage_emitter(action: Resource) -> bool: + if action == null: + return false + return StringName(str(action.get("hit_type"))) == &"melee" + + +func _action_spawns_projectile(action: Resource) -> bool: + return action != null and StringName(str(action.get("hit_type"))) == &"projectile" + + +func _resolve_cost(action: Resource, stat_provider: Variant) -> float: + var combat := _combat_manager_or_null() + if combat != null and combat.has_method("resolve_cost"): + return float(combat.call("resolve_cost", action, {}, stat_provider)) + return float(action.get("base_cost")) + + +func _resolve_reward(action: Resource, judgement: Dictionary, stat_provider: Variant) -> float: + var combat := _combat_manager_or_null() + if combat != null and combat.has_method("resolve_reward"): + return float(combat.call("resolve_reward", action, judgement, stat_provider)) + return StatResolverScript.resolve_reward(action, judgement, stat_provider) + + +func _request_projectile(action: Resource, judgement: StringName = &"perfect") -> void: + var owner := get_parent() + var context := { + "team": _projectile_team(action), + "action": action, + "judgement": {"label": str(judgement)}, + "range": float(action.get("range")) if action != null else 0.0, + "attacker_interrupts": _owner_interrupt_authority(owner), + "source_actor": owner, + } + # Projectile base damage belongs to the shooter (player/minion/boss), not + # the projectile scene default. + if _damage_emitter != null: + context["base_damage"] = int(_damage_emitter.get("damage")) + if owner != null and owner.has_method("projectile_requests_for_action"): + var requests = owner.call("projectile_requests_for_action", action) + if requests is Array and not requests.is_empty(): + for request: Variant in requests: + if request is Dictionary: + _emit_projectile_request( + request.get("projectile_scene", null) as PackedScene, + request.get("spawn_position", Vector2.ZERO) as Vector2, + request.get("direction", Vector2.RIGHT) as Vector2, + context + ) + return + var spawn_position := Vector2.ZERO + var direction := Vector2.RIGHT + if owner is Node2D: + spawn_position = (owner as Node2D).global_position + var heading = owner.get("heading") + if heading is Vector2 and heading != Vector2.ZERO: + direction = heading + if owner != null and owner.has_method("projectile_spawn_position"): + spawn_position = owner.call("projectile_spawn_position", action) + if owner != null and owner.has_method("projectile_direction"): + direction = owner.call("projectile_direction", action) + _emit_projectile_request(null, spawn_position, direction, context) + + +func _owner_interrupt_authority(owner: Node) -> bool: + if owner != null and owner.has_method("is_strong_in_current_time_phase"): + return bool(owner.call("is_strong_in_current_time_phase")) + return true + + +func _projectile_team(action: Resource) -> StringName: + if action != null and action.get("action_tags") is Array: + for tag: Variant in action.get("action_tags"): + if StringName(str(tag)) == &"enemy": + return &"enemy" + return &"player" + + +func _emit_projectile_request(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("projectile_requested"): + bus.emit_signal("projectile_requested", projectile_scene, spawn_position, direction, context) + + +func _combat_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("CombatManager") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/components/action_executor.gd.uid b/scenes/components/action_executor.gd.uid new file mode 100644 index 0000000..a6395aa --- /dev/null +++ b/scenes/components/action_executor.gd.uid @@ -0,0 +1 @@ +uid://deahmqoulk8me diff --git a/scenes/components/ai_intent.gd b/scenes/components/ai_intent.gd new file mode 100644 index 0000000..2c66958 --- /dev/null +++ b/scenes/components/ai_intent.gd @@ -0,0 +1,18 @@ +class_name AIIntent +extends RefCounted + +var action_id: StringName +var timestamp_ms := 0.0 +var judgement: Dictionary = { + "label": "perfect", + "diff": 0.0, + "abs_diff": 0.0, +} + + +static func create(next_action_id: StringName, next_timestamp_ms: float) -> RefCounted: + var script: Script = load("res://scenes/components/ai_intent.gd") + var intent: RefCounted = script.new() + intent.action_id = next_action_id + intent.timestamp_ms = next_timestamp_ms + return intent diff --git a/scenes/components/ai_intent.gd.uid b/scenes/components/ai_intent.gd.uid new file mode 100644 index 0000000..3d59ad4 --- /dev/null +++ b/scenes/components/ai_intent.gd.uid @@ -0,0 +1 @@ +uid://coyppy6e3f8ax diff --git a/scenes/components/attack_buff_component.gd b/scenes/components/attack_buff_component.gd new file mode 100644 index 0000000..46d258e --- /dev/null +++ b/scenes/components/attack_buff_component.gd @@ -0,0 +1,170 @@ +class_name AttackBuffComponent +extends Node + +## Sole writer of the time-anchor attack buff stacks. The stack storage itself +## is the Effect instance inside EffectContainer; damage injection rides the +## existing buffs multiplier slot in resolve_damage (no resolver changes). + +const BUFF_EFFECT_PATH := "res://resources/effects/time_phase/effect_time_anchor_attack_buff.tres" +const BUFF_EFFECT_ID := &"time_anchor_attack_buff" +const MAX_STACKS := 7 +const HEAL_WINDOW_BEATS := 4 +const HEAL_BY_JUDGEMENT := { + &"perfect": 30, + &"good": 20, + &"bad": 10, +} + +## 受伤规则:进入受伤状态掉一半 Buff 层数(向下取整), +## 每掉 1 层换 0.5 秒霸体(SuperArmor:伤害照吃、不打断、无击退)。 +const HURT_ARMOR_EFFECT_PATH := "res://resources/effects/effect_hurt_super_armor.tres" +const HURT_ARMOR_EFFECT_ID := &"hurt_super_armor" +const HURT_ARMOR_SECONDS_PER_STACK := 0.5 + +@export var effect_container_path := NodePath("../EffectContainer") +@export var health_component_path := NodePath("../HealthComponent") +@export var state_machine_path := NodePath("../StateMachine") + +@onready var effect_container: Node = get_node_or_null(effect_container_path) +@onready var health_component: Node = get_node_or_null(health_component_path) +@onready var state_machine: Node = get_node_or_null(state_machine_path) + +var buff_definition: Resource +var hurt_armor_definition: Resource +var _last_healed_window := -1 + + +func _ready() -> void: + if buff_definition == null and ResourceLoader.exists(BUFF_EFFECT_PATH): + buff_definition = load(BUFF_EFFECT_PATH) + if hurt_armor_definition == null and ResourceLoader.exists(HURT_ARMOR_EFFECT_PATH): + hurt_armor_definition = load(HURT_ARMOR_EFFECT_PATH) + if state_machine != null and state_machine.has_signal("axis_changed") and not state_machine.is_connected("axis_changed", _on_state_axis_changed): + state_machine.connect("axis_changed", _on_state_axis_changed) + for bus: Node in _event_buses(): + if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved): + bus.connect("time_anchor_resolved", _on_time_anchor_resolved) + if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset): + bus.connect("chart_reset", _on_chart_reset) + + +func attack_buff_stacks() -> int: + if effect_container == null or not effect_container.has_method("effect_stacks"): + return 0 + return int(effect_container.call("effect_stacks", BUFF_EFFECT_ID)) + + +func _on_time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: + if not held: + return + _apply_time_anchor_heal(anchor, judgement) + var label := _judgement_label(judgement) + if label == &"perfect" or label == &"good": + if effect_container != null and buff_definition != null: + effect_container.call("add_effect", buff_definition, &"time_anchor") + _broadcast() + + +func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void: + if effect_container == null or not effect_container.has_method("set_effect_stacks"): + return + var reduced := maxi(0, attack_buff_stacks() - 2) + effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, reduced) + _broadcast() + + +func _on_state_axis_changed(axis: StringName, _previous: StringName, current: StringName) -> void: + if axis == &"life_state" and current == &"Hitstun": + _drop_stacks_for_hurt() + + +## 受伤惩罚与补偿一体:掉 floor(层数 / 2) 层,掉几层就换几段霸体时间。 +## 霸体期间后续攻击不再触发受伤状态,因此不会连锁掉层。 +func _drop_stacks_for_hurt() -> void: + if effect_container == null or not effect_container.has_method("set_effect_stacks"): + return + var stacks := attack_buff_stacks() + var dropped := floori(stacks * 0.5) + if dropped <= 0: + return + effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, stacks - dropped) + _apply_hurt_super_armor(dropped) + _broadcast() + + +func _apply_hurt_super_armor(dropped_stacks: int) -> void: + if hurt_armor_definition == null or not effect_container.has_method("add_effect"): + return + var armor := hurt_armor_definition.duplicate() as Resource + armor.set("duration", HURT_ARMOR_SECONDS_PER_STACK * float(dropped_stacks)) + effect_container.call("add_effect", armor, &"hurt_buff_drop") + + +func cap_stacks(max_kept: int) -> void: + if effect_container == null or not effect_container.has_method("set_effect_stacks"): + return + var capped := clampi(attack_buff_stacks(), 0, maxi(0, max_kept)) + effect_container.call("set_effect_stacks", BUFF_EFFECT_ID, capped) + _broadcast() + + +func _on_chart_reset(_chart_id: StringName) -> void: + _last_healed_window = -1 + if effect_container != null and effect_container.has_method("remove_effect"): + effect_container.call("remove_effect", BUFF_EFFECT_ID) + _broadcast() + + +func _apply_time_anchor_heal(anchor: Dictionary, judgement: Dictionary) -> void: + var beat_value: Variant = anchor.get("beat", null) + if beat_value == null: + return + var label: StringName = _judgement_label(judgement) + if not HEAL_BY_JUDGEMENT.has(label): + return + var window_index: int = int(floor(float(int(beat_value)) / float(HEAL_WINDOW_BEATS))) + if window_index == _last_healed_window: + return + var health: Node = _health_component_or_null() + if health == null or not health.has_method("heal"): + return + var amount: int = int(HEAL_BY_JUDGEMENT[label]) + var maximum: int = maxi(1, int(health.get("maximum"))) + var current: int = int(health.get("current")) + if current < int(ceil(float(maximum) * 0.5)): + amount *= 2 + health.call("heal", amount) + _last_healed_window = window_index + + +func _judgement_label(judgement: Dictionary) -> StringName: + if judgement.has("label"): + return StringName(str(judgement["label"]).to_lower()) + if judgement.has(&"label"): + return StringName(str(judgement[&"label"]).to_lower()) + return &"" + + +func _health_component_or_null() -> Node: + if health_component != null and is_instance_valid(health_component): + return health_component + health_component = get_node_or_null(health_component_path) + return health_component + + +func _broadcast() -> void: + for bus: Node in _event_buses(): + if bus.has_signal("attack_buff_changed"): + bus.emit_signal("attack_buff_changed", attack_buff_stacks(), MAX_STACKS) + + +func _event_buses() -> Array[Node]: + var buses: Array[Node] = [] + if not is_inside_tree(): + return buses + for child: Node in get_tree().root.get_children(): + if child.has_signal("time_anchor_resolved") and child.has_signal("attack_buff_changed"): + buses.append(child) + return buses diff --git a/scenes/components/attack_buff_component.gd.uid b/scenes/components/attack_buff_component.gd.uid new file mode 100644 index 0000000..ff8a68e --- /dev/null +++ b/scenes/components/attack_buff_component.gd.uid @@ -0,0 +1 @@ +uid://l2m6txa53ylx diff --git a/scenes/components/attack_buff_visual.gd b/scenes/components/attack_buff_visual.gd new file mode 100644 index 0000000..ee7a8f8 --- /dev/null +++ b/scenes/components/attack_buff_visual.gd @@ -0,0 +1,132 @@ +class_name AttackBuffVisual +extends Node2D + +const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/buff_effect" +const DEFAULT_MAX_STACKS := 7 + +@export var frame_directory := DEFAULT_FRAME_DIRECTORY +@export var frames_per_second := 18.0 +@export var center_offset := Vector2(0.0, -86.0) +@export var minimum_scale := 0.85 +@export var maximum_scale := 1.85 +@export var minimum_alpha := 0.28 +@export var maximum_alpha := 0.95 + +var _stacks := 0 +var _max_stacks := DEFAULT_MAX_STACKS +var _frame_index := 0 +var _frame_elapsed := 0.0 +var _pulse_elapsed := 0.0 +var _frames: Array[Texture2D] = [] +var _sprite: Sprite2D + + +func _ready() -> void: + z_index = 1 + _sprite = Sprite2D.new() + _sprite.name = "BuffEffect" + _sprite.centered = true + _sprite.position = center_offset + _sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _sprite.visible = false + add_child(_sprite) + _load_frames() + _connect_event_buses() + call_deferred("_connect_event_buses") + call_deferred("_sync_from_attack_buff_component") + set_attack_buff(0, DEFAULT_MAX_STACKS) + + +func _process(delta: float) -> void: + if _stacks <= 0 or _frames.is_empty(): + return + var intensity := _intensity() + _pulse_elapsed = fmod(_pulse_elapsed + delta * lerpf(1.3, 2.8, intensity), TAU) + _frame_elapsed += delta + var frame_duration := 1.0 / maxf(1.0, frames_per_second * lerpf(0.85, 1.35, intensity)) + while _frame_elapsed >= frame_duration: + _frame_elapsed -= frame_duration + _frame_index = (_frame_index + 1) % _frames.size() + _sprite.texture = _frames[_frame_index] + _apply_effect_state() + + +func set_attack_buff(stacks: int, max_stacks: int) -> void: + _max_stacks = maxi(1, max_stacks) + _stacks = clampi(stacks, 0, _max_stacks) + set_process(_stacks > 0 and not _frames.is_empty()) + visible = _stacks > 0 and not _frames.is_empty() + if _sprite != null: + _sprite.visible = visible + if visible and _sprite.texture == null and not _frames.is_empty(): + _sprite.texture = _frames[_frame_index] + _apply_effect_state() + + +func frame_count() -> int: + return _frames.size() + + +func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void: + set_attack_buff(stacks, max_stacks) + + +func _load_frames() -> void: + _frames.clear() + var dir := DirAccess.open(frame_directory) + if dir == null: + return + # 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap; + # 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。 + var files := PackedStringArray() + dir.list_dir_begin() + var file_name := dir.get_next() + while not file_name.is_empty(): + if not dir.current_is_dir(): + var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap") + if resource_name.begins_with("frame") and resource_name.ends_with(".png") and not files.has(resource_name): + files.append(resource_name) + file_name = dir.get_next() + dir.list_dir_end() + files.sort() + for path_name: String in files: + var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D + if texture != null: + _frames.append(texture) + if not _frames.is_empty() and _sprite != null: + _sprite.texture = _frames[0] + + +func _apply_effect_state() -> void: + if _sprite == null: + return + if _stacks <= 0 or _frames.is_empty(): + _sprite.visible = false + return + var intensity := _intensity() + var pulse := 0.5 + 0.5 * sin(_pulse_elapsed) + _sprite.position = center_offset + _sprite.scale = Vector2.ONE * (lerpf(minimum_scale, maximum_scale, intensity) + pulse * lerpf(0.03, 0.18, intensity)) + _sprite.modulate = Color(1.0, 1.0, 1.0, lerpf(minimum_alpha, maximum_alpha, intensity) * lerpf(0.78, 1.0, pulse)) + _sprite.visible = true + + +func _sync_from_attack_buff_component() -> void: + var actor := get_parent() + if actor != null: + actor = actor.get_parent() + var component := actor.get_node_or_null("AttackBuffComponent") if actor != null else null + if component != null and component.has_method("attack_buff_stacks"): + set_attack_buff(int(component.call("attack_buff_stacks")), DEFAULT_MAX_STACKS) + + +func _connect_event_buses() -> void: + if not is_inside_tree(): + return + for child: Node in get_tree().root.get_children(): + if child.has_signal("attack_buff_changed") and not child.is_connected("attack_buff_changed", _on_attack_buff_changed): + child.connect("attack_buff_changed", _on_attack_buff_changed) + + +func _intensity() -> float: + return clampf(float(_stacks) / float(maxi(1, _max_stacks)), 0.0, 1.0) diff --git a/scenes/components/attack_buff_visual.gd.uid b/scenes/components/attack_buff_visual.gd.uid new file mode 100644 index 0000000..03dadea --- /dev/null +++ b/scenes/components/attack_buff_visual.gd.uid @@ -0,0 +1 @@ +uid://d2ejqfwu2b6t5 diff --git a/scenes/components/burst_component.gd b/scenes/components/burst_component.gd new file mode 100644 index 0000000..9b51fa0 --- /dev/null +++ b/scenes/components/burst_component.gd @@ -0,0 +1,83 @@ +class_name BurstComponent +extends Node + +signal burst_changed(burst_ready: bool, active: bool, cooldown: int) + +const BURST_EFFECT_PATH := "res://resources/effects/effect_burst_power.tres" + +@export var active_beats := 16 +@export var cooldown_beats := 4 + +var burst_ready := false +var active := false +var cooldown := 0 +var _beats_left := 0 +@onready var effect_container: Node = get_node_or_null("../EffectContainer") + + +func _ready() -> void: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") + if rhythm != null and not rhythm.is_connected("beat_ticked", _on_beat_ticked): + rhythm.connect("beat_ticked", _on_beat_ticked) + + +func set_ready(value: bool) -> void: + if active or cooldown > 0: + burst_ready = false + else: + burst_ready = value + burst_changed.emit(burst_ready, active, cooldown) + + +func activate() -> bool: + if not burst_ready or active or cooldown > 0: + return false + burst_ready = false + active = true + _beats_left = active_beats + _apply_burst_effect() + burst_changed.emit(burst_ready, active, cooldown) + return true + + +func damage_mult(_action: Resource = null) -> float: + return _effect_stat_multiplier(&"damage_mult") + + +func cost_mult(_action: Resource = null) -> float: + return _effect_stat_multiplier(&"cost_mult") + + +func move_mult(_action: Resource = null) -> float: + return 1.0 + + +func _on_beat_ticked(_beat_index: int) -> void: + if active: + _beats_left -= 1 + if _beats_left <= 0: + active = false + cooldown = cooldown_beats + burst_changed.emit(burst_ready, active, cooldown) + elif cooldown > 0: + cooldown -= 1 + burst_changed.emit(burst_ready, active, cooldown) + + +func _apply_burst_effect() -> void: + if effect_container == null: + effect_container = get_node_or_null("../EffectContainer") + if effect_container != null and effect_container.has_method("add_effect"): + var burst_effect: Resource = load(BURST_EFFECT_PATH) if ResourceLoader.exists(BURST_EFFECT_PATH) else null + if burst_effect != null: + effect_container.call("add_effect", burst_effect, &"burst") + + +func _effect_stat_multiplier(stat: StringName) -> float: + if not active: + return 1.0 + if effect_container == null: + effect_container = get_node_or_null("../EffectContainer") + if effect_container != null and effect_container.has_method("stat_multiplier"): + return float(effect_container.call("stat_multiplier", stat, null)) + return 1.0 diff --git a/scenes/components/burst_component.gd.uid b/scenes/components/burst_component.gd.uid new file mode 100644 index 0000000..4cd2e94 --- /dev/null +++ b/scenes/components/burst_component.gd.uid @@ -0,0 +1 @@ +uid://bnofufbs2yvx5 diff --git a/scenes/components/charge_component.gd b/scenes/components/charge_component.gd new file mode 100644 index 0000000..32e8795 --- /dev/null +++ b/scenes/components/charge_component.gd @@ -0,0 +1,198 @@ +class_name ChargeComponent +extends Node + +signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool) + +# wave: CHARGING UP OVERLAY loops while holding S (author spec 50fps). +# blade_rain: SWORD RAIN PREP OVERLAY plays frames 1-16 (ring forms) and holds; +# frames 17-19 (ring launches) belong to the blade_rain_cast release animation. +const OVERLAY_PROFILES := { + &"wave": { + "path": "res://assets/art/characters/player/10_wave_charge/charging_up_overlay_fx.png", + "hframes": 5, "vframes": 5, "first": 0, "last": 24, "fps": 50.0, + "offset": Vector2(-92.0, -140.0), "loop": true, + }, + &"blade_rain": { + "path": "res://assets/art/characters/player/08_sword_charge/sword_rain_prep_overlay_fx.png", + "hframes": 10, "vframes": 2, "first": 1, "last": 16, "fps": 25.0, + "offset": Vector2(-64.0, -305.0), "loop": false, + }, +} + +# Fallback ramp when no ActionController drives the gauge (standalone use). +@export var charge_duration := 1.1 +@export var animation_player_path: NodePath +@export var effect_sprite_path: NodePath + +var value := 0.0 +var charge_ready := false +var active := false +var overlay_profile: StringName = &"wave" + +var _effect_time := 0.0 +var _animation_time := 0.0 +var _maximum := 1.1 +var _last_level := 0 + +@onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer +@onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D +@onready var _action_controller: Node = get_node_or_null("../ActionController") + + +func tick(delta: float, is_charging: bool) -> void: + if not is_charging: + if active or value > 0.0 or charge_ready: + cancel() + return + if not active: + _start() + if not active: + return + _update_charge_animation(delta) + var charge_state := _controller_charge_state() + if charge_state.is_empty(): + _maximum = charge_duration + value = minf(charge_duration, value + delta) + charge_ready = value >= charge_duration + else: + # The gauge is level-space: 0 units = level 1 fresh, max units = top + # level. The cast level itself is owned by ActionController. + var max_level := maxi(2, int(charge_state.get("max_level", 3))) + var level := int(charge_state.get("level", 1)) + _maximum = float(max_level - 1) + value = clampf(float(charge_state.get("progress_units", 0.0)), 0.0, _maximum) + charge_ready = level >= max_level + if level > _last_level and _last_level > 0: + _pulse_level_up() + _last_level = level + _update_charge_effect(delta) + _emit_changed() + + +func cancel() -> void: + active = false + value = 0.0 + charge_ready = false + _animation_time = 0.0 + _last_level = 0 + _set_effect_visible(false) + if _effect_sprite != null: + _effect_sprite.modulate = Color.WHITE + _emit_changed() + + +func is_active() -> bool: + return active + + +func is_ready() -> bool: + return charge_ready + + +func maximum() -> float: + return _maximum + + +func _start() -> void: + active = true + value = 0.0 + charge_ready = false + _effect_time = 0.0 + _animation_time = 0.0 + _last_level = 1 + _setup_charge_overlay() + if _effect_sprite != null: + _effect_sprite.modulate = Color.WHITE + _update_charge_effect(0.0) + _emit_changed() + + +func _controller_charge_state() -> Dictionary: + if _action_controller == null: + _action_controller = get_node_or_null("../ActionController") + if _action_controller == null or not _action_controller.has_method("charge_state"): + return {} + var charge_state = _action_controller.call("charge_state") + if charge_state is Dictionary and bool((charge_state as Dictionary).get("charging", false)): + return charge_state + return {} + + +func _pulse_level_up() -> void: + if _effect_sprite != null: + _effect_sprite.modulate = Color(1.7, 1.7, 1.7) + + +func set_overlay_profile(profile: StringName) -> void: + if profile == overlay_profile or not OVERLAY_PROFILES.has(profile): + return + overlay_profile = profile + if active: + _effect_time = 0.0 + _setup_charge_overlay() + + +func _current_overlay() -> Dictionary: + return OVERLAY_PROFILES.get(overlay_profile, OVERLAY_PROFILES[&"wave"]) + + +func _setup_charge_overlay() -> void: + if _effect_sprite == null: + return + var overlay := _current_overlay() + var texture_path := str(overlay.get("path", "")) + var texture: Texture2D = load(texture_path) if ResourceLoader.exists(texture_path) else null + if texture == null: + return + _effect_sprite.texture = texture + _effect_sprite.hframes = maxi(1, int(overlay.get("hframes", 1))) + _effect_sprite.vframes = maxi(1, int(overlay.get("vframes", 1))) + _effect_sprite.offset = overlay.get("offset", Vector2.ZERO) + _effect_sprite.frame = clampi(int(overlay.get("first", 0)), 0, _effect_sprite.hframes * _effect_sprite.vframes - 1) + + +func _update_charge_effect(delta: float) -> void: + if _effect_sprite == null: + return + _effect_sprite.visible = active + if not active: + return + _effect_time += delta + if _effect_sprite.modulate != Color.WHITE: + _effect_sprite.modulate = _effect_sprite.modulate.lerp(Color.WHITE, minf(1.0, delta * 6.0)) + var overlay := _current_overlay() + var first := int(overlay.get("first", 0)) + var last := int(overlay.get("last", first)) + var span := maxi(1, last - first + 1) + var step := int(_effect_time * float(overlay.get("fps", 25.0))) + var frame_index := first + (step % span if bool(overlay.get("loop", true)) else mini(step, span - 1)) + _effect_sprite.frame = clampi(frame_index, 0, _effect_sprite.hframes * _effect_sprite.vframes - 1) + + +func _update_charge_animation(delta: float) -> void: + _animation_time += delta + var intro_length := _animation_length(&"warrior_charge_intro") + if _animation_time < intro_length: + _play_charge_animation(&"warrior_charge_intro") + else: + _play_charge_animation(&"warrior_charge_loop") + + +func _play_charge_animation(animation_name: StringName) -> void: + if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name: + _animation_player.play(animation_name) + + +func _animation_length(animation_name: StringName) -> float: + if _animation_player != null and _animation_player.has_animation(animation_name): + return maxf(0.1, _animation_player.get_animation(animation_name).length) + return 0.1 + + +func _set_effect_visible(is_visible: bool) -> void: + if _effect_sprite != null: + _effect_sprite.visible = is_visible + + +func _emit_changed() -> void: + charge_changed.emit(value, _maximum, charge_ready, active) diff --git a/scenes/components/charge_component.gd.uid b/scenes/components/charge_component.gd.uid new file mode 100644 index 0000000..3f47995 --- /dev/null +++ b/scenes/components/charge_component.gd.uid @@ -0,0 +1 @@ +uid://doo6xoscxjpt2 diff --git a/scenes/components/combo_window.gd b/scenes/components/combo_window.gd new file mode 100644 index 0000000..c25d0d3 --- /dev/null +++ b/scenes/components/combo_window.gd @@ -0,0 +1,122 @@ +class_name ComboWindow +extends Node + +signal combo_updated(inputs: Array[StringName]) +signal combo_cleared(reason: StringName) + +@export var size := 4 +@export var clear_display_time := 0.35 +@export var broadcast_to_bus := true + +var slots: Array[StringName] = [] +var pending_clear_reason: StringName = &"" +var _timer: Timer + + +func _ready() -> void: + _timer = Timer.new() + _timer.one_shot = true + _timer.timeout.connect(flush_pending_clear) + add_child(_timer) + + +func record(input: StringName) -> void: + if input.is_empty(): + return + slots.append(input) + combo_updated.emit(get_slots()) + _emit_bus_signal("combo_updated", [get_slots()]) + if slots.size() >= size: + queue_clear(&"full") + + +func rollback_last(expected_input: StringName = &"") -> bool: + if slots.is_empty(): + return false + var last := slots[slots.size() - 1] + if not expected_input.is_empty() and last != expected_input: + return false + slots.pop_back() + pending_clear_reason = &"" + combo_updated.emit(get_slots()) + _emit_bus_signal("combo_updated", [get_slots()]) + return true + + +func get_slots() -> Array[StringName]: + return slots.duplicate() + + +func has_pending_clear() -> bool: + return not pending_clear_reason.is_empty() + + +func consume_pending_clear_reason() -> StringName: + var reason := pending_clear_reason + pending_clear_reason = &"" + return reason + + +func get_pattern() -> String: + var pattern := "" + for slot: StringName in slots: + if slot != &"Ø": + pattern += str(slot) + return pattern + + +func get_contiguous_pattern() -> String: + var pattern := "" + for index: int in range(slots.size() - 1, -1, -1): + var slot := slots[index] + if slot == &"Ø": + break + pattern = str(slot) + pattern + return pattern + + +func queue_clear(reason: StringName, delay := -1.0) -> void: + pending_clear_reason = reason + if _timer == null: + return + _timer.stop() + _timer.wait_time = clear_display_time if delay < 0.0 else delay + _timer.start() + + +func cancel_pending_clear() -> void: + pending_clear_reason = &"" + if _timer != null: + _timer.stop() + + +func flush_pending_clear() -> void: + var reason := consume_pending_clear_reason() + if reason.is_empty(): + return + if _timer != null: + _timer.stop() + clear(reason) + + +func clear(reason: StringName = &"") -> void: + slots.clear() + pending_clear_reason = &"" + combo_cleared.emit(reason) + _emit_bus_signal("combo_cleared", [reason]) + combo_updated.emit(get_slots()) + _emit_bus_signal("combo_updated", [get_slots()]) + + +func _emit_bus_signal(signal_name: StringName, args: Array) -> void: + if not broadcast_to_bus or not is_inside_tree(): + return + var bus := _event_bus_or_null() + if bus != null: + bus.emit_signal(signal_name, args[0]) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/components/combo_window.gd.uid b/scenes/components/combo_window.gd.uid new file mode 100644 index 0000000..c3c7af4 --- /dev/null +++ b/scenes/components/combo_window.gd.uid @@ -0,0 +1 @@ +uid://bd66pkuusgqck diff --git a/scenes/components/damage_emitter.gd b/scenes/components/damage_emitter.gd new file mode 100644 index 0000000..61a98c7 --- /dev/null +++ b/scenes/components/damage_emitter.gd @@ -0,0 +1,105 @@ +class_name DamageEmitter +extends Area2D + +@export var damage := 10 +@export var hit_type: StringName = &"normal" +@export var base_knockback := Vector2(120.0, 304.056) + +var action_context: Resource +var judgement_context: Dictionary = {} +## Weak-state minion attacks only deal damage; interrupt authority snapshots at +## action start so phase changes mid-swing do not rewrite the hit. +var attacker_interrupts := true +var _default_position := Vector2.ZERO +var _default_shape_size := Vector2.ZERO +# With per-frame hit windows (FrameCollisionDriver) the same receiver can +# enter the area more than once within one swing; each swing hits once. +var _already_hit_ids: Dictionary = {} + + +func _ready() -> void: + _default_position = position + var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node != null and shape_node.shape is RectangleShape2D: + _default_shape_size = (shape_node.shape as RectangleShape2D).size + area_entered.connect(_on_area_entered) + + +func configure_hit(action: Resource, judgement: Dictionary) -> void: + action_context = action + judgement_context = judgement.duplicate() + attacker_interrupts = _owner_interrupt_authority() + _already_hit_ids.clear() + if action != null: + hit_type = StringName(str(action.get("hit_type"))) + _update_hitbox_geometry(action) + monitoring = true + + +func clear_hit() -> void: + monitoring = false + action_context = null + judgement_context = {} + attacker_interrupts = true + _already_hit_ids.clear() + + +func _owner_interrupt_authority() -> bool: + var owner := get_parent() + if owner != null and owner.has_method("is_strong_in_current_time_phase"): + return bool(owner.call("is_strong_in_current_time_phase")) + return true + + +func has_already_hit(receiver: Node) -> bool: + return receiver != null and _already_hit_ids.has(receiver.get_instance_id()) + + +func _update_hitbox_geometry(action: Resource) -> void: + if action == null: + position = _default_position + return + var range := float(action.get("range")) + if range <= 0.0: + position = _default_position + return + var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node != null and shape_node.shape is RectangleShape2D: + var rectangle := shape_node.shape as RectangleShape2D + var height := _default_shape_size.y if _default_shape_size != Vector2.ZERO else rectangle.size.y + rectangle.size = Vector2(maxf(8.0, range), maxf(8.0, height)) + var owner := get_parent() + var direction := 1.0 + if owner != null: + var heading = owner.get("heading") + if heading is Vector2 and absf((heading as Vector2).x) > 0.0: + direction = -1.0 if (heading as Vector2).x < 0.0 else 1.0 + var base_y := _default_position.y + position = Vector2(direction * (range * 0.5 + 10.0), base_y) + + +func _on_area_entered(receiver: Area2D) -> void: + if receiver.is_in_group("damage_receivers"): + if _already_hit_ids.has(receiver.get_instance_id()): + return + _already_hit_ids[receiver.get_instance_id()] = true + var combat := _combat_manager_or_null() + if combat != null and combat.has_method("resolve_hit"): + var result: Dictionary = combat.call("resolve_hit", self, receiver) + _event_bus().emit_signal("damage_dealt", receiver, int(result.get("damage", 0)), hit_type) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _combat_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("CombatManager") diff --git a/scenes/components/damage_emitter.gd.uid b/scenes/components/damage_emitter.gd.uid new file mode 100644 index 0000000..2576f40 --- /dev/null +++ b/scenes/components/damage_emitter.gd.uid @@ -0,0 +1 @@ +uid://ddays88jc3oh3 diff --git a/scenes/components/damage_receiver.gd b/scenes/components/damage_receiver.gd new file mode 100644 index 0000000..646a29c --- /dev/null +++ b/scenes/components/damage_receiver.gd @@ -0,0 +1,30 @@ +class_name DamageReceiver +extends Area2D + +signal damage_received(amount: int, hit_type: StringName, from: Vector2) + + +func _ready() -> void: + add_to_group("damage_receivers") + + +func take_damage(amount: int, hit_type: StringName, from: Vector2) -> void: + damage_received.emit(amount, hit_type, from) + _event_bus().emit_signal("damage_dealt", self, amount, hit_type) + + +func receive_hit(result: Dictionary) -> void: + var amount := int(result.get("damage", 0)) + var resolved_hit_type := StringName(str(result.get("hit_type", &"normal"))) + var from := result.get("from", Vector2.ZERO) as Vector2 + damage_received.emit(amount, resolved_hit_type, from) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/components/damage_receiver.gd.uid b/scenes/components/damage_receiver.gd.uid new file mode 100644 index 0000000..22e5d28 --- /dev/null +++ b/scenes/components/damage_receiver.gd.uid @@ -0,0 +1 @@ +uid://be6nwtel2w0kp diff --git a/scenes/components/effect_container.gd b/scenes/components/effect_container.gd new file mode 100644 index 0000000..65b0736 --- /dev/null +++ b/scenes/components/effect_container.gd @@ -0,0 +1,213 @@ +class_name EffectContainer +extends Node + +signal effect_added(effect_id: StringName) +signal effect_removed(effect_id: StringName) + +const EffectInstanceScript := preload("res://resources/effects/effect_instance.gd") + +@export var beats_per_measure := 4 +@export var initial_effects: Array[Resource] = [] + +var effects: Array[RefCounted] = [] + + +func _ready() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked): + bus.connect("beat_ticked", _on_beat_ticked) + for definition: Resource in initial_effects: + add_effect(definition, &"loadout") + + +func _exit_tree() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked): + bus.disconnect("beat_ticked", _on_beat_ticked) + + +func _process(delta: float) -> void: + tick_time(delta) + + +func add_effect(definition: Resource, source: Variant = &"-") -> void: + if definition == null: + return + var effect_id := StringName(str(definition.get("id"))) + var max_stacks := int(definition.get("max_stacks")) + for effect: RefCounted in effects: + if StringName(str(effect.definition.get("id"))) == effect_id: + effect.stacks = mini(effect.stacks + 1, max(1, max_stacks)) + effect.remaining = float(definition.get("duration")) + effect.source = source + effect_added.emit(effect_id) + return + effects.append(EffectInstanceScript.create(definition, source)) + effect_added.emit(effect_id) + + +func set_effect_stacks(effect_id: StringName, stacks: int) -> void: + if stacks <= 0: + remove_effect(effect_id) + return + for effect: RefCounted in effects: + if StringName(str(effect.definition.get("id"))) == effect_id: + var max_stacks := maxi(1, int(effect.definition.get("max_stacks"))) + effect.stacks = mini(stacks, max_stacks) + return + + +func effect_stacks(effect_id: StringName) -> int: + for effect: RefCounted in effects: + if StringName(str(effect.definition.get("id"))) == effect_id: + return int(effect.stacks) + return 0 + + +func remove_effect(effect_id: StringName) -> void: + for index: int in range(effects.size() - 1, -1, -1): + var effect: RefCounted = effects[index] + if StringName(str(effect.definition.get("id"))) == effect_id: + effects.remove_at(index) + effect_removed.emit(effect_id) + + +func tick_time(delta: float) -> void: + for effect: RefCounted in effects: + effect.tick_time(delta) + _prune_expired() + + +func tick_beats(beats: float) -> void: + for effect: RefCounted in effects: + effect.tick_beats(beats) + _prune_expired() + + +func active_count() -> int: + return effects.size() + + +func active_effect_ids() -> Array[StringName]: + var ids: Array[StringName] = [] + for effect: RefCounted in effects: + ids.append(StringName(str(effect.definition.get("id")))) + return ids + + +func active_effect_summaries() -> Array[Dictionary]: + var summaries: Array[Dictionary] = [] + for effect: RefCounted in effects: + summaries.append({ + "id": StringName(str(effect.definition.get("id"))), + "duration_type": StringName(str(effect.definition.get("duration_type"))), + "remaining": float(effect.remaining), + "stacks": int(effect.stacks), + "source": _source_label(effect.source), + }) + return summaries + + +func dispatch_event(event_name: StringName, context: Dictionary = {}) -> void: + var triggered: Array[Resource] = [] + for effect: RefCounted in effects: + if StringName(str(effect.definition.get("trigger_event"))) != event_name: + continue + if effect.has_method("matches_event_context") and not bool(effect.call("matches_event_context", context)): + continue + var next_effects = effect.definition.get("trigger_effects") + if not next_effects is Array: + continue + for definition: Resource in next_effects: + if definition != null: + triggered.append(definition) + for effect: RefCounted in effects: + if effect.has_method("tick_event"): + effect.call("tick_event", event_name, context) + _prune_expired() + for definition: Resource in triggered: + add_effect(definition) + + +func damage_mult(action: Resource = null) -> float: + return stat_multiplier(&"damage_mult", action) + + +func cost_mult(action: Resource = null) -> float: + return stat_multiplier(&"cost_mult", action) + + +func move_mult(action: Resource = null) -> float: + return stat_multiplier(&"move_mult", action) + + +func defense_modifiers() -> Array[Resource]: + return _active_modifiers("defense_modifiers") + + +func action_rule_modifiers() -> Array[Resource]: + return _active_modifiers("action_rule_modifiers") + + +func stat_multiplier(stat: StringName, _action: Resource = null) -> float: + var multiplier := 1.0 + for effect: RefCounted in effects: + for modifier: Resource in _stat_modifiers(effect): + if StringName(str(modifier.get("stat"))) != stat: + continue + if str(modifier.get("operation")) == "add": + multiplier += float(modifier.get("value")) * maxi(1, int(effect.stacks)) + else: + multiplier *= float(modifier.get("value")) + return multiplier + + +func _stat_modifiers(effect: RefCounted) -> Array: + var modifiers = effect.definition.get("stat_modifiers") + return modifiers if modifiers is Array else [] + + +func _active_modifiers(property_name: String) -> Array[Resource]: + var result: Array[Resource] = [] + for effect: RefCounted in effects: + var modifiers = effect.definition.get(property_name) + if not modifiers is Array: + continue + for modifier: Resource in modifiers: + if modifier != null: + result.append(modifier) + return result + + +func _source_label(source: Variant) -> StringName: + if source == null: + return &"-" + if source is StringName: + return source + if source is String: + return StringName(source) + if source is Node: + return StringName((source as Node).name) + return StringName(str(source)) + + +func _prune_expired() -> void: + for index: int in range(effects.size() - 1, -1, -1): + var effect: RefCounted = effects[index] + if effect.is_expired(): + var effect_id := StringName(str(effect.definition.get("id"))) + effects.remove_at(index) + effect_removed.emit(effect_id) + + +func _on_beat_ticked(beat_index: int) -> void: + tick_beats(1.0) + if beats_per_measure > 0 and beat_index % beats_per_measure == 0: + dispatch_event(&"on_measure_start", {"beat_index": beat_index}) + dispatch_event(&"on_beat", {"beat_index": beat_index}) + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/components/effect_container.gd.uid b/scenes/components/effect_container.gd.uid new file mode 100644 index 0000000..b4a7186 --- /dev/null +++ b/scenes/components/effect_container.gd.uid @@ -0,0 +1 @@ +uid://27i1pv5fu1ae diff --git a/scenes/components/energy_component.gd b/scenes/components/energy_component.gd new file mode 100644 index 0000000..d0c8bb8 --- /dev/null +++ b/scenes/components/energy_component.gd @@ -0,0 +1,54 @@ +class_name EnergyComponent +extends Node + +signal energy_changed(current: int, maximum: int) + +@export var maximum := 100 +@export var current := 0 + + +func _ready() -> void: + _emit_changed() + + +func set_values(next_current: int, next_maximum: int) -> void: + maximum = max(1, next_maximum) + current = clampi(next_current, 0, maximum) + _emit_changed() + + +func set_current(next_current: int) -> void: + var clamped := clampi(next_current, 0, maximum) + if clamped == current: + return + current = clamped + _emit_changed() + + +func change(delta: int) -> void: + set_current(current + delta) + + +func spend(cost: float) -> bool: + var int_cost := int(ceil(cost)) + if int_cost <= 0: + return true + if current < int_cost: + return false + set_current(current - int_cost) + return true + + +func _emit_changed() -> void: + energy_changed.emit(current, maximum) + _event_bus().emit_signal("player_energy_changed", float(current), float(maximum)) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/components/energy_component.gd.uid b/scenes/components/energy_component.gd.uid new file mode 100644 index 0000000..8e18516 --- /dev/null +++ b/scenes/components/energy_component.gd.uid @@ -0,0 +1 @@ +uid://ce44s5ldp64p1 diff --git a/scenes/components/frame_collision_driver.gd b/scenes/components/frame_collision_driver.gd new file mode 100644 index 0000000..0faf3ae --- /dev/null +++ b/scenes/components/frame_collision_driver.gd @@ -0,0 +1,317 @@ +class_name FrameCollisionDriver +extends Node + +## Drives the three collision matrices from the CURRENT animation frame, every +## physics tick, for both the player and the boss: +## body - CharacterBody2D collision_layer/mask (composed with the +## MotionExecutor dash-through ghost bits so nothing goes stale), +## hurtbox - DamageReceiver shape follows the opaque pixel bounds of the +## frame being displayed (pose-accurate damage-receive matrix), +## hitbox - DamageEmitter only monitors on ACTIVE-phase frames whose art +## reaches forward past HIT_REACH_GATE of the sheet's own maximum +## (best-effort per sheet; action range stays the damage floor). +## If no frame of a swing ever passes the gate, the back half of +## the ACTIVE phase falls back to the plain range window so a +## beat-anchored swing can never whiff purely from art timing. +## Facing is derived per tick from heading x visual mirroring, so native-left +## sheets (player) and native-right sheets (boss) both measure true forward +## reach. Frame bounds are computed once per sheet in a single byte-level pass +## and cached statically; per-tick work is table lookups plus rect transforms. + +const ActionControllerScript := preload("res://scenes/components/action_controller.gd") + +const MIN_HURT_SIZE := Vector2(12.0, 16.0) +const HIT_REACH_GATE := 0.72 +const HIT_MIN_FORWARD_PX := 18.0 +const HIT_HEIGHT_FACTOR := 0.8 +const HIT_START_OFFSET_PX := 6.0 + +@export var sprite_path: NodePath = ^"../Visual/CharacterSprite" +@export var damage_emitter_path: NodePath = ^"../DamageEmitter" +@export var damage_receiver_path: NodePath = ^"../DamageReceiver" +@export var action_controller_path: NodePath = ^"../ActionController" +@export var motion_executor_path: NodePath = ^"../MotionExecutor" +@export var body_collision_enabled := true +@export var damage_receiver_enabled := true +@export var damage_emitter_enabled := true +@export var hurtbox_enabled := true +@export var hitbox_enabled := true +## Actor-space pixels trimmed from the opaque bounds (total per axis) so +## clothing fringes and glow pixels do not count as body. +@export var hurt_margin := Vector2(12.0, 8.0) + +var actor: CharacterBody2D +var sprite: Sprite2D +var emitter: Area2D +var receiver: Area2D +var controller: Node +var motion_executor: Node + +var _base_body_layer := 0 +var _base_body_mask := 0 +var _base_emitter_layer := 0 +var _base_emitter_mask := 0 +var _base_receiver_layer := 0 +var _base_receiver_mask := 0 +var _swing_action: Resource +var _swing_had_hit_window := false + +static var _sheet_bounds_cache: Dictionary = {} + + +func _ready() -> void: + # Run after ActionController/ActionExecutor state changes within the tick, + # so this component owns the final matrix values for the physics step. + process_physics_priority = 20 + actor = get_parent() as CharacterBody2D + sprite = get_node_or_null(sprite_path) as Sprite2D + emitter = get_node_or_null(damage_emitter_path) as Area2D + receiver = get_node_or_null(damage_receiver_path) as Area2D + controller = get_node_or_null(action_controller_path) + motion_executor = get_node_or_null(motion_executor_path) + if actor != null: + _base_body_layer = actor.collision_layer + _base_body_mask = actor.collision_mask + if emitter != null: + _base_emitter_layer = emitter.collision_layer + _base_emitter_mask = emitter.collision_mask + _make_shape_unique(emitter) + if receiver != null: + _base_receiver_layer = receiver.collision_layer + _base_receiver_mask = receiver.collision_mask + _make_shape_unique(receiver) + + +func _physics_process(_delta: float) -> void: + refresh_now() + + +func refresh_now() -> void: + _apply_body_matrix() + var frame_rect := _current_frame_actor_rect() + if hurtbox_enabled: + _apply_hurtbox(frame_rect) + if hitbox_enabled: + _apply_hitbox(frame_rect) + + +func _apply_body_matrix() -> void: + if actor == null: + return + var layer := _base_body_layer + var mask := _base_body_mask + if not body_collision_enabled: + layer &= ~MotionExecutor.BODY_GHOST_BITS + mask &= ~MotionExecutor.BODY_GHOST_BITS + if motion_executor != null and motion_executor.has_method("is_ghosting") and motion_executor.is_ghosting(): + layer &= ~MotionExecutor.BODY_GHOST_BITS + mask &= ~MotionExecutor.BODY_GHOST_BITS + actor.collision_layer = layer + actor.collision_mask = mask + + +func _apply_hurtbox(frame_rect: Rect2) -> void: + if receiver == null: + return + receiver.collision_layer = _base_receiver_layer + receiver.collision_mask = _base_receiver_mask + if not damage_receiver_enabled: + receiver.collision_layer = 0 + receiver.collision_mask = 0 + receiver.monitoring = false + receiver.monitorable = false + return + if _actor_is_dead(): + receiver.monitoring = false + receiver.monitorable = false + return + receiver.monitoring = true + receiver.monitorable = true + if frame_rect.size == Vector2.ZERO: + return + var shape_node := receiver.get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node == null or not (shape_node.shape is RectangleShape2D): + return + receiver.position = Vector2.ZERO + var size := frame_rect.size - hurt_margin + size = Vector2(maxf(MIN_HURT_SIZE.x, size.x), maxf(MIN_HURT_SIZE.y, size.y)) + (shape_node.shape as RectangleShape2D).size = size + shape_node.position = frame_rect.get_center() + + +func _apply_hitbox(frame_rect: Rect2) -> void: + if emitter == null: + return + emitter.collision_layer = _base_emitter_layer + emitter.collision_mask = _base_emitter_mask + if not damage_emitter_enabled or _actor_is_dead(): + emitter.collision_layer = 0 if not damage_emitter_enabled else _base_emitter_layer + emitter.collision_mask = 0 if not damage_emitter_enabled else _base_emitter_mask + emitter.monitoring = false + return + if controller == null or actor == null or sprite == null or sprite.texture == null: + # Without a phase source the emitter keeps its own configure/clear + # window behaviour (projectiles, tests, detached components). + return + emitter.position = Vector2.ZERO + var action: Resource = emitter.get("action_context") as Resource + var in_active: bool = int(controller.get("phase")) == ActionControllerScript.Phase.ACTIVE + var is_melee := action != null and StringName(str(action.get("hit_type"))) == &"melee" + if not in_active or not is_melee or frame_rect.size == Vector2.ZERO: + emitter.monitoring = false + _swing_action = null + _swing_had_hit_window = false + return + if action != _swing_action: + _swing_action = action + _swing_had_hit_window = false + var heading_x := 1.0 + var heading: Variant = actor.get("heading") + if heading is Vector2 and absf((heading as Vector2).x) > 0.0: + heading_x = signf((heading as Vector2).x) + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame := clampi(sprite.frame, 0, hframes * vframes - 1) + var anchor_px := _actor_anchor_in_sheet_pixels() + var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame) + if bounds.size == Vector2.ZERO: + emitter.monitoring = false + return + # Which native side of the sheet is "forward" depends on both the heading + # and the Visual mirror: sprite-native +x maps to actor sign(basis_x). + var basis_x := (actor.global_transform.affine_inverse() * sprite.global_transform).x.x + var forward_is_native_left := (basis_x * heading_x) < 0.0 + var native_forward := (anchor_px.x - bounds.position.x) if forward_is_native_left else (bounds.end.x - anchor_px.x) + var sheet_max := _sheet_max_native_extent(sprite.texture, hframes, vframes, anchor_px.x, forward_is_native_left) + var pixel_scale := frame_rect.size.x / maxf(1.0, bounds.size.x) + var forward_px := native_forward * pixel_scale + var reaches := sheet_max > 0.0 and native_forward >= HIT_REACH_GATE * sheet_max and forward_px >= HIT_MIN_FORWARD_PX + if not reaches: + if _swing_had_hit_window or not _in_late_active_fallback(): + emitter.monitoring = false + return + # Fallback: no frame of this swing has reached yet and ACTIVE is half + # over — guarantee the plain range window (old behaviour floor) so a + # beat-anchored swing cannot whiff purely from art timing. + forward_px = 0.0 + else: + _swing_had_hit_window = true + var reach := maxf(forward_px, float(action.get("range"))) + if reach <= HIT_START_OFFSET_PX: + emitter.monitoring = false + return + var height := clampf(frame_rect.size.y * HIT_HEIGHT_FACTOR, 24.0, 140.0) + var shape_node := emitter.get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node != null and shape_node.shape is RectangleShape2D: + var width := maxf(8.0, reach - HIT_START_OFFSET_PX) + (shape_node.shape as RectangleShape2D).size = Vector2(width, height) + shape_node.position = Vector2(heading_x * (HIT_START_OFFSET_PX + width * 0.5), frame_rect.get_center().y) + emitter.monitoring = true + + +func _actor_is_dead() -> bool: + if actor == null: + return false + var state_machine := actor.get_node_or_null("StateMachine") + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead" + var health := actor.get_node_or_null("HealthComponent") + return health != null and int(health.get("current")) <= 0 + + +func _in_late_active_fallback() -> bool: + var duration_value: Variant = controller.get("phase_duration") + var elapsed_value: Variant = controller.get("phase_elapsed") + if not (duration_value is float) or not (elapsed_value is float): + return false + var duration := duration_value as float + return duration > 0.0 and (elapsed_value as float) >= duration * 0.5 + + +func _current_frame_actor_rect() -> Rect2: + if sprite == null or sprite.texture == null or actor == null or not sprite.is_inside_tree(): + return Rect2() + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame := clampi(sprite.frame, 0, hframes * vframes - 1) + var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame) + if bounds.size == Vector2.ZERO: + return Rect2() + var local_origin := _sprite_pixel_origin() + bounds.position + var to_actor := actor.global_transform.affine_inverse() * sprite.global_transform + var corner_a := to_actor * local_origin + var corner_b := to_actor * (local_origin + bounds.size) + return Rect2( + Vector2(minf(corner_a.x, corner_b.x), minf(corner_a.y, corner_b.y)), + (corner_b - corner_a).abs() + ) + + +func _sprite_pixel_origin() -> Vector2: + # Sprite-local coordinate of the sheet frame's top-left drawn pixel. + if sprite.centered: + return sprite.offset - _frame_size() * 0.5 + return sprite.offset + + +func _actor_anchor_in_sheet_pixels() -> Vector2: + # The actor origin expressed in drawn-pixel coordinates of the frame. + var to_sprite := sprite.global_transform.affine_inverse() * actor.global_transform + return (to_sprite * Vector2.ZERO) - _sprite_pixel_origin() + + +func _frame_size() -> Vector2: + return Vector2( + float(sprite.texture.get_width()) / float(maxi(1, sprite.hframes)), + float(sprite.texture.get_height()) / float(maxi(1, sprite.vframes)) + ) + + +func _make_shape_unique(area: Area2D) -> void: + var shape_node := area.get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node != null and shape_node.shape != null: + shape_node.shape = shape_node.shape.duplicate() + + +static func _frame_opaque_bounds(texture: Texture2D, hframes: int, vframes: int, frame: int) -> Rect2: + var all_bounds := _sheet_frame_bounds(texture, hframes, vframes) + if frame < 0 or frame >= all_bounds.size(): + return Rect2() + return all_bounds[frame] + + +static func _sheet_max_native_extent(texture: Texture2D, hframes: int, vframes: int, anchor_x: float, left_side: bool) -> float: + var best := 0.0 + for bounds: Rect2 in _sheet_frame_bounds(texture, hframes, vframes): + if bounds.size == Vector2.ZERO: + continue + var extent := (anchor_x - bounds.position.x) if left_side else (bounds.end.x - anchor_x) + best = maxf(best, extent) + return best + + +static func _sheet_frame_bounds(texture: Texture2D, hframes: int, vframes: int) -> Array: + # All frames of a sheet are measured once and cached. The per-frame crop + + # used-rect run on the C++ side, so even large sheets cost well under a + # millisecond and there is no first-swing hitch. + var key := "%d#%d#%d" % [texture.get_rid().get_id(), hframes, vframes] + if _sheet_bounds_cache.has(key): + return _sheet_bounds_cache[key] + var result: Array = [] + var image := texture.get_image() + if image != null and not image.is_empty(): + if image.is_compressed(): + image.decompress() + var frame_width := maxi(1, image.get_width() / hframes) + var frame_height := maxi(1, image.get_height() / vframes) + for index: int in range(hframes * vframes): + var column := index % hframes + var row := int(float(index) / float(hframes)) + var region := image.get_region(Rect2i(column * frame_width, row * frame_height, frame_width, frame_height)) + var used := region.get_used_rect() + if used.size.x <= 0 or used.size.y <= 0: + result.append(Rect2()) + else: + result.append(Rect2(used)) + _sheet_bounds_cache[key] = result + return result diff --git a/scenes/components/frame_collision_driver.gd.uid b/scenes/components/frame_collision_driver.gd.uid new file mode 100644 index 0000000..2c1b239 --- /dev/null +++ b/scenes/components/frame_collision_driver.gd.uid @@ -0,0 +1 @@ +uid://bdl8602o30i87 diff --git a/scenes/components/health_component.gd b/scenes/components/health_component.gd new file mode 100644 index 0000000..9a5613b --- /dev/null +++ b/scenes/components/health_component.gd @@ -0,0 +1,87 @@ +class_name HealthComponent +extends Node + +signal health_changed(current: int, maximum: int) +signal depleted + +@export var maximum := 100 +@export var current := 100 +@export var hitstun_seconds := 0.4 + +var _hitstun_time_left := 0.0 + + +func _ready() -> void: + _emit_changed() + + +func _process(delta: float) -> void: + if _hitstun_time_left <= 0.0: + return + _hitstun_time_left = maxf(0.0, _hitstun_time_left - delta) + if _hitstun_time_left <= 0.0 and current > 0: + var state_machine := _state_machine_or_null() + if state_machine != null and state_machine.has_method("set_life_state"): + state_machine.call("set_life_state", &"Alive") + + +func set_values(next_current: int, next_maximum: int) -> void: + maximum = max(1, next_maximum) + current = clampi(next_current, 0, maximum) + if current > 0: + _hitstun_time_left = 0.0 + _emit_changed() + + +func apply_damage(amount: int) -> void: + if amount <= 0: + return + current = clampi(current - amount, 0, maximum) + _emit_changed() + if current == 0: + depleted.emit() + + +func receive_hit(result: Dictionary) -> void: + var amount := int(result.get("damage", result.get("amount", 0))) + apply_damage(amount) + var state_machine := _state_machine_or_null() + if state_machine == null or not state_machine.has_method("set_life_state"): + return + if current <= 0: + _hitstun_time_left = 0.0 + state_machine.call("set_life_state", &"Dead") + elif amount > 0 and bool(result.get("interrupts", true)): + _hitstun_time_left = maxf(0.0, hitstun_seconds) + state_machine.call("set_life_state", &"Hitstun") + + +func heal(amount: int) -> void: + if amount <= 0: + return + current = clampi(current + amount, 0, maximum) + _emit_changed() + + +func _emit_changed() -> void: + health_changed.emit(current, maximum) + var bus := _event_bus_or_null() + if bus != null and _is_player_health_component(): + bus.emit_signal("player_health_changed", current, maximum) + + +func _is_player_health_component() -> bool: + var actor := get_parent() + return actor != null and actor.name == "Player" + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") + + +func _state_machine_or_null() -> Node: + if not is_inside_tree(): + return null + return get_node_or_null("../StateMachine") diff --git a/scenes/components/health_component.gd.uid b/scenes/components/health_component.gd.uid new file mode 100644 index 0000000..194b0a3 --- /dev/null +++ b/scenes/components/health_component.gd.uid @@ -0,0 +1 @@ +uid://dk0nbsdn77rb4 diff --git a/scenes/components/input_component.gd b/scenes/components/input_component.gd new file mode 100644 index 0000000..5085161 --- /dev/null +++ b/scenes/components/input_component.gd @@ -0,0 +1,44 @@ +class_name InputComponent +extends Node + +const InputIntentScript := preload("res://scenes/components/input_intent.gd") + +signal intent_created(intent) +signal combo_pressed(symbol: StringName, rhythm_action: StringName) +signal combo_released(symbol: StringName) + +const COMBO_ACTIONS: Dictionary = { + &"combo_w": [&"W", &"w"], + &"combo_a": [&"A", &"a"], + &"combo_d": [&"D", &"d"], + &"combo_s": [&"S", &"s"], + &"combo_space": [&"SP", &"space"], +} + +const COMBO_ACTION_ORDER: Array[StringName] = [ + &"combo_w", + &"combo_a", + &"combo_d", + &"combo_s", + &"combo_space", +] + + +func handle_input_event(event: InputEvent) -> bool: + var key_event := event as InputEventKey + if key_event != null and key_event.echo: + return false + for action_name: StringName in COMBO_ACTION_ORDER: + if event.is_action_pressed(action_name, false, true): + var data: Array = COMBO_ACTIONS[action_name] + var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec())) + intent_created.emit(intent) + combo_pressed.emit(data[0], data[1]) + return true + if event.is_action_released(action_name, true): + var data: Array = COMBO_ACTIONS[action_name] + var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"released", float(Time.get_ticks_msec())) + intent_created.emit(intent) + combo_released.emit(data[0]) + return true + return false diff --git a/scenes/components/input_component.gd.uid b/scenes/components/input_component.gd.uid new file mode 100644 index 0000000..0f185ec --- /dev/null +++ b/scenes/components/input_component.gd.uid @@ -0,0 +1 @@ +uid://dxwomhlyicdep diff --git a/scenes/components/input_intent.gd b/scenes/components/input_intent.gd new file mode 100644 index 0000000..855bedc --- /dev/null +++ b/scenes/components/input_intent.gd @@ -0,0 +1,32 @@ +class_name InputIntent +extends RefCounted + +var symbol: StringName +var rhythm_action: StringName +var event_type: StringName +var timestamp_ms := 0.0 +var judgement: Dictionary = {} + + +static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> RefCounted: + var script: Script = load("res://scenes/components/input_intent.gd") + var intent: RefCounted = script.new() + intent.symbol = next_symbol + intent.rhythm_action = next_rhythm_action + intent.event_type = next_event_type + intent.timestamp_ms = next_timestamp_ms + return intent + + +func is_pressed() -> bool: + return event_type == &"pressed" + + +func is_released() -> bool: + return event_type == &"released" + + +func with_judgement(next_judgement: Dictionary) -> RefCounted: + var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms) + copy.judgement = next_judgement.duplicate() + return copy diff --git a/scenes/components/input_intent.gd.uid b/scenes/components/input_intent.gd.uid new file mode 100644 index 0000000..5885586 --- /dev/null +++ b/scenes/components/input_intent.gd.uid @@ -0,0 +1 @@ +uid://dgrjwtje4wfni diff --git a/scenes/components/motion_executor.gd b/scenes/components/motion_executor.gd new file mode 100644 index 0000000..63c3325 --- /dev/null +++ b/scenes/components/motion_executor.gd @@ -0,0 +1,156 @@ +class_name MotionExecutor +extends Node + +signal motion_started(action: Resource) +signal motion_finished(action: Resource) + +# player_body (layer 6) | enemy_body (layer 7): the pair that body-blocks. +const BODY_GHOST_BITS := (1 << 5) | (1 << 6) +# 幽灵态收尾嵌入时的每帧排斥步长(240px/s,快于小怪追击 120px/s,保证能拉开)。 +const OVERLAP_NUDGE_PER_FRAME := 4.0 + +@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D + +var current_action: Resource +var velocity := Vector2.ZERO +var duration := 0.0 +var elapsed := 0.0 +var active := false +var _saved_collision_mask := -1 +var _saved_collision_layer := -1 +var _restore_pending := false +var _ghost_exit_sign := 1.0 + + +func _physics_process(_delta: float) -> void: + if _restore_pending: + _try_restore_collision() + + +func execute(action: Resource, direction: Vector2, beat_time: float, speed := 220.0) -> void: + if active: + cancel() + current_action = action + duration = maxf(0.01, float(action.get("action_beats")) * maxf(0.01, beat_time)) + elapsed = 0.0 + active = true + # Vertical motion is owned by the fake-height system (height/height_speed); + # feeding move_mult_y into the body velocity leaves the CharacterBody drifting off the ground plane. + var move_x := float(action.get("move_mult_x")) + var horizontal := direction.x if direction.x != 0.0 else signf(move_x) + velocity = Vector2(signf(horizontal) * speed, 0.0) if horizontal != 0.0 else Vector2.ZERO + if _has_action_tag(action, &"dash_through"): + _begin_dash_through() + motion_started.emit(action) + + +func tick(delta: float) -> Vector2: + if not active: + return Vector2.ZERO + elapsed += delta + if elapsed >= duration: + active = false + velocity = Vector2.ZERO + _request_restore_collision() + motion_finished.emit(current_action) + return velocity + + +func cancel() -> void: + active = false + velocity = Vector2.ZERO + _request_restore_collision() + current_action = null + + +func is_ghosting() -> bool: + return _saved_collision_mask != -1 + + +func _has_action_tag(action: Resource, tag: StringName) -> bool: + if action == null: + return false + var tags: Array = action.get("action_tags") + for item: Variant in tags: + if StringName(str(item)) == tag: + return true + return false + + +func _begin_dash_through() -> void: + if actor == null: + actor = get_parent() as CharacterBody2D + if actor == null: + return + # A new dash always cancels any deferred restore from the previous one, so + # the ghost state cannot snap back mid-dash. + _restore_pending = false + # 记录冲刺方向:收尾嵌入时沿该方向推出(保留"从远侧穿出"的手感)。 + if velocity.x != 0.0: + _ghost_exit_sign = signf(velocity.x) + if _saved_collision_mask != -1: + return + # Ghost both directions: stop colliding with enemy/player bodies AND stop + # being collidable by them, so neither side's move_and_slide recovery can + # shove anyone while the dash overlaps a body. + _saved_collision_mask = actor.collision_mask + _saved_collision_layer = actor.collision_layer + actor.collision_mask = actor.collision_mask & ~BODY_GHOST_BITS + actor.collision_layer = actor.collision_layer & ~BODY_GHOST_BITS + + +func _request_restore_collision() -> void: + if _saved_collision_mask == -1: + return + # Never snap collision back while still inside another body: restoring + # mid-overlap lets depenetration shove the actor back out the entry side, + # which reads as "the dash did not pierce". Retry every physics frame. + _restore_pending = true + _try_restore_collision() + + +func _try_restore_collision() -> void: + if actor == null or _saved_collision_mask == -1: + _restore_pending = false + return + if Engine.is_in_physics_frame() and _overlaps_ghosted_bodies(): + # 2026-07-06 策划:除冲刺穿人过程外不得与敌人重叠。嵌入时不再无限期 + # 干等分离,而是沿冲刺方向温和推出,推清后下一帧恢复碰撞。 + _nudge_out_of_overlap() + return + actor.collision_mask = _saved_collision_mask + actor.collision_layer = _saved_collision_layer + _saved_collision_mask = -1 + _saved_collision_layer = -1 + _restore_pending = false + + +## 幽灵态结束但仍嵌在敌人体内:每物理帧沿冲刺方向推 4px(幽灵掩码只剩 +## world 位,绝不会被推进地形);顶到边界墙推不动时反向从入口侧退出。 +func _nudge_out_of_overlap() -> void: + var motion := Vector2(_ghost_exit_sign * OVERLAP_NUDGE_PER_FRAME, 0.0) + var collision := actor.move_and_collide(motion) + if collision != null and collision.get_travel().length() < OVERLAP_NUDGE_PER_FRAME * 0.5: + _ghost_exit_sign = -_ghost_exit_sign + + +func _overlaps_ghosted_bodies() -> bool: + if actor == null or not actor.is_inside_tree(): + return false + var query_mask := _saved_collision_mask & BODY_GHOST_BITS + if query_mask == 0: + return false + var shape_node := actor.get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node == null or shape_node.shape == null: + return false + var params := PhysicsShapeQueryParameters2D.new() + params.shape = shape_node.shape + params.transform = shape_node.global_transform + params.collision_mask = query_mask + params.collide_with_bodies = true + params.collide_with_areas = false + params.exclude = [actor.get_rid()] + var space := actor.get_world_2d().direct_space_state + if space == null: + return false + return not space.intersect_shape(params, 1).is_empty() diff --git a/scenes/components/motion_executor.gd.uid b/scenes/components/motion_executor.gd.uid new file mode 100644 index 0000000..0e7abc4 --- /dev/null +++ b/scenes/components/motion_executor.gd.uid @@ -0,0 +1 @@ +uid://21xrm1ubabdn diff --git a/scenes/components/movement_motor.gd b/scenes/components/movement_motor.gd new file mode 100644 index 0000000..86a5fc7 --- /dev/null +++ b/scenes/components/movement_motor.gd @@ -0,0 +1,237 @@ +class_name MovementMotor +extends Node + +const GRAVITY := 1200.0 +const KNOCKBACK_FRICTION := 480.0 +const ActionRuleResolverScript := preload("res://scripts/resolvers/action_rule_resolver.gd") + +@onready var actor: CharacterBody2D = get_parent() as CharacterBody2D +@onready var state_machine: Node = get_node_or_null("../StateMachine") +@onready var effect_container: Node = get_node_or_null("../EffectContainer") + +# 击退的水平残速还没衰减完;期间 set_heading 不得改写朝向(受击不转身)。 +var _knockback_stray_active := false + + +func handle_input() -> void: + if actor == null or not can_move_freely(): + return + # 自主移动接管 velocity.x,击退残速语义随之结束。 + _knockback_stray_active = false + var direction := get_horizontal_axis() + actor.velocity.x = direction * float(actor.get("speed")) * _move_speed_multiplier() + if direction < 0.0: + actor.set("heading", Vector2.LEFT) + elif direction > 0.0: + actor.set("heading", Vector2.RIGHT) + + +func handle_air_time(delta: float) -> void: + if actor == null: + return + _decay_stray_velocity(delta) + if _presentation_state() != Character.PRESENTATION_JUMP and _ground_state() != &"Airborne": + return + var height := float(actor.get("height")) + var height_speed := float(actor.get("height_speed")) + height += height_speed * delta + if height <= 0.0 and height_speed < 0.0: + actor.set("height", 0.0) + actor.set("height_speed", 0.0) + actor.set("state", Character.PRESENTATION_LAND) + actor.velocity.y = 0.0 + _set_ground_state(&"Grounded") + _reset_air_actions() + _dispatch_effect_event(&"on_landed") + else: + actor.set("height", height) + actor.set("height_speed", height_speed - GRAVITY * delta) + _set_ground_state(&"Airborne") + + +func _decay_stray_velocity(delta: float) -> void: + var state := _presentation_state() + if state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK: + return + actor.velocity.x = move_toward(actor.velocity.x, 0.0, KNOCKBACK_FRICTION * delta) + actor.velocity.y = 0.0 + if actor.velocity.x == 0.0: + _knockback_stray_active = false + + +func handle_movement() -> void: + if actor == null: + return + var state := _presentation_state() + if state == Character.PRESENTATION_JUMP or state == Character.PRESENTATION_ATTACK or state == Character.PRESENTATION_AIR_ATTACK: + return + if _ground_state() == &"Airborne" or float(actor.get("height")) > 0.0 or not is_zero_approx(float(actor.get("height_speed"))): + _set_ground_state(&"Airborne") + return + if state == Character.PRESENTATION_LAND: + actor.set("state", Character.PRESENTATION_IDLE) + elif absf(actor.velocity.x) > 0.0: + actor.set("state", Character.PRESENTATION_WALK) + else: + actor.set("state", Character.PRESENTATION_IDLE) + _set_ground_state(&"Grounded") + + +func set_heading() -> void: + if actor == null: + return + # 受击不转身(2026-07-05 定案):Hitstun/击退残速期间保持受击前朝向。 + if _knockback_stray_active or _life_state() != &"Alive": + return + if actor.velocity.x > 0.0: + actor.set("heading", Vector2.RIGHT) + elif actor.velocity.x < 0.0: + actor.set("heading", Vector2.LEFT) + + +func start_jump() -> bool: + if actor == null or not can_jump(): + return false + actor.set("state", Character.PRESENTATION_JUMP) + actor.set("height_speed", float(actor.get("jump_intensity"))) + _set_ground_state(&"Airborne") + return true + + +func can_jump() -> bool: + if actor == null: + return false + var state := _presentation_state() + return state == Character.PRESENTATION_IDLE or state == Character.PRESENTATION_WALK + + +func can_move_freely() -> bool: + if not _free_movement_inputs_bound(): + return false + if _movement_blocked_by_effect(): + return false + var context := _movement_context() + if bool(ActionRuleResolverScript.can_move_freely(context)): + return true + return _charging_movement_allowed_by_effect() + + +func get_horizontal_axis() -> float: + var axis := 0.0 + if Input.is_action_pressed(&"move_left"): + axis -= 1.0 + if Input.is_action_pressed(&"move_right"): + axis += 1.0 + return axis + + +func apply_knockback(knockback: Vector2) -> void: + if actor == null: + return + actor.velocity.x = knockback.x + _knockback_stray_active = knockback.x != 0.0 + if knockback.y > 0.0: + actor.set("height", maxf(float(actor.get("height")), 0.1)) + actor.set("height_speed", knockback.y) + _set_ground_state(&"Airborne") + elif knockback.y < 0.0: + actor.set("height_speed", knockback.y) + else: + actor.velocity.y = 0.0 + + +## 供覆写了 handle_movement 的宿主(Boss/小怪 AI)查询:击退残速未衰减完时 +## 不得清零 velocity.x,否则击退位移活不过一帧。 +func has_knockback_stray() -> bool: + return _knockback_stray_active + + +## 强制结束击退滑行语义(Boss 受击链强制撤退等"压倒击退"的路径专用)。 +func clear_knockback_stray() -> void: + _knockback_stray_active = false + + +func _ground_state() -> StringName: + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded"))) + return &"Grounded" + + +func _movement_context() -> Dictionary: + var context := { + "life_state": &"Alive", + "action_phase": &"Neutral", + "ground_state": &"Grounded", + } + if state_machine != null and state_machine.has_method("build_context"): + context = state_machine.call("build_context") + context["effect_container"] = effect_container + return context + + +func _presentation_state() -> StringName: + if actor == null: + return Character.PRESENTATION_IDLE + return StringName(str(actor.get("state"))) + + +func _action_phase() -> StringName: + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("action_phase", &"Neutral"))) + return &"Neutral" + + +func _life_state() -> StringName: + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) + return &"Alive" + + +func _charging_movement_allowed_by_effect() -> bool: + if _action_phase() != &"Charging": + return false + if effect_container == null or not effect_container.has_method("action_rule_modifiers"): + return false + for modifier: Resource in effect_container.call("action_rule_modifiers"): + if bool(modifier.get("allow_movement_while_charging")): + return true + return false + + +func _movement_blocked_by_effect() -> bool: + if effect_container == null or not effect_container.has_method("action_rule_modifiers"): + return false + for modifier: Resource in effect_container.call("action_rule_modifiers"): + if bool(modifier.get("block_movement")): + return true + return false + + +func _move_speed_multiplier() -> float: + if effect_container == null or not effect_container.has_method("stat_multiplier"): + return 1.0 + return float(effect_container.call("stat_multiplier", &"move_speed", null)) + + +func _free_movement_inputs_bound() -> bool: + for action_name: StringName in [&"move_left", &"move_right"]: + if InputMap.has_action(action_name) and not InputMap.action_get_events(action_name).is_empty(): + return true + return false + + +func _set_ground_state(next_state: StringName) -> void: + if state_machine != null and state_machine.has_method("set_ground_state"): + state_machine.call("set_ground_state", next_state) + + +func _reset_air_actions() -> void: + if state_machine != null and "air_action_count" in state_machine: + state_machine.set("air_action_count", 0) + + +func _dispatch_effect_event(event_name: StringName) -> void: + if effect_container == null: + effect_container = get_node_or_null("../EffectContainer") + if effect_container != null and effect_container.has_method("dispatch_event"): + effect_container.call("dispatch_event", event_name, {"actor": actor}) diff --git a/scenes/components/movement_motor.gd.uid b/scenes/components/movement_motor.gd.uid new file mode 100644 index 0000000..5670150 --- /dev/null +++ b/scenes/components/movement_motor.gd.uid @@ -0,0 +1 @@ +uid://cc8bjx2eu05cf diff --git a/scenes/components/overhead_charge_segments.gd b/scenes/components/overhead_charge_segments.gd new file mode 100644 index 0000000..ce2feaa --- /dev/null +++ b/scenes/components/overhead_charge_segments.gd @@ -0,0 +1,96 @@ +class_name OverheadChargeSegments +extends Node2D + +@export var charge_component_path: NodePath = ^"../ChargeComponent" +@export var segment_count := 3 +@export var segment_size := Vector2(42.0, 12.0) +@export var segment_gap := 5.0 +@export var border_width := 2.0 +@export var background_color := Color(0.04, 0.055, 0.07, 0.86) +@export var fill_color := Color(0.25, 0.88, 1.0, 0.96) +@export var ready_fill_color := Color(1.0, 0.72, 0.18, 1.0) +@export var border_color := Color(0.88, 0.96, 1.0, 0.9) +@export var inactive_linger_seconds := 0.22 + +var _current := 0.0 +var _maximum := 1.0 +var _is_charge_ready := false +var _active := false +var _visual_linger_remaining := 0.0 + +@onready var _charge_component: Node = get_node_or_null(charge_component_path) + + +func _ready() -> void: + segment_count = maxi(1, segment_count) + z_index = max(z_index, 96) + visible = false + if _charge_component != null and _charge_component.has_signal("charge_changed"): + if not _charge_component.is_connected("charge_changed", _on_charge_changed): + _charge_component.connect("charge_changed", _on_charge_changed) + + +func active() -> bool: + return _active + + +func current_progress_segments() -> float: + if not _active and _visual_linger_remaining <= 0.0: + return 0.0 + if _is_charge_ready: + return float(segment_count) + if _maximum <= float(segment_count - 1) + 0.1: + return clampf(_current + 1.0, 0.0, float(segment_count)) + return clampf((_current / maxf(0.001, _maximum)) * float(segment_count), 0.0, float(segment_count)) + + +func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void: + var was_showing := _active or _visual_linger_remaining > 0.0 + if active: + _current = maxf(0.0, current) + _maximum = maxf(0.001, maximum) + _is_charge_ready = ready + _visual_linger_remaining = 0.0 + modulate.a = 1.0 + visible = true + else: + if was_showing and inactive_linger_seconds > 0.0: + _visual_linger_remaining = inactive_linger_seconds + visible = true + else: + _visual_linger_remaining = 0.0 + modulate.a = 1.0 + visible = false + _active = active + queue_redraw() + + +func _process(delta: float) -> void: + if _visual_linger_remaining <= 0.0: + return + _visual_linger_remaining = maxf(0.0, _visual_linger_remaining - delta) + modulate.a = clampf(_visual_linger_remaining / maxf(0.001, inactive_linger_seconds), 0.0, 1.0) + if _visual_linger_remaining <= 0.0: + visible = false + modulate.a = 1.0 + queue_redraw() + + +func _draw() -> void: + if not _active and _visual_linger_remaining <= 0.0: + return + var safe_count := maxi(1, segment_count) + var total_width := segment_size.x * float(safe_count) + segment_gap * float(maxi(0, safe_count - 1)) + var origin := Vector2(-total_width * 0.5, 0.0) + var progress := current_progress_segments() + var plate_rect := Rect2(origin - Vector2(5.0, 4.0), Vector2(total_width + 10.0, segment_size.y + 8.0)) + draw_rect(plate_rect, Color(0.0, 0.0, 0.0, 0.68), true) + draw_rect(plate_rect, Color(0.9, 0.96, 1.0, 0.42), false, 1.0) + for index: int in range(safe_count): + var rect := Rect2(origin + Vector2(float(index) * (segment_size.x + segment_gap), 0.0), segment_size) + draw_rect(rect, background_color, true) + var fill_ratio := clampf(progress - float(index), 0.0, 1.0) + if fill_ratio > 0.0: + var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (segment_size.x - border_width * 2.0) * fill_ratio), maxf(0.0, segment_size.y - border_width * 2.0))) + draw_rect(fill_rect, ready_fill_color if _is_charge_ready else fill_color, true) + draw_rect(rect, border_color, false, border_width) diff --git a/scenes/components/overhead_charge_segments.gd.uid b/scenes/components/overhead_charge_segments.gd.uid new file mode 100644 index 0000000..345906b --- /dev/null +++ b/scenes/components/overhead_charge_segments.gd.uid @@ -0,0 +1 @@ +uid://cdkmtj1tgh5sf diff --git a/scenes/components/overhead_health_bar.gd b/scenes/components/overhead_health_bar.gd new file mode 100644 index 0000000..155d067 --- /dev/null +++ b/scenes/components/overhead_health_bar.gd @@ -0,0 +1,48 @@ +class_name OverheadHealthBar +extends Node2D + +@export var health_component_path: NodePath = ^"../HealthComponent" +@export var bar_size := Vector2(58.0, 7.0) +@export var border_width := 1.0 +@export var background_color := Color(0.05, 0.04, 0.045, 0.82) +@export var fill_color := Color(0.95, 0.16, 0.13, 0.95) +@export var low_health_color := Color(1.0, 0.62, 0.15, 0.98) +@export var border_color := Color(0.96, 0.9, 0.78, 0.88) +@export var hide_when_depleted := true + +var _current := 1 +var _maximum := 1 + +@onready var _health_component: Node = get_node_or_null(health_component_path) + + +func _ready() -> void: + z_index = max(z_index, 30) + if _health_component != null: + if _health_component.has_signal("health_changed") and not _health_component.is_connected("health_changed", _on_health_changed): + _health_component.connect("health_changed", _on_health_changed) + _on_health_changed(int(_health_component.get("current")), int(_health_component.get("maximum"))) + else: + queue_redraw() + + +func health_ratio() -> float: + return clampf(float(_current) / float(maxi(1, _maximum)), 0.0, 1.0) + + +func _on_health_changed(current: int, maximum: int) -> void: + _current = clampi(current, 0, maxi(1, maximum)) + _maximum = maxi(1, maximum) + visible = _current > 0 or not hide_when_depleted + queue_redraw() + + +func _draw() -> void: + if hide_when_depleted and _current <= 0: + return + var rect := Rect2(-bar_size * 0.5, bar_size) + draw_rect(rect, background_color, true) + var ratio := health_ratio() + var fill_rect := Rect2(rect.position + Vector2(border_width, border_width), Vector2(maxf(0.0, (bar_size.x - border_width * 2.0) * ratio), maxf(0.0, bar_size.y - border_width * 2.0))) + draw_rect(fill_rect, low_health_color if ratio <= 0.32 else fill_color, true) + draw_rect(rect, border_color, false, border_width) diff --git a/scenes/components/overhead_health_bar.gd.uid b/scenes/components/overhead_health_bar.gd.uid new file mode 100644 index 0000000..a98f57e --- /dev/null +++ b/scenes/components/overhead_health_bar.gd.uid @@ -0,0 +1 @@ +uid://c277u75ut0vwy diff --git a/scenes/components/state_machine.gd b/scenes/components/state_machine.gd new file mode 100644 index 0000000..eddebc0 --- /dev/null +++ b/scenes/components/state_machine.gd @@ -0,0 +1,88 @@ +class_name StateMachine +extends Node + +signal axis_changed(axis: StringName, previous: StringName, current: StringName) + +enum GroundState { GROUNDED, AIRBORNE } +enum ActionPhaseState { NEUTRAL, STARTUP, ACTIVE, RECOVERY, CHARGING } +enum LifeState { ALIVE, HITSTUN, DEAD } +enum DefenseState { VULNERABLE, PARRYING, SUPER_ARMOR, INVINCIBLE } + +const GROUND_STATE_NAMES: Array[StringName] = [&"Grounded", &"Airborne"] +const ACTION_PHASE_NAMES: Array[StringName] = [&"Neutral", &"Startup", &"Active", &"Recovery", &"Charging"] +const LIFE_STATE_NAMES: Array[StringName] = [&"Alive", &"Hitstun", &"Dead"] +const DEFENSE_STATE_NAMES: Array[StringName] = [&"Vulnerable", &"Parrying", &"SuperArmor", &"Invincible"] + +var ground_state := GroundState.GROUNDED +var action_phase := ActionPhaseState.NEUTRAL +var life_state := LifeState.ALIVE +var defense_state := DefenseState.VULNERABLE +var air_action_count := 0 +var max_air_action_count := 1 + + +func build_context() -> Dictionary: + var tags: Array[StringName] = [ + get_ground_state(), + get_action_phase(), + get_defense_state(), + get_life_state(), + ] + return { + "ground_state": get_ground_state(), + "action_phase": get_action_phase(), + "defense_state": get_defense_state(), + "life_state": get_life_state(), + "air_action_count": air_action_count, + "max_air_action_count": max_air_action_count, + "tags": tags, + } + + +func get_context() -> Dictionary: + return build_context() + + +func has_tag(tag: StringName) -> bool: + return build_context()["tags"].has(tag) + + +func get_ground_state() -> StringName: + return GROUND_STATE_NAMES[ground_state] + + +func get_action_phase() -> StringName: + return ACTION_PHASE_NAMES[action_phase] + + +func get_life_state() -> StringName: + return LIFE_STATE_NAMES[life_state] + + +func get_defense_state() -> StringName: + return DEFENSE_STATE_NAMES[defense_state] + + +func set_ground_state(next_state: StringName) -> void: + ground_state = _set_axis(&"ground_state", GROUND_STATE_NAMES, ground_state, next_state) + + +func set_action_phase(next_phase: StringName) -> void: + action_phase = _set_axis(&"action_phase", ACTION_PHASE_NAMES, action_phase, next_phase) + + +func set_life_state(next_state: StringName) -> void: + life_state = _set_axis(&"life_state", LIFE_STATE_NAMES, life_state, next_state) + + +func set_defense_state(next_state: StringName) -> void: + defense_state = _set_axis(&"defense_state", DEFENSE_STATE_NAMES, defense_state, next_state) + + +func _set_axis(axis: StringName, names: Array[StringName], current: int, next_name: StringName) -> int: + var next_index := names.find(next_name) + if next_index == -1 or next_index == current: + return current + var previous := names[current] + axis_changed.emit(axis, previous, names[next_index]) + return next_index diff --git a/scenes/components/state_machine.gd.uid b/scenes/components/state_machine.gd.uid new file mode 100644 index 0000000..c916fbd --- /dev/null +++ b/scenes/components/state_machine.gd.uid @@ -0,0 +1 @@ +uid://1cwt1fphdnkr diff --git a/scenes/components/streak_counter.gd b/scenes/components/streak_counter.gd new file mode 100644 index 0000000..3633b0b --- /dev/null +++ b/scenes/components/streak_counter.gd @@ -0,0 +1,63 @@ +class_name StreakCounter +extends Node + +## Sole writer of the streak count (AnchorV1.0 "ComboCounter"). +## Pure fact subscriber: +1 on skill_executed, reset on miss / chart_reset. + +var streak := 0 + + +func _ready() -> void: + for bus: Node in _event_buses(): + if bus.has_signal("skill_executed") and not bus.is_connected("skill_executed", _on_skill_executed): + bus.connect("skill_executed", _on_skill_executed) + if bus.has_signal("judgement_made") and not bus.is_connected("judgement_made", _on_judgement_made): + bus.connect("judgement_made", _on_judgement_made) + if bus.has_signal("time_anchor_resolved") and not bus.is_connected("time_anchor_resolved", _on_time_anchor_resolved): + bus.connect("time_anchor_resolved", _on_time_anchor_resolved) + if bus.has_signal("chart_reset") and not bus.is_connected("chart_reset", _on_chart_reset): + bus.connect("chart_reset", _on_chart_reset) + + +func _on_skill_executed(_skill: Resource, _judgement: StringName) -> void: + streak += 1 + _broadcast() + + +func _on_judgement_made(quality: StringName, _offset_ms: float, _beat_index: int) -> void: + if quality != &"miss": + return + reset() + + +func _on_time_anchor_resolved(_anchor: Dictionary, held: bool, _judgement: Dictionary) -> void: + if held: + return + reset() + + +func _on_chart_reset(_chart_id: StringName) -> void: + reset() + + +func reset() -> void: + if streak == 0: + return + streak = 0 + _broadcast() + + +func _broadcast() -> void: + for bus: Node in _event_buses(): + if bus.has_signal("streak_changed"): + bus.emit_signal("streak_changed", streak) + + +func _event_buses() -> Array[Node]: + var buses: Array[Node] = [] + if not is_inside_tree(): + return buses + for child: Node in get_tree().root.get_children(): + if child.has_signal("skill_executed") and child.has_signal("streak_changed"): + buses.append(child) + return buses diff --git a/scenes/components/streak_counter.gd.uid b/scenes/components/streak_counter.gd.uid new file mode 100644 index 0000000..973b633 --- /dev/null +++ b/scenes/components/streak_counter.gd.uid @@ -0,0 +1 @@ +uid://djvi3n0qxs2wl diff --git a/scenes/components/strong_buff_visual.gd b/scenes/components/strong_buff_visual.gd new file mode 100644 index 0000000..da1bcd5 --- /dev/null +++ b/scenes/components/strong_buff_visual.gd @@ -0,0 +1,89 @@ +class_name StrongBuffVisual +extends Node2D + +const DEFAULT_FRAME_DIRECTORY := "res://assets/ui/strong_buff" + +@export var frame_directory := DEFAULT_FRAME_DIRECTORY +@export var frames_per_second := 20.0 +@export var center_offset := Vector2(0.0, -38.0) +@export var effect_scale := 0.18 + +var _active := false +var _frame_index := 0 +var _frame_elapsed := 0.0 +var _frames: Array[Texture2D] = [] +var _sprite: Sprite2D + + +func _ready() -> void: + z_index = 1 + _sprite = Sprite2D.new() + _sprite.name = "StrongBuffEffect" + _sprite.centered = true + _sprite.position = center_offset + _sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _sprite.visible = false + add_child(_sprite) + _load_frames() + set_active(false) + + +func _process(delta: float) -> void: + if not _active or _frames.is_empty(): + return + _frame_elapsed += delta + var frame_duration := 1.0 / maxf(1.0, frames_per_second) + while _frame_elapsed >= frame_duration: + _frame_elapsed -= frame_duration + _frame_index = (_frame_index + 1) % _frames.size() + _sprite.texture = _frames[_frame_index] + + +func set_active(active: bool) -> void: + _active = active and not _frames.is_empty() + visible = _active + set_process(_active) + if _sprite != null: + _sprite.visible = _active + _sprite.position = center_offset + _sprite.scale = Vector2.ONE * effect_scale + if _active and _sprite.texture == null and not _frames.is_empty(): + _sprite.texture = _frames[_frame_index] + + +func is_active() -> bool: + return _active + + +func frame_count() -> int: + return _frames.size() + + +func current_effect_scale() -> float: + return effect_scale + + +func _load_frames() -> void: + _frames.clear() + var dir := DirAccess.open(frame_directory) + if dir == null: + return + # 导出包内贴图在目录列表里显示为 xxx.png.import / xxx.png.remap; + # 还原原名后 load() 才能命中。编辑器里原图与 .import 同时在列,需去重。 + var files := PackedStringArray() + dir.list_dir_begin() + var file_name := dir.get_next() + while not file_name.is_empty(): + if not dir.current_is_dir(): + var resource_name := file_name.trim_suffix(".import").trim_suffix(".remap") + if resource_name.ends_with(".png") and not files.has(resource_name): + files.append(resource_name) + file_name = dir.get_next() + dir.list_dir_end() + files.sort() + for path_name: String in files: + var texture := load("%s/%s" % [frame_directory, path_name]) as Texture2D + if texture != null: + _frames.append(texture) + if not _frames.is_empty() and _sprite != null: + _sprite.texture = _frames[0] diff --git a/scenes/components/strong_buff_visual.gd.uid b/scenes/components/strong_buff_visual.gd.uid new file mode 100644 index 0000000..e00eec8 --- /dev/null +++ b/scenes/components/strong_buff_visual.gd.uid @@ -0,0 +1 @@ +uid://hr56iy0nnfao diff --git a/scenes/components/time_phase_adapter.gd b/scenes/components/time_phase_adapter.gd new file mode 100644 index 0000000..2a7123c --- /dev/null +++ b/scenes/components/time_phase_adapter.gd @@ -0,0 +1,86 @@ +class_name TimePhaseAdapter +extends Node + +## Swaps this actor's time-phase Effects when the world phase changes. +## Only ever talks to EffectContainer.add_effect / remove_effect; never +## touches stats, state axes or animations directly. Actors without a +## profile are simply unaffected by phase switches. + +@export var profile: Resource +@export var effect_container_path := NodePath("../EffectContainer") + +@onready var effect_container: Node = get_node_or_null(effect_container_path) + + +func _ready() -> void: + for bus: Node in _event_buses(): + if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + apply_time_phase(_current_time_phase()) + + +func apply_time_phase(time_phase: StringName) -> void: + if profile == null or effect_container == null: + return + for definition: Resource in _profile_effects(&"past") + _profile_effects(&"future"): + if definition != null and effect_container.has_method("remove_effect"): + effect_container.call("remove_effect", StringName(str(definition.get("id")))) + for definition: Resource in _profile_effects(time_phase): + if definition != null and effect_container.has_method("add_effect"): + effect_container.call("add_effect", definition, &"time_phase") + + +func visual_key() -> StringName: + if profile != null and profile.has_method("visual_key_for_time_phase"): + return profile.call("visual_key_for_time_phase", _current_time_phase()) + return _current_time_phase() + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + apply_time_phase(current) + + +func _profile_effects(time_phase: StringName) -> Array[Resource]: + var result: Array[Resource] = [] + if profile == null: + return result + var effects = profile.call("effects_for_time_phase", time_phase) if profile.has_method("effects_for_time_phase") else null + if effects is Array: + for definition: Variant in effects: + if definition is Resource: + result.append(definition) + return result + + +func _current_time_phase() -> StringName: + var manager := _time_phase_manager_or_null() + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _time_phase_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return _last_root_child_matching(func(child: Node) -> bool: + return child.has_method("set_time_phase") and child.get("current_time_phase") != null + ) + + +func _event_buses() -> Array[Node]: + var buses: Array[Node] = [] + if not is_inside_tree(): + return buses + for child: Node in get_tree().root.get_children(): + if child.has_signal("time_phase_changed"): + buses.append(child) + return buses + + +func _last_root_child_matching(predicate: Callable) -> Node: + var children := get_tree().root.get_children() + for index: int in range(children.size() - 1, -1, -1): + var child: Node = children[index] + if bool(predicate.call(child)): + return child + return null diff --git a/scenes/components/time_phase_adapter.gd.uid b/scenes/components/time_phase_adapter.gd.uid new file mode 100644 index 0000000..46fe2cd --- /dev/null +++ b/scenes/components/time_phase_adapter.gd.uid @@ -0,0 +1 @@ +uid://byxap76rqsb4j diff --git a/scenes/enemies/boss.gd b/scenes/enemies/boss.gd new file mode 100644 index 0000000..5a2e06e --- /dev/null +++ b/scenes/enemies/boss.gd @@ -0,0 +1,607 @@ +class_name Boss +extends Character + +const DEFAULT_HEADING := Vector2.LEFT +const VISUAL_SCALE := 2.0 +const DEFAULT_ANIMATION_FPS := 12.0 + +const BOSS_ANIMATION_SPECS := { + &"boss_idle": {"path": "res://assets/art/characters/boss/boss_idle.png", "frames": 1, "fps": 4.0, "loop": true}, + &"boss_run": {"path": "res://assets/art/characters/boss/boss_run.png", "frames": 8, "fps": 12.0, "loop": true}, + &"boss_dash": {"path": "res://assets/art/characters/boss/boss_dash.png", "frames": 6, "fps": 14.0, "loop": false}, + &"boss_jump_fall": {"path": "res://assets/art/characters/boss/boss_jump_fall.png", "frames": 2, "fps": 8.0, "loop": false}, + &"boss_take_hit": {"path": "res://assets/art/characters/boss/boss_take_hit.png", "frames": 1, "fps": 8.0, "loop": false}, + &"boss_die": {"path": "res://assets/art/characters/boss/boss_die.png", "frames": 4, "fps": 8.0, "loop": false}, + &"boss_ground_combo_1": {"path": "res://assets/art/characters/boss/boss_ground_combo_1.png", "frames": 8, "fps": 14.0, "loop": false}, + &"boss_ground_combo_2": {"path": "res://assets/art/characters/boss/boss_ground_combo_2.png", "frames": 10, "fps": 14.0, "loop": false}, + &"boss_ground_combo_3": {"path": "res://assets/art/characters/boss/boss_ground_combo_3.png", "frames": 14, "fps": 14.0, "loop": false}, + &"boss_lunging_stab": {"path": "res://assets/art/characters/boss/boss_lunging_stab.png", "frames": 15, "fps": 15.0, "loop": false}, + &"boss_shoot_1": {"path": "res://assets/art/characters/boss/boss_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false}, + &"boss_shoot_2": {"path": "res://assets/art/characters/boss/boss_shoot_2.png", "frames": 2, "fps": 8.0, "loop": false}, + &"boss_2way_shoot": {"path": "res://assets/art/characters/boss/boss_2way_shoot_1.png", "frames": 2, "fps": 8.0, "loop": false}, +} + +@export var max_health := 28000 +@export var current_health := 28000 +## Training-dummy mode: the boss still attacks (BT + chart events) but never +## displaces — no walk/dash/retreat/lunge motion and no hit-chain escape. +@export var stationary := false +@export var combat_enabled := true: + set(value): + combat_enabled = value + _sync_combat_collision() +@export var visual_ground_offset := -40.0 +@export var boss_lunge_speed := 150.0 +@export var projectile_spawn_forward := 70.0 +@export var projectile_spawn_height := 74.0 +@export var escape_after_consecutive_hits := 4 +@export var hit_chain_window_seconds := 1.2 +@export var escape_cooldown_seconds := 4.0 + +@onready var state_machine: Node = get_node_or_null("StateMachine") +@onready var movement_motor: Node = get_node_or_null("MovementMotor") +@onready var action_controller: Node = get_node_or_null("ActionController") +@onready var motion_executor: Node = get_node_or_null("MotionExecutor") +@onready var health_component: Node = get_node_or_null("HealthComponent") +@onready var damage_receiver: Node = get_node_or_null("DamageReceiver") +@onready var frame_collision_driver: Node = get_node_or_null("FrameCollisionDriver") + +var _current_action_animation: StringName = &"" +var _frame_delta := 0.0 +var _dead_state_entered := false +var _death_animation_started := false +var _hit_chain_count := 0 +var _hit_chain_time_left := 0.0 +var _escape_cooldown_left := 0.0 + + +func _ready() -> void: + heading = DEFAULT_HEADING + anim_map = { + PRESENTATION_IDLE: "boss_idle", + PRESENTATION_WALK: "boss_run", + PRESENTATION_JUMP: "boss_jump_fall", + PRESENTATION_LAND: "boss_idle", + PRESENTATION_ATTACK: "boss_ground_combo_1", + PRESENTATION_AIR_ATTACK: "boss_ground_combo_1", + } + _install_boss_animations() + _connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished")) + _wire_action_controller() + _wire_damage_receiver() + if health_component != null and health_component.has_method("set_values"): + health_component.call("set_values", current_health, max_health) + _sync_combat_collision() + state = PRESENTATION_IDLE + _play_animation(&"boss_idle") + + +func _physics_process(delta: float) -> void: + _frame_delta = delta + _tick_hit_chain(delta) + super._physics_process(delta) + + +func handle_input() -> void: + pass + + +## 2026-07-05 定案:Boss 对不消耗能量的攻击恒霸体——伤害照常结算,但不被 +## 打断、不被击退;只有耗能技能(base_cost > 0)能压制 Boss。 +func shrugs_off_hit(action: Resource) -> bool: + if action == null: + return true + return float(action.get("base_cost")) <= 0.0 + + +## 2026-07-05 策划两轮调参:×3 后仍嫌短,再 ×3 → 距离 ×9(初速 ×3)。 +## 只放大水平滑行,击飞高度不变;换算由 CombatManager 负责(距离 ∝ 初速²)。 +func knockback_distance_taken_mult() -> float: + return 9.0 + + +func handle_air_time(delta: float) -> void: + if movement_motor != null and movement_motor.has_method("handle_air_time"): + movement_motor.call("handle_air_time", delta) + else: + super.handle_air_time(delta) + + +func handle_movement() -> void: + if motion_executor != null and bool(motion_executor.get("active")): + velocity = motion_executor.call("tick", _frame_delta) + return + if state == PRESENTATION_JUMP or state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK: + return + # 击退残速期不清零,交给 MovementMotor 的摩擦衰减自然滑行——否则击退 + # 初速活不过一帧,Boss 的水平击退位移恒为 0。 + if not (movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray"))): + velocity.x = 0.0 + if movement_motor != null and movement_motor.has_method("handle_movement"): + movement_motor.call("handle_movement") + else: + super.handle_movement() + + +func handle_animations() -> void: + var life_state := _life_state() + if life_state == &"Dead": + _enter_dead_state() + _play_death_animation_once() + _queue_free_if_death_animation_finished() + return + _dead_state_entered = false + _death_animation_started = false + if life_state == &"Hitstun": + _play_animation(&"boss_take_hit") + return + if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty(): + _play_animation(_current_action_animation) + return + super.handle_animations() + + +func set_heading() -> void: + pass + + +func set_sprite_height_position() -> void: + # Keep the editor-authored ground offset at runtime (WYSIWYG): without this + # override the Character base resets Visual to (0, -height) every physics + # tick and the boss art jumps 32px above its authored position. + if visual != null: + visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height + + +func flip_sprites() -> void: + if visual == null: + return + visual.scale.x = -VISUAL_SCALE if heading == Vector2.LEFT else VISUAL_SCALE + visual.scale.y = VISUAL_SCALE + + +func look_at_target(target: Node2D) -> void: + if target == null: + return + heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT + + +func projectile_spawn_position(_action: Resource) -> Vector2: + return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -projectile_spawn_height) + + +func projectile_direction(_action: Resource) -> Vector2: + return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING + + +func projectile_requests_for_action(action: Resource) -> Array[Dictionary]: + if action != null and StringName(str(action.get("id"))) == &"boss_2way_shoot": + return [ + { + "spawn_position": global_position + Vector2(-projectile_spawn_forward, -projectile_spawn_height), + "direction": Vector2.LEFT, + }, + { + "spawn_position": global_position + Vector2(projectile_spawn_forward, -projectile_spawn_height), + "direction": Vector2.RIGHT, + }, + ] + return [ + { + "spawn_position": projectile_spawn_position(action), + "direction": projectile_direction(action), + }, + ] + + +func is_strong_in_current_time_phase() -> bool: + return true + + +func can_start_enemy_action(action_id: StringName) -> bool: + if not combat_enabled: + return false + # 击退滑行(趔趄)期间不得起手新动作:动作自带的突进位移会立刻接管 + # velocity 并冲回玩家,把击退效果整个吃掉(实测净位移为负)。 + if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")): + return false + var behavior := get_node_or_null("BossBehaviorTree") + if behavior != null and behavior.has_method("phase_allows_action"): + return bool(behavior.call("phase_allows_action", action_id)) + return true + + +func _sync_combat_collision() -> void: + var driver := frame_collision_driver + if driver == null: + driver = get_node_or_null("FrameCollisionDriver") + if driver != null: + driver.set("body_collision_enabled", combat_enabled) + driver.set("damage_receiver_enabled", combat_enabled) + driver.set("damage_emitter_enabled", combat_enabled) + if driver.has_method("refresh_now"): + driver.call("refresh_now") + if combat_enabled: + var tree := get_node_or_null("BossBehaviorTree") + if tree != null: + if tree.has_method("reset_for_combat_entry"): + tree.call("reset_for_combat_entry") + else: + tree.set("enabled", true) + + +func _install_boss_animations() -> void: + if animation_player == null: + return + var library: AnimationLibrary + if animation_player.has_animation_library(""): + library = animation_player.get_animation_library("") + else: + library = AnimationLibrary.new() + animation_player.add_animation_library("", library) + var base_sprite_offset := character_sprite.offset if character_sprite != null else Vector2(-40.0, -47.0) + var idle_anchor := _visible_bottom_anchor_for_spec(BOSS_ANIMATION_SPECS.get(&"boss_idle", {}), base_sprite_offset) + for animation_name: StringName in BOSS_ANIMATION_SPECS: + if library.has_animation(animation_name): + continue + var spec: Dictionary = BOSS_ANIMATION_SPECS[animation_name] + var texture := load(str(spec.get("path", ""))) as Texture2D + if texture == null: + continue + var align_anchor := idle_anchor if animation_name == &"boss_die" else INF + library.add_animation(animation_name, _make_sheet_animation(texture, int(spec.get("frames", 1)), float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), align_anchor, base_sprite_offset)) + + +func _make_sheet_animation(texture: Texture2D, frame_count: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation: + var safe_frame_count := maxi(1, frame_count) + var safe_fps := maxf(1.0, fps) + var frame_time := 1.0 / safe_fps + var animation := Animation.new() + animation.length = maxf(frame_time, float(safe_frame_count) * frame_time) + animation.step = frame_time + animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE + var texture_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture")) + animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(texture_track, 0.0, texture) + var hframes_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes")) + animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(hframes_track, 0.0, safe_frame_count) + var vframes_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes")) + animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(vframes_track, 0.0, 1) + var frame_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame")) + animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE) + for frame_index: int in range(safe_frame_count): + animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index) + if visible_bottom_anchor < INF: + var offset_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset")) + animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE) + for frame_index: int in range(safe_frame_count): + var offset := sprite_offset + var bottom := _visible_frame_bottom_in_texture(texture, safe_frame_count, 1, frame_index) + if bottom != INF: + offset.y = visible_bottom_anchor - bottom + animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset) + var fx_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(fx_track, NodePath("Visual/FxOverlay:visible")) + animation.value_track_set_update_mode(fx_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(fx_track, 0.0, false) + return animation + + +func _wire_action_controller() -> void: + if action_controller == null: + return + _connect_once(action_controller, &"action_started", Callable(self, "_on_action_started")) + _connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started")) + _connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished")) + _connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled")) + + +func _wire_damage_receiver() -> void: + if damage_receiver == null: + return + _connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received")) + + +func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void: + if not source.is_connected(signal_name, callback): + source.connect(signal_name, callback) + + +func _on_action_started(action: Resource, _intent) -> void: + if _life_state() == &"Dead": + return + _face_target_if_available() + _current_action_animation = StringName(str(action.get("animation"))) + state = PRESENTATION_ATTACK + attack_time_left = _action_duration_seconds(action) + 0.05 + attack_lunge_time_left = attack_time_left + _align_action_animation_speed(action) + if animation_player != null: + animation_player.stop() + _play_animation(_current_action_animation) + + +func _on_action_active_started(action: Resource, _intent) -> void: + if _life_state() == &"Dead": + return + if stationary: + return + if motion_executor == null: + return + if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0: + return + var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x")))) + motion_executor.call("execute", action, _motion_direction_for_action(action), _beat_time(), boss_lunge_speed * speed_scale) + + +func _on_action_finished(_action: Resource) -> void: + _current_action_animation = &"" + _reset_animation_speed() + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + if _life_state() == &"Dead": + velocity = Vector2.ZERO + return + state = PRESENTATION_IDLE + velocity = Vector2.ZERO + + +func _on_action_cancelled(_action: Resource, _reason: StringName) -> void: + _current_action_animation = &"" + _reset_animation_speed() + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + if _life_state() == &"Dead": + velocity = Vector2.ZERO + return + state = PRESENTATION_IDLE + velocity = Vector2.ZERO + + +func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void: + if amount <= 0 or _life_state() == &"Dead": + return + # 2026-07-05 定案:霸体下的普攻不打断 Boss,但照样计入受击链—— + # "被连打 4 次后撤逃脱"仍是防贴脸无脑磨血的兜底。 + if _record_consecutive_hit_and_should_escape() and _can_escape_now(): + if _trigger_hit_chain_escape(): + return + if _life_state() == &"Hitstun": + _play_animation(&"boss_take_hit") + + +func _action_duration_seconds(action: Resource) -> float: + if action == null: + return attack_duration + return maxf(0.05, float(action.get("action_beats")) * _beat_time()) + + +func _align_action_animation_speed(action: Resource) -> void: + if animation_player == null or action == null or _current_action_animation.is_empty(): + return + if not animation_player.has_animation(_current_action_animation): + return + var natural_length := animation_player.get_animation(_current_action_animation).length + var action_seconds := _action_duration_seconds(action) + if natural_length <= 0.0 or action_seconds <= 0.0: + return + animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0) + + +func _reset_animation_speed() -> void: + if animation_player != null: + animation_player.speed_scale = 1.0 + + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 + + +func _face_target_if_available() -> void: + var target := _target_or_null() + if target != null: + look_at_target(target) + + +func _target_or_null() -> Node2D: + var tree := get_node_or_null("BossBehaviorTree") + if tree != null: + var tree_target = tree.get("target") + if tree_target is Node2D and is_instance_valid(tree_target): + return tree_target + var container := get_parent() + if container != null: + var sibling := container.get_node_or_null("Player") as Node2D + if sibling != null: + return sibling + return null + + +func _tick_hit_chain(delta: float) -> void: + _escape_cooldown_left = maxf(0.0, _escape_cooldown_left - delta) + if _hit_chain_time_left <= 0.0: + return + _hit_chain_time_left = maxf(0.0, _hit_chain_time_left - delta) + if _hit_chain_time_left <= 0.0: + _hit_chain_count = 0 + + +func _can_escape_now() -> bool: + if stationary: + return false + if _escape_cooldown_left > 0.0: + return false + if state_machine != null and state_machine.has_method("build_context"): + if StringName(str(state_machine.call("build_context").get("ground_state", &"Grounded"))) != &"Grounded": + return false + return true + + +func _record_consecutive_hit_and_should_escape() -> bool: + if _hit_chain_time_left <= 0.0: + _hit_chain_count = 0 + _hit_chain_count += 1 + _hit_chain_time_left = maxf(0.01, hit_chain_window_seconds) + return _hit_chain_count >= maxi(1, escape_after_consecutive_hits) + + +func _trigger_hit_chain_escape() -> bool: + _hit_chain_count = 0 + _hit_chain_time_left = 0.0 + _escape_cooldown_left = maxf(0.0, escape_cooldown_seconds) + _current_action_animation = &"" + attack_time_left = 0.0 + attack_lunge_time_left = 0.0 + velocity = Vector2.ZERO + # 强制撤退压倒击退滑行:清掉残速标志,否则 can_start_enemy_action 的 + # 趔趄门禁会拦下 boss_retreat_dash,受击链逃脱失效。 + if movement_motor != null and movement_motor.has_method("clear_knockback_stray"): + movement_motor.call("clear_knockback_stray") + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + if action_controller != null: + if action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0: + action_controller.call("cancel_current", &"hit_escape") + elif action_controller.has_method("_reset_to_idle"): + action_controller.call("_reset_to_idle") + if state_machine != null: + if state_machine.has_method("set_life_state"): + state_machine.call("set_life_state", &"Alive") + if state_machine.has_method("set_ground_state"): + state_machine.call("set_ground_state", &"Grounded") + if state_machine.has_method("set_action_phase"): + state_machine.call("set_action_phase", &"Neutral") + _face_target_if_available() + var driver := get_node_or_null("EnemyActionDriver") + if driver != null and driver.has_method("start_action"): + driver.call("start_action", &"boss_retreat_dash") + return true + return false + + +func _heading_sign() -> float: + return -1.0 if heading.x < 0.0 else 1.0 + + +func _enter_dead_state() -> void: + if _dead_state_entered: + return + _dead_state_entered = true + _hit_chain_count = 0 + _hit_chain_time_left = 0.0 + _reset_animation_speed() + _current_action_animation = &"" + attack_time_left = 0.0 + attack_lunge_time_left = 0.0 + velocity = Vector2.ZERO + if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0: + action_controller.call("cancel_current", &"death") + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + var damage_emitter := get_node_or_null("DamageEmitter") + if damage_emitter != null and damage_emitter.has_method("clear_hit"): + damage_emitter.call("clear_hit") + var receiver := get_node_or_null("DamageReceiver") as Area2D + if receiver != null: + receiver.monitoring = false + receiver.monitorable = false + var tree := get_node_or_null("BossBehaviorTree") + if tree != null: + tree.set("enabled", false) + + +func _play_death_animation_once() -> void: + if _death_animation_started: + return + _death_animation_started = true + if animation_player != null and animation_player.has_animation(&"boss_die"): + animation_player.play(&"boss_die") + + +func _on_animation_finished(animation_name: StringName) -> void: + if _death_animation_started and animation_name == &"boss_die": + queue_free() + + +func _queue_free_if_death_animation_finished() -> void: + if not _death_animation_started or is_queued_for_deletion(): + return + if animation_player == null: + queue_free() + return + if not animation_player.is_playing(): + queue_free() + + +func _visible_bottom_anchor_for_spec(spec: Dictionary, sprite_offset: Vector2) -> float: + var texture := load(str(spec.get("path", ""))) as Texture2D + if texture == null: + return INF + var frame_count := int(spec.get("frames", 1)) + var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, frame_count), 1, 0) + if bottom == INF: + return INF + return sprite_offset.y + bottom + + +func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float: + if texture == null: + return INF + var image := texture.get_image() + if image == null or image.is_empty(): + return INF + var safe_hframes := maxi(1, hframes) + var safe_vframes := maxi(1, vframes) + var frame_width := image.get_width() / safe_hframes + var frame_height := image.get_height() / safe_vframes + var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1) + var column := safe_frame % safe_hframes + var row := int(safe_frame / safe_hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return float(bottom) + + +func _motion_direction_for_action(action: Resource) -> Vector2: + if _has_action_tag(action, &"retreat"): + return -heading + return heading + + +func _has_action_tag(action: Resource, tag: StringName) -> bool: + if action == null: + return false + var tags: Array = action.get("action_tags") + for item: Variant in tags: + if StringName(str(item)) == tag: + return true + return false + + +func _life_state() -> StringName: + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) + return &"Alive" + + +func _play_animation(animation_name: StringName) -> void: + if animation_player == null or animation_name.is_empty(): + return + if animation_player.has_animation(animation_name) and animation_player.current_animation != String(animation_name): + animation_player.play(animation_name) diff --git a/scenes/enemies/boss.gd.uid b/scenes/enemies/boss.gd.uid new file mode 100644 index 0000000..1f3e360 --- /dev/null +++ b/scenes/enemies/boss.gd.uid @@ -0,0 +1 @@ +uid://ijm7vma4w324 diff --git a/scenes/enemies/boss.tscn b/scenes/enemies/boss.tscn new file mode 100644 index 0000000..fc576b9 --- /dev/null +++ b/scenes/enemies/boss.tscn @@ -0,0 +1,124 @@ +[gd_scene format=3 uid="uid://bnoju71qm1xh7"] + +[ext_resource type="Script" path="res://scenes/enemies/boss.gd" id="1_boss"] +[ext_resource type="Texture2D" uid="uid://b2x7g4eslsmbd" path="res://assets/art/characters/boss/boss_idle.png" id="2_idle"] +[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="3_state_machine"] +[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="4_movement_motor"] +[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="5_combo_window"] +[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="6_action_resolver"] +[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="7_action_executor"] +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="8_action_controller"] +[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="9_motion_executor"] +[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="10_effect_container"] +[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="11_health_component"] +[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="12_damage_receiver"] +[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="13_damage_emitter"] +[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="14_enemy_action_driver"] +[ext_resource type="Script" path="res://scenes/enemies/boss_behavior_tree.gd" id="15_boss_behavior_tree"] +[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="18_frame_collision"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(24, 58) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] +size = Vector2(28, 62) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] +size = Vector2(80, 42) + +[node name="Boss" type="CharacterBody2D" unique_id=11104068 groups=["bosses"]] +collision_layer = 64 +collision_mask = 33 +floor_snap_length = 0.0 +safe_margin = 0.001 +script = ExtResource("1_boss") +speed = 120.0 +attack_lunge_speed = 150.0 + +[node name="Visual" type="Node2D" parent="." unique_id=1328075337] +scale = Vector2(2, 2) + +[node name="CharacterSprite" type="Sprite2D" parent="Visual" unique_id=1061052547] +texture_filter = 2 +texture = ExtResource("2_idle") +centered = false +offset = Vector2(-40, -47) + +[node name="FxOverlay" type="Sprite2D" parent="Visual" unique_id=1946927846] +visible = false +texture_filter = 2 +z_index = 2 +centered = false + +[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1147475571] +position = Vector2(0, -70) +shape = SubResource("RectangleShape2D_body") + +[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1741182662] + +[node name="StateMachine" type="Node" parent="." unique_id=1244013726] +script = ExtResource("3_state_machine") + +[node name="MovementMotor" type="Node" parent="." unique_id=1623369341] +script = ExtResource("4_movement_motor") + +[node name="ComboWindow" type="Node" parent="." unique_id=689578719] +script = ExtResource("5_combo_window") +broadcast_to_bus = false + +[node name="ActionResolver" type="Node" parent="." unique_id=275892237] +script = ExtResource("6_action_resolver") + +[node name="ActionExecutor" type="Node" parent="." unique_id=1902183099] +script = ExtResource("7_action_executor") +damage_emitter_path = NodePath("../DamageEmitter") + +[node name="ActionController" type="Node" parent="." unique_id=163263912] +script = ExtResource("8_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") + +[node name="MotionExecutor" type="Node" parent="." unique_id=1554747471] +script = ExtResource("9_motion_executor") + +[node name="EffectContainer" type="Node" parent="." unique_id=214753755] +script = ExtResource("10_effect_container") + +[node name="HealthComponent" type="Node" parent="." unique_id=363838105] +script = ExtResource("11_health_component") +maximum = 28000 +current = 28000 + +[node name="DamageReceiver" type="Area2D" parent="." unique_id=1741852259] +collision_layer = 4 +collision_mask = 8 +script = ExtResource("12_damage_receiver") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver" unique_id=459196080] +position = Vector2(0, -70) +shape = SubResource("RectangleShape2D_hurtbox") + +[node name="DamageEmitter" type="Area2D" parent="." unique_id=1856891186] +collision_layer = 16 +collision_mask = 2 +monitoring = false +script = ExtResource("13_damage_emitter") +damage = 30 +base_knockback = Vector2(360, 304.056) + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter" unique_id=2047760774] +position = Vector2(0, -70) +shape = SubResource("RectangleShape2D_hitbox") + +[node name="EnemyActionDriver" type="Node" parent="." unique_id=1172727978] +script = ExtResource("14_enemy_action_driver") +action_controller_path = NodePath("../ActionController") + +[node name="BossBehaviorTree" type="Node" parent="." unique_id=1218241436] +script = ExtResource("15_boss_behavior_tree") +target_path = NodePath("../../Player") + +[node name="FrameCollisionDriver" type="Node" parent="."] +script = ExtResource("18_frame_collision") diff --git a/scenes/enemies/boss_behavior_tree.gd b/scenes/enemies/boss_behavior_tree.gd new file mode 100644 index 0000000..b4e1d59 --- /dev/null +++ b/scenes/enemies/boss_behavior_tree.gd @@ -0,0 +1,408 @@ +class_name BossBehaviorTree +extends Node + +@export var enabled := true +@export var target_path: NodePath +@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver") +@export var action_controller_path: NodePath = NodePath("../ActionController") +@export var health_component_path: NodePath = NodePath("../HealthComponent") +## new3 §6.2:近战相位攻击距离 180。 +@export var melee_distance := 180.0 +@export var ranged_distance := 360.0 +@export var dash_distance := 460.0 +## new3 §6.7:远程相位下玩家进入该距离触发后撤。 +@export var retreat_trigger_distance := 240.0 +@export var decision_interval_beats := 2.0 +## new3 §6.4:相位切换后短暂停顿(跳过的决策拍数,1 拍 ≈ 0.46s)。 +@export var phase_switch_pause_beats := 1 +## §18.1 Boss 相位差异:Past 出招慢(重击),Future 出招快(轻击、频率高)。 +## 只缩放决策节拍,不改 HP/防御等核心数值。 +@export var past_decision_interval_scale := 1.0 +@export var future_decision_interval_scale := 0.5 +## §18.2: preparing a special attack raises a conditional TimeAnchorEvent. +@export var special_attack_anchor_lead_beats := 2 +@export var conditional_anchor_actions: Array[StringName] = [&"boss_lunging_stab", &"boss_2way_shoot"] +@export var melee_combo_cooldown_beats := 4.0 +@export var retreat_cooldown_beats := 8.0 +@export var stab_cooldown_beats := 16.0 +@export var two_way_cooldown_beats := 8.0 +@export var dash_cooldown_beats := 4.0 +@export var low_health_ratio := 0.35 +@export var restrict_actions_by_time_phase := true + +@onready var driver: Node = get_node_or_null(enemy_action_driver_path) +@onready var action_controller: Node = get_node_or_null(action_controller_path) +@onready var health_component: Node = get_node_or_null(health_component_path) +@onready var target: Node2D = get_node_or_null(target_path) as Node2D + +var _beat_cursor := 0.0 +var _next_decision_beat := 0 +var _last_seen_beat := 0 +var _combo_index := 0 +var _ranged_index := 0 +var _force_ranged_after_retreat := false +var _last_melee_beat := -999.0 +var _last_retreat_beat := -999.0 +var _last_stab_beat := -999.0 +var _last_two_way_beat := -999.0 +var _last_dash_beat := -999.0 + + +func _ready() -> void: + _connect_beat_bus() + _connect_chart_bus() + _connect_time_phase_bus() + _refresh_target() + _face_target() + + +func _connect_time_phase_bus() -> void: + var bus := _event_bus() + if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + + +## new3 §6.4:相位切换时不立刻攻击——先停顿、调整距离,再进入新相位循环。 +## 以总线上最后看到的拍号为基准(而非全局时钟),测试的合成拍号同样适用。 +func _on_time_phase_changed(_previous: StringName, _current: StringName, _reason: StringName) -> void: + _next_decision_beat = maxi(_next_decision_beat, _last_seen_beat + 1 + maxi(0, phase_switch_pause_beats)) + + +func _exit_tree() -> void: + var bus := _event_bus_or_null() + if bus == null: + return + if bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked): + bus.disconnect("beat_ticked", _on_beat_ticked) + if bus.has_signal("chart_event_upcoming") and bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming): + bus.disconnect("chart_event_upcoming", _on_chart_event_upcoming) + + +func _physics_process(_delta: float) -> void: + if not enabled or _is_dead(): + return + if action_controller != null and int(action_controller.get("phase")) != 0: + return + _refresh_target() + _face_target() + + +func _on_beat_ticked(beat_index: int) -> void: + _last_seen_beat = maxi(_last_seen_beat, beat_index) + if not enabled or _is_dead() or not _owner_combat_enabled(): + return + if action_controller != null and int(action_controller.get("phase")) != 0: + return + if beat_index < _next_decision_beat: + return + _beat_cursor = float(beat_index) + var action_id := _choose_action() + if action_id.is_empty(): + return + if _owner_stationary() and (action_id == &"boss_dash" or action_id == &"boss_retreat_dash"): + action_id = _phase_fallback_action() + if not phase_allows_action(action_id): + action_id = _phase_fallback_action() + if action_id.is_empty() or not phase_allows_action(action_id): + return + _next_decision_beat = beat_index + _decision_beat_step() + if conditional_anchor_actions.has(action_id): + _schedule_conditional_anchor(beat_index) + if driver != null and driver.has_method("start_action"): + driver.call("start_action", action_id) + + +func reset_for_combat_entry() -> void: + enabled = true + _next_decision_beat = 0 + _beat_cursor = 0.0 + _force_ranged_after_retreat = false + _refresh_target() + _face_target() + + +## Conditional TimeAnchorEvent (AnchorV1.0 §18.2): the boss preparing a special +## attack marks an upcoming beat as a time anchor — miss it and the phase flips. +func _schedule_conditional_anchor(beat_index: int) -> void: + var anchor_system := _time_anchor_system_or_null() + if anchor_system == null or not anchor_system.has_method("schedule_time_anchor"): + return + anchor_system.call("schedule_time_anchor", beat_index + maxi(1, special_attack_anchor_lead_beats), &"boss_condition") + + +func _time_anchor_system_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("TimeAnchorSystem") + + +func _choose_action() -> StringName: + if _is_dead(): + return &"" + _refresh_target() + _face_target() + if target == null: + return &"" + var distance := _distance_to_target() + var low_health := _health_ratio() <= low_health_ratio + if restrict_actions_by_time_phase: + var phase := _current_time_phase() + if phase == &"future": + return _choose_future_phase_action(distance) + if phase == &"past": + return _choose_past_phase_action(distance) + if distance <= melee_distance: + return _choose_melee_range_action(low_health) + if distance > dash_distance: + if _cooldown_ready(_last_dash_beat, dash_cooldown_beats): + _last_dash_beat = _beat_cursor + return &"boss_dash" + return _next_ranged_action() + return _choose_mid_range_action(low_health, distance) + + +func _choose_past_phase_action(distance: float) -> StringName: + _force_ranged_after_retreat = false + if distance > dash_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats): + _last_dash_beat = _beat_cursor + return &"boss_dash" + if distance > melee_distance and _cooldown_ready(_last_stab_beat, stab_cooldown_beats): + _last_stab_beat = _beat_cursor + return &"boss_lunging_stab" + if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats): + _last_melee_beat = _beat_cursor + return _next_combo_action() + return &"" + + +func _choose_future_phase_action(distance: float) -> StringName: + _force_ranged_after_retreat = false + # new3 §6.7:远程相位被近身时优先后撤拉开距离(后撤本身不造成伤害)。 + if distance <= retreat_trigger_distance and not _owner_stationary() and _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats): + _last_retreat_beat = _beat_cursor + _force_ranged_after_retreat = true + return &"boss_retreat_dash" + if distance <= melee_distance and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats): + _last_two_way_beat = _beat_cursor + return &"boss_2way_shoot" + return _next_ranged_action() + + +func _choose_melee_range_action(low_health: bool) -> StringName: + _force_ranged_after_retreat = false + if not low_health and _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats): + _last_melee_beat = _beat_cursor + return _next_combo_action() + if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats): + _last_two_way_beat = _beat_cursor + return &"boss_2way_shoot" + if _cooldown_ready(_last_retreat_beat, retreat_cooldown_beats): + _last_retreat_beat = _beat_cursor + _force_ranged_after_retreat = true + return &"boss_retreat_dash" + if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats): + _last_melee_beat = _beat_cursor + return _next_combo_action() + return &"" + + +func _choose_mid_range_action(low_health: bool, distance: float) -> StringName: + if _force_ranged_after_retreat: + _force_ranged_after_retreat = false + return _next_ranged_action() + if not low_health and _cooldown_ready(_last_stab_beat, stab_cooldown_beats): + _last_stab_beat = _beat_cursor + return &"boss_lunging_stab" + if low_health and _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats): + _last_two_way_beat = _beat_cursor + return &"boss_2way_shoot" + if distance > ranged_distance and _cooldown_ready(_last_dash_beat, dash_cooldown_beats): + _last_dash_beat = _beat_cursor + return &"boss_dash" + return _next_ranged_action() + + +func _next_combo_action() -> StringName: + var actions: Array[StringName] = [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3"] + var action_id := actions[_combo_index % actions.size()] + _combo_index += 1 + return action_id + + +func _next_ranged_action() -> StringName: + var actions: Array[StringName] = [&"boss_shoot_1", &"boss_shoot_2"] + var action_id := actions[_ranged_index % actions.size()] + _ranged_index += 1 + return action_id + + +func phase_allows_action(action_id: StringName) -> bool: + if not restrict_actions_by_time_phase or action_id.is_empty(): + return true + var action := ActionResolver.get_action(action_id) + if action == null: + return true + var tags: Array = action.get("action_tags") + var hit_type := StringName(str(action.get("hit_type"))) + var is_melee := hit_type == &"melee" or tags.has(&"melee") + var is_ranged := hit_type == &"projectile" or hit_type == &"ranged" or tags.has(&"ranged") + var phase := _current_time_phase() + if phase == &"past": + return not is_ranged + if phase == &"future": + return not is_melee + return true + + +func _phase_fallback_action() -> StringName: + if not restrict_actions_by_time_phase: + return _next_ranged_action() + if _current_time_phase() == &"future": + if _cooldown_ready(_last_two_way_beat, two_way_cooldown_beats): + _last_two_way_beat = _beat_cursor + return &"boss_2way_shoot" + return _next_ranged_action() + if _cooldown_ready(_last_melee_beat, melee_combo_cooldown_beats): + _last_melee_beat = _beat_cursor + return _next_combo_action() + if _cooldown_ready(_last_stab_beat, stab_cooldown_beats): + _last_stab_beat = _beat_cursor + return &"boss_lunging_stab" + return &"" + + +func _refresh_target() -> void: + if target != null and is_instance_valid(target): + return + target = get_node_or_null(target_path) as Node2D + if target != null: + return + var owner := get_parent() + if owner == null or owner.get_parent() == null: + return + target = owner.get_parent().get_node_or_null("Player") as Node2D + + +func _face_target() -> void: + var owner := get_parent() + if target != null and owner != null and owner.has_method("look_at_target"): + owner.call("look_at_target", target) + + +func _distance_to_target() -> float: + var owner := get_parent() as Node2D + if owner == null or target == null: + return melee_distance + return absf(target.global_position.x - owner.global_position.x) + + +func _health_ratio() -> float: + if health_component == null: + return 1.0 + var maximum := maxf(1.0, float(health_component.get("maximum"))) + return clampf(float(health_component.get("current")) / maximum, 0.0, 1.0) + + +func _owner_combat_enabled() -> bool: + # Boss-room lock: while the door is open the boss neither acts nor raises + # conditional time anchors. + var owner := get_parent() + if owner == null: + return true + var value = owner.get("combat_enabled") + if value is bool: + return value + return true + + +func _owner_stationary() -> bool: + var owner := get_parent() + if owner == null: + return false + var value = owner.get("stationary") + return value is bool and value + + +func _is_dead() -> bool: + if health_component != null and int(health_component.get("current")) <= 0: + return true + var owner := get_parent() + if owner == null: + return false + var state_machine := owner.get_node_or_null("StateMachine") + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead" + return false + + +func _cooldown_ready(last_beat: float, cooldown_beats: float) -> bool: + return _beat_cursor - last_beat >= cooldown_beats + + +func _decision_beat_step() -> int: + # Boss 相位补充说明: the boss has no strong/weak phase — HP, defense and + # its core numbers stay stable across Past/Future. Only move SELECTION + # (melee vs ranged) and decision CADENCE (§18.1 slow/heavy vs fast/light) + # shift with the phase. + var interval_scale := 1.0 + if restrict_actions_by_time_phase: + interval_scale = future_decision_interval_scale if _current_time_phase() == &"future" else past_decision_interval_scale + return maxi(1, int(round(decision_interval_beats * interval_scale))) + + +func _connect_beat_bus() -> void: + var bus := _event_bus() + if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked): + bus.connect("beat_ticked", _on_beat_ticked) + + +func _connect_chart_bus() -> void: + var bus := _event_bus() + if bus != null and bus.has_signal("chart_event_upcoming") and not bus.is_connected("chart_event_upcoming", _on_chart_event_upcoming): + bus.connect("chart_event_upcoming", _on_chart_event_upcoming) + + +func _on_chart_event_upcoming(event: Resource, _time_to_event: float) -> void: + if event == null: + return + var owner := get_parent() + if owner == null or StringName(str(event.get("target_id"))) != StringName(owner.name): + return + var event_beat := int(floor(float(event.call("beat_position")))) + _next_decision_beat = maxi(_next_decision_beat, event_beat + 2) + + +func _event_bus() -> Node: + if not is_inside_tree(): + return null + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") + + +func _current_time_phase() -> StringName: + if not is_inside_tree(): + return &"past" + var children := get_tree().root.get_children() + for index: int in range(children.size() - 1, -1, -1): + var child: Node = children[index] + if child.has_method("set_time_phase") and child.get("current_time_phase") != null: + return StringName(str(child.get("current_time_phase"))) + return &"past" + + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 diff --git a/scenes/enemies/boss_behavior_tree.gd.uid b/scenes/enemies/boss_behavior_tree.gd.uid new file mode 100644 index 0000000..009d59d --- /dev/null +++ b/scenes/enemies/boss_behavior_tree.gd.uid @@ -0,0 +1 @@ +uid://c57cb6ivmypad diff --git a/scenes/enemies/enemy.tscn b/scenes/enemies/enemy.tscn new file mode 100644 index 0000000..7f50b8d --- /dev/null +++ b/scenes/enemies/enemy.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=12 format=3] + +[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="1_state_machine"] +[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="2_combo_window"] +[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="3_action_resolver"] +[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="4_action_executor"] +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="5_action_controller"] +[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="6_effect_container"] +[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="7_damage_emitter"] +[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="8_enemy_action_driver"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(18, 48) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] +size = Vector2(64, 36) + +[node name="Enemy" type="CharacterBody2D"] +collision_layer = 64 +collision_mask = 33 +floor_snap_length = 0.0 +safe_margin = 0.001 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(0, -32) +shape = SubResource("RectangleShape2D_body") + +[node name="StateMachine" type="Node" parent="."] +script = ExtResource("1_state_machine") + +[node name="ComboWindow" type="Node" parent="."] +script = ExtResource("2_combo_window") +broadcast_to_bus = false + +[node name="ActionResolver" type="Node" parent="."] +script = ExtResource("3_action_resolver") + +[node name="ActionExecutor" type="Node" parent="."] +script = ExtResource("4_action_executor") +damage_emitter_path = NodePath("../DamageEmitter") + +[node name="ActionController" type="Node" parent="."] +script = ExtResource("5_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") +beat_anchor_policy = &"ANCHOR_ACTIVE" + +[node name="EffectContainer" type="Node" parent="."] +script = ExtResource("6_effect_container") + +[node name="DamageEmitter" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 2 +monitoring = false +script = ExtResource("7_damage_emitter") +damage = 100 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"] +position = Vector2(0, -32) +shape = SubResource("RectangleShape2D_hitbox") + +[node name="EnemyActionDriver" type="Node" parent="."] +script = ExtResource("8_enemy_action_driver") +action_controller_path = NodePath("../ActionController") diff --git a/scenes/enemies/enemy_action_driver.gd b/scenes/enemies/enemy_action_driver.gd new file mode 100644 index 0000000..fbdd096 --- /dev/null +++ b/scenes/enemies/enemy_action_driver.gd @@ -0,0 +1,39 @@ +class_name EnemyActionDriver +extends Node + +const AIIntentScript := preload("res://scenes/components/ai_intent.gd") + +@export var action_controller_path: NodePath + +@onready var action_controller: Node = get_node_or_null(action_controller_path) + + +func start_action(action_id: StringName) -> void: + if action_controller == null or not action_controller.has_method("submit_ai_intent"): + return + if not _owner_allows_action(action_id): + return + var intent: RefCounted = AIIntentScript.create(action_id, float(Time.get_ticks_msec())) + action_controller.call("submit_ai_intent", intent) + + +func handle_chart_event(event: Resource) -> void: + if event == null: + return + var action_id := StringName(str(event.get("action_id"))) + if action_id.is_empty() and event.get("payload") is Dictionary: + action_id = StringName(str(event.get("payload").get("action_id", &""))) + if action_id.is_empty(): + return + if not _owner_allows_action(action_id): + return + if action_controller != null and int(action_controller.get("phase")) != 0 and action_controller.has_method("cancel_current"): + action_controller.call("cancel_current", &"chart_override") + start_action(action_id) + + +func _owner_allows_action(action_id: StringName) -> bool: + var actor := get_parent() + if actor != null and actor.has_method("can_start_enemy_action"): + return bool(actor.call("can_start_enemy_action", action_id)) + return true diff --git a/scenes/enemies/enemy_action_driver.gd.uid b/scenes/enemies/enemy_action_driver.gd.uid new file mode 100644 index 0000000..7c8a580 --- /dev/null +++ b/scenes/enemies/enemy_action_driver.gd.uid @@ -0,0 +1 @@ +uid://hpgeq4fpf078 diff --git a/scenes/enemies/minion.gd b/scenes/enemies/minion.gd new file mode 100644 index 0000000..c118ea1 --- /dev/null +++ b/scenes/enemies/minion.gd @@ -0,0 +1,665 @@ +class_name Minion +extends Character + +const DEFAULT_HEADING := Vector2.LEFT +const DEFAULT_ANIMATION_FPS := 12.0 +const VISIBLE_FEET_ANCHOR_Y := -1.0 +const PHASE_SWAP_MOVEMENT_HOLD_FRAMES := 2 + +const FORM_SPECS := { + &"jin_zhan_1": { + "native_facing": Vector2.LEFT, + "idle": {"path": "res://assets/art/characters/minions/jin_zhan_1/idle.png", "frames": 6, "fps": 8.0, "loop": true}, + "death": {"path": "res://assets/art/characters/minions/jin_zhan_1/death.png", "frames": 6, "fps": 10.0, "loop": false}, + "dash": {"path": "res://assets/art/characters/minions/jin_zhan_1/dash.png", "frames": 6, "fps": 10.0, "loop": true}, + "attacks": [ + {"name": &"jin_zhan_1_attack", "path": "res://assets/art/characters/minions/jin_zhan_1/attack.png", "frames": 6, "fps": 12.0, "loop": false}, + ], + "actions": [&"minion_jin_zhan_1_attack"], + "sprite_offset": Vector2(-28.0, -48.0), + "projectile_height": 42.0, + }, + &"jin_zhan_3": { + "native_facing": Vector2.RIGHT, + "idle": {"path": "res://assets/art/characters/minions/jin_zhan_3/idle.png", "frames": 7, "fps": 8.0, "loop": true}, + "death": {"path": "res://assets/art/characters/minions/jin_zhan_3/death.png", "frames": 12, "fps": 12.0, "loop": false}, + "dash": {"path": "res://assets/art/characters/minions/jin_zhan_3/dash.png", "frames": 5, "fps": 10.0, "loop": true}, + "attacks": [ + {"name": &"jin_zhan_3_attack_1", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_1.png", "frames": 6, "fps": 12.0, "loop": false}, + {"name": &"jin_zhan_3_attack_2", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_2.png", "frames": 5, "fps": 12.0, "loop": false}, + {"name": &"jin_zhan_3_attack_3", "path": "res://assets/art/characters/minions/jin_zhan_3/attack_3.png", "frames": 6, "fps": 12.0, "loop": false}, + ], + "actions": [&"minion_jin_zhan_3_attack_1", &"minion_jin_zhan_3_attack_2", &"minion_jin_zhan_3_attack_3"], + "sprite_offset": Vector2(-47.0, -62.0), + "projectile_height": 44.0, + }, + &"yuan_cheng_1": { + # 素材原生朝右(眼睛与挥击弧线都在右侧);朝左时再做镜像。 + "native_facing": Vector2.RIGHT, + "idle": {"path": "res://assets/art/characters/minions/yuan_cheng_1/idle.png", "frames": 8, "fps": 8.0, "loop": true}, + "death": {"path": "res://assets/art/characters/minions/yuan_cheng_1/death.png", "frames": 7, "fps": 10.0, "loop": false}, + "dash": {"path": "res://assets/art/characters/minions/yuan_cheng_1/dash.png", "frames": 6, "fps": 10.0, "loop": true}, + "attacks": [ + {"name": &"yuan_cheng_1_attack_1", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_1.png", "frames": 6, "fps": 12.0, "loop": false}, + {"name": &"yuan_cheng_1_attack_2", "path": "res://assets/art/characters/minions/yuan_cheng_1/attack_2.png", "frames": 5, "fps": 12.0, "loop": false}, + ], + "actions": [&"minion_yuan_cheng_1_attack_1", &"minion_yuan_cheng_1_attack_2"], + "sprite_offset": Vector2(-39.0, -48.0), + # 可见脚底在 actor 原点上方约 40px,出手点再高约 46px(甩击释放高度), + # 两个远程形态共用同一条弹道,切相位不跳变。 + "projectile_height": 86.0, + }, + &"yuan_cheng_2": { + "native_facing": Vector2.RIGHT, + "visual_scale": 1.12, + "idle": {"path": "res://assets/art/characters/minions/yuan_cheng_2/idle.png", "frames": 10, "fps": 8.0, "loop": true}, + "death": {"path": "res://assets/art/characters/minions/yuan_cheng_2/death.png", "frames": 18, "fps": 14.0, "loop": false}, + "dash": {"path": "res://assets/art/characters/minions/yuan_cheng_2/dash.png", "frames": 3, "fps": 10.0, "loop": true}, + "attacks": [ + {"name": &"yuan_cheng_2_attack", "path": "res://assets/art/characters/minions/yuan_cheng_2/attack.png", "frames": 13, "fps": 14.0, "loop": false}, + ], + "actions": [&"minion_yuan_cheng_2_attack"], + "sprite_offset": Vector2(-74.0, -97.0), + # 对齐法杖顶端法球中心(脚底上方约 49px),并与小女孩共用同一条弹道。 + "projectile_height": 86.0, + }, +} + +@export var max_health := 650 +@export var current_health := 650 +@export var past_form_id: StringName = &"jin_zhan_3" +@export var future_form_id: StringName = &"jin_zhan_1" +@export var strength_profile: StringName = &"past_strong" +@export var time_phase_profile: Resource +@export var visual_scale := 2.0 +@export var visual_ground_offset := -38.0 +@export var projectile_spawn_forward := 44.0 +@export var minion_lunge_speed := 100.0 +@export var approach_speed := 120.0 + +## Set by MinionBehavior each physics frame: signed walk intent whose magnitude +## scales approach_speed (patrol strolls at ~0.45, a ranged retreat hops at ~1.7). +var approach_direction := 0.0 + +@onready var state_machine: Node = get_node_or_null("StateMachine") +@onready var action_controller: Node = get_node_or_null("ActionController") +@onready var motion_executor: Node = get_node_or_null("MotionExecutor") +@onready var movement_motor: Node = get_node_or_null("MovementMotor") +@onready var health_component: Node = get_node_or_null("HealthComponent") +@onready var damage_receiver: Node = get_node_or_null("DamageReceiver") +@onready var strong_buff_visual: Node = get_node_or_null("Visual/StrongBuffVisual") +@onready var overhead_health_bar: Node2D = get_node_or_null("OverheadHealthBar") as Node2D + +var form_id: StringName = &"" +var _pending_form_id: StringName = &"" +var _current_action_animation: StringName = &"" +var _frame_delta := 0.0 +var _dead_state_entered := false +var _death_animation_started := false +var _phase_swap_hold_frames := 0 + + +func _ready() -> void: + heading = DEFAULT_HEADING + anim_map = { + PRESENTATION_IDLE: "idle", + PRESENTATION_WALK: "idle", + PRESENTATION_JUMP: "idle", + PRESENTATION_LAND: "idle", + PRESENTATION_ATTACK: "idle", + PRESENTATION_AIR_ATTACK: "idle", + } + _install_minion_animations() + _connect_once(animation_player, &"animation_finished", Callable(self, "_on_animation_finished")) + _wire_action_controller() + _wire_damage_receiver() + _connect_time_phase_bus() + _apply_time_phase_profile() + if health_component != null and health_component.has_method("set_values"): + var health_multiplier := _difficulty_health_multiplier() + health_component.call("set_values", int(round(current_health * health_multiplier)), int(round(max_health * health_multiplier))) + apply_time_phase(_current_time_phase()) + state = PRESENTATION_IDLE + _play_idle() + + +func _physics_process(delta: float) -> void: + _frame_delta = delta + super._physics_process(delta) + + +func handle_input() -> void: + pass + + +## 击退滑行(趔趄)期间不得起手新动作(EnemyActionDriver 钩子,boss 同款): +## 否则动作突进会立刻接管 velocity,把击退位移吃掉。 +func can_start_enemy_action(_action_id: StringName) -> bool: + if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")): + return false + return true + + +func handle_air_time(delta: float) -> void: + # 委托 MovementMotor(boss 同款):击退残速的摩擦衰减、假高度轴积分、 + # 落地复位 Grounded 全在里面。旧版只在 JUMP 态积分,导致垂直击退后 + # ground_state 永久卡 Airborne,小怪从此不能出招(allowed_ground_states)。 + if movement_motor != null and movement_motor.has_method("handle_air_time"): + movement_motor.call("handle_air_time", delta) + elif state == PRESENTATION_JUMP: + super.handle_air_time(delta) + + +func handle_movement() -> void: + if motion_executor != null and bool(motion_executor.get("active")): + velocity = motion_executor.call("tick", _frame_delta) + return + if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK: + return + if _phase_swap_hold_frames > 0: + _phase_swap_hold_frames -= 1 + approach_direction = 0.0 + velocity.x = 0.0 + state = PRESENTATION_IDLE + return + # 击退残速期:滑行交给 MovementMotor 的摩擦衰减,AI 不接管 velocity.x + # (否则击退初速活不过一帧,小怪击退位移恒为 0)。必须放在相位切换 + # hold 之后——换相位定身优先,hold 清零速度后 stray 标志随衰减自清。 + if movement_motor != null and movement_motor.has_method("has_knockback_stray") and bool(movement_motor.call("has_knockback_stray")): + state = PRESENTATION_IDLE + return + if absf(approach_direction) > 0.05 and _life_state() != &"Dead": + # 上限 4.0 给受击脱离的跳跃速度(escape_speed_scale 3.2)留出空间。 + velocity.x = clampf(approach_direction, -4.0, 4.0) * approach_speed + state = PRESENTATION_WALK + return + velocity.x = 0.0 + state = PRESENTATION_IDLE + + +func handle_animations() -> void: + var life_state := _life_state() + if life_state == &"Dead": + _enter_dead_state() + _play_death_animation_once() + _queue_free_if_death_animation_finished() + return + _dead_state_entered = false + _death_animation_started = false + if (state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK) and not _current_action_animation.is_empty(): + _play_animation(_current_action_animation) + return + if state == PRESENTATION_WALK: + _play_animation(_dash_animation_name(form_id)) + return + _play_idle() + + +func set_heading() -> void: + pass + + +func set_sprite_height_position() -> void: + if visual != null: + visual.position = Vector2(0.0, visual_ground_offset) + Vector2.UP * height + _refresh_overhead_health_bar_position() + + +func flip_sprites() -> void: + _apply_form_visual_scale() + + +func apply_time_phase(time_phase: StringName) -> void: + _refresh_strong_buff_visual(time_phase) + var next_form := future_form_id if time_phase == &"future" else past_form_id + if next_form == form_id: + _pending_form_id = &"" + if state != PRESENTATION_ATTACK and state != PRESENTATION_AIR_ATTACK and _life_state() != &"Dead": + _apply_sprite_offset() + _play_idle(true) + return + if state == PRESENTATION_ATTACK or state == PRESENTATION_AIR_ATTACK: + _pending_form_id = next_form + return + _hold_phase_swap_position() + form_id = next_form + _pending_form_id = &"" + _apply_sprite_offset() + if _life_state() != &"Dead": + _play_idle(true) + + +func current_action_ids() -> Array[StringName]: + var result: Array[StringName] = [] + var actions: Array = _form_spec().get("actions", []) + for action_id: Variant in actions: + result.append(StringName(str(action_id))) + return result + + +func current_attack_interval_beats() -> int: + return 2 if is_strong_in_current_time_phase() else 4 + + +func is_strong_in_current_time_phase() -> bool: + return _is_strong_in_time_phase(_current_time_phase()) + + +func _is_strong_in_time_phase(time_phase: StringName) -> bool: + return (strength_profile == &"past_strong" and time_phase == &"past") or (strength_profile == &"future_strong" and time_phase == &"future") + + +func look_at_target(target: Node2D) -> void: + if target == null: + return + heading = Vector2.LEFT if target.global_position.x < global_position.x else Vector2.RIGHT + + +func projectile_spawn_position(_action: Resource) -> Vector2: + return global_position + Vector2(_heading_sign() * projectile_spawn_forward, -_projectile_height()) + + +func projectile_direction(_action: Resource) -> Vector2: + return heading.normalized() if heading != Vector2.ZERO else DEFAULT_HEADING + + +func projectile_requests_for_action(action: Resource) -> Array[Dictionary]: + return [{ + "spawn_position": projectile_spawn_position(action), + "direction": projectile_direction(action), + }] + + +func _install_minion_animations() -> void: + if animation_player == null: + return + var library: AnimationLibrary + if animation_player.has_animation_library(""): + library = animation_player.get_animation_library("") + else: + library = AnimationLibrary.new() + animation_player.add_animation_library("", library) + for next_form: StringName in FORM_SPECS.keys(): + var spec: Dictionary = FORM_SPECS[next_form] + var sprite_offset: Vector2 = spec.get("sprite_offset", Vector2(-32.0, -64.0)) + _add_sheet_animation(library, _idle_animation_name(next_form), spec.get("idle", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset) + _add_sheet_animation(library, _death_animation_name(next_form), spec.get("death", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset) + _add_sheet_animation(library, _dash_animation_name(next_form), spec.get("dash", {}), VISIBLE_FEET_ANCHOR_Y, sprite_offset) + for attack: Variant in spec.get("attacks", []): + if attack is Dictionary: + _add_sheet_animation(library, StringName(str((attack as Dictionary).get("name", &""))), attack, VISIBLE_FEET_ANCHOR_Y, sprite_offset) + + +func _add_sheet_animation(library: AnimationLibrary, animation_name: StringName, spec: Dictionary, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> void: + if animation_name.is_empty() or spec.is_empty() or library.has_animation(animation_name): + return + var texture := load(str(spec.get("path", ""))) as Texture2D + if texture == null: + return + var frame_count: int = int(spec.get("frames", 1)) + var hframes: int = int(spec.get("hframes", frame_count)) + library.add_animation(animation_name, _make_sheet_animation(texture, frame_count, hframes, float(spec.get("fps", DEFAULT_ANIMATION_FPS)), bool(spec.get("loop", false)), visible_bottom_anchor, sprite_offset)) + + +func _make_sheet_animation(texture: Texture2D, frame_count: int, hframes: int, fps: float, loop: bool, visible_bottom_anchor := INF, sprite_offset := Vector2.ZERO) -> Animation: + var safe_frame_count := maxi(1, frame_count) + var safe_hframes := maxi(safe_frame_count, hframes) + var safe_fps := maxf(1.0, fps) + var frame_time := 1.0 / safe_fps + var animation := Animation.new() + animation.length = maxf(frame_time, float(safe_frame_count) * frame_time) + animation.step = frame_time + animation.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE + var texture_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(texture_track, NodePath("Visual/CharacterSprite:texture")) + animation.value_track_set_update_mode(texture_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(texture_track, 0.0, texture) + var hframes_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(hframes_track, NodePath("Visual/CharacterSprite:hframes")) + animation.value_track_set_update_mode(hframes_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(hframes_track, 0.0, safe_hframes) + var vframes_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(vframes_track, NodePath("Visual/CharacterSprite:vframes")) + animation.value_track_set_update_mode(vframes_track, Animation.UPDATE_DISCRETE) + animation.track_insert_key(vframes_track, 0.0, 1) + var frame_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(frame_track, NodePath("Visual/CharacterSprite:frame")) + animation.value_track_set_update_mode(frame_track, Animation.UPDATE_DISCRETE) + for frame_index: int in range(safe_frame_count): + animation.track_insert_key(frame_track, float(frame_index) * frame_time, frame_index) + if visible_bottom_anchor < INF: + var offset_track := animation.add_track(Animation.TYPE_VALUE) + animation.track_set_path(offset_track, NodePath("Visual/CharacterSprite:offset")) + animation.value_track_set_update_mode(offset_track, Animation.UPDATE_DISCRETE) + for frame_index: int in range(safe_frame_count): + var offset := sprite_offset + var bottom := _visible_frame_bottom_in_texture(texture, safe_hframes, 1, frame_index) + if bottom != INF: + offset.y = visible_bottom_anchor - bottom + animation.track_insert_key(offset_track, float(frame_index) * frame_time, offset) + return animation + + +func _wire_action_controller() -> void: + if action_controller == null: + return + _connect_once(action_controller, &"action_started", Callable(self, "_on_action_started")) + _connect_once(action_controller, &"action_active_started", Callable(self, "_on_action_active_started")) + _connect_once(action_controller, &"action_finished", Callable(self, "_on_action_finished")) + _connect_once(action_controller, &"action_cancelled", Callable(self, "_on_action_cancelled")) + + +func _wire_damage_receiver() -> void: + if damage_receiver != null: + _connect_once(damage_receiver, &"damage_received", Callable(self, "_on_damage_received")) + + +func _connect_once(source: Object, signal_name: StringName, callback: Callable) -> void: + if not source.is_connected(signal_name, callback): + source.connect(signal_name, callback) + + +func _connect_time_phase_bus() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + + +func _apply_time_phase_profile() -> void: + var adapter := get_node_or_null("TimePhaseAdapter") + if adapter == null: + return + adapter.set("profile", time_phase_profile) + if adapter.has_method("apply_time_phase"): + adapter.call("apply_time_phase", _current_time_phase()) + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + apply_time_phase(current) + + +func _on_action_started(action: Resource, _intent) -> void: + if _life_state() == &"Dead": + return + _face_target_if_available() + _current_action_animation = StringName(str(action.get("animation"))) + state = PRESENTATION_ATTACK + attack_time_left = _action_duration_seconds(action) + 0.05 + attack_lunge_time_left = attack_time_left + _align_action_animation_speed(action) + if animation_player != null: + animation_player.stop() + _play_animation(_current_action_animation) + + +func _on_action_active_started(action: Resource, _intent) -> void: + if _life_state() == &"Dead" or motion_executor == null: + return + if absf(float(action.get("move_mult_x"))) <= 0.0 and absf(float(action.get("move_mult_y"))) <= 0.0: + return + var speed_scale := maxf(1.0, absf(float(action.get("move_mult_x")))) + motion_executor.call("execute", action, heading, _beat_time(), minion_lunge_speed * speed_scale) + + +func _on_action_finished(_action: Resource) -> void: + _finish_action_presentation() + + +func _on_action_cancelled(_action: Resource, _reason: StringName) -> void: + _finish_action_presentation() + + +func _finish_action_presentation() -> void: + _current_action_animation = &"" + _reset_animation_speed() + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + if _life_state() == &"Dead": + velocity = Vector2.ZERO + return + state = PRESENTATION_IDLE + velocity = Vector2.ZERO + if not _pending_form_id.is_empty(): + apply_time_phase(_current_time_phase()) + _play_idle() + + +func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void: + if _life_state() == &"Dead": + return + _play_idle() + + +func _enter_dead_state() -> void: + if _dead_state_entered: + return + _dead_state_entered = true + _current_action_animation = &"" + attack_time_left = 0.0 + attack_lunge_time_left = 0.0 + velocity = Vector2.ZERO + _reset_animation_speed() + if action_controller != null and action_controller.has_method("cancel_current") and int(action_controller.get("phase")) != 0: + action_controller.call("cancel_current", &"death") + if motion_executor != null and motion_executor.has_method("cancel"): + motion_executor.call("cancel") + var damage_emitter := get_node_or_null("DamageEmitter") + if damage_emitter != null and damage_emitter.has_method("clear_hit"): + damage_emitter.call("clear_hit") + var receiver := get_node_or_null("DamageReceiver") as Area2D + if receiver != null: + receiver.monitoring = false + receiver.monitorable = false + var behavior := get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + _refresh_strong_buff_visual(_current_time_phase()) + + +func _play_death_animation_once() -> void: + if _death_animation_started: + return + _death_animation_started = true + _play_animation(_death_animation_name(form_id)) + + +func _on_animation_finished(animation_name: StringName) -> void: + if _death_animation_started and animation_name == _death_animation_name(form_id): + queue_free() + + +func _queue_free_if_death_animation_finished() -> void: + if not _death_animation_started or is_queued_for_deletion(): + return + if animation_player == null: + queue_free() + return + if not animation_player.is_playing(): + queue_free() + + +func _play_idle(force_refresh := false) -> void: + _play_animation(_idle_animation_name(form_id), force_refresh) + + +func _play_animation(animation_name: StringName, force_refresh := false) -> void: + if animation_player == null or animation_name.is_empty(): + return + if not animation_player.has_animation(animation_name): + return + if force_refresh or animation_player.current_animation != String(animation_name): + animation_player.play(animation_name) + if force_refresh: + animation_player.seek(0.0, true) + + +func _align_action_animation_speed(action: Resource) -> void: + if animation_player == null or action == null or _current_action_animation.is_empty(): + return + if not animation_player.has_animation(_current_action_animation): + return + var natural_length := animation_player.get_animation(_current_action_animation).length + var action_seconds := _action_duration_seconds(action) + if natural_length <= 0.0 or action_seconds <= 0.0: + return + animation_player.speed_scale = clampf(natural_length / action_seconds, 0.25, 3.0) + + +func _reset_animation_speed() -> void: + if animation_player != null: + animation_player.speed_scale = 1.0 + + +func _action_duration_seconds(action: Resource) -> float: + if action == null: + return attack_duration + return maxf(0.05, float(action.get("action_beats")) * _beat_time()) + + +func _beat_time() -> float: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return float(rhythm.get("beat_time")) + return 0.5 + + +func _face_target_if_available() -> void: + var target := _target_or_null() + if target != null: + look_at_target(target) + + +func _target_or_null() -> Node2D: + var behavior := get_node_or_null("MinionBehavior") + if behavior != null: + var behavior_target = behavior.get("target") + if behavior_target is Node2D and is_instance_valid(behavior_target): + return behavior_target + var container := get_parent() + if container != null: + var sibling := container.get_node_or_null("Player") as Node2D + if sibling != null: + return sibling + return null + + +func _form_spec() -> Dictionary: + return FORM_SPECS.get(form_id, FORM_SPECS.get(past_form_id, {})) + + +func _form_visual_scale() -> float: + return float(_form_spec().get("visual_scale", visual_scale)) + + +func _native_facing() -> Vector2: + return _form_spec().get("native_facing", Vector2.RIGHT) + + +func _apply_sprite_offset() -> void: + if character_sprite != null: + var sprite_offset: Vector2 = _form_spec().get("sprite_offset", Vector2(-32.0, -64.0)) + var idle_spec: Dictionary = _form_spec().get("idle", {}) + var texture := load(str(idle_spec.get("path", ""))) as Texture2D + var hframes := int(idle_spec.get("hframes", idle_spec.get("frames", 1))) + var bottom := _visible_frame_bottom_in_texture(texture, maxi(1, hframes), 1, 0) + sprite_offset.y = VISIBLE_FEET_ANCHOR_Y - bottom if bottom < INF else sprite_offset.y + character_sprite.offset = sprite_offset + _apply_form_visual_scale() + _refresh_overhead_health_bar_position() + + +func _apply_form_visual_scale() -> void: + if visual == null: + return + var scale_value := _form_visual_scale() + visual.scale.x = scale_value if heading == _native_facing() else -scale_value + visual.scale.y = scale_value + + +func _hold_phase_swap_position() -> void: + _phase_swap_hold_frames = PHASE_SWAP_MOVEMENT_HOLD_FRAMES + approach_direction = 0.0 + velocity.x = 0.0 + + +func _refresh_overhead_health_bar_position() -> void: + if overhead_health_bar == null or visual == null or character_sprite == null: + return + overhead_health_bar.position = Vector2(0.0, visual.position.y + character_sprite.offset.y * absf(visual.scale.y) - 10.0) + + +func _visible_frame_bottom_in_texture(texture: Texture2D, hframes: int, vframes: int, frame_index: int) -> float: + if texture == null: + return INF + var image := texture.get_image() + if image == null or image.is_empty(): + return INF + var safe_hframes := maxi(1, hframes) + var safe_vframes := maxi(1, vframes) + var frame_width := image.get_width() / safe_hframes + var frame_height := image.get_height() / safe_vframes + var safe_frame := clampi(frame_index, 0, safe_hframes * safe_vframes - 1) + var column := safe_frame % safe_hframes + var row := int(safe_frame / safe_hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return float(bottom) + + +func _projectile_height() -> float: + return float(_form_spec().get("projectile_height", 42.0)) + + +func _idle_animation_name(next_form: StringName) -> StringName: + return StringName("%s_idle" % str(next_form)) + + +func _death_animation_name(next_form: StringName) -> StringName: + return StringName("%s_death" % str(next_form)) + + +func _dash_animation_name(next_form: StringName) -> StringName: + return StringName("%s_dash" % str(next_form)) + + +func _life_state() -> StringName: + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) + return &"Alive" + + +func _heading_sign() -> float: + return -1.0 if heading.x < 0.0 else 1.0 + + +func _current_time_phase() -> StringName: + var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _difficulty_health_multiplier() -> float: + var settings := get_tree().root.get_node_or_null("GameSettings") if is_inside_tree() else null + if settings != null and settings.has_method("minion_health_multiplier"): + return maxf(0.01, float(settings.call("minion_health_multiplier"))) + return 1.0 + + +func _refresh_strong_buff_visual(time_phase: StringName = &"") -> void: + if strong_buff_visual == null or not strong_buff_visual.has_method("set_active"): + return + var phase := _current_time_phase() if time_phase.is_empty() else time_phase + strong_buff_visual.call("set_active", _is_strong_in_time_phase(phase) and _life_state() != &"Dead") + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/enemies/minion.gd.uid b/scenes/enemies/minion.gd.uid new file mode 100644 index 0000000..61819e0 --- /dev/null +++ b/scenes/enemies/minion.gd.uid @@ -0,0 +1 @@ +uid://dqbxyv604lcgx diff --git a/scenes/enemies/minion.tscn b/scenes/enemies/minion.tscn new file mode 100644 index 0000000..0f33927 --- /dev/null +++ b/scenes/enemies/minion.tscn @@ -0,0 +1,136 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/enemies/minion.gd" id="1_minion"] +[ext_resource type="Script" path="res://scenes/components/state_machine.gd" id="2_state_machine"] +[ext_resource type="Script" path="res://scenes/components/movement_motor.gd" id="3_movement_motor"] +[ext_resource type="Script" path="res://scenes/components/combo_window.gd" id="4_combo_window"] +[ext_resource type="Script" path="res://scenes/combat/action_resolver.gd" id="5_action_resolver"] +[ext_resource type="Script" path="res://scenes/components/action_executor.gd" id="6_action_executor"] +[ext_resource type="Script" path="res://scenes/components/action_controller.gd" id="7_action_controller"] +[ext_resource type="Script" path="res://scenes/components/motion_executor.gd" id="8_motion_executor"] +[ext_resource type="Script" path="res://scenes/components/effect_container.gd" id="9_effect_container"] +[ext_resource type="Script" path="res://scenes/components/health_component.gd" id="10_health_component"] +[ext_resource type="Script" path="res://scenes/components/damage_receiver.gd" id="11_damage_receiver"] +[ext_resource type="Script" path="res://scenes/components/damage_emitter.gd" id="12_damage_emitter"] +[ext_resource type="Script" path="res://scenes/enemies/enemy_action_driver.gd" id="13_enemy_action_driver"] +[ext_resource type="Script" path="res://scenes/enemies/minion_behavior.gd" id="14_minion_behavior"] +[ext_resource type="Script" path="res://scenes/components/time_phase_adapter.gd" id="15_time_phase_adapter"] +[ext_resource type="Script" path="res://scenes/components/frame_collision_driver.gd" id="16_frame_collision"] +[ext_resource type="Script" path="res://scenes/components/strong_buff_visual.gd" id="17_strong_buff_visual"] +[ext_resource type="Script" path="res://scenes/components/overhead_health_bar.gd" id="18_overhead_health_bar"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(20, 46) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurtbox"] +size = Vector2(24, 50) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hitbox"] +size = Vector2(54, 34) + +[node name="Minion" type="CharacterBody2D" groups=["enemies"]] +collision_layer = 64 +collision_mask = 33 +floor_snap_length = 0.0 +safe_margin = 0.001 +script = ExtResource("1_minion") +speed = 90.0 + +[node name="Visual" type="Node2D" parent="."] +scale = Vector2(2, 2) + +[node name="CharacterSprite" type="Sprite2D" parent="Visual"] +texture_filter = 2 +centered = false +offset = Vector2(-48, -84) + +[node name="FxOverlay" type="Sprite2D" parent="Visual"] +visible = false +texture_filter = 2 +z_index = 2 +centered = false + +[node name="StrongBuffVisual" type="Node2D" parent="Visual"] +z_index = 1 +script = ExtResource("17_strong_buff_visual") + +[node name="OverheadHealthBar" type="Node2D" parent="."] +position = Vector2(0, -118) +z_index = 30 +script = ExtResource("18_overhead_health_bar") +bar_size = Vector2(58, 7) + +[node name="CollisionShape2D" type="CollisionShape2D" parent="."] +position = Vector2(0, -54) +shape = SubResource("RectangleShape2D_body") + +[node name="AnimationPlayer" type="AnimationPlayer" parent="."] + +[node name="StateMachine" type="Node" parent="."] +script = ExtResource("2_state_machine") + +[node name="MovementMotor" type="Node" parent="."] +script = ExtResource("3_movement_motor") + +[node name="ComboWindow" type="Node" parent="."] +script = ExtResource("4_combo_window") +broadcast_to_bus = false + +[node name="ActionResolver" type="Node" parent="."] +script = ExtResource("5_action_resolver") + +[node name="ActionExecutor" type="Node" parent="."] +script = ExtResource("6_action_executor") +damage_emitter_path = NodePath("../DamageEmitter") + +[node name="ActionController" type="Node" parent="."] +script = ExtResource("7_action_controller") +combo_window_path = NodePath("../ComboWindow") +action_resolver_path = NodePath("../ActionResolver") +action_executor_path = NodePath("../ActionExecutor") +state_machine_path = NodePath("../StateMachine") + +[node name="MotionExecutor" type="Node" parent="."] +script = ExtResource("8_motion_executor") + +[node name="EffectContainer" type="Node" parent="."] +script = ExtResource("9_effect_container") + +[node name="HealthComponent" type="Node" parent="."] +script = ExtResource("10_health_component") +maximum = 650 +current = 650 + +[node name="DamageReceiver" type="Area2D" parent="."] +collision_layer = 4 +collision_mask = 8 +script = ExtResource("11_damage_receiver") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageReceiver"] +position = Vector2(0, -54) +shape = SubResource("RectangleShape2D_hurtbox") + +[node name="DamageEmitter" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 2 +monitoring = false +script = ExtResource("12_damage_emitter") +damage = 25 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="DamageEmitter"] +position = Vector2(0, -54) +shape = SubResource("RectangleShape2D_hitbox") + +[node name="EnemyActionDriver" type="Node" parent="."] +script = ExtResource("13_enemy_action_driver") +action_controller_path = NodePath("../ActionController") + +[node name="MinionBehavior" type="Node" parent="."] +script = ExtResource("14_minion_behavior") +target_path = NodePath("../../Player") + +[node name="TimePhaseAdapter" type="Node" parent="."] +script = ExtResource("15_time_phase_adapter") + +[node name="FrameCollisionDriver" type="Node" parent="."] +script = ExtResource("16_frame_collision") diff --git a/scenes/enemies/minion_behavior.gd b/scenes/enemies/minion_behavior.gd new file mode 100644 index 0000000..18f4541 --- /dev/null +++ b/scenes/enemies/minion_behavior.gd @@ -0,0 +1,431 @@ +class_name MinionBehavior +extends Node + +## 小怪行为状态机(关卡设计指南 §7.4 / AnchorV1.0 §17.5)。 +## 近战怪:入场 → 巡逻 → (警戒后在下一个节拍点)追击 → 攻击。 +## 远程怪:入场 → 移动到站位固定 → 距离判断攻击;被近身或连续受击时后撤。 +## 决策与攻击都对齐节拍;移动逐帧执行。 + +const ROLE_MELEE := &"melee" +const ROLE_RANGED := &"ranged" + +const STATE_ENTRY := &"Entry" +const STATE_PATROL := &"Patrol" +const STATE_CHASE := &"Chase" +const STATE_HOLD := &"Hold" +const STATE_RETREAT := &"Retreat" + +@export var enabled := true +## 近战怪会巡逻并追击;远程怪固定站位、会后撤。 +@export var role: StringName = ROLE_MELEE +@export var target_path: NodePath +@export var enemy_action_driver_path: NodePath = NodePath("../EnemyActionDriver") +@export var action_controller_path: NodePath = NodePath("../ActionController") +@export var health_component_path: NodePath = NodePath("../HealthComponent") +## Attacks only start inside this horizontal distance; further away the minion +## repositions instead (near for melee, the whole firing lane for ranged). +## new3 §4.2:近战攻击距离 120。 +@export var attack_range := 120.0 +## Stop approaching a little inside the attack range so the minion does not +## rub against the player collider. new3 §4.2:追踪停止距离 120。 +@export var approach_stop_range := 120.0 +## 警戒范围:玩家进入后近战怪转入追击(下一个节拍点生效)。new3 §4.2:360。 +@export var alert_range := 360.0 +## 近战怪巡逻半径与速度(占 approach_speed 的比例)。 +@export var patrol_radius := 110.0 +@export var patrol_speed_scale := 0.45 +## 远程怪后撤参数(new3 §5.4):玩家进入危险距离时向后退开。 +@export var retreat_distance := 220.0 +@export var retreat_speed_scale := 1.7 +@export var too_close_range := 220.0 +@export var too_close_seconds := 0.6 +## 受击脱离(2026-07-05 定案):连续受击 4 次触发短暂闪烁无敌 + 跳跃形式脱离。 +@export var retreat_after_hits := 4 +@export var hit_chain_window_seconds := 2.0 +@export var retreat_cooldown_seconds := 3.0 +@export var escape_invuln_seconds := 0.4 +@export var escape_speed_scale := 3.2 +## 后撤/脱离结束后的停顿(按节拍对齐,new3 §5.4 推荐 0.4s ≈ 1 拍)。 +@export var post_retreat_pause_beats := 1 +## 可活动区间:巡逻 / 后撤永远不出这段地面。 +@export var arena_min_x := 1120.0 +@export var arena_max_x := 2890.0 +## 增援入场目标(NAN = 原地即战斗区域,直接进入巡逻/站位)。 +@export var entry_target_x := NAN + +@onready var driver: Node = get_node_or_null(enemy_action_driver_path) +@onready var action_controller: Node = get_node_or_null(action_controller_path) +@onready var health_component: Node = get_node_or_null(health_component_path) +@onready var target: Node2D = get_node_or_null(target_path) as Node2D + +var state: StringName = STATE_ENTRY +var _next_decision_beat := 0 +var _action_index := 0 +var _patrol_anchor_x := 0.0 +var _patrol_direction := 1.0 +var _chase_pending := false +var _retreat_target_x := 0.0 +var _too_close_time := 0.0 +var _recent_hits := 0 +var _hit_window_left := 0.0 +var _retreat_cooldown_left := 0.0 +var _escaping := false +var _invuln_left := 0.0 + + +func _ready() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("beat_ticked") and not bus.is_connected("beat_ticked", _on_beat_ticked): + bus.connect("beat_ticked", _on_beat_ticked) + var receiver := get_node_or_null("../DamageReceiver") + if receiver != null and receiver.has_signal("damage_received") and not receiver.is_connected("damage_received", _on_damage_received): + receiver.connect("damage_received", _on_damage_received) + _patrol_anchor_x = _owner_x() + _refresh_target() + if _has_entry_move(): + state = STATE_ENTRY + else: + state = STATE_PATROL if role == ROLE_MELEE else STATE_HOLD + _face_target() + + +func _exit_tree() -> void: + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("beat_ticked") and bus.is_connected("beat_ticked", _on_beat_ticked): + bus.disconnect("beat_ticked", _on_beat_ticked) + + +func _physics_process(delta: float) -> void: + _tick_escape_invulnerability(delta) + if not enabled or _is_dead(): + _set_approach_direction(0.0) + return + if action_controller != null and int(action_controller.get("phase")) != 0: + # 攻击动作进行中:位移交给动作本身。 + _set_approach_direction(0.0) + return + _refresh_target() + _tick_retreat_triggers(delta) + _update_state() + _update_movement() + + +## ------------------------------------------------------- state transitions + + +func _update_state() -> void: + var distance := _distance_to_target() + match state: + STATE_ENTRY: + # 近战怪的入场可以被警戒打断(追击等下一拍);远程怪坚持走到站位。 + # 每帧重算:玩家在下一拍前离开警戒范围就不再追。 + if role == ROLE_MELEE: + _chase_pending = distance <= alert_range + if _entry_reached(): + _arrive_at_battle_area() + STATE_PATROL: + _chase_pending = distance <= alert_range + STATE_CHASE: + if distance > alert_range * 1.25: + # 玩家甩开警戒范围:回到当前位置附近小范围巡逻。 + state = STATE_PATROL + _patrol_anchor_x = _owner_x() + _chase_pending = false + STATE_HOLD: + if _should_escape(): + _begin_escape() + elif _should_retreat(distance): + _begin_retreat() + STATE_RETREAT: + if absf(_owner_x() - _retreat_target_x) <= 10.0: + _finish_retreat() + + +func _arrive_at_battle_area() -> void: + entry_target_x = NAN + if role == ROLE_MELEE: + state = STATE_PATROL + _patrol_anchor_x = _owner_x() + else: + # 到达站位后固定下来,开始距离判断。 + state = STATE_HOLD + + +func _begin_retreat(escape := false) -> void: + var own_x := _owner_x() + var away := 1.0 + if target != null and is_instance_valid(target): + away = 1.0 if own_x >= target.global_position.x else -1.0 + var candidate := clampf(own_x + away * retreat_distance, arena_min_x, arena_max_x) + if absf(candidate - own_x) < 60.0: + # 贴墙没有退路:跳向另一侧(穿过玩家身位)。 + candidate = clampf(own_x - away * retreat_distance, arena_min_x, arena_max_x) + _retreat_target_x = candidate + _too_close_time = 0.0 + _recent_hits = 0 + _retreat_cooldown_left = retreat_cooldown_seconds + _escaping = escape + if escape: + _start_escape_invulnerability() + state = STATE_RETREAT + + +## 受击脱离(2026-07-05 定案):短暂闪烁无敌 + 跳跃形式快速脱离。 +func _begin_escape() -> void: + _begin_retreat(true) + + +func _finish_retreat() -> void: + state = STATE_HOLD + _escaping = false + # 后撤/脱离后的短暂停顿:按节拍推迟下一次攻击决策(new3 §5.4)。 + _next_decision_beat = maxi(_next_decision_beat, _current_beat_index() + maxi(0, post_retreat_pause_beats) + 1) + + +func _should_retreat(distance: float) -> bool: + if role != ROLE_RANGED or _retreat_cooldown_left > 0.0: + return false + return _too_close_time >= too_close_seconds + + +func _should_escape() -> bool: + if role != ROLE_RANGED or _retreat_cooldown_left > 0.0: + return false + return _recent_hits >= maxi(1, retreat_after_hits) + + +func _start_escape_invulnerability() -> void: + _invuln_left = maxf(0.0, escape_invuln_seconds) + _set_damage_receiver_enabled(false) + + +func _tick_escape_invulnerability(delta: float) -> void: + if _invuln_left <= 0.0: + return + _invuln_left = maxf(0.0, _invuln_left - delta) + var visual := _owner_visual() + if _invuln_left <= 0.0: + if visual != null: + visual.modulate.a = 1.0 + if not _is_dead(): + _set_damage_receiver_enabled(true) + return + if visual != null: + # 闪烁表现:无敌期间快速明暗交替。 + visual.modulate.a = 0.35 if fmod(_invuln_left, 0.16) > 0.08 else 1.0 + + +## FrameCollisionDriver 每物理帧都会重申受击矩阵,所以无敌必须通过它的 +## damage_receiver_enabled 开关实现,而不是直接改 Area2D 的 monitoring。 +func _set_damage_receiver_enabled(enabled: bool) -> void: + var collision_driver := get_node_or_null("../FrameCollisionDriver") + if collision_driver != null and "damage_receiver_enabled" in collision_driver: + collision_driver.set("damage_receiver_enabled", enabled) + var receiver := get_node_or_null("../DamageReceiver") as Area2D + if receiver != null: + receiver.set_deferred("monitoring", enabled) + receiver.set_deferred("monitorable", enabled) + + +func _owner_visual() -> Node2D: + var owner := get_parent() + if owner == null: + return null + return owner.get_node_or_null("Visual") as Node2D + + +func _current_beat_index() -> int: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null + if rhythm != null: + return int(rhythm.get("beat_index")) + return 0 + + +func _tick_retreat_triggers(delta: float) -> void: + _retreat_cooldown_left = maxf(0.0, _retreat_cooldown_left - delta) + if _hit_window_left > 0.0: + _hit_window_left = maxf(0.0, _hit_window_left - delta) + if _hit_window_left <= 0.0: + _recent_hits = 0 + if role == ROLE_RANGED and state == STATE_HOLD and _distance_to_target() <= too_close_range: + _too_close_time += delta + else: + _too_close_time = maxf(0.0, _too_close_time - delta * 2.0) + + +func _on_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void: + if role != ROLE_RANGED: + return + if _hit_window_left <= 0.0: + _recent_hits = 0 + _recent_hits += 1 + _hit_window_left = hit_chain_window_seconds + + +## --------------------------------------------------------------- movement + + +func _update_movement() -> void: + match state: + STATE_ENTRY: + _move_toward_x(entry_target_x, 1.0) + STATE_PATROL: + _patrol_step() + STATE_CHASE: + _chase_step() + STATE_HOLD: + _set_approach_direction(0.0) + _face_target() + STATE_RETREAT: + _move_toward_x(_retreat_target_x, escape_speed_scale if _escaping else retreat_speed_scale) + _face_target() + + +func _patrol_step() -> void: + var own_x := _owner_x() + var half := maxf(20.0, patrol_radius) + var low := clampf(_patrol_anchor_x - half, arena_min_x, arena_max_x) + var high := clampf(_patrol_anchor_x + half, arena_min_x, arena_max_x) + if own_x <= low: + _patrol_direction = 1.0 + elif own_x >= high: + _patrol_direction = -1.0 + _set_approach_direction(_patrol_direction * patrol_speed_scale) + _face_walk_direction(_patrol_direction) + + +func _chase_step() -> void: + var owner := get_parent() as Node2D + if owner == null or target == null or not is_instance_valid(target): + _set_approach_direction(0.0) + return + var delta_x := target.global_position.x - owner.global_position.x + if absf(delta_x) <= maxf(8.0, approach_stop_range): + _set_approach_direction(0.0) + else: + _set_approach_direction(signf(delta_x)) + _face_target() + + +func _move_toward_x(target_x: float, speed_scale: float) -> void: + if is_nan(target_x): + _set_approach_direction(0.0) + return + var delta_x := target_x - _owner_x() + if absf(delta_x) <= 8.0: + _set_approach_direction(0.0) + return + _set_approach_direction(signf(delta_x) * speed_scale) + if state == STATE_ENTRY: + _face_walk_direction(signf(delta_x)) + + +func _entry_reached() -> bool: + return not _has_entry_move() or absf(entry_target_x - _owner_x()) <= 10.0 + + +func _has_entry_move() -> bool: + return not is_nan(entry_target_x) + + +func _set_approach_direction(direction: float) -> void: + var owner := get_parent() + if owner != null and "approach_direction" in owner: + owner.set("approach_direction", direction) + + +## ------------------------------------------------------------ beat driven + + +func _on_beat_ticked(beat_index: int) -> void: + if not enabled or _is_dead(): + return + if _chase_pending and role == ROLE_MELEE and state != STATE_CHASE: + # 指南 §7.4:警戒后在下一个节拍点进入追击状态。 + _chase_pending = false + state = STATE_CHASE + if action_controller != null and int(action_controller.get("phase")) != 0: + return + if beat_index < _next_decision_beat: + return + if state == STATE_ENTRY or state == STATE_RETREAT: + return + var owner := get_parent() + if owner == null or not owner.has_method("current_action_ids"): + return + _refresh_target() + if _distance_to_target() > attack_range: + # Out of reach: keep repositioning (handled per-frame); re-check next beat. + _next_decision_beat = beat_index + 1 + return + _face_target() + var action_ids: Array = owner.call("current_action_ids") + if action_ids.is_empty(): + return + var action_id := StringName(str(action_ids[_action_index % action_ids.size()])) + _action_index += 1 + var interval := 1 + if owner.has_method("current_attack_interval_beats"): + interval = maxi(1, int(owner.call("current_attack_interval_beats"))) + _next_decision_beat = beat_index + interval + if driver != null and driver.has_method("start_action"): + driver.call("start_action", action_id) + + +## ----------------------------------------------------------------- lookup + + +func _distance_to_target() -> float: + var owner := get_parent() as Node2D + if owner == null or target == null or not is_instance_valid(target): + return INF + return absf(target.global_position.x - owner.global_position.x) + + +func _owner_x() -> float: + var owner := get_parent() as Node2D + return owner.global_position.x if owner != null else 0.0 + + +func _refresh_target() -> void: + if target != null and is_instance_valid(target): + return + target = get_node_or_null(target_path) as Node2D + if target != null: + return + var owner := get_parent() + if owner == null or owner.get_parent() == null: + return + target = owner.get_parent().get_node_or_null("Player") as Node2D + + +func _face_target() -> void: + var owner := get_parent() + if target != null and owner != null and owner.has_method("look_at_target"): + owner.call("look_at_target", target) + + +func _face_walk_direction(direction: float) -> void: + var owner := get_parent() + if owner == null or is_zero_approx(direction): + return + if "heading" in owner: + owner.set("heading", Vector2.LEFT if direction < 0.0 else Vector2.RIGHT) + + +func _is_dead() -> bool: + if health_component != null and int(health_component.get("current")) <= 0: + return true + var owner := get_parent() + if owner == null: + return false + var state_machine := owner.get_node_or_null("StateMachine") + if state_machine != null and state_machine.has_method("build_context"): + return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead" + return false + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/enemies/minion_behavior.gd.uid b/scenes/enemies/minion_behavior.gd.uid new file mode 100644 index 0000000..a6cc678 --- /dev/null +++ b/scenes/enemies/minion_behavior.gd.uid @@ -0,0 +1 @@ +uid://b3aqe2ksj244o diff --git a/scenes/ground/ground.tscn b/scenes/ground/ground.tscn new file mode 100644 index 0000000..90b5cf0 --- /dev/null +++ b/scenes/ground/ground.tscn @@ -0,0 +1,55 @@ +[gd_scene format=3 uid="uid://cs0rhloanh2u4"] + +[ext_resource type="Texture2D" uid="uid://df1i0jo40oefn" path="res://assets/art/ground/ground.png" id="1_au3k8"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_au3k8"] +size = Vector2(4105, 1) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_rrkwn"] +resource_name = "RectangleShape2D_left_boundary" +size = Vector2(32, 900) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_xmv3o"] +resource_name = "RectangleShape2D_right_boundary" +size = Vector2(32, 900) + +[node name="ground" type="Node2D" unique_id=656914049] + +[node name="StreetUnderfill" type="Polygon2D" parent="." unique_id=2126258380] +visible = false +color = Color(0.078, 0.065, 0.09, 1) +polygon = PackedVector2Array(-22, 540, 4116, 540, 4116, 920, -22, 920) + +[node name="GroundAnchor" type="Marker2D" parent="." unique_id=1875450741] +position = Vector2(2047, 366) + +[node name="GroundBody" type="StaticBody2D" parent="." unique_id=1583776637] +position = Vector2(2047, 366) +collision_mask = 0 + +[node name="Sprite2D" type="Sprite2D" parent="GroundBody" unique_id=376657383] +texture_filter = 2 +scale = Vector2(1.7979, 1.7979) +texture = ExtResource("1_au3k8") +centered = false +offset = Vector2(-1150.5, -480) +region_enabled = true +region_rect = Rect2(0, 90, 2301, 593) + +[node name="CollisionShape2D" type="CollisionShape2D" parent="GroundBody" unique_id=629550857] +position = Vector2(7, -133.5) +shape = SubResource("RectangleShape2D_au3k8") + +[node name="LeftBoundaryBody" type="StaticBody2D" parent="." unique_id=1153049543] +position = Vector2(-21.5, -80) +collision_mask = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="LeftBoundaryBody" unique_id=595374678] +shape = SubResource("RectangleShape2D_rrkwn") + +[node name="RightBoundaryBody" type="StaticBody2D" parent="." unique_id=1425356485] +position = Vector2(4115.5, -80) +collision_mask = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="RightBoundaryBody" unique_id=2009094553] +shape = SubResource("RectangleShape2D_xmv3o") diff --git a/scenes/ground/ground1.tscn b/scenes/ground/ground1.tscn new file mode 100644 index 0000000..e4ad919 --- /dev/null +++ b/scenes/ground/ground1.tscn @@ -0,0 +1,59 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Script" path="res://scenes/ground/ground_background.gd" id="1_ground_background"] +[ext_resource type="Texture2D" path="res://assets/art/ground/past/past.png" id="2_past"] +[ext_resource type="Texture2D" path="res://assets/art/ground/future/future.png" id="3_future"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ground"] +size = Vector2(2301, 8) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_left_boundary"] +resource_name = "RectangleShape2D_left_boundary" +size = Vector2(32, 1100) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_boundary"] +resource_name = "RectangleShape2D_right_boundary" +size = Vector2(32, 1100) + +[node name="ground1" type="Node2D"] +script = ExtResource("1_ground_background") + +[node name="GroundAnchor" type="Marker2D" parent="."] +position = Vector2(2047, 560) + +[node name="ArtLayer" type="Node2D" parent="."] +position = Vector2(896.5, 0) +z_index = -10 + +[node name="PastBackground" type="Sprite2D" parent="ArtLayer"] +texture_filter = 2 +texture = ExtResource("2_past") +centered = false + +[node name="FutureBackground" type="Sprite2D" parent="ArtLayer"] +visible = false +texture_filter = 2 +texture = ExtResource("3_future") +centered = false + +[node name="GroundBody" type="StaticBody2D" parent="."] +position = Vector2(2047, 560) +collision_mask = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="GroundBody"] +position = Vector2(0, 4) +shape = SubResource("RectangleShape2D_ground") + +[node name="LeftBoundaryBody" type="StaticBody2D" parent="."] +position = Vector2(880.5, 150) +collision_mask = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="LeftBoundaryBody"] +shape = SubResource("RectangleShape2D_left_boundary") + +[node name="RightBoundaryBody" type="StaticBody2D" parent="."] +position = Vector2(3213.5, 150) +collision_mask = 0 + +[node name="CollisionShape2D" type="CollisionShape2D" parent="RightBoundaryBody"] +shape = SubResource("RectangleShape2D_right_boundary") \ No newline at end of file diff --git a/scenes/ground/ground_background.gd b/scenes/ground/ground_background.gd new file mode 100644 index 0000000..f9444bd --- /dev/null +++ b/scenes/ground/ground_background.gd @@ -0,0 +1,66 @@ +extends Node2D + +## 2026-07-06 视差定案(策划:远景夕阳要时刻在画面内):背景是"远景天幕", +## 仅以 0.2 倍速随世界滚动(即 80% 跟随相机)——此前 0.92/0.8 的"近景层" +## 参数方向反了,背景几乎钉在世界上,所以一直"看不出视差"。 +## 锚点 1734.5 = 让夕阳(原画 x≈760,世界 x≈1656)在出生点镜头(可见中心 +## 1340.8)恰好居中;相机全程 [1340.8, 2753.2] 内夕阳屏幕位置从中心缓移到 +## 左 282px,始终在画面内(已验算含光晕不出屏)。两端背景覆盖也已验算: +## 极左窗 [880,1801.6]⊂[581.5,2882.5]、极右窗 [2292.4,3214]⊂[1711.5,4012.5]。 +## 已知取舍:步道砖烙在原画里会明显随镜头走(角色脚下滑动感),单张烙死图 +## 无解;美术把"近景步道"拆成独立 1:1 层后即可消除。勿用 Parallax2D +## (历史上因 CanvasModulate/wipe 兼容性移除过,见 decisions.md)。 +const PARALLAX_SCROLL_SCALE := 0.2 +const PARALLAX_ANCHOR_X := 1734.5 + +@onready var past_background: CanvasItem = $ArtLayer/PastBackground +@onready var future_background: CanvasItem = $ArtLayer/FutureBackground +@onready var _art_layer: Node2D = $ArtLayer + +var _art_base_x := 0.0 + + +func _ready() -> void: + _art_base_x = _art_layer.position.x + _set_time_phase(_current_time_phase()) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + + +func _process(_delta: float) -> void: + var camera := get_viewport().get_camera_2d() + if camera == null: + # headless 测试/无相机场景:背景保持原位。 + _art_layer.position.x = _art_base_x + return + # get_screen_center_position 含 limit 夹取,玩家贴墙时背景不会继续滑。 + var center_x := camera.get_screen_center_position().x + _art_layer.position.x = _art_base_x + (center_x - PARALLAX_ANCHOR_X) * (1.0 - PARALLAX_SCROLL_SCALE) + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + _set_time_phase(current) + + +func _set_time_phase(time_phase: StringName) -> void: + var use_future := time_phase == &"future" + if past_background != null: + past_background.visible = not use_future + if future_background != null: + future_background.visible = use_future + + +func _current_time_phase() -> StringName: + if not is_inside_tree(): + return &"past" + var manager := get_tree().root.get_node_or_null("TimePhaseManager") + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") \ No newline at end of file diff --git a/scenes/ground/ground_background.gd.uid b/scenes/ground/ground_background.gd.uid new file mode 100644 index 0000000..c9553cf --- /dev/null +++ b/scenes/ground/ground_background.gd.uid @@ -0,0 +1 @@ +uid://ba7fyvg1ukb8e diff --git a/scenes/main/clock_debug.gd b/scenes/main/clock_debug.gd new file mode 100644 index 0000000..809bdfa --- /dev/null +++ b/scenes/main/clock_debug.gd @@ -0,0 +1,40 @@ +extends Node + +@export var sample_limit := 20 + +var _samples := 0 + + +func _ready() -> void: + set_process_input(true) + var rhythm := _rhythm_manager() + if rhythm != null and rhythm.has_method("start"): + rhythm.call("start") + + +func _input(event: InputEvent) -> void: + var key := event as InputEventKey + if key == null or not key.pressed or key.echo: + return + _print_clock_sample(Time.get_ticks_msec()) + + +func _print_clock_sample(timestamp_ms: float) -> void: + var rhythm := _rhythm_manager() + if rhythm == null: + push_warning("ClockDebug requires the RhythmManager autoload.") + return + + var song_time := float(rhythm.call("input_to_song_time", timestamp_ms)) + var rating: Dictionary = rhythm.call("get_rating_for_time", song_time) + var nearest_beat := int(rating.get("nearest_beat", -1)) + var diff_ms := float(rating.get("diff", 0.0)) * 1000.0 + _samples += 1 + print("clock_debug sample=%d song_time=%.3f beat=%d diff_ms=%.2f" % [_samples, song_time, nearest_beat, diff_ms]) + + if sample_limit > 0 and _samples >= sample_limit: + print("clock_debug sample limit reached") + + +func _rhythm_manager() -> Node: + return get_node_or_null("/root/RhythmManager") diff --git a/scenes/main/clock_debug.gd.uid b/scenes/main/clock_debug.gd.uid new file mode 100644 index 0000000..0764094 --- /dev/null +++ b/scenes/main/clock_debug.gd.uid @@ -0,0 +1 @@ +uid://s13x8nbmfsc8 diff --git a/scenes/main/clock_debug.tscn b/scenes/main/clock_debug.tscn new file mode 100644 index 0000000..4fb4680 --- /dev/null +++ b/scenes/main/clock_debug.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/main/clock_debug.gd" id="1_clock"] + +[node name="ClockDebug" type="Node"] +script = ExtResource("1_clock") diff --git a/scenes/main/main.gd b/scenes/main/main.gd new file mode 100644 index 0000000..0fa0db7 --- /dev/null +++ b/scenes/main/main.gd @@ -0,0 +1,19 @@ +extends Node2D + +@onready var stage: Node = $Stage +@onready var ui: Node = $UILayer/UI + + +func _ready() -> void: + if ui != null and ui.has_method("bind_debug_actor"): + ui.call("bind_debug_actor", get_player()) + if ui != null and ui.has_method("bind_boss_actor"): + ui.call("bind_boss_actor", get_boss()) + + +func get_player() -> Node: + return stage.get_node("ActorsContainer/Player") + + +func get_boss() -> Node: + return stage.get_node("ActorsContainer/Boss") \ No newline at end of file diff --git a/scenes/main/main.gd.uid b/scenes/main/main.gd.uid new file mode 100644 index 0000000..f1cc217 --- /dev/null +++ b/scenes/main/main.gd.uid @@ -0,0 +1 @@ +uid://q1v3lbqlej3y diff --git a/scenes/main/main.tscn b/scenes/main/main.tscn new file mode 100644 index 0000000..b08e84e --- /dev/null +++ b/scenes/main/main.tscn @@ -0,0 +1,28 @@ +[gd_scene format=3 uid="uid://dimsne1iyxg20"] + +[ext_resource type="Script" uid="uid://q1v3lbqlej3y" path="res://scenes/main/main.gd" id="1_main"] +[ext_resource type="PackedScene" uid="uid://dimgym86pdqmd" path="res://scenes/stage/stage.tscn" id="2_stage"] +[ext_resource type="Script" uid="uid://vea0brdgpneb" path="res://scenes/chart/chart_runner.gd" id="3_chart_runner"] +[ext_resource type="Resource" uid="uid://3xrcwnvo4ih3" path="res://resources/charts/stage9_boss_duel.tres" id="4_stage7_chart"] +[ext_resource type="PackedScene" uid="uid://d4fldy342eji2" path="res://scenes/ui/main_ui.tscn" id="5_ui"] +[ext_resource type="Script" uid="uid://c3agddgh7fn4a" path="res://scenes/audio/audio_layer_controller.gd" id="6_audio_layers"] + +[node name="Main" type="Node2D" unique_id=28024565] +script = ExtResource("1_main") + +[node name="Stage" parent="." unique_id=442949244 instance=ExtResource("2_stage")] + +[node name="UILayer" type="CanvasLayer" parent="." unique_id=166482417] +layer = 10 + +[node name="UI" parent="UILayer" unique_id=299590534 instance=ExtResource("5_ui")] + +[node name="ChartRunner" type="Node" parent="." unique_id=110188885] +script = ExtResource("3_chart_runner") +chart = ExtResource("4_stage7_chart") +actors_container_path = NodePath("../Stage/ActorsContainer") + +[node name="AudioLayerController" type="Node" parent="." unique_id=1902714634] +script = ExtResource("6_audio_layers") + +[editable path="Stage"] \ No newline at end of file diff --git a/scenes/main/stage4_playtest.tscn b/scenes/main/stage4_playtest.tscn new file mode 100644 index 0000000..cacbf94 --- /dev/null +++ b/scenes/main/stage4_playtest.tscn @@ -0,0 +1,16 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://scenes/ground/ground.tscn" id="1_ground"] +[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="2_player"] + +[node name="Stage4Playtest" type="Node2D"] + +[node name="Ground" parent="." instance=ExtResource("1_ground")] + +[node name="Player" parent="." instance=ExtResource("2_player")] +position = Vector2(2047, 366) + +[node name="Camera2D" type="Camera2D" parent="."] +position = Vector2(2047, 230) +zoom = Vector2(2.5, 2.5) +enabled = true diff --git a/scenes/stage/actors_container.gd b/scenes/stage/actors_container.gd new file mode 100644 index 0000000..1ebd0ef --- /dev/null +++ b/scenes/stage/actors_container.gd @@ -0,0 +1,73 @@ +class_name ActorsContainer +extends Node2D + +const DEFAULT_PROJECTILE_SCENE := preload("res://scenes/combat/player_projectile.tscn") +# 敌弹减速为玩家弹默认速度 520 的 80%(2026-07-05 定案);玩家弹保持脚本默认值。 +const ENEMY_PROJECTILE_SPEED := 416.0 + + +func _ready() -> void: + _event_bus().connect("projectile_requested", _on_projectile_requested) + + +func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void: + var scene := projectile_scene if projectile_scene != null else DEFAULT_PROJECTILE_SCENE + var projectile := scene.instantiate() + projectile.global_position = spawn_position + projectile.set("direction", direction) + var team := StringName(str(context.get("team", &"player"))) + if team == &"player": + projectile.set("visual_profile", &"wave") + # 耗能技能一律带击退(2026-07-05 定案):玩家弹丸使用与近战相同的 + # 基准击退,实际力度由动作的 knockback_mult 决定。 + projectile.set("base_knockback", Vector2(120.0, 304.056)) + else: + projectile.set("speed", ENEMY_PROJECTILE_SPEED) + # 2026-07-06:敌方弹丸此前基准击退为 (0,0),未来相位 Boss 只用远程 + # → 对玩家的击退数学上恒为零。补上基准(x 与 Boss 近战一致,弹丸 + # 不击飞),实际力度仍由各动作 .tres 的 knockback_mult 决定(0.25~0.5)。 + projectile.set("base_knockback", Vector2(360.0, 0.0)) + _configure_projectile_team(projectile, team) + var action = context.get("action", null) + if action is Resource: + projectile.set("action_context", action) + var judgement = context.get("judgement", null) + if judgement is Dictionary and not judgement.is_empty(): + projectile.set("judgement_context", judgement) + var interrupt_authority = context.get("attacker_interrupts", null) + if interrupt_authority is bool: + projectile.set("attacker_interrupts", interrupt_authority) + var source_actor = context.get("source_actor", null) + if source_actor is Node: + projectile.set("source_actor", source_actor) + var base_damage = context.get("base_damage", null) + if base_damage is int or base_damage is float: + projectile.set("damage", int(base_damage)) + var travel_range := float(context.get("range", 0.0)) + if travel_range > 0.0: + projectile.set("max_range", travel_range) + add_child(projectile) + + +func _configure_projectile_team(projectile: Node, team: StringName) -> void: + var area := projectile as Area2D + if area == null: + return + if team == &"enemy": + area.collision_layer = 1 << 4 + area.collision_mask = 1 << 1 + area.add_to_group("enemy_projectiles") + else: + area.collision_layer = 1 << 3 + area.collision_mask = (1 << 2) | (1 << 4) + area.add_to_group("player_projectiles") + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/stage/actors_container.gd.uid b/scenes/stage/actors_container.gd.uid new file mode 100644 index 0000000..d16e78d --- /dev/null +++ b/scenes/stage/actors_container.gd.uid @@ -0,0 +1 @@ +uid://bs318qe54putv diff --git a/scenes/stage/boss_room_gate.gd b/scenes/stage/boss_room_gate.gd new file mode 100644 index 0000000..e1adc33 --- /dev/null +++ b/scenes/stage/boss_room_gate.gd @@ -0,0 +1,148 @@ +class_name BossRoomGate +extends Node2D + +## Front-area → boss-room door (AnchorV1.0 §4.5 + 关卡设计指南 §8). The door +## starts SEALED: the boss room only opens after all six front-area minions +## are defeated (LevelDirector calls unseal()). Once the player crosses the +## threshold it locks behind them (no going back to farm the front area) and +## GameFlowManager applies the boss-entry rules. + +signal boss_room_entered +signal unsealed + +@export var gate_x := 2960.0 +@export var floor_y := 231.0 +@export var wall_height := 900.0 +@export var wall_thickness := 48.0 +## 通关条件门闩:默认封锁,直到导演宣布小怪清场。 +@export var sealed := true + +var locked := false + +var _wall_body: StaticBody2D +var _wall_shape: CollisionShape2D +var _door_visual: Polygon2D + + +func _ready() -> void: + _build_wall() + _build_visual() + _refresh_visual() + + +func _physics_process(_delta: float) -> void: + if locked or sealed: + return + var player := _player_or_null() + if player == null: + return + # 跨过门线的瞬间就开战:Boss 立刻解锁、面板立刻显示。旧阈值 + # gate_x + wall_thickness 距 Boss 锚点仅 12px,等于要贴脸才触发。 + if player.global_position.x >= gate_x: + lock() + + +## 小怪清场后开放前往 Boss 房的路径(指南 §8)。 +func unseal() -> void: + if not sealed or locked: + return + sealed = false + _disable_wall() + _refresh_visual() + unsealed.emit() + + +func lock() -> void: + if locked: + return + locked = true + _disable_wall() + _refresh_visual() + var boss := _activate_boss_actor() + var flow := _flow_manager_or_null() + if flow != null and flow.has_method("enter_boss_room"): + flow.call("enter_boss_room") + _bind_boss_panel(boss) + boss_room_entered.emit() + + +func _build_wall() -> void: + _wall_body = StaticBody2D.new() + _wall_body.name = "GateWall" + _wall_body.position = Vector2(gate_x, floor_y - wall_height * 0.5) + _wall_body.collision_layer = 1 + _wall_body.collision_mask = 0 + add_child(_wall_body) + _wall_shape = CollisionShape2D.new() + var shape := RectangleShape2D.new() + shape.size = Vector2(wall_thickness, wall_height) + _wall_shape.shape = shape + _wall_shape.disabled = not sealed + _wall_body.add_child(_wall_shape) + + +func _build_visual() -> void: + _door_visual = Polygon2D.new() + _door_visual.name = "DoorVisual" + _door_visual.visible = false + var half := wall_thickness * 0.5 + _door_visual.polygon = PackedVector2Array([ + Vector2(gate_x - half, floor_y), + Vector2(gate_x + half, floor_y), + Vector2(gate_x + half, floor_y - wall_height), + Vector2(gate_x - half, floor_y - wall_height), + ]) + add_child(_door_visual) + + +func _refresh_visual() -> void: + if _door_visual == null: + return + _door_visual.visible = false + _door_visual.color = Color(1.0, 1.0, 1.0, 0.0) + + +func _player_or_null() -> Node2D: + var container := get_parent() + if container == null: + return null + var stage := container + var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D + return player + + +func _disable_wall() -> void: + if _wall_shape != null: + _wall_shape.set_deferred("disabled", true) + if _wall_body != null: + _wall_body.collision_layer = 0 + _wall_body.collision_mask = 0 + _wall_body.visible = false + + +func _activate_boss_actor() -> Node: + var stage := get_parent() + if stage == null: + return null + var boss := stage.get_node_or_null("ActorsContainer/Boss") + if boss == null: + return null + boss.set("combat_enabled", true) + boss.set("stationary", false) + return boss + + +func _bind_boss_panel(boss: Node) -> void: + if boss == null or not is_instance_valid(boss): + return + var stage := get_parent() + var main := stage.get_parent() if stage != null else null + var ui := main.get_node_or_null("UILayer/UI") if main != null else null + if ui != null and ui.has_method("bind_boss_actor"): + ui.call("bind_boss_actor", boss) + + +func _flow_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("GameFlowManager") diff --git a/scenes/stage/boss_room_gate.gd.uid b/scenes/stage/boss_room_gate.gd.uid new file mode 100644 index 0000000..4776e6f --- /dev/null +++ b/scenes/stage/boss_room_gate.gd.uid @@ -0,0 +1 @@ +uid://c34aqq8fscdrv diff --git a/scenes/stage/level_director.gd b/scenes/stage/level_director.gd new file mode 100644 index 0000000..9841f6c --- /dev/null +++ b/scenes/stage/level_director.gd @@ -0,0 +1,245 @@ +class_name LevelDirector +extends Node + +## 第一关出怪导演(关卡设计指南 §7 / §8)。 +## 阶段一:预置近战怪(编辑器摆放,无刷新动画)。 +## 阶段二:击杀后在玩家附近刷 1 只远程怪(可感知、不贴脸)。 +## 阶段三:再击杀后左右两侧每 2 秒依次增援 近战L→远程R→近战R→远程L。 +## 全部 6 只被击败 → 解封 Boss 房路径并广播 front_area_cleared。 + +signal wave_phase_changed(phase: int) +signal front_area_cleared + +const MINION_SCENE := preload("res://scenes/enemies/minion.tscn") +const PAST_STRONG_PROFILE := preload("res://resources/time_phase/profile_past_strong_enemy.tres") +const FUTURE_STRONG_PROFILE := preload("res://resources/time_phase/profile_future_strong_enemy.tres") + +## 阶段三增援表:延迟秒数 / 职责 / 入场方向(指南 §7.3 推荐顺序)。 +const REINFORCEMENTS := [ + {"delay": 0.0, "role": &"melee", "side": &"left"}, + {"delay": 2.0, "role": &"ranged", "side": &"right"}, + {"delay": 4.0, "role": &"melee", "side": &"right"}, + {"delay": 6.0, "role": &"ranged", "side": &"left"}, +] + +@export var actors_container_path: NodePath = NodePath("../ActorsContainer") +@export var gate_path: NodePath = NodePath("../BossRoomGate") +## 阶段三刷新间隔的缩放(测试将其调小以加速)。 +@export var reinforcement_time_scale := 1.0 +## 阶段二远程怪与玩家的刷新距离(可感知范围内、不贴脸)。 +@export var ranged_spawn_distance := 330.0 +## 画面边缘刷新点(摄像机外侧)与入场后的战斗区域。 +@export var left_edge_x := 1150.0 +@export var right_edge_x := 2905.0 +@export var battle_center_x := 2100.0 +@export var arena_min_x := 1120.0 +@export var arena_max_x := 2890.0 +@export var ground_y := 231.0 +## Keep all front-area enemies comfortably left of the boss-room air wall so +## the pre-boss camera can frame their full sprites. +@export var boss_gate_minion_clearance := 220.0 +## 远程怪站位到战斗中心的距离;近战怪入场目标到中心的距离。 +@export var ranged_station_offset := 380.0 +@export var melee_entry_offset := 170.0 +## 2026-07-05 定案:近战小怪基础攻击天生是远程小怪的两倍(原基础 25 翻倍)。 +@export var melee_base_attack := 50 +@export var ranged_base_attack := 25 +## 远程怪最大开火距离必须在镜头可视范围内(约 460px 半宽):刚进画面即可开火, +## 但绝不允许从屏幕外攻击玩家。 +@export var ranged_attack_range := 450.0 + +var phase := 1 +var kills := 0 +var total_spawned := 0 +var cleared := false + +## 通关需要的总击杀数:预置怪 + 阶段二远程怪 1 只 + 阶段三增援(指南 §8 = 6)。 +var _expected_kills := 6 +var _watched: Dictionary = {} +var _spawn_serial := 0 + + +func _ready() -> void: + call_deferred("_register_preplaced_minions") + + +func _register_preplaced_minions() -> void: + var container := _actors_container() + if container == null: + return + var preplaced := 0 + for child: Node in container.get_children(): + if child.is_in_group("enemies") and child.get_node_or_null("HealthComponent") != null: + _configure_front_area_minion(child) + _watch_minion(child) + preplaced += 1 + total_spawned += preplaced + _expected_kills = preplaced + 1 + REINFORCEMENTS.size() + + +## ---------------------------------------------------------------- spawning + + +func spawn_minion(role: StringName, spawn_position: Vector2, entry_target_x: float = NAN) -> Node: + var container := _actors_container() + if container == null: + return null + var minion: Node2D = MINION_SCENE.instantiate() + _spawn_serial += 1 + minion.name = "%sMinion%d" % [("JinZhan" if role == &"melee" else "YuanCheng"), _spawn_serial] + var behavior := minion.get_node_or_null("MinionBehavior") + if role == &"ranged": + minion.set("past_form_id", &"yuan_cheng_1") + minion.set("future_form_id", &"yuan_cheng_2") + minion.set("strength_profile", &"future_strong") + minion.set("time_phase_profile", FUTURE_STRONG_PROFILE) + if behavior != null: + behavior.set("role", &"ranged") + behavior.set("attack_range", ranged_attack_range) + behavior.set("approach_stop_range", 340.0) + behavior.set("alert_range", 540.0) + else: + minion.set("past_form_id", &"jin_zhan_3") + minion.set("future_form_id", &"jin_zhan_1") + minion.set("strength_profile", &"past_strong") + minion.set("time_phase_profile", PAST_STRONG_PROFILE) + if behavior != null: + behavior.set("role", &"melee") + if behavior != null: + behavior.set("entry_target_x", entry_target_x) + _configure_front_area_minion(minion) + minion.position = _front_area_spawn_position(spawn_position) + container.add_child(minion) + total_spawned += 1 + _watch_minion(minion) + return minion + + +func _watch_minion(minion: Node) -> void: + var key := minion.get_instance_id() + if _watched.has(key): + return + var health := minion.get_node_or_null("HealthComponent") + if health == null or not health.has_signal("depleted"): + return + _watched[key] = minion + health.connect("depleted", Callable(self, "_on_minion_died").bind(key)) + + +func _on_minion_died(key: int) -> void: + if not _watched.has(key): + return + _watched.erase(key) + kills += 1 + match kills: + 1: + _start_phase_two() + 2: + _start_phase_three() + if kills >= _expected_kills and not cleared: + _clear_front_area() + + +func _start_phase_two() -> void: + if phase >= 2: + return + phase = 2 + wave_phase_changed.emit(phase) + # 指南 §7.2:在玩家附近可感知处刷新,不贴脸;往地形余量更大的一侧放。 + var player := _player_or_null() + var player_x := battle_center_x if player == null else player.global_position.x + var front_max_x := _front_area_max_x() + var side := 1.0 if (front_max_x - player_x) >= (player_x - arena_min_x) else -1.0 + var spawn_x := clampf(player_x + side * ranged_spawn_distance, arena_min_x + 60.0, front_max_x) + spawn_minion(&"ranged", Vector2(spawn_x, ground_y)) + + +func _start_phase_three() -> void: + if phase >= 3: + return + phase = 3 + wave_phase_changed.emit(phase) + for entry: Dictionary in REINFORCEMENTS: + var delay := float(entry.get("delay", 0.0)) * maxf(0.01, reinforcement_time_scale) + var role := StringName(str(entry.get("role", &"melee"))) + var side := StringName(str(entry.get("side", &"left"))) + if delay <= 0.0: + _spawn_reinforcement(role, side) + else: + var timer := get_tree().create_timer(delay, false) + timer.timeout.connect(Callable(self, "_spawn_reinforcement").bind(role, side)) + + +func _spawn_reinforcement(role: StringName, side: StringName) -> void: + if cleared or not is_inside_tree(): + return + var from_left := side == &"left" + var spawn_x := left_edge_x if from_left else right_edge_x + var direction := 1.0 if from_left else -1.0 + # 入场目标在自己一侧:左侧入场停在中心左侧,右侧入场停在中心右侧, + # 保证玩家不会被瞬间包围(指南 §7.3)。 + var offset := ranged_station_offset if role == &"ranged" else melee_entry_offset + var entry_x := battle_center_x - direction * offset + entry_x = clampf(entry_x, arena_min_x + 40.0, _front_area_max_x()) + spawn_minion(role, Vector2(spawn_x, ground_y), entry_x) + + +## ------------------------------------------------------------------- clear + + +func _clear_front_area() -> void: + cleared = true + var gate := get_node_or_null(gate_path) + if gate != null and gate.has_method("unseal"): + gate.call("unseal") + front_area_cleared.emit() + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("front_area_cleared"): + bus.emit_signal("front_area_cleared") + + +## ------------------------------------------------------------------ lookup + + +func _actors_container() -> Node: + return get_node_or_null(actors_container_path) + + +func _player_or_null() -> Node2D: + var container := _actors_container() + if container == null: + return null + return container.get_node_or_null("Player") as Node2D + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") + + +func _front_area_spawn_position(spawn_position: Vector2) -> Vector2: + return Vector2(clampf(spawn_position.x, arena_min_x + 40.0, _front_area_max_x()), spawn_position.y) + + +func _configure_front_area_minion(minion: Node) -> void: + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("arena_min_x", arena_min_x) + behavior.set("arena_max_x", _front_area_max_x()) + # 按职责区分基础攻击力(近战 50 / 远程 25,2026-07-05 定案)。 + var emitter := minion.get_node_or_null("DamageEmitter") + if emitter != null: + var is_melee := StringName(str(behavior.get("role"))) == &"melee" + emitter.set("damage", melee_base_attack if is_melee else ranged_base_attack) + if minion is Node2D: + var actor := minion as Node2D + actor.global_position = _front_area_spawn_position(actor.global_position) + + +func _front_area_max_x() -> float: + var max_x := arena_max_x + var gate := get_node_or_null(gate_path) + if gate != null: + max_x = minf(max_x, float(gate.get("gate_x")) - boss_gate_minion_clearance) + return maxf(arena_min_x + 80.0, max_x) diff --git a/scenes/stage/level_director.gd.uid b/scenes/stage/level_director.gd.uid new file mode 100644 index 0000000..9a9f96b --- /dev/null +++ b/scenes/stage/level_director.gd.uid @@ -0,0 +1 @@ +uid://b834k71c0wvyu diff --git a/scenes/stage/stage.gd b/scenes/stage/stage.gd new file mode 100644 index 0000000..73a1e9a --- /dev/null +++ b/scenes/stage/stage.gd @@ -0,0 +1,183 @@ +class_name Stage +extends Node2D + +@export var use_authored_camera_view := false +@export var camera_follow_offset := Vector2(0.0, -165.0) +@export var camera_zoom := Vector2(1.25, 1.25) +@export var past_world_tint := Color(1.0, 0.94, 0.82) +@export var future_world_tint := Color(0.55, 0.72, 1.0) +@export var time_phase_wipe_seconds := 0.5 +@export var time_phase_wipe_softness_px := 70.0 + +# blend_mul: inside the expanding circle the framebuffer stays as-is (the new +# scene tint already applied world-wide); outside it is multiplied by +# old_tint/new_tint, which reproduces the previous scene exactly. The rim +# brightens so the old scene visibly "dissolves" outward from the player. +const TIME_PHASE_WIPE_SHADER := """ +shader_type canvas_item; +render_mode blend_mul; +uniform vec2 center_px = vec2(640.0, 360.0); +uniform float radius_px = 999999.0; +uniform float softness_px = 70.0; +uniform vec4 outside_tint = vec4(1.0); +void fragment() { + float dist = distance(FRAGCOORD.xy, center_px); + float outside = smoothstep(radius_px - softness_px, radius_px + softness_px, dist); + float rim = clamp(1.0 - abs(dist - radius_px) / max(softness_px * 1.6, 1.0), 0.0, 1.0); + vec3 tint = mix(vec3(1.0), outside_tint.rgb, outside); + COLOR = vec4(tint * (1.0 + rim * 0.55), 1.0); +} +""" + +@onready var actors_container: Node2D = $ActorsContainer +@onready var player: Node2D = $ActorsContainer/Player +@onready var camera: Camera2D = $Camera2D +@onready var boss_room_gate: Node = $BossRoomGate + +var _world_modulate: CanvasModulate +var _wipe_rect: ColorRect +var _wipe_material: ShaderMaterial +var _wipe_tween: Tween + + +func _ready() -> void: + process_priority = 100 + _configure_camera() + _setup_time_phase_visuals() + + +func _physics_process(_delta: float) -> void: + call_deferred("_update_camera_follow") + + +func _process(_delta: float) -> void: + call_deferred("_update_camera_follow") + if _wipe_rect != null and _wipe_rect.visible: + _update_wipe_center() + + +func get_player() -> Node: + return player + + +func _configure_camera() -> void: + if camera == null: + return + camera.enabled = true + if use_authored_camera_view: + # WYSIWYG: the editor-authored position and zoom are the runtime view. + camera.make_current() + return + camera.zoom = camera_zoom + camera.make_current() + _update_camera_follow() + + +func _update_camera_follow() -> void: + if use_authored_camera_view: + return + if camera == null or player == null: + return + var target_position := player.global_position + camera_follow_offset + if _should_clamp_camera_before_boss_room(): + target_position.x = minf(target_position.x, _sealed_boss_room_camera_max_x()) + camera.global_position = target_position + + +func _should_clamp_camera_before_boss_room() -> bool: + return boss_room_gate != null and bool(boss_room_gate.get("sealed")) + + +func _sealed_boss_room_camera_max_x() -> float: + if boss_room_gate == null or camera == null: + return INF + var viewport_width := get_viewport_rect().size.x + if viewport_width <= 0.0 or camera.zoom.x <= 0.0: + return INF + var boss_entry_x := float(boss_room_gate.get("gate_x")) + float(boss_room_gate.get("wall_thickness")) + var half_view_width := viewport_width / (2.0 * camera.zoom.x) + return boss_entry_x - half_view_width + + +func _setup_time_phase_visuals() -> void: + _world_modulate = get_node_or_null("TimePhaseWorldModulate") as CanvasModulate + if _world_modulate == null: + _world_modulate = CanvasModulate.new() + _world_modulate.name = "TimePhaseWorldModulate" + add_child(_world_modulate) + var wipe_layer := CanvasLayer.new() + wipe_layer.name = "TimePhaseWipeLayer" + wipe_layer.layer = 1 + add_child(wipe_layer) + var shader := Shader.new() + shader.code = TIME_PHASE_WIPE_SHADER + _wipe_material = ShaderMaterial.new() + _wipe_material.shader = shader + _wipe_rect = ColorRect.new() + _wipe_rect.name = "TimePhaseWipe" + _wipe_rect.color = Color.WHITE + _wipe_rect.material = _wipe_material + _wipe_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + _wipe_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + _wipe_rect.visible = false + wipe_layer.add_child(_wipe_rect) + var bus := _event_bus_or_null() + if bus != null and bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + _world_modulate.color = _time_phase_tint(_current_time_phase()) + + +func _on_time_phase_changed(previous: StringName, current: StringName, _reason: StringName) -> void: + var old_tint := _time_phase_tint(previous) + var new_tint := _time_phase_tint(current) + if _world_modulate != null: + _world_modulate.color = new_tint + _start_time_phase_wipe(old_tint, new_tint) + + +func _time_phase_tint(time_phase: StringName) -> Color: + return future_world_tint if time_phase == &"future" else past_world_tint + + +func _start_time_phase_wipe(old_tint: Color, new_tint: Color) -> void: + if _wipe_rect == null or _wipe_material == null: + return + if _wipe_tween != null and _wipe_tween.is_valid(): + _wipe_tween.kill() + var ratio := Color( + clampf(old_tint.r / maxf(0.05, new_tint.r), 0.05, 3.0), + clampf(old_tint.g / maxf(0.05, new_tint.g), 0.05, 3.0), + clampf(old_tint.b / maxf(0.05, new_tint.b), 0.05, 3.0), + 1.0 + ) + _wipe_material.set_shader_parameter("outside_tint", ratio) + _wipe_material.set_shader_parameter("softness_px", time_phase_wipe_softness_px) + _wipe_material.set_shader_parameter("radius_px", 0.0) + _update_wipe_center() + _wipe_rect.visible = true + var max_radius := get_viewport_rect().size.length() + time_phase_wipe_softness_px * 2.0 + _wipe_tween = create_tween() + _wipe_tween.tween_property(_wipe_material, "shader_parameter/radius_px", max_radius, maxf(0.1, time_phase_wipe_seconds)) + _wipe_tween.tween_callback(func() -> void: + _wipe_rect.visible = false + ) + + +func _update_wipe_center() -> void: + if _wipe_material == null or player == null or not is_instance_valid(player): + return + var focus := player.get_global_transform_with_canvas().origin + Vector2(0.0, -60.0) + _wipe_material.set_shader_parameter("center_px", focus) + + +func _current_time_phase() -> StringName: + var manager := get_tree().root.get_node_or_null("TimePhaseManager") if is_inside_tree() else null + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _event_bus_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("EventBus") diff --git a/scenes/stage/stage.gd.uid b/scenes/stage/stage.gd.uid new file mode 100644 index 0000000..bcd3f01 --- /dev/null +++ b/scenes/stage/stage.gd.uid @@ -0,0 +1 @@ +uid://c8xnk16036wyv diff --git a/scenes/stage/stage.tscn b/scenes/stage/stage.tscn new file mode 100644 index 0000000..6747ebf --- /dev/null +++ b/scenes/stage/stage.tscn @@ -0,0 +1,59 @@ +[gd_scene load_steps=11 format=3 uid="uid://dimgym86pdqmd"] + +[ext_resource type="Script" uid="uid://c8xnk16036wyv" path="res://scenes/stage/stage.gd" id="1_stage"] +[ext_resource type="Script" uid="uid://bs318qe54putv" path="res://scenes/stage/actors_container.gd" id="2_actors"] +[ext_resource type="PackedScene" path="res://scenes/ground/ground1.tscn" id="3_ground"] +[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="4_player"] +[ext_resource type="PackedScene" uid="uid://bnoju71qm1xh7" path="res://scenes/enemies/boss.tscn" id="5_boss"] +[ext_resource type="PackedScene" path="res://scenes/enemies/minion.tscn" id="8_minion"] +[ext_resource type="Resource" path="res://resources/time_phase/profile_past_strong_enemy.tres" id="9_past_strong"] +[ext_resource type="Script" path="res://scenes/stage/boss_room_gate.gd" id="11_gate"] +[ext_resource type="Script" path="res://scenes/stage/level_director.gd" id="12_director"] + +[node name="Stage" type="Node2D" unique_id=1175276098] +script = ExtResource("1_stage") +camera_follow_offset = Vector2(0, -165) +camera_zoom = Vector2(1.25, 1.25) +future_world_tint = Color(0.72, 0.84, 1, 1) + +[node name="TimePhaseWorldModulate" type="CanvasModulate" parent="." unique_id=57917128] +color = Color(1, 0.94, 0.82, 1) + +[node name="Ground" parent="." unique_id=819592526 instance=ExtResource("3_ground")] + +[node name="BossRoomGate" type="Node2D" parent="."] +script = ExtResource("11_gate") +gate_x = 2460.0 +floor_y = 560.0 + +[node name="LevelDirector" type="Node" parent="."] +script = ExtResource("12_director") +ground_y = 560.0 +left_edge_x = 1420.0 +right_edge_x = 2240.0 +battle_center_x = 2047.0 +arena_min_x = 1120.0 +arena_max_x = 2240.0 + +[node name="ActorsContainer" type="Node2D" parent="." unique_id=833422275] +script = ExtResource("2_actors") + +[node name="Player" parent="ActorsContainer" unique_id=16275280 instance=ExtResource("4_player")] +position = Vector2(1180, 560) + +[node name="JinZhanMinion" parent="ActorsContainer" unique_id=1373552042 instance=ExtResource("8_minion")] +position = Vector2(2200, 560) +time_phase_profile = ExtResource("9_past_strong") + +[node name="Boss" parent="ActorsContainer" unique_id=319185885 instance=ExtResource("5_boss")] +position = Vector2(2520, 560) +stationary = true +combat_enabled = false + +[node name="Camera2D" type="Camera2D" parent="." unique_id=539412778] +position = Vector2(1180, 395) +zoom = Vector2(1.25, 1.25) +limit_left = 880 +limit_top = -420 +limit_right = 3214 +limit_bottom = 860 diff --git a/scenes/ui/combo_window_hud.gd b/scenes/ui/combo_window_hud.gd new file mode 100644 index 0000000..e06a8ed --- /dev/null +++ b/scenes/ui/combo_window_hud.gd @@ -0,0 +1,132 @@ +class_name ComboWindowHud +extends Control + +@export var slot_count := 4 + +const FRAME_TEXTURE := preload("res://assets/ui/combo/four_slot_judgement_frame.png") +const FRAME_SIZE := Vector2(255.0, 51.0) +const SLOT_SIZE := Vector2(50.0, 51.0) +const SLOT_X := [0.0, 68.0, 137.0, 205.0] + +var frame: TextureRect +var panels: Array[PanelContainer] = [] +var labels: Array[Label] = [] +var clear_tween: Tween + + +func _ready() -> void: + custom_minimum_size = FRAME_SIZE + size = FRAME_SIZE + _build_slots() + var bus := _event_bus() + bus.connect("combo_updated", refresh) + bus.connect("combo_cleared", _on_combo_cleared) + + +func refresh(inputs: Array) -> void: + if labels.is_empty(): + _build_slots() + for index: int in range(labels.size()): + var filled := index < inputs.size() + labels[index].text = _slot_text(inputs[index]) if filled else "∅" + labels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.32) + panels[index].modulate = Color(1.0, 1.0, 1.0, 1.0 if filled else 0.72) + if filled: + _pulse_slot(panels[index]) + + +func _on_combo_cleared(_reason: StringName) -> void: + refresh([]) + _flash_clear() + + +func _build_slots() -> void: + if not labels.is_empty(): + return + frame = TextureRect.new() + frame.name = "SlotFrame" + frame.texture = FRAME_TEXTURE + frame.modulate = Color(1.0, 1.0, 1.0, 0.7) + frame.mouse_filter = Control.MOUSE_FILTER_IGNORE + frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + frame.stretch_mode = TextureRect.STRETCH_SCALE + frame.set_anchors_preset(Control.PRESET_TOP_LEFT) + frame.position = Vector2.ZERO + frame.size = FRAME_SIZE + add_child(frame) + for index: int in range(slot_count): + var panel := PanelContainer.new() + panel.name = "Slot%d" % (index + 1) + panel.position = Vector2(float(SLOT_X[index]), 0.0) + panel.size = SLOT_SIZE + panel.custom_minimum_size = SLOT_SIZE + panel.pivot_offset = SLOT_SIZE * 0.5 + panel.modulate = Color(1.0, 1.0, 1.0, 0.72) + panel.add_theme_stylebox_override("panel", _make_slot_style()) + var label := Label.new() + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.text = "∅" + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_vertical = Control.SIZE_EXPAND_FILL + label.add_theme_color_override("font_color", Color(0.94, 0.98, 1.0, 1.0)) + label.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.9)) + label.add_theme_constant_override("shadow_offset_x", 2) + label.add_theme_constant_override("shadow_offset_y", 2) + label.add_theme_font_size_override("font_size", 25) + panel.add_child(label) + add_child(panel) + panels.append(panel) + labels.append(label) + + +func _slot_text(value: Variant) -> String: + var symbol := StringName(str(value)) + match symbol: + &"SP": + return "sp" + &"Ø": + return "∅" + return str(symbol) + + +func _pulse_slot(panel: PanelContainer) -> void: + var tween := create_tween() + panel.scale = Vector2(1.08, 1.08) + tween.tween_property(panel, "scale", Vector2.ONE, 0.09) + + +func _flash_clear() -> void: + if clear_tween != null and clear_tween.is_valid(): + clear_tween.kill() + clear_tween = create_tween() + clear_tween.set_parallel(true) + for panel: PanelContainer in panels: + panel.scale = Vector2(1.16, 1.16) + panel.modulate = Color(1.0, 1.0, 1.0, 1.0) + clear_tween.tween_property(panel, "scale", Vector2.ONE, 0.20) + clear_tween.tween_property(panel, "modulate", Color(1.0, 1.0, 1.0, 0.72), 0.20) + + +func _make_slot_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.content_margin_left = 4.0 + style.content_margin_top = 2.0 + style.content_margin_right = 4.0 + style.content_margin_bottom = 2.0 + style.bg_color = Color.TRANSPARENT + style.border_width_left = 0 + style.border_width_top = 0 + style.border_width_right = 0 + style.border_width_bottom = 0 + return style + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus \ No newline at end of file diff --git a/scenes/ui/combo_window_hud.gd.uid b/scenes/ui/combo_window_hud.gd.uid new file mode 100644 index 0000000..fb97b1f --- /dev/null +++ b/scenes/ui/combo_window_hud.gd.uid @@ -0,0 +1 @@ +uid://cm81sgp06lw8i diff --git a/scenes/ui/combo_window_hud.tscn b/scenes/ui/combo_window_hud.tscn new file mode 100644 index 0000000..d41507a --- /dev/null +++ b/scenes/ui/combo_window_hud.tscn @@ -0,0 +1,8 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scenes/ui/combo_window_hud.gd" id="1"] + +[node name="ComboWindowHud" type="Control"] +custom_minimum_size = Vector2(255, 51) +mouse_filter = 2 +script = ExtResource("1") \ No newline at end of file diff --git a/scenes/ui/energy_bar.gd b/scenes/ui/energy_bar.gd new file mode 100644 index 0000000..2589f82 --- /dev/null +++ b/scenes/ui/energy_bar.gd @@ -0,0 +1,22 @@ +class_name EnergyBar +extends ProgressBar + + +func _ready() -> void: + show_percentage = false + _event_bus().connect("player_energy_changed", refresh) + + +func refresh(current: float, maximum: float) -> void: + max_value = maxf(0.01, maximum) + value = clampf(current, 0.0, maximum) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus \ No newline at end of file diff --git a/scenes/ui/energy_bar.gd.uid b/scenes/ui/energy_bar.gd.uid new file mode 100644 index 0000000..31ddbd9 --- /dev/null +++ b/scenes/ui/energy_bar.gd.uid @@ -0,0 +1 @@ +uid://ckjx6raiygarl diff --git a/scenes/ui/energy_bar.tscn b/scenes/ui/energy_bar.tscn new file mode 100644 index 0000000..2dd840b --- /dev/null +++ b/scenes/ui/energy_bar.tscn @@ -0,0 +1,19 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scenes/ui/energy_bar.gd" id="1"] +[ext_resource type="Texture2D" path="res://assets/ui/panels/player_blue_ui.png" id="2_blue_fill"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_energy_bg"] +bg_color = Color(0.02, 0.04, 0.07, 0.55) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_energy_fill"] +texture = ExtResource("2_blue_fill") + +[node name="EnergyBar" type="ProgressBar"] +custom_minimum_size = Vector2(128, 15) +theme_override_styles/background = SubResource("StyleBoxFlat_energy_bg") +theme_override_styles/fill = SubResource("StyleBoxTexture_energy_fill") +max_value = 100.0 +value = 100.0 +show_percentage = false +script = ExtResource("1") \ No newline at end of file diff --git a/scenes/ui/main_ui.gd b/scenes/ui/main_ui.gd new file mode 100644 index 0000000..8fb9df5 --- /dev/null +++ b/scenes/ui/main_ui.gd @@ -0,0 +1,771 @@ +class_name MainUI +extends Control + +const DefenseResolverScript := preload("res://scripts/resolvers/defense_resolver.gd") +const ActionPatternExporterScript := preload("res://tools/export_action_patterns.gd") + +@onready var rhythm_track: Control = $RhythmTrack +@onready var status_bars: Control = $StatusBars +@onready var boss_status: Control = $BossStatus +@onready var combo_window: Control = $ComboWindow +@onready var time_phase_hud: Control = $TimePhaseHud +@onready var debug_panel: Control = $DebugPanel +@onready var health_bar: ProgressBar = $StatusBars/HealthBar +@onready var charge_bar: ProgressBar = $StatusBars/ChargeBar +@onready var charge_level_label: Label = $StatusBars/ChargeLevelLabel +@onready var boss_health_bar: ProgressBar = $BossStatus/BossHealthBar +@onready var boss_name_label: Label = $BossStatus/BossNameLabel +@onready var combo_skill_label: Label = $ComboSkillLabel +@onready var state_axes_label: Label = $DebugPanel/StateAxesLabel +@onready var action_label: Label = $DebugPanel/ActionLabel +@onready var anchor_label: Label = $DebugPanel/AnchorLabel +@onready var effects_label: Label = $DebugPanel/EffectsLabel +@onready var defense_label: Label = $DebugPanel/DefenseLabel +@onready var patterns_label: Label = $DebugPanel/PatternsLabel +@onready var hit_log_label: Label = $DebugPanel/HitLogLabel +@onready var calibration_label: Label = $DebugPanel/CalibrationLabel +@onready var time_phase_label: Label = $TimePhaseHud/TimePhaseLabel +@onready var attack_buff_label: Label = $TimePhaseHud/AttackBuffLabel +@onready var streak_label: Label = $TimePhaseHud/StreakLabel +@onready var time_phase_debug_label: Label = $DebugPanel/TimePhaseDebugLabel +@onready var time_anchor_debug_label: Label = $DebugPanel/TimeAnchorDebugLabel + +const DESIGN_SIZE := Vector2(1280.0, 720.0) +const HUD_MIN_SCALE := 0.72 +const HUD_MAX_SCALE := 1.15 +const TIME_PHASE_PAST_COLOR := Color(1.0, 0.84, 0.4) +const TIME_PHASE_FUTURE_COLOR := Color(0.45, 0.85, 1.0) +const COMBO_SKILL_IDLE_SCALE := Vector2.ONE +const COMBO_SKILL_POP_SCALE := Vector2(1.12, 1.12) +const MOVE_LIST_ACTION := &"toggle_move_list" +const MOVE_LIST_PANEL_SIZE := Vector2(430.0, 430.0) +const MOVE_LIST_SECTIONS := [ + { + "title": "基础", + "rows": [ + ["A / D", "左右地面三段斩"], + ["W", "上挑"], + ["S", "格挡 / 空中下劈"], + ["空中 A / D", "空中斩"], + ], + }, + { + "title": "SP 派生", + "rows": [ + ["A/D + SP", "突进斩"], + ["A/A 或 D/D + SP", "连段终结"], + ["A/A/A 或 D/D/D + SP", "震地击"], + ], + }, + { + "title": "蓄力", + "rows": [ + ["A/D 按住后松开", "剑雨 Lv1-3"], + ["S 按住 + SP", "斩波 Lv1-3"], + ], + }, +] + +var charge_bar_ready := false +var charge_flash := 0.0 +var debug_actor: Node +var boss_actor: Node +var _boss_health_component: Node +var _patterns_debug_text := "Patterns -" +var _hit_log_entries: Array[String] = [] +var _last_diff_ms := INF +var _current_time_phase: StringName = &"past" +var _streak_count := 0 +var _attack_buff_stacks := 0 +var _attack_buff_max := 20 +var _last_viewport_size := Vector2.ZERO +var _boss_room_active := false +var move_list_panel: PanelContainer + + +func _ready() -> void: + _ensure_move_list_input_action() + _build_move_list_panel() + _apply_responsive_layout() + var viewport := get_viewport() + var resize_callback := Callable(self, "_apply_responsive_layout") + if viewport != null and not viewport.size_changed.is_connected(resize_callback): + viewport.size_changed.connect(resize_callback) + _apply_bar_styles() + _patterns_debug_text = _debug_patterns() + var bus := _event_bus() + bus.connect("player_health_changed", _on_health_changed) + bus.connect("player_charge_changed", _on_charge_changed) + bus.connect("skill_executed", _on_skill_executed) + if bus.has_signal("judgement_made") and not bus.is_connected("judgement_made", _on_judgement_made): + bus.connect("judgement_made", _on_judgement_made) + if bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed): + bus.connect("hit_confirmed", _on_hit_confirmed) + if bus.has_signal("time_phase_changed") and not bus.is_connected("time_phase_changed", _on_time_phase_changed): + bus.connect("time_phase_changed", _on_time_phase_changed) + if bus.has_signal("attack_buff_changed") and not bus.is_connected("attack_buff_changed", _on_attack_buff_changed): + bus.connect("attack_buff_changed", _on_attack_buff_changed) + if bus.has_signal("streak_changed") and not bus.is_connected("streak_changed", _on_streak_changed): + bus.connect("streak_changed", _on_streak_changed) + if bus.has_signal("flow_state_changed") and not bus.is_connected("flow_state_changed", _on_flow_state_changed): + bus.connect("flow_state_changed", _on_flow_state_changed) + var time_phase_manager := get_tree().root.get_node_or_null("TimePhaseManager") + if time_phase_manager != null: + _current_time_phase = StringName(str(time_phase_manager.get("current_time_phase"))) + _boss_room_active = _current_flow_state() == &"Gameplay_BossRoom" + _update_boss_status_visibility() + _refresh_time_phase_hud() + + +func _unhandled_input(event: InputEvent) -> void: + var key_event := event as InputEventKey + if key_event != null and key_event.echo: + return + var pressed := event.is_action_pressed(MOVE_LIST_ACTION) + if not pressed and key_event != null and key_event.pressed: + pressed = key_event.keycode == KEY_E or key_event.physical_keycode == KEY_E + if not pressed: + return + toggle_move_list() + var viewport := get_viewport() + if viewport != null: + viewport.set_input_as_handled() + + +func _process(delta: float) -> void: + var viewport_size := get_viewport_rect().size + if viewport_size != _last_viewport_size: + _apply_responsive_layout() + _update_charge_bar_flash(delta) + refresh_debug_panel() + + +func _apply_responsive_layout() -> void: + var viewport_size := get_viewport_rect().size + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return + _last_viewport_size = viewport_size + set_anchors_preset(Control.PRESET_FULL_RECT) + offset_left = 0.0 + offset_top = 0.0 + offset_right = 0.0 + offset_bottom = 0.0 + var s := _hud_scale(viewport_size) + + if status_bars != null: + status_bars.set_anchors_preset(Control.PRESET_TOP_LEFT, true) + status_bars.pivot_offset = Vector2.ZERO + status_bars.size = Vector2(261.0, 111.0) + status_bars.position = Vector2(34.0, 28.0) * s + status_bars.scale = Vector2.ONE * (1.25 * s) + + if boss_status != null: + var boss_scale := 1.18 * s + var boss_x := maxf(16.0 * s, viewport_size.x - 298.0 * boss_scale - 28.0 * s) + boss_status.set_anchors_preset(Control.PRESET_TOP_LEFT, true) + boss_status.pivot_offset = Vector2.ZERO + boss_status.size = Vector2(298.0, 79.0) + boss_status.position = Vector2(boss_x, 34.0 * s) + boss_status.scale = Vector2.ONE * boss_scale + + if rhythm_track != null: + rhythm_track.set_anchors_preset(Control.PRESET_FULL_RECT) + rhythm_track.offset_left = 0.0 + rhythm_track.offset_top = 0.0 + rhythm_track.offset_right = 0.0 + rhythm_track.offset_bottom = 0.0 + rhythm_track.scale = Vector2.ONE + + _layout_combo_window(viewport_size, s) + + if combo_skill_label != null: + combo_skill_label.visible = false + combo_skill_label.text = "" + + if time_phase_hud != null: + time_phase_hud.visible = false + + if debug_panel != null: + debug_panel.visible = false + debug_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT, true) + debug_panel.offset_left = -368.0 * s + debug_panel.offset_top = 172.0 * s + debug_panel.offset_right = -24.0 * s + debug_panel.offset_bottom = 348.0 * s + + _layout_move_list_panel(viewport_size, s) + + +func _layout_combo_window(viewport_size: Vector2, s: float) -> void: + if combo_window == null: + return + var combo_scale := 1.28 * s + var combo_size := Vector2(255.0, 51.0) + var x := 42.0 * s + var y := viewport_size.y - 310.0 * s + x = clampf(x, 24.0 * s, maxf(24.0 * s, viewport_size.x - combo_size.x * combo_scale - 24.0 * s)) + y = clampf(y, 170.0 * s, maxf(170.0 * s, viewport_size.y - combo_size.y * combo_scale - 150.0 * s)) + combo_window.set_anchors_preset(Control.PRESET_TOP_LEFT, true) + combo_window.pivot_offset = Vector2.ZERO + combo_window.size = combo_size + combo_window.scale = Vector2.ONE * combo_scale + combo_window.position = Vector2(x, y) + + +func _hud_scale(viewport_size: Vector2) -> float: + return clampf(minf(viewport_size.x / DESIGN_SIZE.x, viewport_size.y / DESIGN_SIZE.y), HUD_MIN_SCALE, HUD_MAX_SCALE) + + +func toggle_move_list() -> void: + set_move_list_visible(move_list_panel == null or not move_list_panel.visible) + + +func set_move_list_visible(visible: bool) -> void: + if move_list_panel == null: + _build_move_list_panel() + move_list_panel.visible = visible + + +func _ensure_move_list_input_action() -> void: + if not InputMap.has_action(MOVE_LIST_ACTION): + InputMap.add_action(MOVE_LIST_ACTION) + if _input_action_has_key(MOVE_LIST_ACTION, KEY_E): + return + var event := InputEventKey.new() + event.keycode = KEY_E + event.physical_keycode = KEY_E + InputMap.action_add_event(MOVE_LIST_ACTION, event) + + +func _input_action_has_key(action_name: StringName, keycode: Key) -> bool: + if not InputMap.has_action(action_name): + return false + for event: InputEvent in InputMap.action_get_events(action_name): + var key_event := event as InputEventKey + if key_event != null and (key_event.keycode == keycode or key_event.physical_keycode == keycode): + return true + return false + + +func _build_move_list_panel() -> void: + if move_list_panel != null: + return + move_list_panel = PanelContainer.new() + move_list_panel.name = "MoveListPanel" + move_list_panel.visible = false + move_list_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + move_list_panel.z_index = 40 + move_list_panel.custom_minimum_size = MOVE_LIST_PANEL_SIZE + move_list_panel.add_theme_stylebox_override( + "panel", + _make_style(Color(0.045, 0.055, 0.07, 0.88), Color(0.7, 0.95, 1.0, 0.55)) + ) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 18) + margin.add_theme_constant_override("margin_top", 16) + margin.add_theme_constant_override("margin_right", 18) + margin.add_theme_constant_override("margin_bottom", 16) + move_list_panel.add_child(margin) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 9) + margin.add_child(content) + + var title := _make_move_list_label("招式表", 27, Color(0.92, 0.98, 1.0), HORIZONTAL_ALIGNMENT_CENTER) + title.custom_minimum_size = Vector2(0.0, 34.0) + content.add_child(title) + + for section: Dictionary in MOVE_LIST_SECTIONS: + _add_move_list_section(content, section) + + add_child(move_list_panel) + + +func _add_move_list_section(content: VBoxContainer, section: Dictionary) -> void: + var section_title := _make_move_list_label(str(section.get("title", "")), 16, Color(0.98, 0.78, 0.36), HORIZONTAL_ALIGNMENT_LEFT) + section_title.custom_minimum_size = Vector2(0.0, 22.0) + content.add_child(section_title) + + var grid := GridContainer.new() + grid.columns = 2 + grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_theme_constant_override("h_separation", 12) + grid.add_theme_constant_override("v_separation", 5) + content.add_child(grid) + + for row: Array in section.get("rows", []): + _add_move_list_row(grid, str(row[0]), str(row[1])) + + +func _add_move_list_row(grid: GridContainer, key_text: String, move_text: String) -> void: + var key_cell := PanelContainer.new() + key_cell.custom_minimum_size = Vector2(145.0, 27.0) + key_cell.add_theme_stylebox_override( + "panel", + _make_style(Color(0.1, 0.13, 0.16, 0.95), Color(0.35, 0.72, 0.92, 0.65)) + ) + var key_label := _make_move_list_label(key_text, 14, Color(0.88, 0.96, 1.0), HORIZONTAL_ALIGNMENT_CENTER) + key_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + key_cell.add_child(key_label) + grid.add_child(key_cell) + + var move_label := _make_move_list_label(move_text, 15, Color(0.86, 0.9, 0.92), HORIZONTAL_ALIGNMENT_LEFT) + move_label.custom_minimum_size = Vector2(220.0, 27.0) + move_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + move_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + move_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(move_label) + + +func _make_move_list_label(text: String, font_size: int, color: Color, alignment: HorizontalAlignment) -> Label: + var label := Label.new() + label.text = text + label.horizontal_alignment = alignment + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", color) + return label + + +func _layout_move_list_panel(viewport_size: Vector2, s: float) -> void: + if move_list_panel == null: + return + var panel_scale := 0.96 * s + var margin := 30.0 * s + var min_y := 126.0 * s + var desired_y := maxf(min_y, viewport_size.y * 0.2) + var max_y := maxf(min_y, viewport_size.y - MOVE_LIST_PANEL_SIZE.y * panel_scale - 52.0 * s) + move_list_panel.set_anchors_preset(Control.PRESET_TOP_LEFT, true) + move_list_panel.pivot_offset = Vector2.ZERO + move_list_panel.size = MOVE_LIST_PANEL_SIZE + move_list_panel.scale = Vector2.ONE * panel_scale + move_list_panel.position = Vector2( + maxf(18.0 * s, viewport_size.x - MOVE_LIST_PANEL_SIZE.x * panel_scale - margin), + clampf(desired_y, min_y, max_y) + ) + +func _on_health_changed(current: int, maximum: int) -> void: + health_bar.max_value = max(1, maximum) + health_bar.value = clampi(current, 0, maximum) + + +func _on_charge_changed(current: float, maximum: float, ready: bool, active: bool) -> void: + charge_bar.max_value = maxf(0.01, maximum) + charge_bar.value = clampf(current, 0.0, maximum) + charge_bar_ready = ready and active + _update_charge_level_display(current, maximum, ready, active) + if charge_bar_ready: + return + charge_bar.modulate = Color(1.0, 1.0, 1.0, 1.0 if active or ready else 0.45) + + +func _update_charge_level_display(current: float, maximum: float, ready: bool, active: bool) -> void: + if charge_level_label == null: + return + if not active: + charge_level_label.text = "" + return + var max_level := maxi(2, int(round(maximum)) + 1) + var level := clampi(1 + int(floor(current + 0.0001)), 1, max_level) + charge_level_label.text = "CHARGE LV%d / %d%s" % [level, max_level, " MAX" if ready else ""] + var level_color := _charge_level_color(level) + charge_level_label.add_theme_color_override("font_color", level_color) + charge_bar.add_theme_stylebox_override( + "fill", + _make_style(level_color, Color.TRANSPARENT, false) + ) + + +func _charge_level_color(level: int) -> Color: + match level: + 1: + return Color(0.92, 0.72, 0.25) + 2: + return Color(1.0, 0.55, 0.2) + return Color(1.0, 0.32, 0.28) + + +func _on_skill_executed(skill: Resource, _judgement: StringName) -> void: + if combo_skill_label == null or not combo_skill_label.visible: + return + var display_name := str(skill.get("display_name")) + if display_name.is_empty(): + display_name = str(skill.get("id")).to_upper() + combo_skill_label.text = display_name + var tween := create_tween() + combo_skill_label.scale = COMBO_SKILL_POP_SCALE + tween.tween_property(combo_skill_label, "scale", COMBO_SKILL_IDLE_SCALE, 0.12) + + +func bind_debug_actor(actor: Node) -> void: + debug_actor = actor + _apply_responsive_layout() + refresh_debug_panel() + + +func bind_boss_actor(actor: Node) -> void: + boss_actor = actor + _refresh_boss_name_label() + _bind_boss_health_component() + _update_boss_status_visibility() + + +func _on_flow_state_changed(_previous: StringName, current: StringName) -> void: + _boss_room_active = current == &"Gameplay_BossRoom" + _update_boss_status_visibility() + + +func _update_boss_status_visibility() -> void: + if boss_status == null: + return + boss_status.visible = _boss_room_active and boss_actor != null and is_instance_valid(boss_actor) + + +func _current_flow_state() -> StringName: + var flow := get_tree().root.get_node_or_null("GameFlowManager") + if flow == null: + return &"" + return StringName(str(flow.get("state"))) + + +func _refresh_boss_name_label() -> void: + if boss_name_label == null: + return + if boss_actor == null or not is_instance_valid(boss_actor): + return + var display_name: Variant = boss_actor.get("display_name") + if display_name != null and not str(display_name).is_empty(): + boss_name_label.text = str(display_name) + + +func refresh_debug_panel() -> void: + if debug_actor == null or not is_instance_valid(debug_actor): + _write_debug_defaults() + return + var state_machine := debug_actor.get_node_or_null("StateMachine") + var context := {} + if state_machine != null and state_machine.has_method("build_context"): + context = state_machine.call("build_context") + state_axes_label.text = "State %s / %s / %s / %s" % [ + context.get("life_state", &"Alive"), + context.get("ground_state", &"Grounded"), + context.get("action_phase", &"Neutral"), + context.get("defense_state", &"Vulnerable"), + ] + var action_controller := debug_actor.get_node_or_null("ActionController") + action_label.text = "Action %s" % _debug_action_id(action_controller) + anchor_label.text = "Anchor %s stretch %.3f" % [ + _debug_anchor_grid(action_controller), + _debug_startup_stretch(action_controller), + ] + var effect_container := debug_actor.get_node_or_null("EffectContainer") + effects_label.text = "Effects %s" % _debug_effects(effect_container) + defense_label.text = _debug_baseline_defense(context, effect_container) + patterns_label.text = _patterns_debug_text + calibration_label.text = _debug_calibration() + _write_hit_log_label() + _write_time_phase_debug() + + +func _write_debug_defaults() -> void: + state_axes_label.text = "State Alive / Grounded / Neutral / Vulnerable" + action_label.text = "Action -" + anchor_label.text = "Anchor - stretch 0.000" + effects_label.text = "Effects -" + defense_label.text = "Baseline Vulnerable damage 1.00 interrupt true" + patterns_label.text = _patterns_debug_text + calibration_label.text = _debug_calibration() + _write_hit_log_label() + _write_time_phase_debug() + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + _current_time_phase = current + _refresh_time_phase_hud() + + +func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void: + _attack_buff_stacks = stacks + _attack_buff_max = max_stacks + _refresh_time_phase_hud() + + +func _on_streak_changed(count: int) -> void: + _streak_count = count + _refresh_time_phase_hud() + + +func _refresh_time_phase_hud() -> void: + if time_phase_label == null: + return + var phase_color := TIME_PHASE_FUTURE_COLOR if _current_time_phase == &"future" else TIME_PHASE_PAST_COLOR + time_phase_label.text = str(_current_time_phase).to_upper() + _next_time_anchor_hint() + time_phase_label.add_theme_color_override("font_color", phase_color) + attack_buff_label.text = "ATK BUFF %d/%d (+%d%%)" % [_attack_buff_stacks, _attack_buff_max, _attack_buff_stacks * 10] + streak_label.text = "STREAK %d" % _streak_count + + +func _next_time_anchor_hint() -> String: + var time_anchor_system := get_tree().root.get_node_or_null("TimeAnchorSystem") + var rhythm := get_tree().root.get_node_or_null("RhythmManager") + if time_anchor_system == null or rhythm == null or not time_anchor_system.has_method("armed_anchor_summaries"): + return "" + var summaries: Array = time_anchor_system.call("armed_anchor_summaries") + if summaries.is_empty(): + return "" + var beat_time := maxf(0.001, float(rhythm.get("beat_time"))) + var current_beat := (float(rhythm.call("song_position")) + float(rhythm.get("beat_offset"))) / beat_time + var next_beat := int((summaries[0] as Dictionary).get("beat", 0)) + var beats_left := maxf(0.0, float(next_beat) - current_beat) + return " ANCHOR %db" % int(ceil(beats_left)) + + +func _write_time_phase_debug() -> void: + if time_phase_debug_label == null: + return + time_phase_debug_label.text = "TimePhase %s streak %d buff %d/%d" % [ + _current_time_phase, _streak_count, _attack_buff_stacks, _attack_buff_max, + ] + time_anchor_debug_label.text = "TimeAnchor %s" % _debug_time_anchor_summary() + _refresh_time_phase_hud() + + +func _debug_time_anchor_summary() -> String: + var time_anchor_system := get_tree().root.get_node_or_null("TimeAnchorSystem") + if time_anchor_system == null or not time_anchor_system.has_method("armed_anchor_summaries"): + return "-" + var parts: Array[String] = [] + var summaries: Array = time_anchor_system.call("armed_anchor_summaries") + for summary: Variant in summaries: + if summary is Dictionary: + parts.append("b%d(%s)" % [int((summary as Dictionary).get("beat", 0)), (summary as Dictionary).get("source", &"-")]) + var last: Dictionary = time_anchor_system.call("last_resolved_summary") if time_anchor_system.has_method("last_resolved_summary") else {} + var last_text := "-" + if not last.is_empty(): + last_text = "b%d %s" % [int(last.get("beat", 0)), "held" if bool(last.get("held", false)) else "broken"] + if parts.is_empty(): + return "armed - last %s" % last_text + return "armed %s last %s" % [" ".join(parts), last_text] + + +func _on_hit_confirmed(result: Dictionary) -> void: + _hit_log_entries.push_front(_format_hit_result(result)) + _write_hit_log_label() + + +func _on_judgement_made(_quality: StringName, offset_ms: float, _beat_index: int) -> void: + _last_diff_ms = offset_ms + calibration_label.text = _debug_calibration() + + +func get_hit_log_entries() -> Array[String]: + return _hit_log_entries.duplicate() + + +func export_hit_log() -> String: + return "\n".join(_hit_log_entries) + + +func _debug_action_id(action_controller: Node) -> String: + if action_controller == null: + return "-" + var action: Resource = action_controller.get("current_action") as Resource + if action == null: + return "-" + var action_id := str(action.get("id")) + return action_id if not action_id.is_empty() else "-" + + +func _debug_anchor_grid(action_controller: Node) -> String: + if action_controller == null: + return "-" + return str(action_controller.get("anchor_grid")) + + +func _debug_startup_stretch(action_controller: Node) -> float: + if action_controller == null: + return 0.0 + return float(action_controller.get("startup_stretch_seconds")) + + +func _debug_effects(effect_container: Node) -> String: + if effect_container == null: + return "-" + if effect_container.has_method("active_effect_summaries"): + var summaries: Array = effect_container.call("active_effect_summaries") + if summaries.is_empty(): + return "-" + var parts: Array[String] = [] + for summary: Variant in summaries: + if summary is Dictionary: + parts.append(_format_effect_summary(summary)) + return ", ".join(parts) if not parts.is_empty() else "-" + if effect_container.has_method("active_effect_ids"): + var ids: Array = effect_container.call("active_effect_ids") + return "-" if ids.is_empty() else _join_string_names(ids) + return "-" + + +func _debug_baseline_defense(context: Dictionary, effect_container: Node) -> String: + var defense_context := context.duplicate() + if effect_container != null: + defense_context["effect_container"] = effect_container + var result: Dictionary = DefenseResolverScript.resolve_effective_defense(defense_context, []) + return "Baseline %s damage %.2f interrupt %s" % [ + result.get("defense_state", &"Vulnerable"), + float(result.get("damage_mult", 1.0)), + str(bool(result.get("interrupts", true))).to_lower(), + ] + + +func _debug_patterns() -> String: + var patterns: Dictionary = ActionPatternExporterScript.export_patterns() + if patterns.is_empty(): + return "Patterns -" + var highlighted := [] + for key: String in ["A", "D", "S", "W"]: + if patterns.has(key): + highlighted.append("%s->%s" % [key, patterns[key]]) + return "Patterns %d total %s" % [ + patterns.size(), + ", ".join(highlighted), + ] + + +func _format_hit_result(result: Dictionary) -> String: + var action: Resource = result.get("action", null) as Resource + var action_id := "-" + if action != null: + action_id = str(action.get("id")) + var defense = result.get("defense", {}) + var defense_state := &"Vulnerable" + if defense is Dictionary: + defense_state = StringName(str(defense.get("defense_state", &"Vulnerable"))) + return "%s dmg %d %s interrupt %s" % [ + action_id, + int(result.get("damage", 0)), + defense_state, + str(bool(result.get("interrupts", true))).to_lower(), + ] + + +func _write_hit_log_label() -> void: + if _hit_log_entries.is_empty(): + hit_log_label.text = "Hits -" + return + var compact := _hit_log_entries.duplicate() + if compact.size() > 3: + compact.resize(3) + hit_log_label.text = "Hits %s" % " | ".join(compact) + + +func _format_effect_summary(summary: Dictionary) -> String: + return "%s %s x%d src:%s" % [ + summary.get("id", &"-"), + _format_effect_remaining(StringName(str(summary.get("duration_type", &"infinite"))), float(summary.get("remaining", 0.0))), + int(summary.get("stacks", 1)), + summary.get("source", &"-"), + ] + + +func _format_effect_remaining(duration_type: StringName, remaining: float) -> String: + match duration_type: + &"seconds": + return "%.1fs" % remaining + &"beats": + return "%.1fb" % remaining + &"actions": + return "%da" % int(round(remaining)) + &"hits": + return "%dhit" % int(round(remaining)) + &"hurts": + return "%dhurt" % int(round(remaining)) + &"measures": + return "%dmeasure" % int(round(remaining)) + &"until_event": + return "until" + return "inf" + + +func _debug_calibration() -> String: + if is_inf(_last_diff_ms): + return "Diff -- ms" + return "Diff %+.0f ms" % _last_diff_ms + + +func _join_string_names(values: Array) -> String: + var parts: Array[String] = [] + for value: Variant in values: + parts.append(str(value)) + return ", ".join(parts) + + +func _update_charge_bar_flash(delta: float) -> void: + if not charge_bar_ready: + charge_flash = 0.0 + return + charge_flash = fmod(charge_flash + delta * 7.0, TAU) + var alpha := 0.62 + 0.38 * absf(sin(charge_flash)) + charge_bar.modulate = Color(1.0, 1.0, 1.0, alpha) + + +func _apply_bar_styles() -> void: + # Health and boss health bars keep their editor-authored StyleBoxTexture + # skins (playerui / boss_ui panel wells) so the editor view matches the + # running game; only the flat charge bar is styled at runtime. + charge_bar.add_theme_stylebox_override( + "background", + _make_style(Color(0.08, 0.07, 0.12, 0.86), Color(0.42, 0.36, 0.75, 0.9)) + ) + charge_bar.add_theme_stylebox_override( + "fill", + _make_style(Color(0.92, 0.72, 0.25, 1.0), Color.TRANSPARENT, false) + ) + + +func _make_style(bg_color: Color, border_color: Color, has_border := true) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = bg_color + if has_border: + style.border_width_left = 1 + style.border_width_top = 1 + style.border_width_right = 1 + style.border_width_bottom = 1 + style.border_color = border_color + return style + + +func _bind_boss_health_component() -> void: + if _boss_health_component != null and is_instance_valid(_boss_health_component): + var old_callback := Callable(self, "_on_boss_health_changed") + if _boss_health_component.is_connected("health_changed", old_callback): + _boss_health_component.disconnect("health_changed", old_callback) + _boss_health_component = null + if boss_actor == null or not is_instance_valid(boss_actor): + boss_health_bar.max_value = 1.0 + boss_health_bar.value = 0.0 + return + _boss_health_component = boss_actor.get_node_or_null("HealthComponent") + if _boss_health_component == null: + boss_health_bar.max_value = 1.0 + boss_health_bar.value = 0.0 + return + var callback := Callable(self, "_on_boss_health_changed") + if not _boss_health_component.is_connected("health_changed", callback): + _boss_health_component.connect("health_changed", callback) + _on_boss_health_changed(int(_boss_health_component.get("current")), int(_boss_health_component.get("maximum"))) + + +func _on_boss_health_changed(current: int, maximum: int) -> void: + boss_health_bar.max_value = max(1, maximum) + boss_health_bar.value = clampi(current, 0, maximum) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/ui/main_ui.gd.uid b/scenes/ui/main_ui.gd.uid new file mode 100644 index 0000000..f6b1f7e --- /dev/null +++ b/scenes/ui/main_ui.gd.uid @@ -0,0 +1 @@ +uid://ckv47o2ktmwjg diff --git a/scenes/ui/main_ui.tscn b/scenes/ui/main_ui.tscn new file mode 100644 index 0000000..371b39f --- /dev/null +++ b/scenes/ui/main_ui.tscn @@ -0,0 +1,313 @@ +[gd_scene format=3 uid="uid://d4fldy342eji2"] + +[ext_resource type="Script" uid="uid://ckv47o2ktmwjg" path="res://scenes/ui/main_ui.gd" id="1"] +[ext_resource type="PackedScene" uid="uid://csydrlqpqyx3s" path="res://scenes/ui/rhythm_track.tscn" id="2"] +[ext_resource type="PackedScene" path="res://scenes/ui/combo_window_hud.tscn" id="3"] +[ext_resource type="PackedScene" path="res://scenes/ui/energy_bar.tscn" id="4"] +[ext_resource type="Texture2D" uid="uid://28b848eqeh1m" path="res://assets/ui/panels/playerui.png" id="5_player_panel"] +[ext_resource type="Texture2D" uid="uid://dvvfegdyyadmt" path="res://assets/ui/panels/player_red_ui.png" id="6_player_fill"] +[ext_resource type="Texture2D" uid="uid://bra1w65idoypp" path="res://assets/ui/panels/boss_ui.png" id="7_boss_panel"] +[ext_resource type="Texture2D" uid="uid://c2i4gxu07l8pd" path="res://assets/ui/panels/boss_red_ui.png" id="8_boss_fill"] +[ext_resource type="Texture2D" uid="uid://co5xfbp12oxkm" path="res://assets/ui/panels/boss_portrait.png" id="9_boss_portrait"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hp_bg"] +bg_color = Color(0.09, 0.06, 0.07, 0.55) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_hp_fill"] +texture = ExtResource("6_player_fill") + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_boss_bg"] +bg_color = Color(0.08, 0.05, 0.06, 0.55) + +[sub_resource type="StyleBoxTexture" id="StyleBoxTexture_boss_fill"] +texture = ExtResource("8_boss_fill") + +[node name="MainUI" type="Control" unique_id=1839627378] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1") + +[node name="RhythmTrack" parent="." unique_id=1068155307 instance=ExtResource("2")] +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -432.0 +offset_top = 0.0 +offset_right = 432.0 +offset_bottom = 0.0 +grow_horizontal = 2 +pivot_offset = Vector2(432, 0) +scale = Vector2(1, 1) + +[node name="StatusBars" type="Control" parent="." unique_id=988601372] +anchors_preset = 0 +offset_left = 34.0 +offset_top = 28.0 +offset_right = 295.0 +offset_bottom = 139.0 +scale = Vector2(1.25, 1.25) +[node name="PanelArt" type="TextureRect" parent="StatusBars" unique_id=471259199] +layout_mode = 0 +offset_right = 261.0 +offset_bottom = 111.0 +texture = ExtResource("5_player_panel") +expand_mode = 1 +[node name="HealthBar" type="ProgressBar" parent="StatusBars" unique_id=1224155284] +layout_mode = 0 +offset_left = 118.0 +offset_top = 24.0 +offset_right = 242.0 +offset_bottom = 37.0 +theme_override_styles/background = SubResource("StyleBoxFlat_hp_bg") +theme_override_styles/fill = SubResource("StyleBoxTexture_hp_fill") +value = 100.0 +show_percentage = false +[node name="EnergyBar" parent="StatusBars" unique_id=1355963744 instance=ExtResource("4")] +layout_mode = 0 +offset_left = 116.0 +offset_top = 48.0 +offset_right = 244.0 +offset_bottom = 63.0 +[node name="ChargeBar" type="ProgressBar" parent="StatusBars" unique_id=720162692] +visible = false +layout_mode = 0 +offset_left = 116.0 +offset_top = 74.0 +offset_right = 244.0 +offset_bottom = 87.0 +max_value = 1.1 +show_percentage = false +[node name="ChargeLevelLabel" type="Label" parent="StatusBars" unique_id=360971327] +visible = false +layout_mode = 0 +offset_left = 116.0 +offset_top = 70.0 +offset_right = 244.0 +offset_bottom = 92.0 +theme_override_colors/font_color = Color(0.92, 0.72, 0.25, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 16 +[node name="BossStatus" type="Control" parent="." unique_id=529865591] +visible = false +anchors_preset = 0 +offset_left = 900.36 +offset_top = 34.0 +offset_right = 1198.36 +offset_bottom = 113.0 +scale = Vector2(1.18, 1.18) +[node name="PanelArt" type="TextureRect" parent="BossStatus" unique_id=1398889507] +layout_mode = 0 +offset_right = 298.0 +offset_bottom = 79.0 +texture = ExtResource("7_boss_panel") +expand_mode = 1 +[node name="BossPortrait" type="TextureRect" parent="BossStatus"] +layout_mode = 0 +offset_left = 236.0 +offset_top = 11.0 +offset_right = 293.0 +offset_bottom = 69.0 +texture = ExtResource("9_boss_portrait") +[node name="BossHealthBar" type="ProgressBar" parent="BossStatus" unique_id=255410015] +layout_mode = 0 +offset_left = 25.0 +offset_top = 35.0 +offset_right = 219.0 +offset_bottom = 50.0 +theme_override_styles/background = SubResource("StyleBoxFlat_boss_bg") +theme_override_styles/fill = SubResource("StyleBoxTexture_boss_fill") +max_value = 28000.0 +value = 28000.0 +show_percentage = false +[node name="BossNameLabel" type="Label" parent="BossStatus" unique_id=1576490707] +visible = false +layout_mode = 0 +offset_left = 25.0 +offset_top = 8.0 +offset_right = 219.0 +offset_bottom = 30.0 +theme_override_colors/font_color = Color(0.95, 0.86, 0.72, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 18 +text = "DREAD HARBINGER" +horizontal_alignment = 1 +vertical_alignment = 1 +[node name="ComboWindow" parent="." unique_id=586844780 instance=ExtResource("3")] +layout_mode = 0 +offset_left = 42.0 +offset_top = 410.0 +offset_right = 297.0 +offset_bottom = 461.0 +scale = Vector2(1.28, 1.28) +[node name="ComboSkillLabel" type="Label" parent="." unique_id=1934463479] +visible = false +layout_mode = 1 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -360.0 +offset_top = 548.0 +offset_right = 360.0 +offset_bottom = 590.0 +grow_horizontal = 2 +theme_override_colors/font_color = Color(1, 0.84, 0.26, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 3 +theme_override_constants/shadow_offset_y = 3 +theme_override_font_sizes/font_size = 30 +horizontal_alignment = 1 +vertical_alignment = 1 +[node name="TimePhaseHud" type="VBoxContainer" parent="." unique_id=293359189] +visible = false +layout_mode = 0 +offset_left = 40.0 +offset_top = 430.0 +offset_right = 420.0 +offset_bottom = 540.0 +theme_override_constants/separation = 4 + +[node name="TimePhaseLabel" type="Label" parent="TimePhaseHud" unique_id=1712218313] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.84, 0.4, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 30 +text = "PAST" + +[node name="AttackBuffLabel" type="Label" parent="TimePhaseHud" unique_id=1432771975] +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.62, 0.32, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 20 +text = "ATK BUFF 0/20" + +[node name="StreakLabel" type="Label" parent="TimePhaseHud" unique_id=920703172] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.85) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 20 +text = "STREAK 0" + +[node name="DebugPanel" type="VBoxContainer" parent="." unique_id=666041163] +visible = false +layout_mode = 0 +offset_left = 912.0 +offset_top = 172.0 +offset_right = 1270.0 +offset_bottom = 348.0 +theme_override_constants/separation = 4 + +[node name="StateAxesLabel" type="Label" parent="DebugPanel" unique_id=1326887607] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "State Alive / Grounded / Neutral / Vulnerable" +horizontal_alignment = 2 + +[node name="ActionLabel" type="Label" parent="DebugPanel" unique_id=823075570] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Action -" +horizontal_alignment = 2 + +[node name="AnchorLabel" type="Label" parent="DebugPanel" unique_id=287138792] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Anchor - stretch 0.000" +horizontal_alignment = 2 + +[node name="EffectsLabel" type="Label" parent="DebugPanel" unique_id=850058697] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Effects -" +horizontal_alignment = 2 + +[node name="DefenseLabel" type="Label" parent="DebugPanel" unique_id=1580894607] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Baseline Vulnerable damage 1.00 interrupt true" +horizontal_alignment = 2 + +[node name="PatternsLabel" type="Label" parent="DebugPanel" unique_id=1764315844] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Patterns -" +horizontal_alignment = 2 + +[node name="HitLogLabel" type="Label" parent="DebugPanel" unique_id=137664462] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Hits -" +horizontal_alignment = 2 + +[node name="CalibrationLabel" type="Label" parent="DebugPanel" unique_id=1640694321] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "Diff -- ms" +horizontal_alignment = 2 + +[node name="TimePhaseDebugLabel" type="Label" parent="DebugPanel" unique_id=1559421767] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "TimePhase past streak 0 buff 0" +horizontal_alignment = 2 + +[node name="TimeAnchorDebugLabel" type="Label" parent="DebugPanel" unique_id=683859755] +layout_mode = 2 +theme_override_colors/font_color = Color(0.84, 0.94, 1, 1) +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 1 +theme_override_constants/shadow_offset_y = 1 +theme_override_font_sizes/font_size = 12 +text = "TimeAnchor -" +horizontal_alignment = 2 diff --git a/scenes/ui/rhythm_track.gd b/scenes/ui/rhythm_track.gd new file mode 100644 index 0000000..78c939e --- /dev/null +++ b/scenes/ui/rhythm_track.gd @@ -0,0 +1,561 @@ +class_name RhythmTrack +extends Control + +@onready var judgement_label: Label = $JudgementLabel +@onready var track_background: TextureRect = $TrackBackground +@onready var track_line: TextureRect = $TrackLine +@onready var center_base: TextureRect = $CenterBase +@onready var center_flash: TextureRect = $CenterFlash +@onready var left_mover: TextureRect = $LeftMover +@onready var right_mover: TextureRect = $RightMover +@onready var chart_marker_container: Control = $ChartMarkerContainer +@onready var combo_count_label: Label = $HudReadoutRow/ComboCountLabel +@onready var attack_buff_value_label: Label = $HudReadoutRow/AttackBuffValueLabel +@onready var buff_pip_row: HBoxContainer = $BuffPipRow +@onready var judgement_art: TextureRect = $JudgementArt + +@export var bpm := 80.0 +@export var responsive_layout := true + +# Full track skin per time phase: map A (past) is the purple/gold set, map B +# (future) the red "d" set. Time anchors never add screen elements — the mover +# itself swaps to the special-beat texture. A held special beat shows the +# OTHER phase's hit emblem (cross variant) while the flash stays own-phase. +const TRACK_SKINS := { + &"past": { + &"background": preload("res://assets/ui/rhythm/b.png"), + &"center_idle": preload("res://assets/ui/rhythm/anchor01.png"), + &"center_hit_normal": preload("res://assets/ui/rhythm/anchor02.png"), + &"center_hit_special": preload("res://assets/ui/rhythm/anchor02d.png"), + &"flash_normal": preload("res://assets/ui/rhythm/c01.png"), + &"flash_special": preload("res://assets/ui/rhythm/c02.png"), + &"mover_normal": preload("res://assets/ui/rhythm/star.png"), + &"mover_special": preload("res://assets/ui/rhythm/sanchor.png"), + }, + &"future": { + &"background": preload("res://assets/ui/rhythm/bd.png"), + &"center_idle": preload("res://assets/ui/rhythm/anchor01d.png"), + &"center_hit_normal": preload("res://assets/ui/rhythm/anchor02d.png"), + &"center_hit_special": preload("res://assets/ui/rhythm/anchor02.png"), + &"flash_normal": preload("res://assets/ui/rhythm/c01d.png"), + &"flash_special": preload("res://assets/ui/rhythm/c02d.png"), + &"mover_normal": preload("res://assets/ui/rhythm/stard.png"), + &"mover_special": preload("res://assets/ui/rhythm/sanchord.png"), + }, +} + +const DESIGN_SIZE := Vector2(1280.0, 720.0) +const HUD_MIN_SCALE := 0.72 +const HUD_MAX_SCALE := 1.15 +const CENTER_HIT_HOLD_SECONDS := 0.62 +const BEAT_TICK_FLASH_PEAK := 0.35 +const BEAT_FLASH_DECAY_RATE := 8.0 +const CENTER_HIT_FLASH_DECAY_RATE := 2.8 +const CENTER_HIT_FLASH_SCALE := 1.55 +const TIME_ANCHOR_HELD_COLOR := Color(0.45, 1.0, 0.75) +const TIME_ANCHOR_BROKEN_COLOR := Color(1.0, 0.2, 0.9) +const TIME_ANCHOR_MOVER_SCALE := 1.65 +const BUFF_SLOT_COUNT := 7 +const BUFF_ON_TEXTURE := preload("res://assets/ui/buff/buff_on.png") +const BUFF_OFF_TEXTURE := preload("res://assets/ui/buff/buff_off.png") +const JUDGEMENT_ARTS := { + &"perfect": preload("res://assets/ui/judgements/perfect.png"), + &"good": preload("res://assets/ui/judgements/good.png"), + &"bad": preload("res://assets/ui/judgements/bad.png"), + &"miss": preload("res://assets/ui/judgements/miss.png"), +} +## 2026-07-05 策划:判定字样显示 1 秒后自动消失;新判定顶替时重置计时。 +const JUDGEMENT_VISIBLE_SECONDS := 1.0 + +var chart_markers: Array[Control] = [] +var _time_anchor_beats: Dictionary = {} +var _last_beat_index := -1 +var _last_upcoming_beat := -1 +var _time_phase: StringName = &"past" +var _streak_count := 0 +var _attack_buff_stacks := 0 +var _attack_buff_max := BUFF_SLOT_COUNT +var _center_hit_timer := 0.0 +var _judgement_visible_left := 0.0 +var _time_anchor_feedback_latched := false +var track_center := Vector2.ZERO +var left_mover_start := Vector2.ZERO +var right_mover_start := Vector2.ZERO +var mover_size := Vector2.ZERO +var normal_mover_size := Vector2.ZERO +var time_anchor_mover_size := Vector2.ZERO +var center_flash_size := Vector2.ZERO +var normal_center_flash_size := Vector2.ZERO +var hit_center_flash_size := Vector2.ZERO +var center_flash_decay_rate := BEAT_FLASH_DECAY_RATE +var center_flash_color := Color.WHITE +var center_flash_peak := 1.0 +var beat_flash := 0.0 +var beat_age := 0.0 +var feedback_flash := 0.0 +var _last_layout_size := Vector2.ZERO +var _responsive_scale := 1.0 + + +func _ready() -> void: + _apply_responsive_layout() + _cache_rhythm_track_layout() + center_flash.modulate = Color(1.0, 1.0, 1.0, 0.0) + _sync_bpm_with_rhythm_manager() + _apply_time_phase_skin(_initial_time_phase()) + _refresh_combo_count() + _refresh_attack_buff_display() + var bus := _event_bus() + bus.connect("beat_ticked", _on_beat_ticked) + bus.connect("judgement_made", _on_judgement_made) + bus.connect("chart_event_upcoming", _on_chart_event_upcoming) + bus.connect("chart_event_triggered", _on_chart_event_triggered) + if bus.has_signal("time_anchor_scheduled"): + bus.connect("time_anchor_scheduled", _on_time_anchor_scheduled) + if bus.has_signal("time_anchor_resolved"): + bus.connect("time_anchor_resolved", _on_time_anchor_resolved) + if bus.has_signal("time_phase_changed"): + bus.connect("time_phase_changed", _on_time_phase_changed) + if bus.has_signal("chart_reset"): + bus.connect("chart_reset", _on_chart_reset_clear_time_anchor_markers) + if bus.has_signal("streak_changed"): + bus.connect("streak_changed", _on_streak_changed) + if bus.has_signal("attack_buff_changed"): + bus.connect("attack_buff_changed", _on_attack_buff_changed) + + +func _apply_responsive_layout() -> void: + if not responsive_layout: + return + var viewport_size := size + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + viewport_size = get_viewport_rect().size + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return + _last_layout_size = viewport_size + _responsive_scale = _hud_scale(viewport_size) + var s := _responsive_scale + var center_x := viewport_size.x * 0.5 + var track_y := viewport_size.y - 94.0 * s + var axis_y := track_y + 18.0 * s + var track_max_w := maxf(320.0 * s, viewport_size.x - 96.0 * s) + var track_w := minf(832.0 * s, track_max_w) + + _set_control_rect(track_background, Vector2(center_x, track_y + 14.0 * s), Vector2(track_w, 116.0 * s)) + _set_control_rect(track_line, Vector2(center_x, axis_y), Vector2(maxf(120.0 * s, track_w - 100.0 * s), 47.0 * s)) + _set_control_rect(center_base, Vector2(center_x, axis_y), Vector2(112.0 * s, 131.0 * s)) + _set_control_rect(center_flash, Vector2(center_x, axis_y), Vector2(160.0 * s, 151.0 * s)) + normal_center_flash_size = center_flash.size + hit_center_flash_size = normal_center_flash_size * CENTER_HIT_FLASH_SCALE + _set_center_flash_profile(false) + normal_mover_size = Vector2(68.0 * s, 62.0 * s) + time_anchor_mover_size = normal_mover_size * TIME_ANCHOR_MOVER_SCALE + _set_control_rect(left_mover, Vector2(center_x - track_w * 0.43, axis_y), normal_mover_size) + _set_control_rect(right_mover, Vector2(center_x + track_w * 0.43, axis_y), normal_mover_size) + + _set_control_top_left(chart_marker_container, Vector2.ZERO, viewport_size) + _set_control_top_left($HudReadoutRow, Vector2(154.0 * s, 135.0 * s), Vector2(260.0 * s, 76.0 * s)) + combo_count_label.custom_minimum_size = Vector2(240.0 * s, 32.0 * s) + attack_buff_value_label.custom_minimum_size = Vector2(240.0 * s, 32.0 * s) + combo_count_label.add_theme_font_size_override("font_size", int(round(26.0 * s))) + attack_buff_value_label.add_theme_font_size_override("font_size", int(round(26.0 * s))) + + var buff_total_w := (42.0 * 7.0 + 12.0 * 6.0) * s + _set_control_top_left(buff_pip_row, Vector2(center_x - buff_total_w * 0.5, 132.0 * s), Vector2(buff_total_w, 58.0 * s)) + buff_pip_row.add_theme_constant_override("separation", int(round(12.0 * s))) + for child: Node in buff_pip_row.get_children(): + var pip := child as TextureRect + if pip != null: + pip.custom_minimum_size = Vector2(42.0 * s, 55.0 * s) + + var judgement_center := Vector2(center_x, viewport_size.y * 0.56) + _set_control_rect(judgement_art, judgement_center, Vector2(270.0 * s, 72.0 * s)) + _set_control_rect(judgement_label, judgement_center, Vector2(500.0 * s, 74.0 * s)) + judgement_label.add_theme_font_size_override("font_size", int(round(32.0 * s))) + _cache_rhythm_track_layout() + + +func _hud_scale(viewport_size: Vector2) -> float: + return clampf(minf(viewport_size.x / DESIGN_SIZE.x, viewport_size.y / DESIGN_SIZE.y), HUD_MIN_SCALE, HUD_MAX_SCALE) + + +func _set_control_rect(control: Control, center: Vector2, target_size: Vector2) -> void: + control.offset_left = center.x - target_size.x * 0.5 + control.offset_top = center.y - target_size.y * 0.5 + control.offset_right = center.x + target_size.x * 0.5 + control.offset_bottom = center.y + target_size.y * 0.5 + + +func _set_control_top_left(control: Control, top_left: Vector2, target_size: Vector2) -> void: + control.offset_left = top_left.x + control.offset_top = top_left.y + control.offset_right = top_left.x + target_size.x + control.offset_bottom = top_left.y + target_size.y + +func _sync_bpm_with_rhythm_manager() -> void: + var rhythm := get_tree().root.get_node_or_null("RhythmManager") + if rhythm != null: + var manager_bpm := float(rhythm.get("bpm")) + if manager_bpm > 0.0: + bpm = manager_bpm + + +func _initial_time_phase() -> StringName: + var manager := get_tree().root.get_node_or_null("TimePhaseManager") + if manager != null: + return StringName(str(manager.get("current_time_phase"))) + return &"past" + + +func _process(delta: float) -> void: + if responsive_layout and size != _last_layout_size: + _apply_responsive_layout() + var visual_delta := minf(delta, 1.0 / 30.0) + beat_age += delta + beat_flash = maxf(0.0, beat_flash - visual_delta * center_flash_decay_rate) + if _center_hit_timer > 0.0: + _center_hit_timer -= delta + if _center_hit_timer <= 0.0: + center_base.texture = _skin()[&"center_idle"] + var upcoming := _upcoming_beat_index() + if upcoming != _last_upcoming_beat: + _refresh_mover_textures() + _update_movers() + if feedback_flash > 0.0: + feedback_flash = maxf(0.0, feedback_flash - visual_delta * 4.0) + var feedback_scale := Vector2.ONE * (1.0 + feedback_flash * 0.18) + judgement_label.scale = feedback_scale + judgement_art.scale = feedback_scale + # 满 1 秒才隐藏,倒计时期间不动 modulate/text,避免与脉冲/判定色互相干扰。 + if _judgement_visible_left > 0.0: + _judgement_visible_left -= visual_delta + if _judgement_visible_left <= 0.0: + _judgement_visible_left = 0.0 + judgement_label.visible = false + judgement_art.visible = false + + +func _on_beat_ticked(beat_index: int) -> void: + _last_beat_index = beat_index + _set_center_flash_profile(false) + center_flash.texture = _skin()[&"flash_normal"] + center_flash_color = Color.WHITE + center_flash_peak = BEAT_TICK_FLASH_PEAK + beat_flash = 1.0 + beat_age = 0.0 + _refresh_mover_textures() + _update_movers() + + +func _on_judgement_made(quality: StringName, offset_ms: float, _beat_index: int) -> void: + if _time_anchor_feedback_latched: + # time_anchor_resolved already drove the center emblem, flash and label + # for this very judgement; don't overwrite the anchor feedback with the + # plain grade readout. + _time_anchor_feedback_latched = false + return + var color := _judgement_color(quality) + judgement_label.text = "%s %s" % [ + str(quality).to_upper(), + _format_signed_ms(offset_ms / 1000.0), + ] + judgement_label.modulate = color + _show_judgement_art(quality) + feedback_flash = 1.0 + if quality != &"miss": + _show_center_hit(false) + + +func _on_chart_event_upcoming(_event: Resource, _time_to_event: float) -> void: + return + + +func _on_chart_event_triggered(event: Resource) -> void: + _set_center_flash_profile(false) + center_flash.texture = _skin()[&"flash_normal"] + if StringName(str(event.get("event_type"))) == &"camera_pulse": + center_flash_color = Color(1.0, 0.84, 0.26, 1.0) + else: + center_flash_color = _chart_marker_color(event) + center_flash_peak = 1.0 + beat_flash = 1.0 + _update_movers() + + +func _on_time_anchor_scheduled(anchor: Dictionary) -> void: + _time_anchor_beats[int(anchor.get("beat", 0))] = true + _refresh_mover_textures() + + +func _on_time_anchor_resolved(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: + _time_anchor_beats.erase(int(anchor.get("beat", 0))) + _refresh_mover_textures() + # Held anchors resolve inside the judgement_made dispatch (judgement dict is + # non-empty there); latch so the follow-up grade readout keeps this feedback. + _time_anchor_feedback_latched = not judgement.is_empty() + if held: + _show_center_hit(true) + judgement_label.text = "ANCHOR HELD" + judgement_label.modulate = TIME_ANCHOR_HELD_COLOR + _show_judgement_art(_judgement_from_payload(judgement)) + else: + _set_center_flash_profile(false) + center_flash.texture = _skin()[&"flash_special"] + center_flash_color = TIME_ANCHOR_BROKEN_COLOR + center_flash_peak = 1.0 + beat_flash = 1.0 + if judgement.is_empty(): + judgement_label.text = "" + judgement_label.visible = false + judgement_art.visible = false + feedback_flash = 0.0 + _judgement_visible_left = 0.0 + else: + judgement_label.text = "TIME SHIFT!" + judgement_label.modulate = TIME_ANCHOR_BROKEN_COLOR + _show_judgement_art(_judgement_from_payload(judgement, &"miss")) + feedback_flash = 1.0 + if held: + feedback_flash = 1.0 + _update_movers() + + +func _show_center_hit(special: bool) -> void: + # Normal hit: own-phase anchor02 variant + c01 variant flash. Special hit: + # the cross-phase anchor02 variant + own-phase c02 variant flash. + var skin := _skin() + center_base.texture = skin[&"center_hit_special"] if special else skin[&"center_hit_normal"] + _set_center_flash_profile(true) + center_flash.texture = skin[&"flash_special"] if special else skin[&"flash_normal"] + center_flash_color = Color.WHITE + center_flash_peak = 1.0 + beat_flash = 1.0 + _center_hit_timer = CENTER_HIT_HOLD_SECONDS + _update_movers() + + +func _refresh_mover_textures() -> void: + # The beat the movers are converging toward is the next integer beat; if it + # is a time anchor the rhythm point itself upgrades to the special texture. + var skin := _skin() + var upcoming := _upcoming_beat_index() + _last_upcoming_beat = upcoming + var special := _time_anchor_beats.has(upcoming) + var texture: Texture2D = skin[&"mover_special"] if special else skin[&"mover_normal"] + mover_size = time_anchor_mover_size if special else normal_mover_size + if left_mover != null: + left_mover.texture = texture + if right_mover != null: + right_mover.texture = texture + _update_movers() + + +func _on_time_phase_changed(_previous: StringName, current: StringName, _reason: StringName) -> void: + _set_center_flash_profile(false) + _apply_time_phase_skin(current) + center_flash_color = Color(0.45, 0.85, 1.0) if current == &"future" else Color(1.0, 0.84, 0.4) + center_flash_peak = 1.0 + beat_flash = 1.0 + _update_movers() + + +func _apply_time_phase_skin(time_phase: StringName) -> void: + _time_phase = time_phase if TRACK_SKINS.has(time_phase) else &"past" + var skin := _skin() + track_background.texture = skin[&"background"] + track_line.visible = false + track_line.texture = null + center_base.texture = skin[&"center_idle"] + center_flash.texture = skin[&"flash_normal"] + _set_center_flash_profile(false) + _center_hit_timer = 0.0 + _refresh_mover_textures() + + +func _skin() -> Dictionary: + return TRACK_SKINS[_time_phase] + + +func _on_chart_reset_clear_time_anchor_markers(_chart_id: StringName) -> void: + _time_anchor_beats.clear() + _refresh_mover_textures() + + +func _on_streak_changed(count: int) -> void: + _streak_count = maxi(0, count) + _refresh_combo_count() + + +func _on_attack_buff_changed(stacks: int, max_stacks: int) -> void: + _attack_buff_max = maxi(1, max_stacks) + _attack_buff_stacks = clampi(stacks, 0, BUFF_SLOT_COUNT) + _refresh_attack_buff_display() + + +func _refresh_combo_count() -> void: + combo_count_label.text = "combo %d" % _streak_count + + +func _refresh_attack_buff_display() -> void: + attack_buff_value_label.text = "ATK x %d%%" % (_attack_buff_stacks * 100) + for index: int in range(buff_pip_row.get_child_count()): + var pip := buff_pip_row.get_child(index) as TextureRect + if pip != null: + pip.texture = BUFF_ON_TEXTURE if index < _attack_buff_stacks else BUFF_OFF_TEXTURE + + +func _show_judgement_art(quality: StringName) -> void: + var normalized := StringName(str(quality).to_lower()) + judgement_art.texture = JUDGEMENT_ARTS.get(normalized, JUDGEMENT_ARTS[&"miss"]) + judgement_art.visible = true + _judgement_visible_left = JUDGEMENT_VISIBLE_SECONDS + var feedback_scale := Vector2(1.18, 1.18) + judgement_label.scale = feedback_scale + judgement_art.scale = feedback_scale + + +func _judgement_from_payload(judgement: Dictionary, fallback := &"miss") -> StringName: + if judgement.has("label"): + return StringName(str(judgement["label"]).to_lower()) + if judgement.has(&"label"): + return StringName(str(judgement[&"label"]).to_lower()) + if judgement.has("quality"): + return StringName(str(judgement["quality"]).to_lower()) + if judgement.has(&"quality"): + return StringName(str(judgement[&"quality"]).to_lower()) + return fallback + + +func _update_movers() -> void: + var progress := _current_beat_progress() + _set_control_center(left_mover, left_mover_start.lerp(track_center, progress), mover_size) + _set_control_center(right_mover, right_mover_start.lerp(track_center, progress), mover_size) + _set_control_center(center_flash, track_center, center_flash_size) + center_flash.modulate = Color(center_flash_color.r, center_flash_color.g, center_flash_color.b, beat_flash * center_flash_peak) + + +## Movers follow the musical clock itself so their glide is continuous — the +## old frame-accumulated beat_age plus the beat_flash snap made every beat +## teleport the points to the center and back out. The arrival at the center +## coincides exactly with the beat (progress wraps 1 → 0 on the tick). +func _current_beat_progress() -> float: + var rhythm := _rhythm_manager_or_null() + if rhythm != null and rhythm.has_method("get_current_beat_progress") and bool(rhythm.get("running")): + return clampf(float(rhythm.call("get_current_beat_progress")), 0.0, 1.0) + var seconds_per_beat := 60.0 / maxf(1.0, bpm) + return clampf(beat_age / seconds_per_beat, 0.0, 1.0) + + +## 节奏点正收束的目标拍:直接由音乐时钟推导,而不是等物理帧上报的 +## beat_ticked(会晚约一个物理帧),否则拍界处锚点纹理会闪一下普通纹理。 +func _upcoming_beat_index() -> int: + var rhythm := _rhythm_manager_or_null() + if rhythm != null and bool(rhythm.get("running")) and rhythm.has_method("song_position"): + var beat_seconds := float(rhythm.get("beat_time")) + var adjusted := float(rhythm.call("song_position")) + float(rhythm.get("beat_offset")) + if beat_seconds > 0.0 and adjusted >= 0.0: + return int(floor(adjusted / beat_seconds)) + 1 + return _last_beat_index + 1 + + +func _rhythm_manager_or_null() -> Node: + if not is_inside_tree(): + return null + return get_tree().root.get_node_or_null("RhythmManager") + + +func _set_center_flash_profile(hit: bool) -> void: + center_flash_size = hit_center_flash_size if hit else normal_center_flash_size + center_flash_decay_rate = CENTER_HIT_FLASH_DECAY_RATE if hit else BEAT_FLASH_DECAY_RATE + + +func _cache_rhythm_track_layout() -> void: + track_center = _control_center(center_base) + left_mover_start = _control_center(left_mover) + right_mover_start = _control_center(right_mover) + normal_mover_size = left_mover.size if normal_mover_size == Vector2.ZERO else normal_mover_size + time_anchor_mover_size = normal_mover_size * TIME_ANCHOR_MOVER_SCALE + mover_size = normal_mover_size + normal_center_flash_size = center_flash.size if normal_center_flash_size == Vector2.ZERO else normal_center_flash_size + hit_center_flash_size = normal_center_flash_size * CENTER_HIT_FLASH_SCALE + center_flash_size = normal_center_flash_size + + +func _control_center(control: Control) -> Vector2: + return Vector2( + (control.offset_left + control.offset_right) * 0.5, + (control.offset_top + control.offset_bottom) * 0.5 + ) + + +func _set_control_center(control: Control, center: Vector2, size: Vector2) -> void: + control.offset_left = center.x - size.x * 0.5 + control.offset_top = center.y - size.y * 0.5 + control.offset_right = center.x + size.x * 0.5 + control.offset_bottom = center.y + size.y * 0.5 + + +func _chart_marker_text(event: Resource) -> String: + match StringName(str(event.get("event_type"))): + &"show_accent_marker": + return "ACC" + &"enemy_action": + return _enemy_action_marker_text(event) + &"camera_pulse": + return "CAM" + return str(event.get("event_type")).to_upper() + + +func _chart_marker_color(event: Resource) -> Color: + match StringName(str(event.get("event_type"))): + &"show_accent_marker": + return Color("ffd84a") + &"enemy_action": + return Color("ff3355") + &"camera_pulse": + return Color("ffffff") + return Color("00f2ff") + + +func _enemy_action_marker_text(event: Resource) -> String: + var action_id := str(event.get("action_id")) + if action_id.is_empty(): + return "ACT" + if action_id.begins_with("enemy_"): + action_id = action_id.substr(6) + return action_id.replace("_", " ").to_upper() + + +func _judgement_color(quality: StringName) -> Color: + match quality: + &"perfect": + return Color("00f2ff") + &"good": + return Color("ffffff") + &"bad": + return Color("ffaa00") + _: + return Color("ff0055") + + +func _chart_marker_position(time_to_event: float) -> Vector2: + var seconds_per_beat := 60.0 / maxf(1.0, bpm) + var beat_distance := clampf(time_to_event / seconds_per_beat, 0.0, 4.0) + var x := track_center.x + beat_distance * 92.0 * _responsive_scale + return Vector2(x - 33.0 * _responsive_scale, track_center.y + 44.0 * _responsive_scale) + + +func _format_signed_ms(seconds: float) -> String: + if is_inf(seconds): + return "-- ms" + return "%+.0f ms" % (seconds * 1000.0) + + +func _event_bus() -> Node: + var root := get_tree().root + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus diff --git a/scenes/ui/rhythm_track.gd.uid b/scenes/ui/rhythm_track.gd.uid new file mode 100644 index 0000000..c8c9ec3 --- /dev/null +++ b/scenes/ui/rhythm_track.gd.uid @@ -0,0 +1 @@ +uid://cl8oghmhtwkl diff --git a/scenes/ui/rhythm_track.tscn b/scenes/ui/rhythm_track.tscn new file mode 100644 index 0000000..2bda74d --- /dev/null +++ b/scenes/ui/rhythm_track.tscn @@ -0,0 +1,204 @@ +[gd_scene format=3 uid="uid://csydrlqpqyx3s"] + +[ext_resource type="Script" uid="uid://cl8oghmhtwkl" path="res://scenes/ui/rhythm_track.gd" id="1"] +[ext_resource type="Texture2D" uid="uid://6qsuvcixdqmp" path="res://assets/ui/rhythm/b.png" id="2_track_bg"] +[ext_resource type="Texture2D" uid="uid://dml5nkni6bpoq" path="res://assets/ui/rhythm/anchor01.png" id="4_center_idle"] +[ext_resource type="Texture2D" uid="uid://cgswvlcqufim" path="res://assets/ui/rhythm/c01.png" id="5_center_flash"] +[ext_resource type="Texture2D" uid="uid://rt631il3y6h7" path="res://assets/ui/rhythm/star.png" id="6_star"] +[ext_resource type="Texture2D" path="res://assets/ui/buff/buff_off.png" id="7_buff_off"] +[ext_resource type="Texture2D" path="res://assets/ui/judgements/perfect.png" id="8_judgement_perfect"] + +[node name="RhythmTrack" type="Control" unique_id=1294325361] +layout_mode = 3 +anchors_preset = 5 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -432.0 +offset_top = 54.0 +offset_right = 432.0 +offset_bottom = 294.0 +grow_horizontal = 2 +script = ExtResource("1") + +[node name="TrackBackground" type="TextureRect" parent="." unique_id=325202199] +layout_mode = 0 +offset_left = 16.0 +offset_top = 19.0 +offset_right = 848.0 +offset_bottom = 135.0 +texture = ExtResource("2_track_bg") +expand_mode = 1 +stretch_mode = 5 + +[node name="TrackLine" type="TextureRect" parent="." unique_id=1123045159] +visible = false +layout_mode = 0 +offset_left = 66.0 +offset_top = 40.0 +offset_right = 798.0 +offset_bottom = 87.0 +expand_mode = 1 +stretch_mode = 5 + +[node name="LeftMover" type="TextureRect" parent="." unique_id=790581017] +layout_mode = 0 +offset_left = 32.0 +offset_top = 32.5 +offset_right = 100.0 +offset_bottom = 94.5 +texture = ExtResource("6_star") +expand_mode = 1 +stretch_mode = 5 + +[node name="RightMover" type="TextureRect" parent="." unique_id=46330219] +layout_mode = 0 +offset_left = 764.0 +offset_top = 32.5 +offset_right = 832.0 +offset_bottom = 94.5 +texture = ExtResource("6_star") +expand_mode = 1 +stretch_mode = 5 + +[node name="CenterBase" type="TextureRect" parent="." unique_id=652811094] +z_index = 2 +layout_mode = 0 +offset_left = 389.0 +offset_top = -2.0 +offset_right = 475.0 +offset_bottom = 129.0 +texture = ExtResource("4_center_idle") +expand_mode = 1 +stretch_mode = 5 + +[node name="CenterFlash" type="TextureRect" parent="." unique_id=1409206211] +modulate = Color(1, 1, 1, 0) +z_index = 1 +layout_mode = 0 +offset_left = 359.0 +offset_top = -12.0 +offset_right = 505.0 +offset_bottom = 139.0 +texture = ExtResource("5_center_flash") +expand_mode = 1 +stretch_mode = 5 + +[node name="ChartMarkerContainer" type="Control" parent="." unique_id=92513456] +anchors_preset = 0 +offset_right = 864.0 +offset_bottom = 106.0 +mouse_filter = 2 + +[node name="HudReadoutRow" type="VBoxContainer" parent="."] +layout_mode = 0 +offset_left = 214.0 +offset_top = 104.0 +offset_right = 650.0 +offset_bottom = 138.0 +theme_override_constants/separation = 2 +alignment = 1 + +[node name="ComboCountLabel" type="Label" parent="HudReadoutRow"] +custom_minimum_size = Vector2(190, 34) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.88, 0.46, 1) +theme_override_colors/font_shadow_color = Color(0.18, 0.03, 0.01, 0.95) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 28 +text = "combo 0" +horizontal_alignment = 0 +vertical_alignment = 1 + +[node name="AttackBuffValueLabel" type="Label" parent="HudReadoutRow"] +custom_minimum_size = Vector2(190, 34) +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.88, 0.46, 1) +theme_override_colors/font_shadow_color = Color(0.18, 0.03, 0.01, 0.95) +theme_override_constants/shadow_offset_x = 2 +theme_override_constants/shadow_offset_y = 2 +theme_override_font_sizes/font_size = 28 +text = "ATK x 0%" +horizontal_alignment = 0 +vertical_alignment = 1 + +[node name="BuffPipRow" type="HBoxContainer" parent="."] +layout_mode = 0 +offset_left = 249.0 +offset_top = 132.0 +offset_right = 615.0 +offset_bottom = 190.0 +theme_override_constants/separation = 12 +alignment = 1 + +[node name="BuffPip1" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip2" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip3" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip4" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip5" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip6" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="BuffPip7" type="TextureRect" parent="BuffPipRow"] +custom_minimum_size = Vector2(42, 55) +layout_mode = 2 +texture = ExtResource("7_buff_off") +expand_mode = 1 +stretch_mode = 5 + +[node name="JudgementArt" type="TextureRect" parent="."] +layout_mode = 0 +offset_left = 310.0 +offset_top = 222.0 +offset_right = 554.0 +offset_bottom = 282.0 +pivot_offset = Vector2(122, 30) +texture = ExtResource("8_judgement_perfect") +expand_mode = 1 +stretch_mode = 5 + +[node name="JudgementLabel" type="Label" parent="." unique_id=1712665799] +visible = false +layout_mode = 0 +offset_left = 202.0 +offset_top = 222.0 +offset_right = 662.0 +offset_bottom = 282.0 +theme_override_font_sizes/font_size = 30 +text = "READY" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/scripts/resolvers/action_rule_resolver.gd b/scripts/resolvers/action_rule_resolver.gd new file mode 100644 index 0000000..a64cf7d --- /dev/null +++ b/scripts/resolvers/action_rule_resolver.gd @@ -0,0 +1,81 @@ +class_name ActionRuleResolver +extends RefCounted + + +static func can_execute(context: Dictionary, action: Resource) -> bool: + if action == null: + return false + var life_state := _context_axis(context, "life_state", &"Alive") + if life_state != &"Alive": + return false + var allowed_ground := _string_name_array(action.get("allowed_ground_states")) + if not allowed_ground.is_empty() and not allowed_ground.has(_context_axis(context, "ground_state", &"Grounded")): + return false + var allowed_phases := _string_name_array(action.get("allowed_action_phases")) + if not allowed_phases.is_empty() and not allowed_phases.has(_context_axis(context, "action_phase", &"Neutral")): + return false + if not _passes_effect_rule_modifiers(context, action): + return false + return true + + +static func can_move_freely(context: Dictionary) -> bool: + if _context_axis(context, "life_state", &"Alive") != &"Alive": + return false + if _context_axis(context, "action_phase", &"Neutral") != &"Neutral": + return false + for modifier: Resource in _action_rule_modifiers(context): + if bool(modifier.get("block_movement")): + return false + return true + + +static func _context_axis(context: Dictionary, key: String, fallback: StringName) -> StringName: + if context.has(key): + return StringName(str(context[key])) + return fallback + + +static func _string_name_array(value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if value is Array: + for item: Variant in value: + result.append(StringName(str(item))) + return result + + +static func _passes_effect_rule_modifiers(context: Dictionary, action: Resource) -> bool: + var action_tags := _string_name_array(action.get("action_tags")) + for modifier: Resource in _action_rule_modifiers(context): + var blocked_tags := _string_name_array(modifier.get("blocked_action_tags")) + if not blocked_tags.is_empty() and _has_any(action_tags, blocked_tags): + return false + var allowed_tags := _string_name_array(modifier.get("allowed_action_tags")) + if not allowed_tags.is_empty() and not _has_any(action_tags, allowed_tags): + return false + return true + + +static func _action_rule_modifiers(context: Dictionary) -> Array[Resource]: + if context.has("action_rule_modifiers"): + return _resource_array(context["action_rule_modifiers"]) + var container = context.get("effect_container", null) + if container != null and container.has_method("action_rule_modifiers"): + return _resource_array(container.call("action_rule_modifiers")) + return [] + + +static func _resource_array(value: Variant) -> Array[Resource]: + var result: Array[Resource] = [] + if value is Array: + for item: Variant in value: + if item is Resource: + result.append(item) + return result + + +static func _has_any(values: Array[StringName], candidates: Array[StringName]) -> bool: + for value: StringName in values: + if candidates.has(value): + return true + return false diff --git a/scripts/resolvers/action_rule_resolver.gd.uid b/scripts/resolvers/action_rule_resolver.gd.uid new file mode 100644 index 0000000..249f44d --- /dev/null +++ b/scripts/resolvers/action_rule_resolver.gd.uid @@ -0,0 +1 @@ +uid://crbvbt3lx55cx diff --git a/scripts/resolvers/combat_resolver.gd b/scripts/resolvers/combat_resolver.gd new file mode 100644 index 0000000..e428849 --- /dev/null +++ b/scripts/resolvers/combat_resolver.gd @@ -0,0 +1,122 @@ +class_name CombatResolver +extends RefCounted + +const StatResolverScript := preload("res://scripts/resolvers/stat_resolver.gd") +const DefenseResolverScript := preload("res://scripts/resolvers/defense_resolver.gd") + + +static func resolve_hit(emitter: Area2D, receiver: Area2D) -> Dictionary: + var action: Resource = emitter.get("action_context") as Resource + var judgement: Dictionary = emitter.get("judgement_context") + var receiver_context := _receiver_context(receiver, emitter) + var attacker_effects := _owner_effect_container(emitter) + var defense := DefenseResolverScript.resolve_effective_defense(receiver_context, _action_tags(action)) + var raw_damage := StatResolverScript.resolve_damage(float(emitter.get("damage")), action, judgement, attacker_effects, null) + var final_damage := int(round(raw_damage * float(defense.get("damage_mult", 1.0)))) + var attacker_interrupts := _attacker_interrupts(emitter) + # 受击方免压制钩子(2026-07-05 定案):Boss 对不耗能量的攻击恒霸体—— + # 伤害照常结算,但打断与击退一并取消。 + if attacker_interrupts and _receiver_shrugs_off(receiver, action): + attacker_interrupts = false + var knockback := _resolved_knockback(emitter, action, judgement, attacker_effects, defense) if attacker_interrupts else Vector2.ZERO + return { + "damage": final_damage, + "hit_type": StringName(str(emitter.get("hit_type"))), + "action": action, + "action_tags": _action_tags(action), + "judgement": judgement, + "from": emitter.global_position, + "emitter": emitter, + "receiver": receiver, + "defense": defense, + "interrupts": bool(defense.get("interrupts", true)) and attacker_interrupts, + "knockback": knockback, + } + + +static func _attacker_interrupts(emitter: Area2D) -> bool: + var value = emitter.get("attacker_interrupts") + if value is bool: + return value + return true + + +static func _receiver_shrugs_off(receiver: Area2D, action: Resource) -> bool: + var owner := receiver.get_parent() + if owner == null or not owner.has_method("shrugs_off_hit"): + return false + return bool(owner.call("shrugs_off_hit", action)) + + +static func _receiver_context(receiver: Area2D, emitter: Area2D = null) -> Dictionary: + var owner := receiver.get_parent() + if owner != null: + var context := {} + var state_machine := owner.get_node_or_null("StateMachine") + if state_machine != null and state_machine.has_method("build_context"): + context = state_machine.call("build_context") + var effect_container := owner.get_node_or_null("EffectContainer") + if effect_container != null: + context["effect_container"] = effect_container + var block_judgement := _receiver_block_judgement(owner) + if not block_judgement.is_empty(): + context["block_judgement"] = block_judgement + if emitter != null and owner is Node2D: + context["attack_from_front"] = _attack_from_front(owner as Node2D, emitter) + return context + return {} + + +static func _receiver_block_judgement(owner: Node) -> StringName: + var action_controller := owner.get_node_or_null("ActionController") + if action_controller == null: + return &"" + var intent = action_controller.get("current_intent") + if intent == null: + return &"" + var judgement = intent.get("judgement") + if judgement is Dictionary and judgement.has("label"): + return StringName(str(judgement["label"])) + return &"" + + +static func _attack_from_front(owner: Node2D, emitter: Area2D) -> bool: + var heading = owner.get("heading") + if not (heading is Vector2) or is_zero_approx((heading as Vector2).x): + return true + var to_attacker := emitter.global_position.x - owner.global_position.x + if is_zero_approx(to_attacker): + return true + return signf(to_attacker) == signf((heading as Vector2).x) + + +static func _owner_effect_container(area: Area2D) -> Node: + var source = area.get("source_actor") + if source is Node and is_instance_valid(source): + return (source as Node).get_node_or_null("EffectContainer") + var owner := area.get_parent() + if owner == null: + return null + return owner.get_node_or_null("EffectContainer") + + +static func _action_tags(action: Resource) -> Array[StringName]: + var result: Array[StringName] = [] + if action != null and action.get("action_tags") is Array: + for tag: Variant in action.get("action_tags"): + result.append(StringName(str(tag))) + return result + + +static func _resolved_knockback(emitter: Area2D, action: Resource, judgement: Dictionary, attacker_effects: Variant, defense: Dictionary) -> Vector2: + var defense_state := StringName(str(defense.get("defense_state", &"Vulnerable"))) + if defense_state == &"Invincible" or defense_state == &"SuperArmor": + return Vector2.ZERO + return StatResolverScript.resolve_knockback(action, judgement, _base_knockback(emitter), attacker_effects) + + +static func _base_knockback(emitter: Area2D) -> Vector2: + var value = emitter.get("base_knockback") + if value is Vector2: + return value + return Vector2.ZERO diff --git a/scripts/resolvers/combat_resolver.gd.uid b/scripts/resolvers/combat_resolver.gd.uid new file mode 100644 index 0000000..c3c1b5e --- /dev/null +++ b/scripts/resolvers/combat_resolver.gd.uid @@ -0,0 +1 @@ +uid://dbhmsc608i4f5 diff --git a/scripts/resolvers/defense_resolver.gd b/scripts/resolvers/defense_resolver.gd new file mode 100644 index 0000000..517807a --- /dev/null +++ b/scripts/resolvers/defense_resolver.gd @@ -0,0 +1,111 @@ +class_name DefenseResolver +extends RefCounted + + +static func resolve_effective_defense(context: Dictionary, attack_tags: Array[StringName] = []) -> Dictionary: + var defense_state := StringName(str(context.get("defense_state", &"Vulnerable"))) + # 背刺破格挡只作用于姿态本身的 Parrying,先降级再合并 effect 修饰符: + # 否则霸体(SuperArmor,优先级低于 Parrying)会先被格挡态吞掉、 + # 再随背刺降级一起丢失,违反霸体「无击退、不打断」的承诺。 + if defense_state == &"Parrying" and not bool(context.get("attack_from_front", true)): + defense_state = &"Vulnerable" + var modifiers := _defense_modifiers(context, attack_tags) + for modifier: Resource in modifiers: + var modified_state := StringName(str(modifier.get("defense_state"))) + if not modified_state.is_empty(): + defense_state = _stronger_defense_state(defense_state, modified_state) + var damage_mult := _damage_mult_for_state(defense_state, context) + var interrupts := defense_state != &"Invincible" and defense_state != &"SuperArmor" and defense_state != &"Parrying" + for modifier: Resource in modifiers: + damage_mult *= float(modifier.get("damage_mult")) + interrupts = interrupts and bool(modifier.get("interrupts")) + return { + "defense_state": defense_state, + "damage_mult": damage_mult, + "interrupts": interrupts, + } + + +static func _damage_mult_for_state(defense_state: StringName, context: Dictionary = {}) -> float: + match defense_state: + &"Invincible": + return 0.0 + &"Parrying": + return _parry_damage_mult(context) + &"SuperArmor": + return 1.0 + _: + return 1.0 + + +static func _parry_damage_mult(context: Dictionary) -> float: + match StringName(str(context.get("block_judgement", &"perfect"))): + &"good": + return 0.2 + &"bad": + return 0.4 + &"miss": + return 1.0 + return 0.0 + + +static func _defense_modifiers(context: Dictionary, attack_tags: Array[StringName]) -> Array[Resource]: + var modifiers: Array[Resource] = [] + if context.has("defense_modifiers"): + modifiers = _resource_array(context["defense_modifiers"]) + else: + var container = context.get("effect_container", null) + if container != null and container.has_method("defense_modifiers"): + modifiers = _resource_array(container.call("defense_modifiers")) + var matched: Array[Resource] = [] + for modifier: Resource in modifiers: + if _modifier_matches_attack_tags(modifier, attack_tags): + matched.append(modifier) + return matched + + +static func _resource_array(value: Variant) -> Array[Resource]: + var result: Array[Resource] = [] + if value is Array: + for item: Variant in value: + if item is Resource: + result.append(item) + return result + + +static func _modifier_matches_attack_tags(modifier: Resource, attack_tags: Array[StringName]) -> bool: + var required_tags := _string_name_array(modifier.get("required_attack_tags")) + return required_tags.is_empty() or _has_any(attack_tags, required_tags) + + +static func _string_name_array(value: Variant) -> Array[StringName]: + var result: Array[StringName] = [] + if value is Array: + for item: Variant in value: + result.append(StringName(str(item))) + return result + + +static func _has_any(values: Array[StringName], candidates: Array[StringName]) -> bool: + for value: StringName in values: + if candidates.has(value): + return true + return false + + +static func _stronger_defense_state(current_state: StringName, candidate_state: StringName) -> StringName: + return candidate_state if _defense_priority(candidate_state) > _defense_priority(current_state) else current_state + + +static func _defense_priority(defense_state: StringName) -> int: + match defense_state: + &"Invincible": + return 4 + &"Parrying": + return 3 + &"SuperArmor": + return 2 + &"Vulnerable": + return 1 + _: + return 0 diff --git a/scripts/resolvers/defense_resolver.gd.uid b/scripts/resolvers/defense_resolver.gd.uid new file mode 100644 index 0000000..10e0c1c --- /dev/null +++ b/scripts/resolvers/defense_resolver.gd.uid @@ -0,0 +1 @@ +uid://d0jjx3wiwtj2i diff --git a/scripts/resolvers/stat_resolver.gd b/scripts/resolvers/stat_resolver.gd new file mode 100644 index 0000000..c1a0c47 --- /dev/null +++ b/scripts/resolvers/stat_resolver.gd @@ -0,0 +1,129 @@ +class_name StatResolver +extends RefCounted + +const JudgementPolicyResource := preload("res://resources/judgement_policy.tres") + + +static func resolve_damage(base_attack: float, action: Resource, judgement: Variant = {}, buffs: Variant = null, burst: Variant = null) -> float: + var judgement_dict := _as_dict(judgement) + var action_mult := _resource_float(action, "damage_mult", 1.0) + var judgement_mult := _judgement_channel_mult(judgement_dict, &"damage", "damage_mult", 1.0) + var buff_mult := _provider_mult(buffs, "damage_mult", action) + var burst_mult := _provider_mult(burst, "damage_mult", action) + return base_attack * action_mult * judgement_mult * buff_mult * burst_mult + + +static func resolve_cost(action: Resource, judgement: Variant = {}, provider: Variant = null) -> float: + var resolved_provider: Variant = provider + var judgement_dict := _as_dict(judgement) + if not (judgement is Dictionary) and provider == null: + resolved_provider = judgement + var base_cost := _resource_float(action, "base_cost", 0.0) + var judgement_mult := _judgement_channel_mult(judgement_dict, &"cost", "cost_mult", 1.0) + var provider_mult := _provider_mult(resolved_provider, "cost_mult", action) + return maxf(0.0, base_cost * judgement_mult * provider_mult) + + +static func resolve_reward(action: Resource, judgement: Variant = {}, provider: Variant = null) -> float: + var judgement_dict := _as_dict(judgement) + var reward := _resource_float(action, "base_reward", 0.0) + var judgement_mult := _judgement_channel_mult(judgement_dict, &"reward", "reward_mult", 1.0) + var provider_mult := _provider_mult(provider, "reward_mult", action) + return maxf(0.0, reward * judgement_mult * provider_mult) + + +static func resolve_move(action: Resource, judgement: Variant = {}, burst: Variant = null) -> Vector2: + var judgement_dict := _as_dict(judgement) + var judgement_mult := _dict_float(judgement_dict, "move_mult", 1.0) + var burst_mult := _provider_mult(burst, "move_mult", action) + return Vector2( + _resource_float(action, "move_mult_x", 0.0), + _resource_float(action, "move_mult_y", 0.0) + ) * judgement_mult * burst_mult + + +static func resolve_knockback(action: Resource, judgement: Variant, base_knockback: Vector2, provider: Variant = null) -> Vector2: + var judgement_dict := _as_dict(judgement) + # 击退跟随伤害判定通道(good 0.85 / bad 0.7),可被 judgement 里的显式 + # knockback_mult 覆盖 —— 与 test_combat_manager_resolvers 的契约一致。 + var judgement_mult := _judgement_channel_mult(judgement_dict, &"damage", "knockback_mult", 1.0) + var provider_mult := _provider_mult(provider, "knockback_mult", action) + return Vector2( + base_knockback.x * _resource_float(action, "knockback_mult_x", 0.0), + base_knockback.y * _resource_float(action, "knockback_mult_y", 0.0) + ) * judgement_mult * provider_mult + + +static func resolve_action_snapshot(action: Resource, judgement: Variant = {}, provider: Variant = null) -> Dictionary: + var resolved_provider: Variant = provider + var judgement_dict := _as_dict(judgement) + if not (judgement is Dictionary) and provider == null: + resolved_provider = judgement + return { + "cost": resolve_cost(action, judgement_dict, resolved_provider), + "reward": resolve_reward(action, judgement_dict, resolved_provider), + "move": resolve_move(action, judgement_dict, resolved_provider), + "startup_beats": _resolved_stat(action, "startup_beats", resolved_provider, 0.0), + "active_beats": _resolved_stat(action, "active_beats", resolved_provider, 0.0), + "recovery_beats": _resolved_stat(action, "recovery_beats", resolved_provider, 0.0), + } + + +static func _judgement_channel_mult(judgement: Dictionary, channel: StringName, override_key: String, fallback: float) -> float: + if _dict_has(judgement, override_key): + return _dict_float(judgement, override_key, fallback) + var label := _judgement_label(judgement) + if label.is_empty() or JudgementPolicyResource == null or not JudgementPolicyResource.has_method("multiplier"): + return fallback + return float(JudgementPolicyResource.call("multiplier", label, channel, fallback)) + + +static func _resource_float(resource: Resource, property_name: String, fallback: float) -> float: + if resource == null: + return fallback + var value = resource.get(property_name) + if value == null: + return fallback + return float(value) + + +static func _dict_float(values: Dictionary, key: String, fallback: float) -> float: + if values.has(key): + return float(values[key]) + var name_key := StringName(key) + if values.has(name_key): + return float(values[name_key]) + return fallback + + +static func _dict_has(values: Dictionary, key: String) -> bool: + return values.has(key) or values.has(StringName(key)) + + +static func _judgement_label(judgement: Dictionary) -> StringName: + if judgement.has("label"): + return StringName(str(judgement["label"]).to_lower()) + if judgement.has(&"label"): + return StringName(str(judgement[&"label"]).to_lower()) + return &"" + + +static func _as_dict(value: Variant) -> Dictionary: + if value is Dictionary: + return value + return {} + + +static func _resolved_stat(action: Resource, property_name: String, provider: Variant, fallback: float) -> float: + var base_value := _resource_float(action, property_name, fallback) + return maxf(0.0, base_value * _provider_mult(provider, property_name, action)) + + +static func _provider_mult(provider: Variant, method_name: String, action: Resource) -> float: + if provider == null: + return 1.0 + if provider.has_method(method_name): + return float(provider.call(method_name, action)) + if provider.has_method("stat_multiplier"): + return float(provider.call("stat_multiplier", StringName(method_name), action)) + return 1.0 diff --git a/scripts/resolvers/stat_resolver.gd.uid b/scripts/resolvers/stat_resolver.gd.uid new file mode 100644 index 0000000..ed704e8 --- /dev/null +++ b/scripts/resolvers/stat_resolver.gd.uid @@ -0,0 +1 @@ +uid://bhi2kegtvcxi6 diff --git a/tests/_visual_menu_capture.gd.uid b/tests/_visual_menu_capture.gd.uid new file mode 100644 index 0000000..9782a75 --- /dev/null +++ b/tests/_visual_menu_capture.gd.uid @@ -0,0 +1 @@ +uid://dx6s4rdqhe8i7 diff --git a/tests/fixtures/actions/test_block_start.tres b/tests/fixtures/actions/test_block_start.tres new file mode 100644 index 0000000..bf0803d --- /dev/null +++ b/tests/fixtures/actions/test_block_start.tres @@ -0,0 +1,22 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_test_block_start"] + +[resource] +script = ExtResource("1_test_block_start") +id = &"test_block_start" +display_name = "Test Block Start" +input_pattern = Array[StringName]([&"S"]) +damage_mult = 0.0 +move_mult_x = 0.0 +move_mult_y = 0.0 +hit_type = &"defense" +clear_window = false +startup_beats = 0.1 +active_beats = 0.5 +recovery_beats = 0.2 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"basic", &"block", &"down"]) +defense_tags = Array[StringName]([&"parry"]) +animation = &"block_start" diff --git a/tests/fixtures/actions/test_s_followup_combo.tres b/tests/fixtures/actions/test_s_followup_combo.tres new file mode 100644 index 0000000..05e77fa --- /dev/null +++ b/tests/fixtures/actions/test_s_followup_combo.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3] + +[ext_resource type="Script" path="res://resources/action_data.gd" id="1_test_s_followup"] + +[resource] +script = ExtResource("1_test_s_followup") +id = &"test_s_followup_combo" +display_name = "Test S Follow-up Combo" +input_pattern = Array[StringName]([&"S", &"D"]) +damage_mult = 1.0 +move_mult_x = 0.0 +move_mult_y = 0.0 +hit_type = &"normal" +clear_window = true +startup_beats = 0.25 +active_beats = 0.25 +recovery_beats = 0.5 +cancel_from = 0.5 +allowed_ground_states = Array[StringName]([&"Grounded"]) +action_tags = Array[StringName]([&"combo", &"right"]) +animation = &"atk_ground_1" diff --git a/tests/test_action_controller_flow.gd b/tests/test_action_controller_flow.gd new file mode 100644 index 0000000..4b161cc --- /dev/null +++ b/tests/test_action_controller_flow.gd @@ -0,0 +1,162 @@ +extends SceneTree + +var failures: Array[String] = [] +var started: Array[StringName] = [] +var rejected: Array[StringName] = [] +var judged: Array[Dictionary] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var fixture := await _controller_fixture() + if fixture.is_empty(): + _finish() + return + var controller: Node = fixture["controller"] + var combo: Node = fixture["combo"] + controller.connect("action_started", _on_action_started) + controller.connect("action_rejected", _on_action_rejected) + _event_bus().connect("judgement_made", _on_judgement_made) + + controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 12)) + _expect_array(combo.call("get_slots"), [&"A"], "A should enter ComboWindow") + _expect_string(str(started[started.size() - 1]), "ground_attack_left_1", "A should start left attack") + _expect_int(int(judged[judged.size() - 1].get("beat")), 12, "judgement_made should include nearest beat") + + controller.call("_reset_to_idle") + controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 13)) + _expect_array(combo.call("get_slots"), [&"A", &"A"], "second A should stay in ComboWindow") + _expect_string(str(started[started.size() - 1]), "ground_attack_left_2", "AA should start left second attack") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + controller.call("submit_intent", _intent(&"D", &"d", &"pressed", "miss", 14)) + _expect_array(combo.call("get_slots"), [&"Ø"], "miss should record explicit miss placeholder") + _expect_bool(started.is_empty(), true, "miss should not start an action") + _expect_bool(rejected.has(&"miss"), true, "miss should reject with miss reason") + + var context: Dictionary = controller.call("_resolver_context") + _expect(context.has("burst_action_id"), "resolver context should expose burst_action_id") + _expect(context.has("counter_action_id"), "resolver context should expose counter_action_id") + _expect(context.has("blade_chain_action_id"), "resolver context should expose blade_chain_action_id") + fixture["root"].free() + _finish() + + +func _controller_fixture() -> Dictionary: + var combo_script: Script = load("res://scenes/components/combo_window.gd") + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + var state_script: Script = load("res://scenes/components/state_machine.gd") + var energy_script: Script = load("res://scenes/components/energy_component.gd") + var controller_script: Script = load("res://scenes/components/action_controller.gd") + for item: Dictionary in [ + {"script": combo_script, "label": "ComboWindow"}, + {"script": resolver_script, "label": "ActionResolver"}, + {"script": state_script, "label": "StateMachine"}, + {"script": energy_script, "label": "EnergyComponent"}, + {"script": controller_script, "label": "ActionController"}, + ]: + _expect(item["script"] != null, "%s script should load" % item["label"]) + if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null: + return {} + + var fixture_root := Node.new() + root.add_child(fixture_root) + var combo: Node = combo_script.new() + combo.name = "ComboWindow" + fixture_root.add_child(combo) + var resolver: Node = resolver_script.new() + resolver.name = "ActionResolver" + fixture_root.add_child(resolver) + var state: Node = state_script.new() + state.name = "StateMachine" + fixture_root.add_child(state) + var energy: Node = energy_script.new() + energy.name = "EnergyComponent" + fixture_root.add_child(energy) + var controller: Node = controller_script.new() + controller.name = "ActionController" + controller.set("combo_window_path", NodePath("../ComboWindow")) + controller.set("action_resolver_path", NodePath("../ActionResolver")) + controller.set("state_machine_path", NodePath("../StateMachine")) + fixture_root.add_child(controller) + await process_frame + energy.call("set_values", 99, 99) + return { + "root": fixture_root, + "combo": combo, + "controller": controller, + } + + +func _intent(symbol: StringName, rhythm_action: StringName, event_type: StringName, label: String, beat: int) -> RefCounted: + var intent_script: Script = load("res://scenes/components/input_intent.gd") + var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, event_type, float(Time.get_ticks_msec())) + intent.set("judgement", { + "label": label, + "diff": 0.0, + "abs_diff": 0.0, + "nearest_beat": beat, + }) + return intent + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _on_action_rejected(_intent, reason: StringName) -> void: + rejected.append(reason) + + +func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: + judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index}) + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS action controller flow") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) + diff --git a/tests/test_action_controller_flow.gd.uid b/tests/test_action_controller_flow.gd.uid new file mode 100644 index 0000000..e412303 --- /dev/null +++ b/tests/test_action_controller_flow.gd.uid @@ -0,0 +1 @@ +uid://bl5r1ip6l2ymo diff --git a/tests/test_action_pattern_export.gd b/tests/test_action_pattern_export.gd new file mode 100644 index 0000000..850c589 --- /dev/null +++ b/tests/test_action_pattern_export.gd @@ -0,0 +1,61 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var exporter: Script = load("res://tools/export_action_patterns.gd") + _expect(exporter != null, "Action pattern export tool should load") + if exporter != null: + var patterns: Dictionary = exporter.call("export_patterns") + var expected := { + "A": &"ground_attack_left_1", + "AA": &"ground_attack_left_2", + "AAA": &"ground_attack_left_3", + "ASP": &"dash_slash_left", + "AASP": &"combo_finisher_left", + "AAASP": &"ground_smash_left", + "D": &"ground_attack_right_1", + "DD": &"ground_attack_right_2", + "DDD": &"ground_attack_right_3", + "DSP": &"dash_slash_right", + "DDSP": &"combo_finisher_right", + "DDDSP": &"ground_smash_right", + "W": &"launcher_up", + "S": &"block_start", + } + _expect_int(patterns.size(), expected.size(), "Exported pattern count") + for key: String in expected: + _expect(patterns.get(key) == expected[key], "Pattern %s should map to %s" % [key, expected[key]]) + for forbidden_key: String in ["AD", "DA", "ASPSP"]: + _expect(not patterns.has(forbidden_key), "Forbidden pattern %s should not be exported" % forbidden_key) + _expect(not patterns.has("SS"), "SS should not be exported as a separate skill; runtime should reuse trailing S block fallback") + var text := str(exporter.call("to_text")) + _expect(text.contains("A -> ground_attack_left_1"), "Pattern export text should include A") + _expect(text.contains("W -> launcher_up"), "Pattern export text should include W") + _expect(text.contains("S -> block_start"), "Pattern export text should include S") + _finish() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS action pattern export") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_action_pattern_export.gd.uid b/tests/test_action_pattern_export.gd.uid new file mode 100644 index 0000000..40552b8 --- /dev/null +++ b/tests/test_action_pattern_export.gd.uid @@ -0,0 +1 @@ +uid://di3k4o5megqdl diff --git a/tests/test_architecture_refactor.gd b/tests/test_architecture_refactor.gd new file mode 100644 index 0000000..1c6ad88 --- /dev/null +++ b/tests/test_architecture_refactor.gd @@ -0,0 +1,141 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _check_event_bus_autoload() + _check_current_main_scene_loads() + _check_player_components() + _check_skill_resources() + _check_project_layers_and_inputs() + _finish() + + +func _check_event_bus_autoload() -> void: + _expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus should be registered as a game autoload") + if ProjectSettings.has_setting("autoload/EventBus"): + var path := str(ProjectSettings.get_setting("autoload/EventBus")) + _expect(path.contains("res://autoload/event_bus.gd"), "EventBus autoload should point at autoload/event_bus.gd") + var event_bus_script := load("res://autoload/event_bus.gd") + _expect(event_bus_script != null, "autoload/event_bus.gd should exist") + _expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager should be registered as a game autoload") + _expect(ProjectSettings.has_setting("autoload/CombatManager"), "CombatManager should be registered as a game autoload") + + +func _check_current_main_scene_loads() -> void: + var main_scene_path := str(ProjectSettings.get_setting("application/run/main_scene", "")) + _expect(main_scene_path == "res://scenes/main/main.tscn", "Phase-7 main scene should route through the migrated Stage/Main layer") + var main_scene: PackedScene = load(main_scene_path) + _expect(main_scene != null, "%s should load" % main_scene_path) + if main_scene != null: + var main := main_scene.instantiate() + get_root().add_child(main) + _expect(main.has_node("Stage"), "Phase-7 main scene should instance Stage") + _expect(main.has_node("ChartRunner"), "Phase-7 main scene should own ChartRunner") + _expect(not main.has_node("RhythmConductor"), "Main scene should use RhythmManager autoload instead of a RhythmConductor child") + main.free() + + +func _check_player_components() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(player_scene != null, "player.tscn should load") + if player_scene != null: + var player := player_scene.instantiate() + get_root().add_child(player) + for node_name: String in [ + "StateMachine", + "InputComponent", + "ComboWindow", + "ActionResolver", + "ActionExecutor", + "MotionExecutor", + "MovementMotor", + "BurstComponent", + "ChargeComponent", + "EnergyComponent", + "EffectContainer", + "HealthComponent", + "DamageReceiver", + "DamageEmitter", + ]: + _expect(player.has_node(node_name), "Player should have %s child component" % node_name) + player.free() + var source := _read_text("res://scenes/characters/player.gd") + _expect_not_contains(source, "KEY_", "Player should not match raw KEY_* values") + _expect_not_contains(source, "get_first_node_in_group(\"rhythm_conductor\")", "Player should not look up RhythmConductor by group") + _expect_not_contains(source, "PlayerProjectile.new()", "Player should not instantiate projectiles directly") + _expect_not_contains(source, "get_parent()", "Player should not reach upward with get_parent()") + + +func _check_skill_resources() -> void: + var action_script := load("res://resources/action_data.gd") + _expect(action_script != null, "resources/action_data.gd should exist") + var action_dir := DirAccess.open("res://resources/actions") + _expect(action_dir != null, "resources/actions should exist") + if action_dir != null: + var action_files: Array[String] = [] + for file_name: String in action_dir.get_files(): + if file_name.ends_with(".tres"): + action_files.append(file_name) + _expect(action_files.size() >= 12, "phase-4+ player actions should be saved as .tres resources") + var tracker_script := load("res://scenes/components/combo_window.gd") + var resolver_script := load("res://scenes/combat/action_resolver.gd") + _expect(tracker_script != null, "ComboWindow script should load") + _expect(resolver_script != null, "ActionResolver script should load") + if tracker_script == null or resolver_script == null: + return + var window = tracker_script.new() + window.record(&"A") + var resolved = resolver_script.resolve(window) + _expect(resolved is Resource, "ActionResolver.resolve should return an ActionData Resource, not a Dictionary") + if resolved is Resource: + _expect(resolved.get("input_pattern") is Array, "Resolved ActionData should expose input_pattern") + _expect(str(resolved.get("id")) == "ground_attack_left_1", "A pattern should resolve to ground_attack_left_1") + resolver_script.clear_cache() + window.free() + + +func _check_project_layers_and_inputs() -> void: + var expected_layers := { + "layer_names/2d_physics/layer_1": "world", + "layer_names/2d_physics/layer_2": "player_hurtbox", + "layer_names/2d_physics/layer_3": "enemy_hurtbox", + "layer_names/2d_physics/layer_4": "player_hitbox", + "layer_names/2d_physics/layer_5": "enemy_hitbox", + } + for key: String in expected_layers: + _expect(ProjectSettings.has_setting(key), "%s should be configured" % key) + if ProjectSettings.has_setting(key): + _expect(str(ProjectSettings.get_setting(key)) == expected_layers[key], "%s should be named %s" % [key, expected_layers[key]]) + for action_name: String in ["move_left", "move_right", "combo_w", "combo_a", "combo_d", "combo_s", "combo_space"]: + _expect(InputMap.has_action(action_name), "InputMap should define %s" % action_name) + _expect(not InputMap.has_action("player_space"), "InputMap should remove duplicate player_space action") + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_not_contains(source: String, needle: String, label: String) -> void: + if source.contains(needle): + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS architecture refactor") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_architecture_refactor.gd.uid b/tests/test_architecture_refactor.gd.uid new file mode 100644 index 0000000..00c76ab --- /dev/null +++ b/tests/test_architecture_refactor.gd.uid @@ -0,0 +1 @@ +uid://dfs2gbjkn4png diff --git a/tests/test_attack_buff.gd b/tests/test_attack_buff.gd new file mode 100644 index 0000000..b2fb512 --- /dev/null +++ b/tests/test_attack_buff.gd @@ -0,0 +1,136 @@ +extends SceneTree + +const BUFF_ID := &"time_anchor_attack_buff" + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + + var actor := Node.new() + actor.name = "TestActor" + var container: Node = load("res://scenes/components/effect_container.gd").new() + container.name = "EffectContainer" + actor.add_child(container) + var buff_component: Node = load("res://scenes/components/attack_buff_component.gd").new() + buff_component.name = "AttackBuffComponent" + actor.add_child(buff_component) + root.add_child(actor) + await process_frame + + var broadcasts: Array = [] + bus.connect("attack_buff_changed", func(stacks: int, max_stacks: int) -> void: + broadcasts.append([stacks, max_stacks]) + ) + + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + health.set("maximum", 1000) + health.set("current", 400) + actor.add_child(health) + await process_frame + + # --- Perfect/Good held anchors add stacks; damage formula is 1 + 0.50 x stacks. --- + for _index: int in range(3): + bus.emit_signal("time_anchor_resolved", {}, true, {"label": "perfect"}) + _expect_int(int(buff_component.call("attack_buff_stacks")), 3, "three held anchors should give three stacks") + var damage := float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null)) + _expect_float(damage, 250.0, "damage should scale by 1 + 0.50 x stacks") + if not broadcasts.is_empty(): + _expect_int(int(broadcasts[broadcasts.size() - 1][1]), 7, "broadcast should carry max stacks 7") + + # --- Broken anchors do not add stacks. --- + bus.emit_signal("time_anchor_resolved", {}, false, {}) + _expect_int(int(buff_component.call("attack_buff_stacks")), 3, "broken anchors must not add stacks") + + # --- Bad anchors hold/heal but do not add attack-buff stacks. --- + bus.emit_signal("time_anchor_resolved", {"beat": 15}, true, {"label": "bad"}) + _expect_int(int(buff_component.call("attack_buff_stacks")), 3, "bad held anchors must not add attack buff") + + # --- Cap at 7; extra Perfect/Good holds keep the cap. --- + for _index: int in range(30): + bus.emit_signal("time_anchor_resolved", {}, true, {"label": "good"}) + _expect_int(int(buff_component.call("attack_buff_stacks")), 7, "stacks should cap at 7") + damage = float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null)) + _expect_float(damage, 450.0, "capped stacks should give +350% damage") + + # --- Each actual phase switch subtracts two stacks, bottoming at zero. --- + var expected_reductions := [5, 3, 1, 0] + for expected: int in expected_reductions: + bus.emit_signal("time_phase_changed", &"past", &"future", &"time_anchor_broken") + _expect_int(int(buff_component.call("attack_buff_stacks")), expected, "phase switch should subtract stacks to %d" % expected) + var active_ids: Array = container.call("active_effect_ids") + _expect_bool(active_ids.has(BUFF_ID), false, "zero stacks should remove the buff effect") + + # --- Effect stacks and component report stay equal. --- + bus.emit_signal("time_anchor_resolved", {}, true, {"label": "perfect"}) + _expect_int(int(container.call("effect_stacks", BUFF_ID)), int(buff_component.call("attack_buff_stacks")), "effect stacks and component report should match") + + # --- First held anchor per 4-beat window heals; low health doubles the heal. --- + health.call("set_values", 400, 1000) + bus.emit_signal("time_anchor_resolved", {"beat": 8}, true, {"label": "perfect"}) + _expect_int(int(health.get("current")), 460, "perfect anchor should heal 60 while below half health") + bus.emit_signal("time_anchor_resolved", {"beat": 9}, true, {"label": "good"}) + _expect_int(int(health.get("current")), 460, "second held anchor in the same 4-beat window should not heal") + bus.emit_signal("time_anchor_resolved", {"beat": 12}, true, {"label": "bad"}) + _expect_int(int(health.get("current")), 480, "bad anchor in a new window should heal 20 while below half health") + health.call("set_values", 980, 1000) + bus.emit_signal("time_anchor_resolved", {"beat": 16}, true, {"label": "perfect"}) + _expect_int(int(health.get("current")), 1000, "time-anchor healing should not exceed max health") + + # --- chart_reset zeroes the buff. --- + bus.emit_signal("chart_reset", &"test") + _expect_int(int(buff_component.call("attack_buff_stacks")), 0, "chart_reset should zero the buff") + _expect_bool(container.call("active_effect_ids").has(BUFF_ID), false, "chart_reset should remove the buff effect") + + # --- Regression: stack-aware aggregation keeps max_stacks = 1 behaviour. --- + var definition: Resource = load("res://resources/effects/effect_definition.gd").new() + definition.set("id", &"test_single_stack_add") + definition.set("duration_type", &"infinite") + definition.set("max_stacks", 1) + var modifier: Resource = load("res://resources/effects/stat_modifier.gd").new() + modifier.set("stat", &"damage_mult") + modifier.set("operation", "add") + modifier.set("value", 0.5) + var stat_modifiers: Array[Resource] = [modifier] + definition.set("stat_modifiers", stat_modifiers) + container.call("add_effect", definition) + container.call("add_effect", definition) + _expect_float(float(container.call("stat_multiplier", &"damage_mult")), 1.5, "max_stacks 1 add modifiers keep the migration baseline") + + _finish() + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS attack buff") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_attack_buff.gd.uid b/tests/test_attack_buff.gd.uid new file mode 100644 index 0000000..2dfbd45 --- /dev/null +++ b/tests/test_attack_buff.gd.uid @@ -0,0 +1 @@ +uid://dvlr82gqcqewi diff --git a/tests/test_blade_rain_alignment.gd b/tests/test_blade_rain_alignment.gd new file mode 100644 index 0000000..1cc4a60 --- /dev/null +++ b/tests/test_blade_rain_alignment.gd @@ -0,0 +1,80 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_blade_rain_lands_on_player_ground("res://resources/actions/jian_yu_left_lv3.tres", Vector2.LEFT, "A/left") + await _check_blade_rain_lands_on_player_ground("res://resources/actions/jian_yu_right_lv3.tres", Vector2.RIGHT, "D/right") + _finish() + + +func _check_blade_rain_lands_on_player_ground(action_path: String, heading: Vector2, label: String) -> void: + var parent := Node2D.new() + root.add_child(parent) + var player_scene := load("res://scenes/characters/player.tscn") as PackedScene + var action := load(action_path) as Resource + _expect(player_scene != null, "%s player scene should load" % label) + _expect(action != null, "%s action should load" % label) + if player_scene == null or action == null: + parent.free() + return + var player := player_scene.instantiate() as Node2D + parent.add_child(player) + player.global_position = Vector2(1000.0, 560.0) + player.set("heading", heading) + await process_frame + + player.call("_spawn_world_fx", action) + await process_frame + + var tile_count := 0 + for child: Node in parent.get_children(): + if child == player: + continue + var fx := child as Node2D + if fx == null: + continue + var texture := child.get("texture") as Texture2D + if texture == null or texture.resource_path.find("rain_lading_tile") == -1: + continue + tile_count += 1 + var vframes := maxi(1, int(child.get("vframes"))) + var sprite_scale := child.get("sprite_scale") as Vector2 + var frame_height := float(texture.get_height()) / float(vframes) + var visual_bottom_y := fx.global_position.y + frame_height * sprite_scale.y * 0.5 + # The player art keeps its feet above the frame bottom, so the visible + # ground line sits VISIBLE_GROUND_LINE_OFFSET above the actor origin. + var ground_line_y: float = player.global_position.y + float(player.get_script().VISIBLE_GROUND_LINE_OFFSET) + _expect_float(visual_bottom_y, ground_line_y, "%s blade-rain landing tile bottom should share the player's visible ground line" % label) + _expect_int(tile_count, 8, "%s level-3 blade rain should spawn eight landing tiles" % label) + parent.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.02: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS blade rain alignment") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_blade_rain_alignment.gd.uid b/tests/test_blade_rain_alignment.gd.uid new file mode 100644 index 0000000..a0ba6aa --- /dev/null +++ b/tests/test_blade_rain_alignment.gd.uid @@ -0,0 +1 @@ +uid://bguqa0k4shg87 diff --git a/tests/test_boss_integration.gd b/tests/test_boss_integration.gd new file mode 100644 index 0000000..8229bda --- /dev/null +++ b/tests/test_boss_integration.gd @@ -0,0 +1,738 @@ +extends SceneTree + +var failures: Array[String] = [] +var started_actions: Array[StringName] = [] +var projectile_requests: Array[Dictionary] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_boss_assets() + await _check_boss_scene_contract() + _check_boss_action_resources() + await _check_stage_and_main_contract() + await _check_stage_camera_follows_player_with_wider_view() + await _check_visible_ground_alignment() + await _check_boss_behavior_tree_uses_beat_ticks() + await _check_boss_entry_clears_precombat_chart_delay() + await _check_boss_behavior_tree_handles_point_blank_pressure() + await _check_boss_escapes_after_sustained_hit_chain() + await _check_boss_driver_starts_beat_anchored_actions() + await _check_boss_projectile_request_and_visual() + await _check_bidirectional_damage_flow() + await _check_boss_death_state_is_terminal() + _check_boss_script_debt_cleanup() + _finish() + + +func _check_boss_assets() -> void: + var expected_assets := { + "boss_idle.png": 1, + "boss_run.png": 8, + "boss_dash.png": 6, + "boss_jump_fall.png": 2, + "boss_take_hit.png": 1, + "boss_die.png": 4, + "boss_ground_combo_1.png": 8, + "boss_ground_combo_2.png": 10, + "boss_ground_combo_3.png": 14, + "boss_lunging_stab.png": 15, + "boss_shoot_1.png": 2, + "boss_shoot_2.png": 2, + "boss_2way_shoot_1.png": 2, + "boss_2way_shoot_2.png": 2, + } + for file_name: String in expected_assets: + var texture: Texture2D = load("res://assets/art/characters/boss/%s" % file_name) + _expect(texture != null, "Boss asset %s should load" % file_name) + if texture != null: + _expect_int(texture.get_width(), 80 * int(expected_assets[file_name]), "%s width should match 80px frames" % file_name) + _expect_int(texture.get_height(), 48, "%s height should be 48px" % file_name) + + +func _check_boss_scene_contract() -> void: + var scene: PackedScene = load("res://scenes/enemies/boss.tscn") + _expect(scene != null, "Boss scene should load") + if scene == null: + return + var boss := scene.instantiate() + root.add_child(boss) + await process_frame + for node_path: String in [ + "Visual", + "Visual/CharacterSprite", + "Visual/FxOverlay", + "AnimationPlayer", + "StateMachine", + "MovementMotor", + "ActionResolver", + "ActionExecutor", + "ActionController", + "MotionExecutor", + "EffectContainer", + "HealthComponent", + "DamageReceiver", + "DamageEmitter", + "EnemyActionDriver", + "BossBehaviorTree", + ]: + _expect(boss.get_node_or_null(node_path) != null, "Boss should contain %s" % node_path) + _expect_int(int(boss.get_node("HealthComponent").get("maximum")), 28000, "Boss HP baseline should live on HealthComponent") + _expect_float(absf(float(boss.get_node("Visual").scale.x)), 2.0, "Boss Visual should keep the x1 reference scale") + _expect_float(float(boss.get_node("Visual").scale.y), 2.0, "Boss Visual should keep the x1 reference scale") + _expect_float(float(boss.get("visual_ground_offset")), -40.0, "Boss Visual offset should match Rthythm_archor_game") + _expect_float(float(boss.get("projectile_spawn_height")), 74.0, "Boss projectile lane should match Rthythm_archor_game") + var animation_player: AnimationPlayer = boss.get_node_or_null("AnimationPlayer") + if animation_player != null: + for animation_name: StringName in _boss_animation_names(): + _expect(animation_player.has_animation(animation_name), "Boss AnimationPlayer should expose %s" % animation_name) + boss.queue_free() + await process_frame + + +func _check_boss_action_resources() -> void: + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + _expect(resolver_script != null, "ActionResolver should load") + if resolver_script == null: + return + resolver_script.clear_cache() + var animation_names := _boss_animation_names() + for action_id: StringName in [ + &"boss_combo_1", + &"boss_combo_2", + &"boss_combo_3", + &"boss_retreat_dash", + &"boss_lunging_stab", + &"boss_dash", + &"boss_shoot_1", + &"boss_shoot_2", + &"boss_2way_shoot", + ]: + var action: Resource = resolver_script.get_action(action_id) + _expect(action != null, "%s should be a Boss ActionData resource" % action_id) + if action == null: + continue + _expect_bool(Array(action.get("input_pattern")).is_empty(), true, "%s should not expose a player input pattern" % action_id) + _expect(animation_names.has(action.get("animation")), "%s animation should exist on Boss AnimationPlayer" % action_id) + _expect(float(action.get("damage_mult")) >= 0.0, "%s damage should be data-driven on ActionData" % action_id) + _expect(float(action.get("knockback_mult_x")) >= 0.0, "%s should configure horizontal knockback for player hit reaction" % action_id) + resolver_script.clear_cache() + + +func _check_stage_and_main_contract() -> void: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + var main_scene: PackedScene = load("res://scenes/main/main.tscn") + _expect(stage_scene != null, "Stage scene should load") + _expect(main_scene != null, "Main scene should load") + _expect_equal(ProjectSettings.get_setting("application/run/main_scene"), "res://scenes/main/main.tscn", "Project main scene should move to phase-7 main.tscn") + if stage_scene != null: + var stage := stage_scene.instantiate() + root.add_child(stage) + await process_frame + _expect(stage.has_node("ActorsContainer/Player"), "Stage should instance Player under ActorsContainer") + _expect(stage.has_node("ActorsContainer/Boss"), "Stage should instance Boss under ActorsContainer") + if stage.has_node("ActorsContainer/Player") and stage.has_node("ActorsContainer/Boss"): + var player := stage.get_node("ActorsContainer/Player") as Node2D + var boss := stage.get_node("ActorsContainer/Boss") + var boss_tree := boss.get_node("BossBehaviorTree") + _expect(boss_tree.get("target") == player, "BossBehaviorTree should resolve the Player target in Stage") + boss.call("look_at_target", player) + boss.call("flip_sprites") + _expect(boss.get("heading") == Vector2.LEFT, "Boss should face left when Player is left of Boss") + _expect(float(boss.get_node("Visual").scale.x) < 0.0, "Boss visual should flip for a left-facing target because the source art faces right") + stage.queue_free() + await process_frame + if main_scene != null: + var main: Node = main_scene.instantiate() + root.add_child(main) + await process_frame + _expect(main.has_node("Stage"), "Main should instance Stage") + _expect(main.has_node("ChartRunner"), "Main should own ChartRunner") + _expect(main.get_node("ChartRunner").get("chart") != null, "Main ChartRunner should reference the stage-7 boss chart") + main.queue_free() + await process_frame + + +func _check_stage_camera_follows_player_with_wider_view() -> void: + root.size = Vector2i(1152, 648) + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D + var camera := stage.get_node_or_null("Camera2D") as Camera2D + var gate := stage.get_node_or_null("BossRoomGate") + _expect(player != null, "Stage authored camera check should find Player") + _expect(camera != null, "Stage authored camera check should find Camera2D") + _expect(gate != null, "Stage authored camera check should find BossRoomGate") + if player != null and camera != null and gate != null: + _expect_bool(bool(stage.get("use_authored_camera_view")), false, "Stage should keep the x1 follow-camera baseline") + var camera_start := camera.global_position + _expect_vector(camera_start, Vector2(1180, 395), "Stage Camera2D should start over the left-spawn player (new2)") + _expect_float(camera.zoom.x, 1.25, "Stage Camera2D should keep the x1 zoom") + _expect_float(camera.zoom.y, 1.25, "Stage Camera2D should keep the x1 zoom") + player.global_position = Vector2(float(gate.get("gate_x")), player.global_position.y) + stage.call("_update_camera_follow") + var boss_entry_x := float(gate.get("gate_x")) + float(gate.get("wall_thickness")) + var half_view_width := get_root().size.x / (2.0 * camera.zoom.x) + _expect_float(camera.global_position.x + half_view_width, boss_entry_x, "Stage Camera2D should not show Boss room while the gate is sealed") + gate.call("unseal") + await process_frame + stage.call("_update_camera_follow") + _expect_vector(camera.global_position, player.global_position + Vector2(0.0, -165.0), "Stage Camera2D should follow Player after Boss room unlock") + stage.queue_free() + await process_frame + + +func _check_visible_ground_alignment() -> void: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D + var melee := stage.get_node_or_null("ActorsContainer/JinZhanMinion") as Node2D + var boss := stage.get_node_or_null("ActorsContainer/Boss") as Node2D + _expect(player != null, "Player should exist for anchor alignment check") + _expect(melee != null, "Initial minion should exist for anchor alignment check") + _expect(boss != null, "Boss should exist for anchor alignment check") + if player != null and melee != null and boss != null: + _expect_float(player.global_position.y, melee.global_position.y, "Player and minion anchors should share one horizontal line") + _expect_float(player.global_position.y, boss.global_position.y, "Player and Boss anchors should share one horizontal line") + stage.queue_free() + await process_frame + + +func _check_boss_behavior_tree_uses_beat_ticks() -> void: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + var bus := _ensure_event_bus() + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var boss: Node = stage.get_node("ActorsContainer/Boss") + var controller: Node = boss.get_node("ActionController") + var boss_tree: Node = boss.get_node("BossBehaviorTree") + boss.set("combat_enabled", true) + boss.set("stationary", false) + boss_tree.set("enabled", true) + boss_tree.set("decision_interval_beats", 2.0) + # 决策间隔在 future 相位会 x0.5(§18.1)。真实 TimeAnchorSystem 在测试 + # 进程里会因锚点 Miss 在随机时刻把相位翻到 future,这里钉回 past, + # 保证 interval 断言按 1.0 缩放判定。 + var phase_manager := root.get_node_or_null("TimePhaseManager") + if phase_manager != null and phase_manager.has_method("reset_to_initial"): + phase_manager.call("reset_to_initial", &"past") + # 相位钉回会触发 new3 §6.4 的切相位停顿;本段只验证节拍门控本身, + # 停顿契约由 test_minion_phase_system 覆盖,这里清掉。 + boss_tree.set("_next_decision_beat", 0) + controller.connect("action_started", _on_action_started) + started_actions.clear() + await process_frame + await process_frame + _expect(started_actions.is_empty(), "BossBehaviorTree should not start attacks from delta time between beats") + + bus.emit_signal("beat_ticked", 1) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should choose attacks on beat_ticked") + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + started_actions.clear() + + bus.emit_signal("beat_ticked", 2) + await process_frame + _expect(started_actions.is_empty(), "BossBehaviorTree should respect decision_interval_beats instead of attacking every frame") + bus.emit_signal("beat_ticked", 3) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should attack again on the next eligible beat grid") + stage.queue_free() + await process_frame + + +func _check_boss_entry_clears_precombat_chart_delay() -> void: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + var bus := _ensure_event_bus() + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var boss: Node = stage.get_node("ActorsContainer/Boss") + var controller: Node = boss.get_node("ActionController") + var boss_tree: Node = boss.get_node("BossBehaviorTree") + controller.connect("action_started", _on_action_started) + started_actions.clear() + boss.set("combat_enabled", false) + boss.set("stationary", true) + boss_tree.set("enabled", true) + boss_tree.call("_on_chart_event_upcoming", _make_chart_event(100, &"Boss", &"boss_lunging_stab"), 2.0) + _expect(int(boss_tree.get("_next_decision_beat")) > 1, "test setup should defer Boss decisions before room entry") + boss.set("combat_enabled", true) + boss.set("stationary", false) + bus.emit_signal("beat_ticked", 1) + await process_frame + _expect(not started_actions.is_empty(), "Boss should act on the first eligible beat after boss-room entry, even after precombat chart previews") + stage.queue_free() + await process_frame + + +func _check_boss_behavior_tree_handles_point_blank_pressure() -> void: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + var bus := _ensure_event_bus() + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var player: Node2D = stage.get_node("ActorsContainer/Player") + var boss: Node2D = stage.get_node("ActorsContainer/Boss") + var controller: Node = boss.get_node("ActionController") + var boss_tree: Node = boss.get_node("BossBehaviorTree") + boss.set("combat_enabled", true) + player.global_position = Vector2(100.0, 360.0) + boss.global_position = Vector2(170.0, 360.0) + boss.set("stationary", false) + boss_tree.set("enabled", true) + boss_tree.set("restrict_actions_by_time_phase", false) + boss_tree.set("decision_interval_beats", 1.0) + controller.connect("action_started", _on_action_started) + started_actions.clear() + bus.emit_signal("beat_ticked", 20) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should act when trapped at point blank") + if not started_actions.is_empty(): + _expect(str(started_actions[started_actions.size() - 1]).begins_with("boss_combo"), "BossBehaviorTree should answer point-blank pressure with melee first, not by fleeing") + + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + boss_tree.set("_last_melee_beat", 21.0) + started_actions.clear() + bus.emit_signal("beat_ticked", 22) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should still act at point blank while melee is on cooldown") + if not started_actions.is_empty(): + _expect_equal(started_actions[started_actions.size() - 1], &"boss_2way_shoot", "Point-blank pressure with melee on cooldown should be punished with the 2-way shot") + + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + boss_tree.set("_last_melee_beat", 23.0) + boss_tree.set("_last_two_way_beat", 23.0) + started_actions.clear() + bus.emit_signal("beat_ticked", 24) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should act when both melee and 2-way are on cooldown") + if not started_actions.is_empty(): + _expect_equal(started_actions[started_actions.size() - 1], &"boss_retreat_dash", "BossBehaviorTree should only retreat once its point-blank answers are on cooldown") + + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + player.global_position = Vector2(100.0, 360.0) + boss.global_position = Vector2(360.0, 360.0) + started_actions.clear() + bus.emit_signal("beat_ticked", 25) + await process_frame + _expect(not started_actions.is_empty(), "BossBehaviorTree should follow a retreat with ranged pressure") + if not started_actions.is_empty(): + _expect(str(started_actions[started_actions.size() - 1]).begins_with("boss_shoot"), "BossBehaviorTree should use a shoot action after creating range") + stage.queue_free() + await process_frame + + +func _check_boss_escapes_after_sustained_hit_chain() -> void: + var combat: Node = _ensure_combat_manager() + combat.call("_physics_process", 1.0 / 60.0) + var player: Node = load("res://scenes/characters/player.tscn").instantiate() + var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate() + root.add_child(player) + root.add_child(boss) + await process_frame + var controller: Node = boss.get_node("ActionController") + var state_machine: Node = boss.get_node("StateMachine") + var escape_threshold := int(boss.get("escape_after_consecutive_hits")) + _expect(escape_threshold >= 3, "Boss should tolerate a short combo before burning its escape") + controller.connect("action_started", _on_action_started) + started_actions.clear() + boss.get_node("HealthComponent").call("set_values", 8000, 8000) + var player_action: Resource = load("res://resources/actions/ground_attack_left_1.tres") + player.get_node("DamageEmitter").call("configure_hit", player_action, {"label": "perfect"}) + for hit_index: int in range(escape_threshold - 1): + combat.call("_physics_process", 1.0 / 60.0) + combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver")) + await process_frame + # 2026-07-05 定案:普攻恒霸体——打不出 Boss 硬直,但受击链照常累计。 + _expect_equal(StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))), &"Alive", "Boss should shrug off normal attacks without hitstun before the escape threshold") + _expect(not started_actions.has(&"boss_retreat_dash"), "Boss should not burn retreat before the hit chain threshold") + combat.call("_physics_process", 1.0 / 60.0) + combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver")) + await process_frame + _expect(started_actions.has(&"boss_retreat_dash"), "Boss should escape after a sustained hit chain") + _expect_equal(StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))), &"Alive", "Boss should leave Hitstun when forced retreat starts") + _expect_int(int(controller.get("phase")), 1, "Boss forced retreat should start through ActionController Startup") + + started_actions.clear() + for hit_index: int in range(escape_threshold + 1): + combat.call("_physics_process", 1.0 / 60.0) + combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver")) + await process_frame + _expect(not started_actions.has(&"boss_retreat_dash"), "Boss escape should respect its cooldown instead of chaining escapes") + player.queue_free() + boss.queue_free() + await process_frame + + +func _check_boss_driver_starts_beat_anchored_actions() -> void: + started_actions.clear() + var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate() + root.add_child(boss) + await process_frame + var controller: Node = boss.get_node("ActionController") + var driver: Node = boss.get_node("EnemyActionDriver") + controller.connect("action_started", _on_action_started) + driver.call("start_action", &"boss_combo_1") + _expect(started_actions.has(&"boss_combo_1"), "Boss driver should start boss actions through ActionController") + _expect_equal(controller.get("beat_anchor_policy"), &"ANCHOR_ACTIVE", "Boss ActionController should keep Active beat anchoring") + _expect_int(int(controller.get("phase")), 1, "Boss action should enter Startup before Active") + boss.queue_free() + await process_frame + + +func _check_boss_projectile_request_and_visual() -> void: + projectile_requests.clear() + var bus := _ensure_event_bus() + if not bus.is_connected("projectile_requested", _on_projectile_requested): + bus.connect("projectile_requested", _on_projectile_requested) + var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate() + root.add_child(boss) + await process_frame + var shoot: Resource = load("res://resources/actions/enemies/boss_shoot_1.tres") + _expect(shoot != null, "boss_shoot_1 should load for projectile request") + if shoot != null: + _expect_bool(bool(boss.get_node("ActionExecutor").call("execute", shoot, &"perfect", null)), true, "Boss projectile action should execute") + _expect_int(projectile_requests.size(), 1, "Boss shoot action should request one projectile at Active") + if not projectile_requests.is_empty(): + var request := projectile_requests[0] + var spawn_position := request.get("spawn_position", Vector2.ZERO) as Vector2 + var direction := request.get("direction", Vector2.ZERO) as Vector2 + var context := request.get("context", {}) as Dictionary + _expect(spawn_position.x < boss.global_position.x, "Boss projectile should spawn from the muzzle side when facing left") + _expect(spawn_position.y < boss.global_position.y, "Boss projectile should spawn above the Boss root, not from the feet") + _expect(absf((boss.global_position.y - spawn_position.y) - 74.0) <= 2.0, "Boss projectile should spawn at the torso lane of the drawn bodies, got %.2f px above root" % (boss.global_position.y - spawn_position.y)) + _expect(direction.x < 0.0, "Boss projectile should travel toward the current heading") + _expect_equal(StringName(str(context.get("team", &""))), &"enemy", "Boss projectile request should carry the enemy team context") + _expect(context.get("action", null) == shoot, "Boss projectile request should carry the shooting ActionData for damage resolution") + projectile_requests.clear() + var two_way: Resource = load("res://resources/actions/enemies/boss_2way_shoot.tres") + if two_way != null: + _expect_bool(bool(boss.get_node("ActionExecutor").call("execute", two_way, &"perfect", null)), true, "Boss two-way projectile action should execute") + _expect_int(projectile_requests.size(), 2, "Boss two-way shoot should request left and right projectiles") + if projectile_requests.size() == 2: + var first_direction := projectile_requests[0].get("direction", Vector2.ZERO) as Vector2 + var second_direction := projectile_requests[1].get("direction", Vector2.ZERO) as Vector2 + _expect(first_direction.x < 0.0 or second_direction.x < 0.0, "Boss two-way shoot should include a left projectile") + _expect(first_direction.x > 0.0 or second_direction.x > 0.0, "Boss two-way shoot should include a right projectile") + var projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate() + root.add_child(projectile) + # 下面的帧号断言手动喂 _process/_physics_process;关闭引擎自动 tick, + # 避免 await 帧的真实 delta 混入动画计时造成帧号随墙钟抖动。 + projectile.set_process(false) + projectile.set_physics_process(false) + await process_frame + _expect(projectile.has_method("max_travel_distance"), "Projectile should expose max_travel_distance for Boss spacing checks") + if projectile.has_method("max_travel_distance"): + var retreat_action: Resource = load("res://resources/actions/enemies/boss_retreat_dash.tres") + var beat_time := 0.5 + var retreat_distance := float(boss.get("boss_lunge_speed")) * absf(float(retreat_action.get("move_mult_x"))) * float(retreat_action.get("action_beats")) * beat_time + _expect(float(projectile.call("max_travel_distance")) > retreat_distance + float(boss.get("projectile_spawn_forward")), "Projectile max range should exceed Boss retreat distance plus muzzle offset") + var sprite := projectile.get_node_or_null("Sprite") as Sprite2D + if sprite == null and projectile.get_child_count() > 0: + sprite = projectile.get_child(0) as Sprite2D + _expect(sprite != null, "Projectile should create a Sprite2D child") + if sprite != null: + _expect_equal(sprite.texture.resource_path, "res://assets/art/effects/effect_sheet.png", "Projectile should use effect_sheet.png") + _expect_int(sprite.hframes, 6, "effect_sheet is a 6x2 grid of 32px cells, not five columns") + _expect_int(sprite.vframes, 2, "effect_sheet should be treated as two rows") + projectile.call("_process", 0.13) + _expect_int(sprite.frame, 2, "Projectile flight animation should advance through the four flight frames") + projectile.call("_process", 0.13) + _expect_int(sprite.frame, 0, "Projectile flight animation should loop while flying instead of vanishing mid-air") + projectile.set("direction", Vector2.RIGHT) + for step: int in range(10): + projectile.call("_physics_process", 0.1) + _expect_bool(projectile.is_queued_for_deletion(), true, "Projectile should expire by travel range, not by animation time") + projectile.queue_free() + + var hit_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate() + var player: Node = load("res://scenes/characters/player.tscn").instantiate() + root.add_child(hit_projectile) + root.add_child(player) + await process_frame + _expect(hit_projectile is Area2D, "Projectile root should be an Area2D so it can collide with DamageReceiver") + player.get_node("HealthComponent").call("set_values", 1000, 1000) + if hit_projectile is Area2D: + _ensure_combat_manager() + hit_projectile.call("_on_area_entered", player.get_node("DamageReceiver")) + _expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Projectile collision should reduce Player health") + _expect_bool(bool(hit_projectile.get("visible")), false, "Projectile should hide immediately after a hit instead of appearing to pass through") + _expect_bool(hit_projectile.is_queued_for_deletion(), true, "Projectile should be queued for deletion after a hit") + hit_projectile.queue_free() + + var sweep_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate() + root.add_child(sweep_projectile) + await process_frame + player.global_position = Vector2(100.0, 0.0) + player.get_node("HealthComponent").call("set_values", 1000, 1000) + player.get_node("StateMachine").call("set_life_state", &"Alive") + sweep_projectile.set("direction", Vector2.RIGHT) + (sweep_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -72.0) + await physics_frame + (sweep_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -72.0) + _expect(sweep_projectile.has_method("_physics_process"), "Projectile should move and sweep for hits during physics ticks") + if sweep_projectile.has_method("_physics_process"): + sweep_projectile.call("_physics_process", 1.0 / 60.0) + _expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Projectile sweep should hit before the visual center overlaps Player") + _expect_bool(bool(sweep_projectile.get("visible")), false, "Projectile sweep hit should hide immediately") + _expect((sweep_projectile as Node2D).global_position.x < player.global_position.x - 11.0, "Projectile center should still be outside Player hurtbox when sweep hit resolves") + sweep_projectile.queue_free() + + var physical_projectile: Node = load("res://scenes/combat/player_projectile.tscn").instantiate() + root.add_child(physical_projectile) + await process_frame + player.global_position = Vector2(100.0, 0.0) + player.get_node("HealthComponent").call("set_values", 1000, 1000) + player.get_node("StateMachine").call("set_life_state", &"Alive") + physical_projectile.set("direction", Vector2.RIGHT) + (physical_projectile as Node2D).global_position = player.global_position + Vector2(-30.0, -70.0) + _expect((int((physical_projectile as Area2D).collision_mask) & int(player.get_node("DamageReceiver").collision_layer)) != 0, "Projectile mask should include Player DamageReceiver layer") + _expect((int(player.get_node("DamageReceiver").collision_mask) & int((physical_projectile as Area2D).collision_layer)) != 0, "Player DamageReceiver mask should include projectile layer") + for frame: int in range(4): + await physics_frame + _expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Boss projectile at muzzle lane should hit Player through real Area2D physics overlap") + if is_instance_valid(physical_projectile): + _expect_bool(bool(physical_projectile.get("visible")), false, "Physical projectile hit should hide immediately") + _expect_bool(physical_projectile.is_queued_for_deletion(), true, "Physical projectile hit should queue deletion") + physical_projectile.queue_free() + player.queue_free() + boss.queue_free() + await process_frame + + +func _check_bidirectional_damage_flow() -> void: + var combat: Node = _ensure_combat_manager() + var player: Node = load("res://scenes/characters/player.tscn").instantiate() + var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate() + root.add_child(player) + root.add_child(boss) + await process_frame + player.get_node("HealthComponent").call("set_values", 1000, 1000) + boss.get_node("HealthComponent").call("set_values", 8000, 8000) + var player_action: Resource = load("res://resources/actions/ground_attack_left_1.tres") + var boss_action: Resource = load("res://resources/actions/enemies/boss_combo_1.tres") + player.get_node("DamageEmitter").call("configure_hit", player_action, {"label": "perfect"}) + boss.get_node("DamageEmitter").call("configure_hit", boss_action, {"label": "perfect"}) + combat.call("resolve_hit", player.get_node("DamageEmitter"), boss.get_node("DamageReceiver")) + combat.call("resolve_hit", boss.get_node("DamageEmitter"), player.get_node("DamageReceiver")) + _expect(int(boss.get_node("HealthComponent").get("current")) < 8000, "Player hitbox should damage Boss HealthComponent through CombatManager") + _expect(int(player.get_node("HealthComponent").get("current")) < 1000, "Boss hitbox should damage Player HealthComponent through CombatManager") + player.queue_free() + boss.queue_free() + await process_frame + + +func _check_boss_death_state_is_terminal() -> void: + var boss: Node = load("res://scenes/enemies/boss.tscn").instantiate() + root.add_child(boss) + await process_frame + var state_machine: Node = boss.get_node("StateMachine") + var animation_player: AnimationPlayer = boss.get_node("AnimationPlayer") + var motion: Node = boss.get_node("MotionExecutor") + var emitter: Area2D = boss.get_node("DamageEmitter") + var receiver: Area2D = boss.get_node("DamageReceiver") + var behavior_tree: Node = boss.get_node("BossBehaviorTree") + var action: Resource = load("res://resources/actions/enemies/boss_lunging_stab.tres") + emitter.call("configure_hit", action, {"label": "perfect"}) + motion.call("execute", action, Vector2.LEFT, 0.5, 120.0) + boss.set("velocity", Vector2(80.0, 0.0)) + state_machine.call("set_life_state", &"Dead") + boss.call("handle_animations") + _expect_equal(animation_player.current_animation, "boss_die", "Boss should start the death animation when life_state becomes Dead") + _expect_bool(bool(animation_player.is_playing()), true, "Boss death animation should play when Dead is first observed") + _expect_bool(bool(emitter.get("monitoring")), false, "Boss death should disable active hitboxes") + _expect_bool(bool(motion.get("active")), false, "Boss death should cancel active movement") + _expect_equal(boss.get("velocity"), Vector2.ZERO, "Boss death should stop current velocity") + _expect_bool(bool(behavior_tree.get("enabled")), false, "Boss death should stop BehaviorTree decisions") + _expect_int(int(boss.get("collision_layer")), 64, "Boss body collision layer should remain inspectable after death") + _expect_int(int(boss.get("collision_mask")), 33, "Boss body collision mask should remain restored after death") + _expect_int(int(receiver.get("collision_layer")), 4, "Boss DamageReceiver layer should remain configured after death") + animation_player.advance(animation_player.get_animation("boss_die").length + 0.2) + animation_player.stop() + boss.call("handle_animations") + _expect_bool(bool(animation_player.is_playing()), false, "Boss death animation should not restart after it has already played once") + boss.queue_free() + await process_frame + + +func _check_boss_script_debt_cleanup() -> void: + var boss_source := _read_text("res://scenes/enemies/boss.gd") + var tree_source := _read_text("res://scenes/enemies/boss_behavior_tree.gd") + _expect(not boss_source.contains("iron_sentinel"), "boss.gd should not keep iron_sentinel art debt") + _expect(not boss_source.contains("_knockback_time_left"), "boss.gd should not keep the local knockback timer hack") + _expect(not tree_source.to_lower().contains("limboai"), "boss_behavior_tree.gd should not reference limboai") + _expect(not tree_source.contains("skill_"), "boss_behavior_tree.gd should use new boss ActionData ids") + + +func _boss_animation_names() -> Array[StringName]: + return [ + &"boss_idle", + &"boss_run", + &"boss_dash", + &"boss_jump_fall", + &"boss_take_hit", + &"boss_die", + &"boss_ground_combo_1", + &"boss_ground_combo_2", + &"boss_ground_combo_3", + &"boss_lunging_stab", + &"boss_shoot_1", + &"boss_shoot_2", + &"boss_2way_shoot", + ] + + +func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float: + if sprite.texture == null: + failures.append("%s should have a texture" % sprite.get_path()) + return INF + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + failures.append("%s texture image should be readable" % sprite.get_path()) + return INF + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + failures.append("%s should contain visible pixels" % sprite.get_path()) + return INF + return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y + + +func _world_to_screen(world_position: Vector2, camera_position: Vector2, zoom: Vector2, viewport_size: Vector2) -> Vector2: + return (world_position - camera_position) * zoom + viewport_size * 0.5 + + +func _on_action_started(action: Resource, _intent) -> void: + started_actions.append(StringName(str(action.get("id")))) + + +func _on_projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2, context: Dictionary = {}) -> void: + projectile_requests.append({ + "scene": projectile_scene, + "spawn_position": spawn_position, + "direction": direction, + "context": context, + }) + + +func _ensure_event_bus() -> Node: + var existing := root.get_node_or_null("EventBus") + if existing != null: + return existing + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _ensure_combat_manager() -> Node: + var existing := root.get_node_or_null("CombatManager") + if existing != null: + return existing + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + return combat + + +func _make_chart_event(beat: int, target_id: StringName, action_id: StringName) -> Resource: + var event_script: Script = load("res://resources/chart_event.gd") + var event: Resource = event_script.new() + event.set("event_type", &"enemy_action") + event.set("beat_index", beat) + event.set("target_id", target_id) + event.set("action_id", action_id) + event.set("lead_beats", 2.0) + return event + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.03: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS boss integration") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_boss_integration.gd.uid b/tests/test_boss_integration.gd.uid new file mode 100644 index 0000000..2a20239 --- /dev/null +++ b/tests/test_boss_integration.gd.uid @@ -0,0 +1 @@ +uid://b6hawactspree diff --git a/tests/test_boss_room_entry_hud_and_wall.gd b/tests/test_boss_room_entry_hud_and_wall.gd new file mode 100644 index 0000000..145e8e3 --- /dev/null +++ b/tests/test_boss_room_entry_hud_and_wall.gd @@ -0,0 +1,76 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var main_scene := load("res://scenes/main/main.tscn") as PackedScene + _expect(main_scene != null, "main.tscn should load") + if main_scene == null: + _finish() + return + var main := main_scene.instantiate() + root.add_child(main) + await process_frame + await process_frame + + var flow := root.get_node_or_null("GameFlowManager") + if flow != null: + flow.set("state", &"Gameplay_FrontArea") + flow.set("_boss_room_entered", false) + + var stage := main.get_node("Stage") + var gate: Node = stage.get_node("BossRoomGate") + var player := stage.get_node("ActorsContainer/Player") as Node2D + var boss := stage.get_node("ActorsContainer/Boss") as Node2D + var ui := main.get_node("UILayer/UI") + var boss_status := ui.get_node_or_null("BossStatus") as Control + var boss_health_bar := ui.get_node_or_null("BossStatus/BossHealthBar") as ProgressBar + var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D + + _expect(boss_status != null, "Main UI should expose BossStatus") + if boss_status == null or boss_health_bar == null: + main.free() + _finish() + return + + _expect(not boss_status.visible, "BossStatus should be hidden before entering the boss room") + gate.call("unseal") + await process_frame + player.global_position = Vector2(boss.global_position.x, float(gate.get("floor_y"))) + gate.call("_physics_process", 0.016) + await process_frame + + _expect(bool(gate.get("locked")), "BossRoomGate should lock once the player crosses into the boss room") + _expect(wall_shape.disabled, "BossRoomGate should keep the air wall collision disabled after entry") + _expect(boss_status.visible, "BossStatus should show immediately after boss-room entry") + _expect_float(float(boss_health_bar.max_value), 28000.0, "BossStatus should bind the authored Boss health immediately") + _expect_float(float(boss_health_bar.value), 28000.0, "BossStatus should show full Boss health before the first hit") + + main.free() + _finish() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS boss room entry hud and wall") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_boss_room_entry_hud_and_wall.gd.uid b/tests/test_boss_room_entry_hud_and_wall.gd.uid new file mode 100644 index 0000000..16d80bd --- /dev/null +++ b/tests/test_boss_room_entry_hud_and_wall.gd.uid @@ -0,0 +1 @@ +uid://brsjdcla5hntq diff --git a/tests/test_boss_room_gate.gd b/tests/test_boss_room_gate.gd new file mode 100644 index 0000000..5614834 --- /dev/null +++ b/tests/test_boss_room_gate.gd @@ -0,0 +1,115 @@ +extends SceneTree + +## Boss-room gate (AnchorV1.0 §4.5 + 关卡设计指南 §8): starts SEALED until the +## front area is cleared, opens via unseal(), locks behind the player, then +## fires the entry hook, and keeps the air wall fully gone after entry. + +var failures: Array[String] = [] +var entered_signals := 0 +var unsealed_signals := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + + var gate: Node = stage.get_node_or_null("BossRoomGate") + _expect_bool(gate != null, true, "stage should carry a BossRoomGate") + if gate == null: + _finish() + return + gate.connect("boss_room_entered", func() -> void: entered_signals += 1) + gate.connect("unsealed", func() -> void: unsealed_signals += 1) + + var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D + var door_visual := gate.get_node_or_null("DoorVisual") as CanvasItem + _expect_bool(bool(gate.get("sealed")), true, "gate starts sealed until the six minions fall (指南 §8)") + _expect_bool(wall_shape.disabled, false, "sealed gate blocks the boss room physically") + _expect_bool(door_visual != null, true, "gate should keep an inspectable DoorVisual node") + if door_visual != null: + _expect_bool(door_visual.visible, false, "sealed boss-room air wall should stay invisible") + _expect_bool(bool(gate.get("locked")), false, "gate starts unlocked") + var flow := root.get_node_or_null("GameFlowManager") + if flow != null: + flow.set("state", &"Gameplay_FrontArea") + flow.set("_boss_room_entered", false) + + var player := stage.get_node("ActorsContainer/Player") as Node2D + var boss: Node = stage.get_node("ActorsContainer/Boss") + var floor_y := float(gate.get("floor_y")) + _expect_bool( + (boss as Node2D).global_position.x >= float(gate.get("gate_x")) + float(gate.get("wall_thickness")), + true, + "Boss authored anchor should sit inside the boss-room trigger line so reaching the Boss starts the fight" + ) + _expect_bool(bool(boss.get("combat_enabled")), false, "boss combat stays locked until the room is entered") + + # While sealed, even a player beyond the threshold must not trigger the lock. + player.global_position = Vector2(float(gate.get("gate_x")) + 120.0, floor_y) + gate.call("_physics_process", 0.016) + _expect_bool(bool(gate.get("locked")), false, "sealed gate never locks") + player.global_position = Vector2(float(gate.get("gate_x")) - 120.0, floor_y) + + # Front area cleared → unseal opens the way. + gate.call("unseal") + await process_frame + _expect_bool(bool(gate.get("sealed")), false, "unseal() clears the seal") + _expect_bool(wall_shape.disabled, true, "unsealed gate opens the passage") + if door_visual != null: + _expect_bool(door_visual.visible, false, "unsealed boss-room passage should not draw an air wall") + _expect_int(unsealed_signals, 1, "unseal() announces itself once") + + # Player left of the gate: nothing happens. + gate.call("_physics_process", 0.016) + _expect_bool(bool(gate.get("locked")), false, "gate must not lock while the player is in the front area") + + # The instant the player steps across the gate line the room starts — the + # boss must NOT wait until the player is point-blank at its anchor. + player.global_position = Vector2(float(gate.get("gate_x")) + 1.0, floor_y) + gate.call("_physics_process", 0.016) + _expect_bool(bool(gate.get("locked")), true, "crossing the gate line immediately locks the gate") + _expect_int(entered_signals, 1, "entering emits boss_room_entered once") + await process_frame + _expect_bool(wall_shape.disabled, true, "locked gate should keep the air wall collision fully disabled") + if door_visual != null: + _expect_bool(door_visual.visible, false, "locked boss-room air wall should remain invisible") + _expect_bool(bool(boss.get("combat_enabled")), true, "locking the gate should wake the Boss even when the flow manager is not driving the scene") + _expect_bool(bool(boss.get("stationary")), false, "locking the gate should release the Boss from training-dummy mode") + if flow != null: + _expect_string(str(flow.get("state")), "Gameplay_BossRoom", "gate entry should switch GameFlow into boss-room gameplay") + gate.call("_physics_process", 0.016) + _expect_int(entered_signals, 1, "the gate never re-fires after locking") + + stage.free() + _finish() + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS boss room gate") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_boss_room_gate.gd.uid b/tests/test_boss_room_gate.gd.uid new file mode 100644 index 0000000..205308b --- /dev/null +++ b/tests/test_boss_room_gate.gd.uid @@ -0,0 +1 @@ +uid://bv3k1f02hr4j6 diff --git a/tests/test_charge_hold_gate.gd b/tests/test_charge_hold_gate.gd new file mode 100644 index 0000000..8d8c76e --- /dev/null +++ b/tests/test_charge_hold_gate.gd @@ -0,0 +1,207 @@ +extends SceneTree + +var failures: Array[String] = [] +var started: Array[StringName] = [] +var judged: Array[Dictionary] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var fixture := await _controller_fixture() + if fixture.is_empty(): + _finish() + return + var controller: Node = fixture["controller"] + var combo: Node = fixture["combo"] + var state: Node = fixture["state"] + controller.connect("action_started", _on_action_started) + _event_bus().connect("judgement_made", _on_judgement_made) + + controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 20)) + _expect(started.has(&"ground_attack_left_1"), "A after_tap should execute the normal A action first") + _expect_array(combo.call("get_slots"), [&"A"], "A after_tap should enter ComboWindow") + controller.call("_tick_charge_hold", 0.2) + controller.call("_reset_to_idle", false) + controller.call("_tick_charge_hold", 0.0) + _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "A after_tap should enter Charging after hold threshold") + controller.call("_tick_charge_hold", 0.5) + controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 21)) + _expect(started.has(&"jian_yu_left_lv2"), "A release should select a left blade rain action by time spent charging") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"A", &"a", &"pressed", "perfect", 30)) + controller.call("_tick_charge_hold", 0.2) + controller.call("_reset_to_idle", false) + controller.call("_tick_charge_hold", 0.0) + _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held A should be charging before SP arrives") + controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 31)) + _expect(started.has(&"dash_slash_left"), "SP during a held-A charge should break the charge into the [A][sp] dash") + _expect(not started.has(&"jian_yu_left_lv1"), "SP during a held-A charge must not cast blade rain") + controller.call("submit_intent", _intent(&"A", &"a", &"released", "perfect", 32)) + _expect(not started.has(&"jian_yu_left_lv1"), "Releasing A after the dash derive must not cast blade rain") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 22)) + _expect_array(combo.call("get_slots"), [&"S"], "S should enter ComboWindow as a normal down action") + _expect(started.has(&"block_start"), "S should start block_start") + _expect_int(int(judged[judged.size() - 1].get("beat")), 22, "S should emit judgement feedback using its timestamp") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 24)) + controller.call("submit_intent", _intent(&"S", &"s", &"released", "perfect", 24)) + _expect(started.has(&"block_start"), "S release should not add a second action") + _expect_array(combo.call("get_slots"), [&"S"], "S release should not add another ComboWindow entry") + _expect_int(judged.size(), 1, "S release should not be judged as a second press") + controller.call("_tick_charge_hold", 1.0) + controller.call("_reset_to_idle", false) + controller.call("_tick_charge_hold", 1.0) + _expect_equal(state.call("build_context").get("action_phase"), &"Neutral", "Released S should cancel charge tracking instead of entering Charging later") + + controller.call("_reset_to_idle") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 25)) + _expect(started.has(&"block_start"), "Second timed S should trigger block_start again") + _expect_array(combo.call("get_slots"), [&"S", &"S"], "Repeated S should remain visible as two timed block inputs") + _expect_int(int(judged[judged.size() - 1].get("beat")), 25, "Repeated S should judge the second press") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + combo.call("record", &"S") + combo.call("record", &"SP") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 26)) + _expect(started.has(&"block_start"), "S should remain a plain block input after earlier S/SP slots") + _expect_array(combo.call("get_slots"), [&"S", &"SP", &"S"], "S should append normally without blade-chain clearing") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + judged.clear() + controller.call("submit_intent", _intent(&"S", &"s", &"pressed", "perfect", 27)) + controller.call("_tick_charge_hold", 1.0) + controller.call("_reset_to_idle", false) + controller.call("_tick_charge_hold", 0.0) + _expect_equal(state.call("build_context").get("action_phase"), &"Charging", "Held S should enter Charging only while the key remains held") + controller.call("_tick_charge_hold", 1.0) + controller.call("submit_intent", _intent(&"SP", &"space", &"pressed", "perfect", 28)) + _expect(started.has(&"zhan_bo_lv3"), "Held S then SP should release blade wave by time spent charging") + + fixture["root"].free() + _finish() + + +func _controller_fixture() -> Dictionary: + var combo_script: Script = load("res://scenes/components/combo_window.gd") + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + var state_script: Script = load("res://scenes/components/state_machine.gd") + var energy_script: Script = load("res://scenes/components/energy_component.gd") + var controller_script: Script = load("res://scenes/components/action_controller.gd") + if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null: + failures.append("Phase 3 component scripts should load") + return {} + var fixture_root := Node.new() + root.add_child(fixture_root) + var combo: Node = combo_script.new() + combo.name = "ComboWindow" + fixture_root.add_child(combo) + var resolver: Node = resolver_script.new() + resolver.name = "ActionResolver" + fixture_root.add_child(resolver) + var state: Node = state_script.new() + state.name = "StateMachine" + fixture_root.add_child(state) + var energy: Node = energy_script.new() + energy.name = "EnergyComponent" + fixture_root.add_child(energy) + var controller: Node = controller_script.new() + controller.name = "ActionController" + controller.set("combo_window_path", NodePath("../ComboWindow")) + controller.set("action_resolver_path", NodePath("../ActionResolver")) + controller.set("state_machine_path", NodePath("../StateMachine")) + fixture_root.add_child(controller) + await process_frame + energy.call("set_values", 99, 99) + return { + "root": fixture_root, + "combo": combo, + "controller": controller, + "state": state, + } + + +func _intent(symbol: StringName, rhythm_action: StringName, event_type: StringName, label: String, beat: int) -> RefCounted: + var intent_script: Script = load("res://scenes/components/input_intent.gd") + var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, event_type, float(Time.get_ticks_msec())) + intent.set("judgement", { + "label": label, + "diff": 0.0, + "abs_diff": 0.0, + "nearest_beat": beat, + }) + return intent + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: + judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index}) + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, str(expected), str(actual)]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS charge hold gate") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_charge_hold_gate.gd.uid b/tests/test_charge_hold_gate.gd.uid new file mode 100644 index 0000000..ee8b6d6 --- /dev/null +++ b/tests/test_charge_hold_gate.gd.uid @@ -0,0 +1 @@ +uid://ctiymt4bs4qtm diff --git a/tests/test_chart_layer.gd b/tests/test_chart_layer.gd new file mode 100644 index 0000000..a253783 --- /dev/null +++ b/tests/test_chart_layer.gd @@ -0,0 +1,204 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_resources_load() + await _check_runner_upcoming_and_triggered_once() + await _check_runner_broadcasts_empty_action_and_unknown_events() + _check_stage7_chart_resource_shape() + _check_stage9_chart_resource_shape() + _finish() + + +func _check_resources_load() -> void: + for path: String in [ + "res://resources/chart_event.gd", + "res://resources/chart_track.gd", + "res://resources/beat_chart.gd", + "res://scenes/chart/chart_runner.gd", + ]: + _expect(load(path) != null, "%s should load" % path) + + +func _check_runner_upcoming_and_triggered_once() -> void: + var runner := _make_runner(_make_chart()) + var upcoming: Array[StringName] = [] + var triggered: Array[StringName] = [] + runner.connect("chart_event_upcoming", func(event: Resource, _time_to_event: float) -> void: + upcoming.append(_event_identity(event)) + ) + runner.connect("chart_event_triggered", func(event: Resource) -> void: + triggered.append(_event_identity(event)) + ) + + runner.call("update_for_song_time", 0.49) + _expect_array(upcoming, [&"boss_combo_1"], "Boss action upcoming should expose action_id at lead window") + _expect(triggered.is_empty(), "No event should trigger before event time") + + runner.call("update_for_song_time", 1.0) + runner.call("update_for_song_time", 1.1) + _expect_array(triggered, [&"boss_combo_1"], "Boss action should trigger once") + + runner.call("update_for_song_time", 2.99) + runner.call("update_for_song_time", 3.0) + runner.call("update_for_song_time", 3.2) + _expect(upcoming.count(&"boss_lunging_stab") == 1, "Special boss action upcoming should fire once") + _expect(triggered.count(&"boss_lunging_stab") == 1, "Special boss action should trigger once") + runner.queue_free() + await process_frame + + +func _check_runner_broadcasts_empty_action_and_unknown_events() -> void: + var bus := _ensure_event_bus() + var mirrored: Array[StringName] = [] + bus.connect("chart_event_triggered", func(event: Resource) -> void: + mirrored.append(_event_identity(event)) + ) + var chart_script: Script = load("res://resources/beat_chart.gd") + var track_script: Script = load("res://resources/chart_track.gd") + var chart: Resource = chart_script.new() + var track: Resource = track_script.new() + chart.set("chart_id", &"wide_channel_chart") + track.set("track_id", &"boss_special") + track.set("track_type", &"boss") + track.set("events", [ + _make_event(1, &"time_anchor", &"Boss", 0.0, &""), + _make_event(2, &"phase_warning", &"", 0.0, &""), + ]) + chart.set("tracks", [track]) + var runner := _make_runner(chart) + runner.call("update_for_song_time", 0.5) + _expect_array(mirrored, [&"time_anchor"], "ChartRunner should broadcast unknown empty-action events") + runner.call("update_for_song_time", 1.0) + _expect_array(mirrored, [&"time_anchor", &"phase_warning"], "ChartRunner should keep the future event wide channel") + runner.queue_free() + await process_frame + + +func _check_stage7_chart_resource_shape() -> void: + var chart: Resource = load("res://resources/charts/stage7_boss_opening.tres") + _expect(chart != null, "Stage-7 boss opening chart should load") + if chart == null: + return + var track_ids: Array[StringName] = [] + for track: Resource in chart.get("tracks"): + track_ids.append(StringName(str(track.get("track_id")))) + for event: Resource in track.get("events"): + if StringName(str(event.get("event_type"))) == &"enemy_action": + _expect_equal(event.get("target_id"), &"Boss", "Boss chart action target_id should match ActorsContainer/Boss") + _expect(track_ids.has(&"track_boss_melee"), "Boss chart should split melee events into their own track") + _expect(track_ids.has(&"track_boss_ranged"), "Boss chart should split ranged events into their own track") + _expect(track_ids.has(&"track_boss_special"), "Boss chart should reserve a special track") + + +func _check_stage9_chart_resource_shape() -> void: + var chart: Resource = load("res://resources/charts/stage9_boss_duel.tres") + _expect(chart != null, "Stage-9 boss duel chart should load") + if chart == null: + return + _expect_equal(chart.get("chart_id"), &"stage9_boss_duel", "Stage-9 chart id") + _expect(int(chart.get("total_beats")) >= 64, "Stage-9 chart should cover a full 16-bar duel at 4/4") + var track_ids: Array[StringName] = [] + var action_ids: Array[StringName] = [] + for track: Resource in chart.get("tracks"): + track_ids.append(StringName(str(track.get("track_id")))) + for event: Resource in track.get("events"): + if StringName(str(event.get("event_type"))) != &"enemy_action": + continue + _expect_equal(event.get("target_id"), &"Boss", "Stage-9 Boss event target_id should match ActorsContainer/Boss") + action_ids.append(StringName(str(event.get("action_id")))) + _expect(track_ids.has(&"track_boss_melee"), "Stage-9 chart should keep melee track") + _expect(track_ids.has(&"track_boss_ranged"), "Stage-9 chart should keep ranged track") + _expect(track_ids.has(&"track_boss_special"), "Stage-9 chart should keep special track") + for required_action: StringName in [&"boss_combo_1", &"boss_combo_2", &"boss_combo_3", &"boss_lunging_stab", &"boss_shoot_1", &"boss_shoot_2", &"boss_2way_shoot", &"boss_retreat_dash"]: + _expect(action_ids.has(required_action), "Stage-9 chart should include %s" % required_action) + + +func _make_event(beat: int, event_type: StringName, target_id := &"", lead_beats := 1.0, action_id := &"") -> Resource: + var event_script: Script = load("res://resources/chart_event.gd") + var event: Resource = event_script.new() + event.set("beat_index", beat) + event.set("event_type", event_type) + event.set("target_id", target_id) + event.set("lead_beats", lead_beats) + event.set("action_id", action_id) + return event + + +func _make_chart() -> Resource: + var chart_script: Script = load("res://resources/beat_chart.gd") + var track_script: Script = load("res://resources/chart_track.gd") + var chart: Resource = chart_script.new() + var melee: Resource = track_script.new() + var special: Resource = track_script.new() + chart.set("chart_id", &"test_boss_chart") + melee.set("track_id", &"track_boss_melee") + melee.set("track_type", &"boss") + melee.set("events", [ + _make_event(2, &"enemy_action", &"Boss", 1.0, &"boss_combo_1"), + ]) + special.set("track_id", &"track_boss_special") + special.set("track_type", &"boss") + special.set("events", [ + _make_event(6, &"enemy_action", &"Boss", 2.0, &"boss_lunging_stab"), + ]) + chart.set("tracks", [melee, special]) + return chart + + +func _make_runner(chart: Resource) -> Node: + var runner_script: Script = load("res://scenes/chart/chart_runner.gd") + var runner: Node = runner_script.new() + runner.set("beat_time_override", 0.5) + runner.call("set_chart", chart) + root.add_child(runner) + return runner + + +func _ensure_event_bus() -> Node: + var existing := root.get_node_or_null("EventBus") + if existing != null: + return existing + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _event_identity(event: Resource) -> StringName: + var action_id := StringName(str(event.get("action_id"))) + if not action_id.is_empty(): + return action_id + return StringName(str(event.get("event_type"))) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS chart layer") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_chart_layer.gd.uid b/tests/test_chart_layer.gd.uid new file mode 100644 index 0000000..450a165 --- /dev/null +++ b/tests/test_chart_layer.gd.uid @@ -0,0 +1 @@ +uid://b4xmeny2oi6t2 diff --git a/tests/test_chart_time_phase_mask.gd b/tests/test_chart_time_phase_mask.gd new file mode 100644 index 0000000..3dc6559 --- /dev/null +++ b/tests/test_chart_time_phase_mask.gd @@ -0,0 +1,187 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_schema_defaults_and_track_propagation() + await _check_runner_filtering_and_initial_phase() + _check_validation_tool() + _finish() + + +func _check_schema_defaults_and_track_propagation() -> void: + var event: Resource = load("res://resources/chart_event.gd").new() + _expect_equal(event.get("time_phase_mask"), &"both", "ChartEvent default mask should be both") + _expect_bool(bool(event.call("matches_time_phase", &"past")), true, "mask both should match past") + _expect_bool(bool(event.call("matches_time_phase", &"future")), true, "mask both should match future") + event.set("time_phase_mask", &"past") + _expect_bool(bool(event.call("matches_time_phase", &"future")), false, "mask past should reject future") + + var track: Resource = load("res://resources/chart_track.gd").new() + track.set("time_phase_mask", &"future") + var default_event: Resource = load("res://resources/chart_event.gd").new() + var explicit_event: Resource = load("res://resources/chart_event.gd").new() + explicit_event.set("time_phase_mask", &"past") + track.set("events", [default_event, explicit_event]) + track.call("sorted_events") + _expect_equal(default_event.get("time_phase_mask"), &"future", "track mask should flow into default events") + _expect_equal(explicit_event.get("time_phase_mask"), &"past", "explicitly masked events keep their own mask") + + var chart: Resource = load("res://resources/beat_chart.gd").new() + _expect_equal(chart.get("initial_time_phase"), &"past", "BeatChart initial_time_phase should default to past") + + +func _check_runner_filtering_and_initial_phase() -> void: + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var manager: Node = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + var system: Node = load("res://autoload/time_anchor_system.gd").new() + system.name = "TimeAnchorSystem" + system.set("periodic_scheduling_enabled", false) + root.add_child(system) + await process_frame + + var past_event: Resource = load("res://resources/chart_event.gd").new() + past_event.set("event_id", &"mask_past_event") + past_event.set("beat_index", 1) + past_event.set("event_type", &"phase_warning") + past_event.set("time_phase_mask", &"past") + var future_event: Resource = load("res://resources/chart_event.gd").new() + future_event.set("event_id", &"mask_future_event") + future_event.set("beat_index", 1) + future_event.set("event_type", &"phase_warning") + future_event.set("time_phase_mask", &"future") + var track: Resource = load("res://resources/chart_track.gd").new() + track.set("track_id", &"mask_track") + track.set("events", [past_event, future_event]) + var chart: Resource = load("res://resources/beat_chart.gd").new() + chart.set("chart_id", &"mask_chart") + chart.set("tracks", [track]) + chart.set("initial_time_phase", &"future") + + var runner: Node = load("res://scenes/chart/chart_runner.gd").new() + runner.name = "ChartRunner" + runner.set("chart", chart) + runner.set("beat_time_override", 0.5) + root.add_child(runner) + await process_frame + + _expect_equal(manager.get("current_time_phase"), &"future", "runner should apply the chart's initial_time_phase") + + var triggered: Array = [] + bus.connect("chart_event_triggered", func(event: Resource) -> void: + triggered.append(StringName(str(event.get("event_id")))) + ) + + # Trigger-time final ruling: only the mask that matches the live phase dispatches. + runner.call("update_for_song_time", 1.0) + _expect_bool(triggered.has(&"mask_future_event"), true, "matching mask should dispatch") + _expect_bool(triggered.has(&"mask_past_event"), false, "mismatching mask should be skipped") + + # Skipped events are never re-dispatched after the phase flips back. + manager.call("set_time_phase", &"past", &"debug") + runner.call("update_for_song_time", 2.0) + _expect_bool(triggered.has(&"mask_past_event"), false, "skipped events must not be re-dispatched") + + # force_time_phase chart events route through TimeAnchorSystem to the manager. + var reasons: Array = [] + bus.connect("time_phase_changed", func(_previous: StringName, _current: StringName, reason: StringName) -> void: + reasons.append(reason) + ) + var force_event: Resource = load("res://resources/chart_event.gd").new() + force_event.set("event_id", &"force_future") + force_event.set("event_type", &"force_time_phase") + force_event.set("payload", {"target_time_phase": &"future"}) + bus.emit_signal("chart_event_triggered", force_event) + _expect_equal(manager.get("current_time_phase"), &"future", "force_time_phase should switch the phase") + _expect_bool(reasons.has(&"chart_forced"), true, "forced switch should carry the chart_forced reason") + + # Same-phase force does not broadcast a fact. + var reason_count := reasons.size() + bus.emit_signal("chart_event_triggered", force_event) + _expect_int(reasons.size(), reason_count, "forcing the current phase must not broadcast") + + # runner.reset discards armed anchors and reapplies the initial phase. + system.call("schedule_time_anchor", 30, &"chart") + runner.call("reset") + _expect_int(int(system.call("anchored_beat_count")), 0, "chart reset should discard armed anchors") + _expect_equal(manager.get("current_time_phase"), &"future", "chart reset should restore the chart initial phase") + + +func _check_validation_tool() -> void: + var tool_script: Script = load("res://tools/time_anchor_chart_tool.gd") + var chart: Resource = load("res://resources/beat_chart.gd").new() + var track: Resource = load("res://resources/chart_track.gd").new() + var half_beat_anchor: Resource = load("res://resources/chart_event.gd").new() + half_beat_anchor.set("event_id", &"bad_half_beat") + half_beat_anchor.set("event_type", &"time_anchor") + half_beat_anchor.set("beat_index", 3) + half_beat_anchor.set("subdivision", 1) + half_beat_anchor.set("subdivisions_per_beat", 2) + var duplicate_a: Resource = load("res://resources/chart_event.gd").new() + duplicate_a.set("event_id", &"dup_a") + duplicate_a.set("event_type", &"time_anchor") + duplicate_a.set("beat_index", 8) + var duplicate_b: Resource = load("res://resources/chart_event.gd").new() + duplicate_b.set("event_id", &"dup_b") + duplicate_b.set("event_type", &"time_anchor") + duplicate_b.set("beat_index", 8) + var bad_mask: Resource = load("res://resources/chart_event.gd").new() + bad_mask.set("event_id", &"bad_mask") + bad_mask.set("event_type", &"phase_warning") + bad_mask.set("beat_index", 10) + bad_mask.set("time_phase_mask", &"yesterday") + var bad_force: Resource = load("res://resources/chart_event.gd").new() + bad_force.set("event_id", &"bad_force") + bad_force.set("event_type", &"force_time_phase") + bad_force.set("beat_index", 12) + track.set("events", [half_beat_anchor, duplicate_a, duplicate_b, bad_mask, bad_force]) + chart.set("tracks", [track]) + var errors: Array = tool_script.call("validate_chart", chart) + _expect_int(errors.size(), 4, "validator should flag half-beat anchor, duplicate anchors, bad mask and bad force payload") + + var clean_errors: Array = tool_script.call("validate_chart", load("res://resources/charts/stage9_boss_duel.tres")) + _expect_int(clean_errors.size(), 0, "shipping chart should validate cleanly") + + var profile_errors: Array = tool_script.call("validate_time_phase_profile", load("res://resources/time_phase/profile_past_strong_enemy.tres")) + _expect_int(profile_errors.size(), 0, "shipping time phase profile should validate cleanly") + + var generated: Array = tool_script.call("generate_periodic_time_anchor_events", 16, 64) + _expect_int(generated.size(), 3, "generator should emit anchors at 16/32/48 for 64 beats") + + var simulated: Array = tool_script.call("simulate_periodic_schedule", load("res://resources/time_anchor_frequency_default.tres"), 0, 28, [18] as Array[int]) + _expect_equal(str(simulated), str([4, 8, 12, 16, 22, 26]), "simulation should restart the 4-beat window at the explicit beat") + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS chart time phase mask") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_chart_time_phase_mask.gd.uid b/tests/test_chart_time_phase_mask.gd.uid new file mode 100644 index 0000000..1a7b8f7 --- /dev/null +++ b/tests/test_chart_time_phase_mask.gd.uid @@ -0,0 +1 @@ +uid://c2mktpfn4tj00 diff --git a/tests/test_combat_manager_resolvers.gd b/tests/test_combat_manager_resolvers.gd new file mode 100644 index 0000000..12224d7 --- /dev/null +++ b/tests/test_combat_manager_resolvers.gd @@ -0,0 +1,681 @@ +extends SceneTree + +var failures: Array[String] = [] +var received_hits: Array[Dictionary] = [] +var confirmed_hits: Array[Dictionary] = [] +var hit_flow_events: Array[StringName] = [] +var target_cancel_reasons: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_resolver_scripts() + _check_player_scene_has_phase5_combat_nodes() + _check_judgement_policy_scales_stats() + _check_health_component_life_state() + await _check_combat_manager_resolve_hit() + await _check_combat_knockback_resolution() + await _check_combat_stump_acceptance() + await _check_knockback_lifts_target() + await _check_combat_manager_drives_target_hit_flow() + await _check_lethal_hit_cancels_with_death_reason() + await _check_effect_defense_modifiers_affect_combat_hit_flow() + await _check_dead_targets_are_skipped() + await _check_attack_tags_filter_defense_modifiers() + await _check_combat_hit_dispatches_effect_events() + await _check_combat_hit_event_filters_use_action_context() + await _check_combat_hit_dispatches_kill_and_parry_events() + await _check_action_on_hit_effects_apply_to_receiver() + _finish() + + +func _check_resolver_scripts() -> void: + for path: String in [ + "res://scripts/resolvers/stat_resolver.gd", + "res://scripts/resolvers/defense_resolver.gd", + "res://scripts/resolvers/action_rule_resolver.gd", + "res://scripts/resolvers/combat_resolver.gd", + "res://resources/judgement_policy.gd", + "res://resources/judgement_policy.tres", + ]: + _expect(load(path) != null, "%s should load" % path) + _expect(ProjectSettings.get_setting("autoload/CombatManager", "") == "*res://autoload/combat_manager.gd", "CombatManager should be registered as an autoload") + _expect(int(ProjectSettings.call("get_order", "autoload/EventBus")) < int(ProjectSettings.call("get_order", "autoload/RhythmManager")), "EventBus autoload should initialize before RhythmManager") + _expect(int(ProjectSettings.call("get_order", "autoload/RhythmManager")) < int(ProjectSettings.call("get_order", "autoload/CombatManager")), "CombatManager autoload should initialize after RhythmManager") + var combat: Node = load("res://autoload/combat_manager.gd").new() + _expect(combat.has_method("resolve_hit"), "CombatManager should expose resolve_hit(emitter, receiver)") + combat.free() + var emitter_source := _read_text("res://scenes/components/damage_emitter.gd") + _expect(emitter_source.contains("resolve_hit"), "DamageEmitter should route hits through CombatManager.resolve_hit") + _expect(not emitter_source.contains("receiver.emit_signal(\"damage_received\""), "DamageEmitter should not emit receiver damage directly") + + +func _check_player_scene_has_phase5_combat_nodes() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(scene != null, "player.tscn should load for phase-5 combat node check") + if scene == null: + return + var player := scene.instantiate() + root.add_child(player) + var action_executor := player.get_node_or_null("ActionExecutor") + var health := player.get_node_or_null("HealthComponent") + _expect(player.get_node_or_null("DamageEmitter") is Area2D, "Player should have DamageEmitter in phase 5") + _expect(player.get_node_or_null("DamageReceiver") is Area2D, "Player should have DamageReceiver in phase 5") + _expect(health != null, "Player should have HealthComponent in phase 5") + if health != null: + _expect_float(float(health.get("hitstun_seconds")), 0.4, "HealthComponent.hitstun_seconds should match hit stun action 14") + _expect(action_executor != null and action_executor.get("damage_emitter_path") == NodePath("../DamageEmitter"), "ActionExecutor should point at Player DamageEmitter") + _expect(action_executor != null and action_executor.get("energy_component_path") == NodePath("../EnergyComponent"), "ActionExecutor should point at Player EnergyComponent") + player.free() + + +func _check_judgement_policy_scales_stats() -> void: + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + if stat_resolver == null: + return + var action := _make_action(20.0, 10.0, 1.0) + _expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "perfect"}))), 100, "Perfect damage should use policy damage x1.0") + _expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "good"}))), 85, "Good damage should use policy damage x0.85") + _expect_int(int(round(stat_resolver.call("resolve_damage", 100.0, action, {"label": "bad"}))), 70, "Bad damage should use policy damage x0.7") + _expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "perfect"}))), 16, "Perfect cost should use policy cost x0.8") + _expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "good"}))), 20, "Good cost should use policy cost x1.0") + _expect_int(int(round(stat_resolver.call("resolve_cost", action, {"label": "bad"}))), 24, "Bad cost should use policy cost x1.2") + _expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "perfect"}))), 10, "Perfect reward should use policy reward x1.0") + _expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "good"}))), 8, "Good reward should use policy reward x0.8") + _expect_int(int(round(stat_resolver.call("resolve_reward", action, {"label": "bad"}))), 6, "Bad reward should use policy reward x0.6") + + +func _check_health_component_life_state() -> void: + var host := Node.new() + root.add_child(host) + var state_machine: Node = load("res://scenes/components/state_machine.gd").new() + state_machine.name = "StateMachine" + host.add_child(state_machine) + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + host.add_child(health) + await process_frame + _expect_float(float(health.get("hitstun_seconds")), 0.4, "HealthComponent default hitstun should be 0.4 seconds") + health.call("set_values", 10, 10) + health.call("receive_hit", {"damage": 3}) + _expect_int(int(health.get("current")), 7, "HealthComponent.receive_hit should apply damage") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "Non-lethal hit should write LifeState Hitstun") + health.call("receive_hit", {"damage": 20}) + _expect_equal(state_machine.call("build_context").get("life_state"), &"Dead", "Lethal hit should write LifeState Dead") + host.free() + + +func _check_combat_manager_resolve_hit() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + root.add_child(emitter) + root.add_child(receiver) + emitter.set("damage", 10) + receiver.connect("damage_received", _on_damage_received) + await process_frame + var result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect_int(int(result.get("damage", 0)), 10, "CombatManager.resolve_hit should return resolved damage") + _expect_int(received_hits.size(), 1, "CombatManager.resolve_hit should notify the receiver once") + emitter.free() + receiver.free() + combat.free() + + +func _check_combat_knockback_resolution() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + root.add_child(emitter) + root.add_child(receiver) + var action := _make_action(0.0, 0.0, 0.0, 0.5, 2.0) + emitter.set("damage", 0) + emitter.set("base_knockback", Vector2(40, 100)) + emitter.set("action_context", action) + emitter.set("judgement_context", {"label": "good"}) + await process_frame + var result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect_vector(result.get("knockback", Vector2.ZERO), Vector2(17, 170), "Combat knockback should come from ActionData fields and judgement policy") + emitter.free() + receiver.free() + combat.free() + + +func _check_combat_stump_acceptance() -> void: + confirmed_hits.clear() + var bus := root.get_node_or_null("EventBus") + _expect(bus != null and bus.has_signal("hit_confirmed"), "EventBus should expose hit_confirmed for stump acceptance") + if bus != null and bus.has_signal("hit_confirmed") and not bus.is_connected("hit_confirmed", _on_hit_confirmed): + bus.connect("hit_confirmed", _on_hit_confirmed) + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var target := _make_target("Phase5CombatStump", false) + var health: Node = target.get_node("HealthComponent") + var receiver: Area2D = target.get_node("DamageReceiver") + var action := _make_action(0.0, 0.0, 1.0) + var expected_damage := { + "perfect": 100, + "good": 85, + "bad": 70, + } + for label: String in expected_damage.keys(): + health.call("set_values", 999, 999) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + emitter.set("damage", 100) + emitter.set("action_context", action) + emitter.set("judgement_context", {"label": label}) + await process_frame + var result: Dictionary = combat.call("resolve_hit", emitter, receiver) + var expected := int(expected_damage[label]) + _expect_int(int(result.get("damage", -1)), expected, "Stump hit_confirmed damage should scale for %s" % label) + _expect_int(int(health.get("current")), 999 - expected, "Stump health should reflect %s damage" % label) + emitter.free() + _expect_int(confirmed_hits.size(), 3, "Combat stump should emit hit_confirmed once for each judgement") + if confirmed_hits.size() == 3: + _expect_int(int(confirmed_hits[0].get("damage", -1)), 100, "Perfect stump hit_confirmed damage") + _expect_int(int(confirmed_hits[1].get("damage", -1)), 85, "Good stump hit_confirmed damage") + _expect_int(int(confirmed_hits[2].get("damage", -1)), 70, "Bad stump hit_confirmed damage") + target.free() + combat.free() + + +func _check_knockback_lifts_target() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + combat.free() + return + var target := scene.instantiate() + target.name = "KnockupTarget" + root.add_child(target) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + var action := _make_action(0.0, 0.0, 0.0, 0.0, 1.0) + emitter.set("damage", 0) + emitter.set("base_knockback", Vector2(0, 120)) + emitter.set("action_context", action) + emitter.set("judgement_context", {"label": "perfect"}) + await process_frame + combat.call("resolve_hit", emitter, target.get_node("DamageReceiver")) + _expect_equal(target.get_node("StateMachine").call("get_ground_state"), &"Airborne", "Vertical knockback should set target GroundState Airborne") + _expect(float(target.get("height_speed")) > 0.0, "Vertical knockback should set positive height speed") + emitter.free() + target.free() + combat.free() + + +func _check_combat_manager_drives_target_hit_flow() -> void: + received_hits.clear() + hit_flow_events.clear() + target_cancel_reasons.clear() + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var target := Node.new() + target.name = "Target" + root.add_child(target) + var state_machine: Node = load("res://scenes/components/state_machine.gd").new() + state_machine.name = "StateMachine" + target.add_child(state_machine) + var action_controller: Node = load("res://scenes/components/action_controller.gd").new() + action_controller.name = "ActionController" + target.add_child(action_controller) + action_controller.connect("action_cancelled", _on_target_action_cancelled) + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + target.add_child(health) + health.connect("health_changed", _on_target_health_changed) + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + receiver.name = "DamageReceiver" + target.add_child(receiver) + receiver.connect("damage_received", _on_target_damage_received) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + emitter.set("damage", 3) + await process_frame + health.call("set_values", 10, 10) + hit_flow_events.clear() + combat.call("resolve_hit", emitter, receiver) + _expect_int(int(health.get("current")), 7, "CombatManager should call target HealthComponent.receive_hit exactly once") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "CombatManager hit flow should let HealthComponent write LifeState") + _expect(hit_flow_events.has(&"cancel"), "CombatManager should interrupt the target action before applying hit facts") + _expect(hit_flow_events.has(&"health"), "CombatManager should apply health facts through HealthComponent") + _expect(hit_flow_events.has(&"damage_signal"), "CombatManager should still notify DamageReceiver listeners once") + if hit_flow_events.has(&"cancel") and hit_flow_events.has(&"health"): + _expect(hit_flow_events.find(&"cancel") < hit_flow_events.find(&"health"), "CombatManager should cancel action before HealthComponent writes hit facts") + if hit_flow_events.has(&"cancel") and hit_flow_events.has(&"damage_signal"): + _expect(hit_flow_events.find(&"cancel") < hit_flow_events.find(&"damage_signal"), "CombatManager should cancel action before DamageReceiver emits damage_received") + emitter.free() + target.free() + combat.free() + + +func _check_lethal_hit_cancels_with_death_reason() -> void: + received_hits.clear() + hit_flow_events.clear() + target_cancel_reasons.clear() + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var target := _make_target("LethalCancelTarget", true) + var health: Node = target.get_node("HealthComponent") + var receiver: Area2D = target.get_node("DamageReceiver") + health.call("set_values", 3, 3) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + emitter.set("damage", 5) + await process_frame + combat.call("resolve_hit", emitter, receiver) + _expect(target_cancel_reasons.has(&"death"), "Lethal CombatManager hit should cancel current action with death reason") + _expect_equal(target.get_node("StateMachine").call("build_context").get("life_state"), &"Dead", "Lethal CombatManager hit should still let HealthComponent write Dead") + emitter.free() + target.free() + combat.free() + + +func _check_effect_defense_modifiers_affect_combat_hit_flow() -> void: + received_hits.clear() + hit_flow_events.clear() + target_cancel_reasons.clear() + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var target := Node.new() + target.name = "InvincibleTarget" + root.add_child(target) + var state_machine: Node = load("res://scenes/components/state_machine.gd").new() + state_machine.name = "StateMachine" + target.add_child(state_machine) + var action_controller: Node = load("res://scenes/components/action_controller.gd").new() + action_controller.name = "ActionController" + target.add_child(action_controller) + action_controller.connect("action_cancelled", _on_target_action_cancelled) + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + target.add_child(health) + var effect_container: Node = load("res://scenes/components/effect_container.gd").new() + effect_container.name = "EffectContainer" + target.add_child(effect_container) + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + receiver.name = "DamageReceiver" + target.add_child(receiver) + receiver.connect("damage_received", _on_target_damage_received) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + emitter.set("damage", 3) + await process_frame + health.call("set_values", 10, 10) + effect_container.call("add_effect", _make_invincible_effect()) + var result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect_int(int(result.get("damage", -1)), 0, "Defense Effect should reduce combat damage to zero") + _expect_int(int(health.get("current")), 10, "Invincible Defense Effect should prevent HealthComponent damage") + _expect(not hit_flow_events.has(&"cancel"), "Invincible Defense Effect should prevent interrupt cancellation") + _expect(hit_flow_events.has(&"damage_signal"), "CombatManager should still notify hit facts for an invincible hit") + emitter.free() + target.free() + combat.free() + + +func _check_dead_targets_are_skipped() -> void: + received_hits.clear() + hit_flow_events.clear() + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var target := _make_target("DeadTarget", true) + var state_machine: Node = target.get_node("StateMachine") + var health: Node = target.get_node("HealthComponent") + var receiver: Area2D = target.get_node("DamageReceiver") + receiver.connect("damage_received", _on_target_damage_received) + state_machine.call("set_life_state", &"Dead") + health.call("set_values", 0, 10) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + root.add_child(emitter) + emitter.set("damage", 3) + await process_frame + var result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect(result.is_empty(), "CombatManager should return an empty result when receiver LifeState is Dead") + _expect_int(int(health.get("current")), 0, "Dead target health should not be touched") + _expect(not hit_flow_events.has(&"cancel"), "Dead target should not be interrupted") + _expect(not hit_flow_events.has(&"damage_signal"), "Dead target should not emit damage_received") + emitter.free() + target.free() + combat.free() + + +func _check_attack_tags_filter_defense_modifiers() -> void: + received_hits.clear() + hit_flow_events.clear() + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var projectile_target := _make_target("ProjectileShieldTarget", true) + var projectile_health: Node = projectile_target.get_node("HealthComponent") + var projectile_effects: Node = projectile_target.get_node("EffectContainer") + projectile_health.call("set_values", 10, 10) + projectile_effects.call("add_effect", _make_tagged_invincible_effect(&"projectile")) + var projectile_emitter: Area2D = _make_tagged_emitter(&"projectile") + root.add_child(projectile_emitter) + await process_frame + var projectile_result: Dictionary = combat.call("resolve_hit", projectile_emitter, projectile_target.get_node("DamageReceiver")) + _expect_int(int(projectile_result.get("damage", -1)), 0, "Tagged DefenseModifier should apply to matching attack tags") + _expect_int(int(projectile_health.get("current")), 10, "Matching tagged defense should prevent damage") + + var melee_target := _make_target("MeleeShieldTarget", true) + var melee_health: Node = melee_target.get_node("HealthComponent") + var melee_effects: Node = melee_target.get_node("EffectContainer") + melee_health.call("set_values", 10, 10) + melee_effects.call("add_effect", _make_tagged_invincible_effect(&"projectile")) + var melee_emitter: Area2D = _make_tagged_emitter(&"melee") + root.add_child(melee_emitter) + await process_frame + var melee_result: Dictionary = combat.call("resolve_hit", melee_emitter, melee_target.get_node("DamageReceiver")) + _expect_int(int(melee_result.get("damage", -1)), 3, "Tagged DefenseModifier should not apply to non-matching attack tags") + _expect_int(int(melee_health.get("current")), 7, "Non-matching tagged defense should allow damage") + projectile_emitter.free() + melee_emitter.free() + projectile_target.free() + melee_target.free() + combat.free() + + +func _check_combat_hit_dispatches_effect_events() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var attacker := _make_actor("HitEventAttacker", true) + var target := _make_target("HurtEventTarget", true) + var attacker_effects: Node = attacker.get_node("EffectContainer") + var target_effects: Node = target.get_node("EffectContainer") + attacker_effects.call("add_effect", _make_event_listener_effect(&"on_hit", &"test_on_hit_reward")) + target_effects.call("add_effect", _make_event_listener_effect(&"on_hurt", &"test_on_hurt_reward")) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + emitter.name = "DamageEmitter" + attacker.add_child(emitter) + emitter.set("damage", 3) + await process_frame + combat.call("resolve_hit", emitter, target.get_node("DamageReceiver")) + _expect_int(int(attacker_effects.call("active_count")), 2, "CombatManager should dispatch on_hit to attacker EffectContainer") + _expect_int(int(target_effects.call("active_count")), 2, "CombatManager should dispatch on_hurt to receiver EffectContainer") + attacker.free() + target.free() + combat.free() + + +func _check_combat_hit_event_filters_use_action_context() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var attacker := _make_actor("FilteredHitEventAttacker", true) + var projectile_target := _make_target("FilteredProjectileTarget", true) + var melee_target := _make_target("FilteredMeleeTarget", true) + var attacker_effects: Node = attacker.get_node("EffectContainer") + attacker_effects.call("add_effect", _make_tag_filtered_event_listener(&"on_hit", &"test_filtered_melee_hit_reward", &"melee")) + var projectile_emitter: Area2D = _make_tagged_emitter(&"projectile") + projectile_emitter.name = "ProjectileEmitter" + attacker.add_child(projectile_emitter) + await process_frame + combat.call("resolve_hit", projectile_emitter, projectile_target.get_node("DamageReceiver")) + _expect_int(int(attacker_effects.call("active_count")), 1, "Filtered on_hit should ignore non-matching action tags from CombatManager") + var melee_emitter: Area2D = _make_tagged_emitter(&"melee") + melee_emitter.name = "MeleeEmitter" + attacker.add_child(melee_emitter) + await process_frame + combat.call("resolve_hit", melee_emitter, melee_target.get_node("DamageReceiver")) + _expect_int(int(attacker_effects.call("active_count")), 2, "Filtered on_hit should receive matching action tags from CombatManager") + attacker.free() + projectile_target.free() + melee_target.free() + combat.free() + + +func _check_combat_hit_dispatches_kill_and_parry_events() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + + var attacker := _make_actor("KillEventAttacker", true) + var lethal_target := _make_target("KillEventTarget", true) + var attacker_effects: Node = attacker.get_node("EffectContainer") + var target_health: Node = lethal_target.get_node("HealthComponent") + attacker_effects.call("add_effect", _make_event_listener_effect(&"on_kill", &"test_on_kill_reward")) + target_health.call("set_values", 3, 3) + var lethal_emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + lethal_emitter.name = "DamageEmitter" + attacker.add_child(lethal_emitter) + lethal_emitter.set("damage", 5) + await process_frame + combat.call("resolve_hit", lethal_emitter, lethal_target.get_node("DamageReceiver")) + _expect_int(int(attacker_effects.call("active_count")), 2, "CombatManager should dispatch on_kill to attacker EffectContainer after lethal damage") + + var parry_attacker := _make_actor("ParryEventAttacker", true) + var parry_target := _make_target("ParryEventTarget", true) + var parry_target_effects: Node = parry_target.get_node("EffectContainer") + var parry_state_machine: Node = parry_target.get_node("StateMachine") + parry_target_effects.call("add_effect", _make_event_listener_effect(&"on_parry_success", &"test_on_parry_reward")) + parry_state_machine.call("set_defense_state", &"Parrying") + var parry_emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + parry_emitter.name = "DamageEmitter" + parry_attacker.add_child(parry_emitter) + parry_emitter.set("damage", 5) + await process_frame + combat.call("resolve_hit", parry_emitter, parry_target.get_node("DamageReceiver")) + _expect_int(int(parry_target_effects.call("active_count")), 2, "CombatManager should dispatch on_parry_success to parrying receiver EffectContainer") + + attacker.free() + lethal_target.free() + parry_attacker.free() + parry_target.free() + combat.free() + + +func _check_action_on_hit_effects_apply_to_receiver() -> void: + var combat: Node = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + var attacker := _make_actor("OnHitEffectAttacker", true) + var target := _make_target("OnHitEffectTarget", true) + var target_effects: Node = target.get_node("EffectContainer") + var action: Resource = load("res://resources/action_data.gd").new() + action.set("id", &"test_rooting_action") + var effect_definition: Resource = load("res://resources/effects/effect_definition.gd").new() + effect_definition.set("id", &"test_on_hit_effect") + effect_definition.set("duration_type", &"infinite") + var effects: Array[Resource] = [effect_definition] + action.set("on_hit_effects", effects) + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + emitter.name = "DamageEmitter" + attacker.add_child(emitter) + emitter.set("damage", 1) + emitter.set("action_context", action) + await process_frame + combat.call("resolve_hit", emitter, target.get_node("DamageReceiver")) + _expect(target_effects.call("active_effect_ids").has(&"test_on_hit_effect"), "CombatManager should apply ActionData.on_hit_effects to the receiver") + attacker.free() + target.free() + combat.free() + + +func _on_damage_received(amount: int, hit_type: StringName, from: Vector2) -> void: + received_hits.append({ + "amount": amount, + "hit_type": hit_type, + "from": from, + }) + + +func _on_hit_confirmed(result: Dictionary) -> void: + confirmed_hits.append(result) + + +func _on_target_action_cancelled(_action: Resource, reason: StringName) -> void: + target_cancel_reasons.append(reason) + if reason == &"interrupt" or reason == &"death": + hit_flow_events.append(&"cancel") + + +func _on_target_health_changed(_current: int, _maximum: int) -> void: + hit_flow_events.append(&"health") + + +func _on_target_damage_received(_amount: int, _hit_type: StringName, _from: Vector2) -> void: + hit_flow_events.append(&"damage_signal") + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _make_invincible_effect() -> Resource: + var definition: Resource = load("res://resources/effects/effect_definition.gd").new() + definition.set("id", &"test_invincible") + definition.set("duration_type", &"infinite") + var modifier: Resource = load("res://resources/effects/defense_modifier.gd").new() + modifier.set("defense_state", &"Invincible") + modifier.set("damage_mult", 0.0) + modifier.set("interrupts", false) + var modifiers: Array[Resource] = [modifier] + definition.set("defense_modifiers", modifiers) + return definition + + +func _make_tagged_invincible_effect(required_attack_tag: StringName) -> Resource: + var definition: Resource = load("res://resources/effects/effect_definition.gd").new() + definition.set("id", &"test_tagged_invincible") + definition.set("duration_type", &"infinite") + var modifier: Resource = load("res://resources/effects/defense_modifier.gd").new() + modifier.set("defense_state", &"Invincible") + modifier.set("damage_mult", 0.0) + modifier.set("interrupts", false) + var required_tags: Array[StringName] = [required_attack_tag] + modifier.set("required_attack_tags", required_tags) + var modifiers: Array[Resource] = [modifier] + definition.set("defense_modifiers", modifiers) + return definition + + +func _make_tagged_emitter(action_tag: StringName) -> Area2D: + var emitter: Area2D = load("res://scenes/components/damage_emitter.gd").new() + emitter.set("damage", 3) + var action: Resource = load("res://resources/action_data.gd").new() + var action_tags: Array[StringName] = [action_tag] + action.set("action_tags", action_tags) + emitter.set("action_context", action) + return emitter + + +func _make_action(base_cost: float, base_reward: float, damage_mult: float, knockback_mult_x := 0.0, knockback_mult_y := 0.0) -> Resource: + var action: Resource = load("res://resources/action_data.gd").new() + action.set("base_cost", base_cost) + action.set("base_reward", base_reward) + action.set("damage_mult", damage_mult) + action.set("knockback_mult_x", knockback_mult_x) + action.set("knockback_mult_y", knockback_mult_y) + return action + + +func _make_event_listener_effect(event_name: StringName, reward_id: StringName) -> Resource: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var reward: Resource = definition_script.new() + reward.set("id", reward_id) + reward.set("duration_type", &"infinite") + var listener: Resource = definition_script.new() + listener.set("id", StringName("listener_%s" % event_name)) + listener.set("duration_type", &"infinite") + listener.set("trigger_event", event_name) + var triggered_effects: Array[Resource] = [reward] + listener.set("trigger_effects", triggered_effects) + return listener + + +func _make_tag_filtered_event_listener(event_name: StringName, reward_id: StringName, required_action_tag: StringName) -> Resource: + var listener: Resource = _make_event_listener_effect(event_name, reward_id) + var required_tags: Array[StringName] = [required_action_tag] + listener.set("required_event_action_tags", required_tags) + return listener + + +func _make_actor(actor_name: String, include_effect_container: bool) -> Node: + var actor := Node.new() + actor.name = actor_name + root.add_child(actor) + if include_effect_container: + var effect_container: Node = load("res://scenes/components/effect_container.gd").new() + effect_container.name = "EffectContainer" + actor.add_child(effect_container) + return actor + + +func _make_target(target_name: String, include_effect_container: bool) -> Node: + var target := Node.new() + target.name = target_name + root.add_child(target) + var state_machine: Node = load("res://scenes/components/state_machine.gd").new() + state_machine.name = "StateMachine" + target.add_child(state_machine) + var action_controller: Node = load("res://scenes/components/action_controller.gd").new() + action_controller.name = "ActionController" + target.add_child(action_controller) + action_controller.connect("action_cancelled", _on_target_action_cancelled) + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + target.add_child(health) + if include_effect_container: + var effect_container: Node = load("res://scenes/components/effect_container.gd").new() + effect_container.name = "EffectContainer" + target.add_child(effect_container) + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + receiver.name = "DamageReceiver" + target.add_child(receiver) + return target + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Variant, expected: Vector2, label: String) -> void: + if not (actual is Vector2) or not (actual as Vector2).is_equal_approx(expected): + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS combat manager resolvers") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_combat_manager_resolvers.gd.uid b/tests/test_combat_manager_resolvers.gd.uid new file mode 100644 index 0000000..a1468d8 --- /dev/null +++ b/tests/test_combat_manager_resolvers.gd.uid @@ -0,0 +1 @@ +uid://b3hc7avarimds diff --git a/tests/test_combo_hud.gd b/tests/test_combo_hud.gd new file mode 100644 index 0000000..191efda --- /dev/null +++ b/tests/test_combo_hud.gd @@ -0,0 +1,114 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_energy_bar_scene_has_editor_segments() + + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if scene == null: + push_error("Could not load main_ui.tscn") + quit(1) + return + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + _expect_node(ui, "RhythmTrack", "UI should include RhythmTrack") + _expect_node(ui, "ComboWindow", "UI should include ComboWindow") + _expect_node(ui, "StatusBars/HealthBar", "UI should include HealthBar") + _expect_node(ui, "StatusBars/EnergyBar", "UI should include EnergyBar") + _expect_node(ui, "StatusBars/ChargeBar", "UI should include ChargeBar") + + var combo_window := ui.get_node("ComboWindow") + _expect_bool(combo_window.get_child_count() >= 4, true, "ComboWindowHud should build four visual slots") + + var bus := _event_bus() + bus.emit_signal("player_health_changed", 42, 100) + bus.emit_signal("player_energy_changed", 3.0, 10.0) + bus.emit_signal("player_charge_changed", 0.8, 1.1, false, true) + bus.emit_signal("combo_updated", [&"A", &"D", &"W", &"S"]) + await process_frame + + var health_bar := ui.get_node("StatusBars/HealthBar") as ProgressBar + var charge_bar := ui.get_node("StatusBars/ChargeBar") as ProgressBar + _expect_float(float(health_bar.value), 42.0, "HealthBar should follow EventBus health") + _expect_float(float(charge_bar.value), 0.8, "ChargeBar should follow EventBus charge") + + var labels := _combo_slot_texts(combo_window) + _expect_array(labels, ["A", "D", "W", "S"], "ComboWindowHud should render the four directional/action symbols") + bus.emit_signal("combo_updated", [&"SP", &"Ø"]) + await process_frame + labels = _combo_slot_texts(combo_window) + _expect_array(labels, ["sp", "∅", "∅", "∅"], "ComboWindowHud should normalize space and miss placeholders without judgement grades") + + ui.free() + _finish() + + +func _check_energy_bar_scene_has_editor_segments() -> void: + var scene: PackedScene = load("res://scenes/ui/energy_bar.tscn") + if scene == null: + failures.append("Could not load energy_bar.tscn") + return + var energy_bar := scene.instantiate() + _expect_bool(energy_bar is ProgressBar, true, "EnergyBar should match the reference ProgressBar implementation") + _expect_bool(energy_bar.get_child_count() == 0, true, "Reference EnergyBar should not build editor-visible segment children") + energy_bar.free() + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect_node(node: Node, path: String, label: String) -> void: + if node.get_node_or_null(path) == null: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _combo_slot_texts(combo_window: Node) -> Array[String]: + var texts: Array[String] = [] + for child: Node in combo_window.get_children(): + var panel := child as PanelContainer + if panel == null or panel.get_child_count() == 0: + continue + var label := panel.get_child(0) as Label + if label != null: + texts.append(label.text) + return texts + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS combo hud") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_combo_hud.gd.uid b/tests/test_combo_hud.gd.uid new file mode 100644 index 0000000..dd916c7 --- /dev/null +++ b/tests/test_combo_hud.gd.uid @@ -0,0 +1 @@ +uid://bplnlyowxbej0 diff --git a/tests/test_combo_window.gd b/tests/test_combo_window.gd new file mode 100644 index 0000000..b4d06c2 --- /dev/null +++ b/tests/test_combo_window.gd @@ -0,0 +1,75 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var combo_script: Script = load("res://scenes/components/combo_window.gd") + _expect(combo_script != null, "ComboWindow script should load") + if combo_script == null: + _finish() + return + + var combo: Node = combo_script.new() + root.add_child(combo) + await process_frame + combo.set("size", 4) + + combo.call("record", &"A") + combo.call("record", &"Ø") + combo.call("record", &"SP") + _expect_array(combo.call("get_slots"), [&"A", &"Ø", &"SP"], "ComboWindow should preserve visible miss slots") + _expect_string(str(combo.call("get_pattern")), "ASP", "get_pattern should ignore miss placeholders") + _expect_string(str(combo.call("get_contiguous_pattern")), "SP", "contiguous pattern should not cross miss placeholders") + _expect_bool(bool(combo.call("has_pending_clear")), false, "three slots should not request full clear") + combo.call("clear", &"reset") + + for symbol: StringName in [&"A", &"D", &"W", &"S"]: + combo.call("record", symbol) + _expect_array(combo.call("get_slots"), [&"A", &"D", &"W", &"S"], "fourth input should remain visible before clear") + _expect_bool(bool(combo.call("has_pending_clear")), true, "fourth input should queue full clear") + _expect_string(str(combo.call("consume_pending_clear_reason")), "full", "fourth input clear reason") + combo.call("queue_clear", &"full", 0.001) + combo.call("flush_pending_clear") + _expect_array(combo.call("get_slots"), [], "flush_pending_clear should clear full window") + + combo.call("record", &"A") + combo.call("record", &"A") + combo.call("clear", &"skill:ground_attack_left_2") + _expect_array(combo.call("get_slots"), [], "successful skill clear should empty slots") + combo.free() + _finish() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS combo window") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_combo_window.gd.uid b/tests/test_combo_window.gd.uid new file mode 100644 index 0000000..5b33f32 --- /dev/null +++ b/tests/test_combo_window.gd.uid @@ -0,0 +1 @@ +uid://x8hi3mo3s3ei diff --git a/tests/test_dash_overlap_separation.gd b/tests/test_dash_overlap_separation.gd new file mode 100644 index 0000000..11cd0b4 --- /dev/null +++ b/tests/test_dash_overlap_separation.gd @@ -0,0 +1,130 @@ +extends SceneTree + +## 2026-07-06 策划契约:冲刺穿人机制保留,但其余时间玩家不得与敌人重叠。 +## 旧行为:dash_through 落点嵌在敌人体内时,恢复碰撞的逻辑"干等分离"—— +## 嵌着就无限期保持幽灵态,玩家可长时间站在小怪/Boss 身体里。 +## 新行为:嵌入时沿冲刺方向每物理帧温和推出(保留"从远侧穿出"手感), +## 推清后恢复身体碰撞。 + +const ENEMY_BODY_BIT := 1 << 6 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + await _check_embedded_dash_gets_separated() + await _check_clean_dash_restores_immediately() + _finish() + + +func _make_actors() -> Dictionary: + var player := (load("res://scenes/characters/player.tscn") as PackedScene).instantiate() as CharacterBody2D + root.add_child(player) + var minion := (load("res://scenes/enemies/minion.tscn") as PackedScene).instantiate() as CharacterBody2D + root.add_child(minion) + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + minion.set("approach_direction", 0.0) + return {"player": player, "minion": minion} + + +func _dash_action() -> Resource: + var script: Script = load("res://resources/action_data.gd") + var action: Resource = script.new() + var action_tags: Array[StringName] = [&"dash_through"] + action.set("id", &"test_dash_embed") + action.set("action_tags", action_tags) + action.set("move_mult_x", 1.0) + action.set("startup_beats", 0.25) + action.set("active_beats", 0.25) + action.set("recovery_beats", 0.25) + return action + + +func _check_embedded_dash_gets_separated() -> void: + var actors := _make_actors() + var player := actors["player"] as CharacterBody2D + var minion := actors["minion"] as CharacterBody2D + await process_frame + player.global_position = Vector2(500.0, 300.0) + # 冲刺时长 0.75 拍 × 0.5s × 220px/s ≈ 82px:小怪摆在 80px 处,落点必然嵌入。 + minion.global_position = Vector2(580.0, 300.0) + await physics_frame + + var motion: Node = player.get_node("MotionExecutor") + motion.call("execute", _dash_action(), Vector2.RIGHT, 0.5, 220.0) + _expect((int(player.collision_mask) & ENEMY_BODY_BIT) == 0, "冲刺期间保持幽灵态(机制保留)") + + # 裸场景无 handle_movement 驱动,手动按真实链路推进:tick → move_and_slide。 + var finished := false + for _index: int in range(90): + player.velocity = motion.call("tick", 1.0 / 60.0) + player.move_and_slide() + await physics_frame + if not bool(motion.get("active")): + finished = true + break + _expect(finished, "冲刺应在 90 帧内结束") + + # 新契约:嵌入不再无限期保持幽灵态——有限帧内推出并恢复碰撞。 + var separated := false + for _index: int in range(45): + await physics_frame + if not bool(motion.call("is_ghosting")): + separated = true + break + _expect(separated, "落点嵌入后应在 45 帧内被推出并恢复碰撞(旧行为:无限期重叠)") + if separated: + _expect((int(player.collision_mask) & ENEMY_BODY_BIT) != 0, "分离后玩家身体碰撞恢复") + _expect(player.global_position.x > minion.global_position.x, + "应从冲刺方向的远侧穿出(不被弹回入口侧)") + var gap := absf(player.global_position.x - minion.global_position.x) + _expect(gap >= 18.0, "分离后身体不再重叠(间距 %.1f px ≥ 半宽和)" % gap) + player.free() + minion.free() + + +func _check_clean_dash_restores_immediately() -> void: + # 落点干净(前方无敌人)时行为与旧版一致:结束即恢复。 + var player := (load("res://scenes/characters/player.tscn") as PackedScene).instantiate() as CharacterBody2D + root.add_child(player) + await process_frame + player.global_position = Vector2(500.0, 300.0) + var motion: Node = player.get_node("MotionExecutor") + motion.call("execute", _dash_action(), Vector2.RIGHT, 0.5, 220.0) + for _index: int in range(90): + player.velocity = motion.call("tick", 1.0 / 60.0) + player.move_and_slide() + await physics_frame + if not bool(motion.get("active")): + break + await physics_frame + await physics_frame + _expect(not bool(motion.call("is_ghosting")), "干净落点:冲刺结束立即恢复碰撞") + _expect((int(player.collision_mask) & ENEMY_BODY_BIT) != 0, "干净落点:敌人身体位复位") + player.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS dash overlap separation") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_dash_overlap_separation.gd.uid b/tests/test_dash_overlap_separation.gd.uid new file mode 100644 index 0000000..b536513 --- /dev/null +++ b/tests/test_dash_overlap_separation.gd.uid @@ -0,0 +1 @@ +uid://4min0niv6jjo diff --git a/tests/test_death_presentation.gd b/tests/test_death_presentation.gd new file mode 100644 index 0000000..149da9f --- /dev/null +++ b/tests/test_death_presentation.gd @@ -0,0 +1,146 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_player_death_aligns_and_disappears() + await _check_stage_camera_survives_player_disappearance() + await _check_minion_death_aligns_and_disappears() + await _check_boss_death_aligns_and_disappears() + _finish() + + +func _check_player_death_aligns_and_disappears() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(scene != null, "Player scene should load") + if scene == null: + return + var player := scene.instantiate() + root.add_child(player) + await process_frame + var sprite := player.get_node("Visual/CharacterSprite") as Sprite2D + var baseline := _visible_sprite_bottom_global_y(sprite) + player.get_node("StateMachine").call("set_life_state", &"Dead") + player.call("handle_animations") + _expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Player first death frame should keep the same visible ground line") + player.call("_tick_manual_animation", 0.55) + _expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Player fall-back death frame should keep the same visible ground line") + player.call("_tick_manual_animation", 2.0) + await process_frame + _expect_bool(not is_instance_valid(player) or player.is_queued_for_deletion(), true, "Player corpse should disappear after death animation") + + +func _check_stage_camera_survives_player_disappearance() -> void: + var scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(scene != null, "Stage scene should load for player death cleanup") + if scene == null: + return + var stage := scene.instantiate() + root.add_child(stage) + await process_frame + var player := stage.get_node("ActorsContainer/Player") + player.get_node("StateMachine").call("set_life_state", &"Dead") + player.call("handle_animations") + player.call("_tick_manual_animation", 3.0) + await process_frame + await process_frame + stage.call("_update_camera_follow") + stage.call("_update_wipe_center") + _expect_bool(not is_instance_valid(player) or player.is_queued_for_deletion(), true, "Stage player corpse should be gone before camera follow updates") + stage.queue_free() + await process_frame + + +func _check_minion_death_aligns_and_disappears() -> void: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + _expect(scene != null, "Minion scene should load") + if scene == null: + return + var minion := scene.instantiate() + root.add_child(minion) + await process_frame + var sprite := minion.get_node("Visual/CharacterSprite") as Sprite2D + var baseline := _visible_sprite_bottom_global_y(sprite) + minion.get_node("StateMachine").call("set_life_state", &"Dead") + minion.call("handle_animations") + _expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Minion first death frame should keep the same visible ground line") + var animation_player := minion.get_node("AnimationPlayer") as AnimationPlayer + var animation_name := str(animation_player.current_animation) + animation_player.advance(animation_player.get_animation(animation_name).length + 0.2) + minion.call("handle_animations") + await process_frame + _expect_bool(not is_instance_valid(minion) or minion.is_queued_for_deletion(), true, "Minion corpse should disappear after death animation") + + +func _check_boss_death_aligns_and_disappears() -> void: + var scene: PackedScene = load("res://scenes/enemies/boss.tscn") + _expect(scene != null, "Boss scene should load") + if scene == null: + return + var boss := scene.instantiate() + root.add_child(boss) + await process_frame + var sprite := boss.get_node("Visual/CharacterSprite") as Sprite2D + var baseline := _visible_sprite_bottom_global_y(sprite) + boss.get_node("StateMachine").call("set_life_state", &"Dead") + boss.call("handle_animations") + _expect_float(_visible_sprite_bottom_global_y(sprite), baseline, "Boss first death frame should keep the same visible ground line") + var animation_player := boss.get_node("AnimationPlayer") as AnimationPlayer + animation_player.advance(animation_player.get_animation("boss_die").length + 0.2) + boss.call("handle_animations") + await process_frame + _expect_bool(not is_instance_valid(boss) or boss.is_queued_for_deletion(), true, "Boss corpse should disappear after death animation") + + +func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float: + if sprite == null or sprite.texture == null: + return INF + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return INF + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.03: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS test_death_presentation") + else: + printerr("FAIL test_death_presentation") + for failure: String in failures: + printerr(" - %s" % failure) + quit(failures.size()) diff --git a/tests/test_death_presentation.gd.uid b/tests/test_death_presentation.gd.uid new file mode 100644 index 0000000..bfba52d --- /dev/null +++ b/tests/test_death_presentation.gd.uid @@ -0,0 +1 @@ +uid://cs5q71fcr3uha diff --git a/tests/test_debug_ui.gd b/tests/test_debug_ui.gd new file mode 100644 index 0000000..3da0ee3 --- /dev/null +++ b/tests/test_debug_ui.gd @@ -0,0 +1,184 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var ui_scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + if ui_scene == null or player_scene == null: + failures.append("Debug UI test scenes should load") + _finish() + return + + var ui := ui_scene.instantiate() + var player := player_scene.instantiate() + root.add_child(player) + root.add_child(ui) + await process_frame + + for node_path: String in [ + "DebugPanel", + "DebugPanel/StateAxesLabel", + "DebugPanel/ActionLabel", + "DebugPanel/AnchorLabel", + "DebugPanel/EffectsLabel", + "DebugPanel/DefenseLabel", + "DebugPanel/PatternsLabel", + "DebugPanel/HitLogLabel", + "DebugPanel/CalibrationLabel", + ]: + _expect(ui.has_node(node_path), "MainUI should expose %s" % node_path) + + if ui.has_method("bind_debug_actor"): + ui.call("bind_debug_actor", player) + else: + failures.append("MainUI should expose bind_debug_actor(actor)") + + var state_machine: Node = player.get_node("StateMachine") + state_machine.call("set_ground_state", &"Airborne") + state_machine.call("set_action_phase", &"Startup") + state_machine.call("set_life_state", &"Hitstun") + state_machine.call("set_defense_state", &"Parrying") + + var action_controller: Node = player.get_node("ActionController") + action_controller.set("current_action", load("res://resources/actions/ground_attack_left_1.tres")) + action_controller.set("startup_stretch_seconds", 0.125) + + var effect_container: Node = player.get_node("EffectContainer") + if _method_arg_count(effect_container, "add_effect") >= 2: + effect_container.call("add_effect", load("res://resources/effects/effect_root.tres"), &"debug_test") + effect_container.call("add_effect", load("res://resources/effects/effect_temporary_invincible.tres"), &"debug_defense") + else: + failures.append("EffectContainer.add_effect should accept a source argument for debug UI") + effect_container.call("add_effect", load("res://resources/effects/effect_root.tres")) + + if ui.has_method("refresh_debug_panel"): + ui.call("refresh_debug_panel") + else: + failures.append("MainUI should expose refresh_debug_panel()") + + _expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Airborne", "Debug state axes should include GroundState") + _expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Startup", "Debug state axes should include ActionPhaseState") + _expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Hitstun", "Debug state axes should include LifeState") + _expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Parrying", "Debug state axes should include DefenseState") + _expect_label_contains(ui, "DebugPanel/ActionLabel", "ground_attack_left_1", "Debug action label should show current action id") + _expect_label_contains(ui, "DebugPanel/AnchorLabel", "0.125", "Debug anchor label should show Startup stretch seconds") + _expect_label_contains(ui, "DebugPanel/EffectsLabel", "effect_root", "Debug effects label should show active effect ids") + _expect_label_contains(ui, "DebugPanel/EffectsLabel", "1.5s", "Debug effects label should show active effect remaining seconds") + _expect_label_contains(ui, "DebugPanel/EffectsLabel", "x1", "Debug effects label should show active effect stacks") + _expect_label_contains(ui, "DebugPanel/EffectsLabel", "debug_test", "Debug effects label should show active effect source") + _expect_label_contains(ui, "DebugPanel/DefenseLabel", "Baseline", "Debug defense label should identify baseline defense") + _expect_label_contains(ui, "DebugPanel/DefenseLabel", "Invincible", "Debug defense label should show synthesized defense state") + _expect_label_contains(ui, "DebugPanel/DefenseLabel", "damage 0.00", "Debug defense label should show baseline damage multiplier") + _expect_label_contains(ui, "DebugPanel/DefenseLabel", "interrupt false", "Debug defense label should show baseline interrupt result") + _expect_label_contains(ui, "DebugPanel/PatternsLabel", "A->ground_attack_left_1", "Debug patterns label should show exported legal patterns") + _expect_label_contains(ui, "DebugPanel/PatternsLabel", "S->block_start", "Debug patterns label should show standalone S block") + _expect_label_not_contains(ui, "DebugPanel/PatternsLabel", "SS->", "Debug patterns label should not show removed S S combo") + _expect_label_contains(ui, "DebugPanel/CalibrationLabel", "Diff", "Debug calibration label should show rhythm diff calibration data") + + var bus := root.get_node_or_null("EventBus") + if bus != null and bus.has_signal("hit_confirmed"): + bus.emit_signal("hit_confirmed", { + "damage": 7, + "hit_type": &"melee", + "action": load("res://resources/actions/ground_attack_left_1.tres"), + "defense": {"defense_state": &"Parrying"}, + "interrupts": false, + }) + else: + failures.append("EventBus should expose hit_confirmed(result) for debug hit logs") + _expect_label_contains(ui, "DebugPanel/HitLogLabel", "ground_attack_left_1", "Debug hit log should show hit action id") + _expect_label_contains(ui, "DebugPanel/HitLogLabel", "dmg 7", "Debug hit log should show resolved damage") + _expect_label_contains(ui, "DebugPanel/HitLogLabel", "Parrying", "Debug hit log should show effective defense") + _expect_label_contains(ui, "DebugPanel/HitLogLabel", "interrupt false", "Debug hit log should show interrupt result") + if bus != null and bus.has_signal("hit_confirmed"): + for index: int in range(4): + bus.emit_signal("hit_confirmed", { + "damage": index + 1, + "hit_type": &"melee", + "action": load("res://resources/actions/ground_attack_left_1.tres"), + "defense": {"defense_state": &"Vulnerable"}, + "interrupts": true, + }) + if ui.has_method("get_hit_log_entries"): + var entries: Array = ui.call("get_hit_log_entries") + _expect_int(entries.size(), 5, "Debug UI should retain full hit history beyond the compact label") + else: + failures.append("MainUI should expose get_hit_log_entries()") + if ui.has_method("export_hit_log"): + _expect(ui.call("export_hit_log").contains("ground_attack_left_1"), "Debug UI should export hit history text") + else: + failures.append("MainUI should expose export_hit_log()") + + ui.free() + player.free() + await _check_main_scene_binds_debug_actor() + _finish() + + +func _expect_label_contains(root_node: Node, path: String, text: String, label: String) -> void: + var node := root_node.get_node_or_null(path) as Label + if node == null: + failures.append("%s: missing %s" % [label, path]) + return + if not node.text.contains(text): + failures.append("%s: expected '%s' to contain '%s'" % [label, node.text, text]) + + +func _expect_label_not_contains(root_node: Node, path: String, text: String, label: String) -> void: + var node := root_node.get_node_or_null(path) as Label + if node == null: + failures.append("%s: missing %s" % [label, path]) + return + if node.text.contains(text): + failures.append("%s: expected '%s' not to contain '%s'" % [label, node.text, text]) + + +func _method_arg_count(object: Object, method_name: String) -> int: + for method: Dictionary in object.get_method_list(): + if str(method.get("name", "")) != method_name: + continue + var args = method.get("args", []) + return args.size() if args is Array else 0 + return 0 + + +func _check_main_scene_binds_debug_actor() -> void: + var main_scene: PackedScene = load("res://scenes/main/main.tscn") + if main_scene == null: + failures.append("main.tscn should load") + return + var main := main_scene.instantiate() + root.add_child(main) + await process_frame + var player: Node = main.call("get_player") + var ui: Node = main.get_node("UI") + player.get_node("StateMachine").call("set_action_phase", &"Startup") + ui.call("refresh_debug_panel") + _expect_label_contains(ui, "DebugPanel/StateAxesLabel", "Startup", "Main should bind Player to UI DebugPanel") + main.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS debug ui") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_debug_ui.gd.uid b/tests/test_debug_ui.gd.uid new file mode 100644 index 0000000..d3d69b9 --- /dev/null +++ b/tests/test_debug_ui.gd.uid @@ -0,0 +1 @@ +uid://dbs7hl6v3kwds diff --git a/tests/test_difficulty_minion_health.gd b/tests/test_difficulty_minion_health.gd new file mode 100644 index 0000000..322d3f0 --- /dev/null +++ b/tests/test_difficulty_minion_health.gd @@ -0,0 +1,98 @@ +extends SceneTree + +## 难度小怪血量倍率:简单保持基准 650,普通 ×3 = 1950,困难 ×5 = 3250。 +## 注意 --script 测试模式下 autoload 同样会加载,GameSettings 始终存在, +## 因此这里显式钉住难度(直接写字段、不落盘),逐档验证。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _ensure_named_node("EventBus", "res://autoload/event_bus.gd") + _ensure_named_node("TimePhaseManager", "res://autoload/time_phase_manager.gd") + var settings := _ensure_named_node("GameSettings", "res://autoload/game_settings.gd") + + _expect_float(float(settings.call("minion_health_multiplier", &"easy")), 1.0, "easy multiplier") + _expect_float(float(settings.call("minion_health_multiplier", &"normal")), 3.0, "normal multiplier") + _expect_float(float(settings.call("minion_health_multiplier", &"hard")), 5.0, "hard multiplier") + + settings.set("difficulty", &"easy") + _expect_int(await _spawn_minion_max_health(), 650, "easy minion HP keeps the 650 baseline") + settings.set("difficulty", &"normal") + _expect_int(await _spawn_minion_max_health(), 1950, "normal minion HP is 3x the baseline") + settings.set("difficulty", &"hard") + _expect_int(await _spawn_minion_max_health(), 3250, "hard minion HP is 5x the baseline") + + # Boss 不吃小怪倍率:难度仍为 hard 时 Boss 血量保持自身导出值。 + settings.set("difficulty", &"hard") + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + if boss_scene != null: + var boss := boss_scene.instantiate() + root.add_child(boss) + await process_frame + var boss_health := boss.get_node_or_null("HealthComponent") + if boss_health != null: + _expect_int(int(boss_health.get("maximum")), int(boss.get("max_health")), "boss HP must not scale with the minion multiplier") + boss.queue_free() + await process_frame + + settings.set("difficulty", &"normal") + _finish() + + +func _spawn_minion_max_health() -> int: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + if scene == null: + failures.append("minion scene failed to load") + return -1 + var minion: Node2D = scene.instantiate() + root.add_child(minion) + await process_frame + var health := minion.get_node_or_null("HealthComponent") + if health == null: + failures.append("minion has no HealthComponent") + minion.queue_free() + await process_frame + return -1 + var maximum := int(health.get("maximum")) + var current := int(health.get("current")) + if current != maximum: + failures.append("minion should spawn at full HP: %d / %d" % [current, maximum]) + minion.queue_free() + await process_frame + return maximum + + +func _ensure_named_node(node_name: String, script_path: String) -> Node: + var existing := root.get_node_or_null(node_name) + if existing != null: + return existing + var node: Node = load(script_path).new() + node.name = node_name + root.add_child(node) + return node + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %f, got %f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS difficulty minion health") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_difficulty_minion_health.gd.uid b/tests/test_difficulty_minion_health.gd.uid new file mode 100644 index 0000000..5059487 --- /dev/null +++ b/tests/test_difficulty_minion_health.gd.uid @@ -0,0 +1 @@ +uid://co8c4blyov4oc diff --git a/tests/test_difficulty_windows.gd b/tests/test_difficulty_windows.gd new file mode 100644 index 0000000..d4cb6dc --- /dev/null +++ b/tests/test_difficulty_windows.gd @@ -0,0 +1,91 @@ +extends SceneTree + +## Difficulty judgement windows + the dynamic bad-window cap +## (AnchorV1.0 §9.5 / §21.8). + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var settings: Node = load("res://autoload/game_settings.gd").new() + root.add_child(settings) + var rhythm: Node = load("res://autoload/rhythm_manager.gd").new() + rhythm.set("starts_on_ready", false) + root.add_child(rhythm) + await process_frame + + # --- §21.8 window tables --- + _expect_vector(settings.call("difficulty_windows", &"easy"), Vector3(0.090, 0.160, 0.260), "easy windows") + _expect_vector(settings.call("difficulty_windows", &"normal"), Vector3(0.060, 0.115, 0.215), "normal windows") + _expect_vector(settings.call("difficulty_windows", &"hard"), Vector3(0.030, 0.070, 0.170), "hard windows") + + # --- applying a difficulty rewrites the rhythm manager windows --- + var windows: Vector3 = settings.call("difficulty_windows", &"hard") + rhythm.call("apply_judgement_windows", windows.x, windows.y, windows.z) + _expect_float(float(rhythm.get("perfect_window")), 0.030, "hard perfect window applied") + _expect_float(float(rhythm.get("good_window")), 0.070, "hard good window applied") + _expect_float(float(rhythm.get("bad_window")), 0.170, "hard bad window applied") + + # --- default (normal) values live on the manager itself --- + var fresh: Node = load("res://autoload/rhythm_manager.gd").new() + fresh.set("starts_on_ready", false) + root.add_child(fresh) + await process_frame + _expect_float(float(fresh.get("perfect_window")), 0.060, "default perfect window is Normal") + _expect_float(float(fresh.get("good_window")), 0.115, "default good window is Normal") + _expect_float(float(fresh.get("bad_window")), 0.215, "default bad window is Normal") + + # --- §9.5 dynamic cap: bad <= beat_interval * 0.48, cascading --- + fresh.set("bpm", 240.0) # beat_time 0.25s → cap 0.12 + var effective: Vector3 = fresh.call("effective_windows") + _expect_float(effective.z, 0.12, "bad window capped at 48% of the beat") + _expect_float(effective.y, 0.115, "good window stays when under the cap") + _expect_float(effective.x, 0.060, "perfect window stays when under the cap") + + fresh.set("bpm", 600.0) # beat_time 0.1s → cap 0.048: everything collapses + effective = fresh.call("effective_windows") + _expect_float(effective.z, 0.048, "extreme BPM bad cap") + _expect_float(effective.y, 0.048, "good window can never exceed the capped bad window") + _expect_float(effective.x, 0.048, "perfect window can never exceed the capped good window") + + # --- the cap is what judgement actually uses --- + fresh.set("bpm", 240.0) + fresh.set("running", true) + var rating: Dictionary = fresh.call("get_rating_for_time", 0.25 + 0.13) + _expect_string(str(rating.get("label")), "miss", "an offset outside the capped bad window judges miss") + rating = fresh.call("get_rating_for_time", 0.25 + 0.118) + _expect_string(str(rating.get("label")), "bad", "an offset between good and the capped bad window judges bad") + + settings.free() + rhythm.free() + fresh.free() + _finish() + + +func _expect_vector(actual: Vector3, expected: Vector3, label: String) -> void: + if (actual - expected).length() > 0.0005: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.0005: + failures.append("%s: expected %f, got %f" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS difficulty windows") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_difficulty_windows.gd.uid b/tests/test_difficulty_windows.gd.uid new file mode 100644 index 0000000..f6a2c86 --- /dev/null +++ b/tests/test_difficulty_windows.gd.uid @@ -0,0 +1 @@ +uid://ms0h61yd1lcw diff --git a/tests/test_effect_container.gd b/tests/test_effect_container.gd new file mode 100644 index 0000000..b2dfae3 --- /dev/null +++ b/tests/test_effect_container.gd @@ -0,0 +1,458 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_effect_scripts_and_player_component() + _check_phase6_effect_resources_have_single_stack_baseline() + _check_duration_and_stat_modifier() + _check_active_effect_summaries_include_runtime_debug_data() + _check_effect_container_exposes_rule_modifiers() + _check_dispatch_event_adds_triggered_effects() + _check_event_filters_gate_triggered_effects() + _check_action_and_hit_duration_expire_on_matching_events() + _check_measure_and_until_event_duration() + _check_burst_effect_resource_drives_stat_provider() + await _check_seconds_effects_tick_from_process() + await _check_beat_event_dispatches_effect_event() + await _check_perfect_input_dispatches_effect_event() + await _check_player_initial_effect_loadout() + await process_frame + _finish() + + +func _check_effect_scripts_and_player_component() -> void: + for path: String in [ + "res://resources/effects/effect_definition.gd", + "res://resources/effects/effect_instance.gd", + "res://resources/effects/stat_modifier.gd", + "res://resources/effects/defense_modifier.gd", + "res://resources/effects/action_rule_modifier.gd", + "res://scenes/components/effect_container.gd", + ]: + _expect(load(path) != null, "%s should load" % path) + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + _expect(player.get_node_or_null("EffectContainer") != null, "Player should own EffectContainer") + player.queue_free() + + +func _check_duration_and_stat_modifier() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var modifier_script: Script = load("res://resources/effects/stat_modifier.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null: + return + var definition: Resource = definition_script.new() + definition.set("id", &"test_double_damage") + definition.set("duration_type", &"seconds") + definition.set("duration", 0.5) + var modifier: Resource = modifier_script.new() + modifier.set("stat", &"damage_mult") + modifier.set("operation", &"multiply") + modifier.set("value", 2.0) + var stat_modifiers: Array[Resource] = [modifier] + definition.set("stat_modifiers", stat_modifiers) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", definition) + _expect_int(int(container.call("active_count")), 1, "EffectContainer should add an effect instance") + var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null)) + _expect_float(damage, 20.0, "StatResolver should read damage multiplier from EffectContainer") + container.call("tick_time", 0.6) + _expect_int(int(container.call("active_count")), 0, "EffectContainer should expire seconds-based effects") + container.free() + + +func _check_active_effect_summaries_include_runtime_debug_data() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or container_script == null: + return + var definition: Resource = definition_script.new() + definition.set("id", &"test_debug_stack") + definition.set("duration_type", &"seconds") + definition.set("duration", 2.0) + definition.set("max_stacks", 3) + var container: Node = container_script.new() + root.add_child(container) + if _method_arg_count(container, "add_effect") >= 2: + container.call("add_effect", definition, &"test_source") + container.call("add_effect", definition, &"test_source") + else: + failures.append("EffectContainer.add_effect should accept a source argument") + container.call("add_effect", definition) + container.call("add_effect", definition) + _expect(container.has_method("active_effect_summaries"), "EffectContainer should expose active_effect_summaries for debug UI") + if container.has_method("active_effect_summaries"): + var summaries: Array = container.call("active_effect_summaries") + _expect_int(summaries.size(), 1, "Stacked effects should produce one summary") + if not summaries.is_empty() and summaries[0] is Dictionary: + var summary: Dictionary = summaries[0] + _expect_equal(summary.get("id"), &"test_debug_stack", "Effect summary should include id") + _expect_equal(summary.get("duration_type"), &"seconds", "Effect summary should include duration_type") + _expect_int(int(summary.get("stacks", 0)), 2, "Effect summary should include stack count") + _expect_float(float(summary.get("remaining", 0.0)), 2.0, "Effect summary should include remaining duration") + _expect_string(str(summary.get("source", "")), "test_source", "Effect summary should include source") + container.free() + + +func _check_effect_container_exposes_rule_modifiers() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var defense_modifier_script: Script = load("res://resources/effects/defense_modifier.gd") + var action_rule_modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or defense_modifier_script == null or action_rule_modifier_script == null or container_script == null: + return + var definition: Resource = definition_script.new() + definition.set("id", &"test_rule_modifiers") + definition.set("duration_type", &"infinite") + var defense_modifier: Resource = defense_modifier_script.new() + defense_modifier.set("defense_state", &"Invincible") + var defense_modifiers: Array[Resource] = [defense_modifier] + definition.set("defense_modifiers", defense_modifiers) + var action_rule_modifier: Resource = action_rule_modifier_script.new() + var blocked_tags: Array[StringName] = [&"spell"] + action_rule_modifier.set("blocked_action_tags", blocked_tags) + var action_rule_modifiers: Array[Resource] = [action_rule_modifier] + definition.set("action_rule_modifiers", action_rule_modifiers) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", definition) + _expect(container.has_method("defense_modifiers"), "EffectContainer should expose active defense modifiers") + _expect(container.has_method("action_rule_modifiers"), "EffectContainer should expose active action-rule modifiers") + if container.has_method("defense_modifiers"): + _expect_int(container.call("defense_modifiers").size(), 1, "EffectContainer.defense_modifiers should return active defense modifiers") + if container.has_method("action_rule_modifiers"): + _expect_int(container.call("action_rule_modifiers").size(), 1, "EffectContainer.action_rule_modifiers should return active action-rule modifiers") + container.free() + + +func _check_dispatch_event_adds_triggered_effects() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var modifier_script: Script = load("res://resources/effects/stat_modifier.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + if definition_script == null or modifier_script == null or container_script == null or stat_resolver == null: + return + var reward: Resource = definition_script.new() + reward.set("id", &"test_perfect_reward_damage") + reward.set("duration_type", &"infinite") + var modifier: Resource = modifier_script.new() + modifier.set("stat", &"damage_mult") + modifier.set("operation", &"multiply") + modifier.set("value", 1.5) + var stat_modifiers: Array[Resource] = [modifier] + reward.set("stat_modifiers", stat_modifiers) + + var listener: Resource = definition_script.new() + listener.set("id", &"test_on_perfect_listener") + listener.set("duration_type", &"infinite") + listener.set("trigger_event", &"on_perfect") + var triggered_effects: Array[Resource] = [reward] + listener.set("trigger_effects", triggered_effects) + + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", listener) + _expect(container.has_method("dispatch_event"), "EffectContainer should expose dispatch_event(event_name, context)") + if container.has_method("dispatch_event"): + container.call("dispatch_event", &"on_perfect", {}) + _expect_int(int(container.call("active_count")), 2, "dispatch_event should add triggered effects") + var damage := float(stat_resolver.call("resolve_damage", 10.0, null, {"label": "perfect"}, container, null)) + _expect_float(damage, 15.0, "Triggered reward effect should contribute modifiers") + container.free() + + +func _check_phase6_effect_resources_have_single_stack_baseline() -> void: + for path: String in _phase6_effect_resource_paths(): + var definition: Resource = load(path) + _expect(definition != null, "%s should load" % path) + if definition != null: + _expect_int(int(definition.get("max_stacks")), 1, "%s max_stacks should stay at the migration baseline" % path) + + +func _check_event_filters_gate_triggered_effects() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or container_script == null: + return + var reward: Resource = definition_script.new() + reward.set("id", &"test_melee_hit_reward") + reward.set("duration_type", &"infinite") + var listener: Resource = definition_script.new() + listener.set("id", &"test_melee_hit_listener") + listener.set("duration_type", &"infinite") + listener.set("trigger_event", &"on_hit") + var required_tags: Array[StringName] = [&"melee"] + listener.set("required_event_action_tags", required_tags) + var triggered_effects: Array[Resource] = [reward] + listener.set("trigger_effects", triggered_effects) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", listener) + container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])}) + _expect_int(int(container.call("active_count")), 1, "Filtered trigger should ignore non-matching action tags") + container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])}) + _expect_int(int(container.call("active_count")), 2, "Filtered trigger should add rewards for matching action tags") + container.free() + + +func _check_action_and_hit_duration_expire_on_matching_events() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or container_script == null: + return + var action_limited: Resource = definition_script.new() + action_limited.set("id", &"test_next_action_only") + action_limited.set("duration_type", &"actions") + action_limited.set("duration", 1.0) + var hit_limited: Resource = definition_script.new() + hit_limited.set("id", &"test_next_melee_hit_only") + hit_limited.set("duration_type", &"hits") + hit_limited.set("duration", 1.0) + var required_tags: Array[StringName] = [&"melee"] + hit_limited.set("required_event_action_tags", required_tags) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", action_limited) + container.call("dispatch_event", &"on_action_start", {"action": _make_action_with_tags([&"skill"])}) + _expect_bool(not container.call("active_effect_ids").has(&"test_next_action_only"), true, "Action-duration effects should expire after matching action events") + container.call("add_effect", hit_limited) + container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"projectile"])}) + _expect_bool(container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should ignore non-matching action tags") + container.call("dispatch_event", &"on_hit", {"action": _make_action_with_tags([&"melee"])}) + _expect_bool(not container.call("active_effect_ids").has(&"test_next_melee_hit_only"), true, "Hit-duration effects should expire after matching hit events") + container.free() + + +func _check_measure_and_until_event_duration() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or container_script == null: + return + var measure_limited: Resource = definition_script.new() + measure_limited.set("id", &"test_two_measure_buff") + measure_limited.set("duration_type", &"measures") + measure_limited.set("duration", 2.0) + var until_landed: Resource = definition_script.new() + until_landed.set("id", &"test_until_landed_buff") + until_landed.set("duration_type", &"until_event") + until_landed.set("until_event", &"on_landed") + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", measure_limited) + container.call("dispatch_event", &"on_measure_start", {}) + _expect_bool(container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should survive until all measures are consumed") + container.call("dispatch_event", &"on_measure_start", {}) + _expect_bool(not container.call("active_effect_ids").has(&"test_two_measure_buff"), true, "Measure-duration effects should expire after N measure-start events") + container.call("add_effect", until_landed) + container.call("tick_time", 999.0) + container.call("dispatch_event", &"on_hurt", {}) + _expect_bool(container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should ignore time and unrelated events") + container.call("dispatch_event", &"on_landed", {}) + _expect_bool(not container.call("active_effect_ids").has(&"test_until_landed_buff"), true, "UntilEvent effects should expire when their configured event fires") + container.free() + + +func _check_burst_effect_resource_drives_stat_provider() -> void: + var container_script: Script = load("res://scenes/components/effect_container.gd") + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + var action_script: Script = load("res://resources/action_data.gd") + var burst_effect: Resource = load("res://resources/effects/effect_burst_power.tres") + if container_script == null or stat_resolver == null or action_script == null: + return + _expect(burst_effect != null, "Burst power should be represented as an EffectDefinition resource") + if burst_effect == null: + return + var action: Resource = action_script.new() + action.set("base_cost", 10.0) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", burst_effect, &"burst") + var damage := float(stat_resolver.call("resolve_damage", 10.0, action, {"label": "perfect"}, container, null)) + var cost := float(stat_resolver.call("resolve_cost", action, container)) + _expect_float(damage, 12.0, "Burst effect resource should boost damage through EffectContainer stat modifiers") + _expect_float(cost, 0.0, "Burst effect resource should make action costs free through EffectContainer stat modifiers") + container.free() + + +func _check_seconds_effects_tick_from_process() -> void: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if definition_script == null or container_script == null: + return + var definition: Resource = definition_script.new() + definition.set("id", &"test_runtime_seconds_expire") + definition.set("duration_type", &"seconds") + definition.set("duration", 0.02) + var container: Node = container_script.new() + root.add_child(container) + await process_frame + container.call("add_effect", definition) + await create_timer(0.08).timeout + await process_frame + _expect_int(int(container.call("active_count")), 0, "Seconds-based effects should expire automatically while EffectContainer is in the scene tree") + container.free() + + +func _check_beat_event_dispatches_effect_event() -> void: + var event_bus := _ensure_event_bus() + var container_script: Script = load("res://scenes/components/effect_container.gd") + if event_bus == null or container_script == null: + return + var container: Node = container_script.new() + root.add_child(container) + await process_frame + container.call("add_effect", _make_event_listener_effect(&"on_beat", &"test_on_beat_reward")) + event_bus.emit_signal("beat_ticked", 8) + _expect_int(int(container.call("active_count")), 2, "EffectContainer should dispatch on_beat when EventBus beat_ticked fires") + container.free() + + +func _check_perfect_input_dispatches_effect_event() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + if player_scene == null or definition_script == null: + return + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + var effect_container: Node = player.get_node("EffectContainer") + var controller: Node = player.get_node("ActionController") + effect_container.call("add_effect", _make_event_listener_effect(&"on_perfect", &"test_perfect_runtime_reward")) + controller.call("submit_intent", _perfect_intent()) + var active_ids: Array = effect_container.call("active_effect_ids") + _expect_bool(active_ids.has(&"test_perfect_runtime_reward"), true, "Perfect input should dispatch on_perfect to EffectContainer") + _expect_bool(active_ids.has(&"effect_perfect_damage_buff"), true, "Player loadout should turn Perfect input into the configured damage reward") + player.free() + + +func _check_player_initial_effect_loadout() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + if player_scene == null: + return + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + var effect_container: Node = player.get_node("EffectContainer") + var ids: Array = effect_container.call("active_effect_ids") + _expect_bool(ids.has(&"effect_on_perfect_damage_reward"), true, "Player should start with the Perfect reward listener as a real Effect loadout") + _expect_bool(ids.has(&"effect_on_landed_haste"), true, "Player should start with the landed Haste listener as a real Effect loadout") + effect_container.call("dispatch_event", &"on_landed", {}) + _expect_bool(effect_container.call("active_effect_ids").has(&"effect_haste"), true, "Player landed listener should apply Haste through EffectContainer") + player.free() + + +func _make_event_listener_effect(event_name: StringName, reward_id: StringName) -> Resource: + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var reward: Resource = definition_script.new() + reward.set("id", reward_id) + reward.set("duration_type", &"infinite") + var listener: Resource = definition_script.new() + listener.set("id", StringName("listener_%s" % event_name)) + listener.set("duration_type", &"infinite") + listener.set("trigger_event", event_name) + var triggered_effects: Array[Resource] = [reward] + listener.set("trigger_effects", triggered_effects) + return listener + + +func _phase6_effect_resource_paths() -> Array[String]: + return [ + "res://resources/effects/effect_root.tres", + "res://resources/effects/effect_slow.tres", + "res://resources/effects/effect_haste.tres", + "res://resources/effects/effect_silence.tres", + "res://resources/effects/effect_temporary_invincible.tres", + "res://resources/effects/effect_temporary_super_armor.tres", + "res://resources/effects/effect_burst_power.tres", + "res://resources/effects/effect_on_landed_haste.tres", + "res://resources/effects/effect_perfect_damage_buff.tres", + "res://resources/effects/effect_on_perfect_damage_reward.tres", + ] + + +func _ensure_event_bus() -> Node: + var existing := root.get_node_or_null("EventBus") + if existing != null: + return existing + var bus_script: Script = load("res://autoload/event_bus.gd") + if bus_script == null: + failures.append("EventBus script should load") + return null + var bus: Node = bus_script.new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _perfect_intent() -> RefCounted: + var intent_script: Script = load("res://scenes/components/input_intent.gd") + var intent: RefCounted = intent_script.call("create", &"A", &"a", &"pressed", float(Time.get_ticks_msec())) + intent.set("judgement", {"label": "perfect", "diff": 0.0, "abs_diff": 0.0}) + return intent + + +func _make_action_with_tags(tags: Array[StringName]) -> Resource: + var action: Resource = load("res://resources/action_data.gd").new() + action.set("action_tags", tags) + return action + + +func _method_arg_count(object: Object, method_name: String) -> int: + for method: Dictionary in object.get_method_list(): + if str(method.get("name", "")) != method_name: + continue + var args = method.get("args", []) + return args.size() if args is Array else 0 + return 0 + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS effect container") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_effect_container.gd.uid b/tests/test_effect_container.gd.uid new file mode 100644 index 0000000..04c05b3 --- /dev/null +++ b/tests/test_effect_container.gd.uid @@ -0,0 +1 @@ +uid://d3qmrj5615qkh diff --git a/tests/test_effect_resources.gd b/tests/test_effect_resources.gd new file mode 100644 index 0000000..e1d325c --- /dev/null +++ b/tests/test_effect_resources.gd @@ -0,0 +1,90 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var effect_definition_script: Script = load("res://resources/effects/effect_definition.gd") + var effect_instance_script: Script = load("res://resources/effects/effect_instance.gd") + var stat_modifier_script: Script = load("res://resources/effects/stat_modifier.gd") + var defense_modifier_script: Script = load("res://resources/effects/defense_modifier.gd") + var action_rule_modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd") + + _expect(effect_definition_script != null, "EffectDefinition script should load") + _expect(effect_instance_script != null, "EffectInstance script should load") + _expect(stat_modifier_script != null, "StatModifier script should load") + _expect(defense_modifier_script != null, "DefenseModifier script should load") + _expect(action_rule_modifier_script != null, "ActionRuleModifier script should load") + if effect_definition_script == null or effect_instance_script == null or stat_modifier_script == null or defense_modifier_script == null or action_rule_modifier_script == null: + _finish() + return + + var definition: Resource = effect_definition_script.new() + definition.set("id", &"test_effect") + definition.set("duration_type", &"beats") + definition.set("duration", 4.0) + _expect_int(int(definition.get("max_stacks")), 1, "EffectDefinition max_stacks default") + + var stat_modifier: Resource = stat_modifier_script.new() + stat_modifier.set("stat", &"damage") + stat_modifier.set("operation", "multiply") + stat_modifier.set("value", 1.2) + var stat_modifiers: Array[Resource] = [stat_modifier] + definition.set("stat_modifiers", stat_modifiers) + + var defense_modifier: Resource = defense_modifier_script.new() + defense_modifier.set("defense_state", &"SuperArmor") + var defense_modifiers: Array[Resource] = [defense_modifier] + definition.set("defense_modifiers", defense_modifiers) + + var action_rule_modifier: Resource = action_rule_modifier_script.new() + action_rule_modifier.set("block_movement", true) + var action_rule_modifiers: Array[Resource] = [action_rule_modifier] + definition.set("action_rule_modifiers", action_rule_modifiers) + + var instance: RefCounted = effect_instance_script.call("create", definition, &"test") + _expect(instance != null, "EffectInstance.create should return an instance") + if instance != null: + _expect_float(float(instance.get("remaining")), 4.0, "EffectInstance should copy beat duration") + instance.call("tick_beats", 1.0) + _expect_float(float(instance.get("remaining")), 3.0, "EffectInstance should tick beat duration") + _expect_bool(bool(instance.call("matches_event_context", {"action_tags": [&"melee"]})), true, "EffectInstance should accept unrestricted context") + + _expect_int((definition.get("stat_modifiers") as Array).size(), 1, "EffectDefinition should hold stat modifiers") + _expect_int((definition.get("defense_modifiers") as Array).size(), 1, "EffectDefinition should hold defense modifiers") + _expect_int((definition.get("action_rule_modifiers") as Array).size(), 1, "EffectDefinition should hold action rule modifiers") + _finish() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS effect resources") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_effect_resources.gd.uid b/tests/test_effect_resources.gd.uid new file mode 100644 index 0000000..de6eb7e --- /dev/null +++ b/tests/test_effect_resources.gd.uid @@ -0,0 +1 @@ +uid://8ndphd3qrm0a diff --git a/tests/test_enemy_action_pipeline.gd b/tests/test_enemy_action_pipeline.gd new file mode 100644 index 0000000..f49570f --- /dev/null +++ b/tests/test_enemy_action_pipeline.gd @@ -0,0 +1,193 @@ +extends SceneTree + +var failures: Array[String] = [] +var started_actions: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_scripts_and_scene() + _check_boss_action_resources() + await _check_enemy_driver_uses_action_controller() + await _check_chart_event_reaches_boss_driver() + _check_chart_event_action_id() + _finish() + + +func _check_scripts_and_scene() -> void: + for path: String in [ + "res://scenes/components/ai_intent.gd", + "res://scenes/enemies/enemy_action_driver.gd", + "res://scenes/enemies/enemy.tscn", + "res://scenes/enemies/boss.tscn", + ]: + _expect(load(path) != null, "%s should load" % path) + var action_dir := DirAccess.open("res://resources/actions/enemies") + _expect(action_dir != null, "Enemy action resource directory should exist") + + +func _check_boss_action_resources() -> void: + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + _expect(resolver_script != null, "ActionResolver should load for enemy resources") + if resolver_script == null: + return + resolver_script.clear_cache() + for action_id: StringName in _boss_action_ids(): + var action: Resource = resolver_script.get_action(action_id) + _expect(action != null, "%s should be a real Boss ActionData resource" % action_id) + if action == null: + continue + _expect_bool(str(action.resource_path).begins_with("res://resources/actions/enemies/"), true, "%s should load from the enemy action directory" % action_id) + _expect_bool(Array(action.get("input_pattern")).is_empty(), true, "%s should not expose a player input pattern" % action_id) + _expect_bool(Array(action.get("action_tags")).has(&"enemy"), true, "%s should carry the enemy action tag" % action_id) + _expect_bool(Array(action.get("action_tags")).has(&"boss"), true, "%s should carry the boss action tag" % action_id) + _expect(float(action.get("startup_beats")) > 0.0, "%s should have a startup phase" % action_id) + _expect(float(action.get("active_beats")) > 0.0, "%s should have an active phase" % action_id) + _expect(float(action.get("recovery_beats")) > 0.0, "%s should have a recovery phase" % action_id) + _expect_bool(str(action.get("id")).begins_with("skill_"), false, "%s should not keep legacy skill_* naming" % action_id) + var stab: Resource = resolver_script.get_action(&"boss_lunging_stab") + if stab != null: + _expect_float(float(stab.get("damage_mult")), 3.0, "boss_lunging_stab damage") + _expect_float(float(stab.get("move_mult_x")), 2.0, "boss_lunging_stab lunge distance") + _expect_bool(Array(stab.get("defense_tags")).has(&"super_armor"), true, "boss_lunging_stab should carry super_armor defense tag") + var shoot: Resource = resolver_script.get_action(&"boss_shoot_1") + if shoot != null: + _expect_equal(shoot.get("hit_type"), &"projectile", "boss_shoot_1 should use projectile hit_type") + _expect_bool(resolver_script.resolve_pattern("boss_combo_1") == null, true, "Boss action ids should not resolve as player combo patterns") + resolver_script.clear_cache() + + +func _check_enemy_driver_uses_action_controller() -> void: + var scene: PackedScene = load("res://scenes/enemies/enemy.tscn") + if scene == null: + return + var enemy := scene.instantiate() + root.add_child(enemy) + await process_frame + var controller: Node = enemy.get_node("ActionController") + var driver: Node = enemy.get_node("EnemyActionDriver") + controller.connect("action_started", _on_action_started) + started_actions.clear() + driver.call("start_action", &"boss_combo_1") + _expect(started_actions.has(&"boss_combo_1"), "EnemyActionDriver should start Boss actions through ActionController") + _expect_int(int(controller.get("phase")), 1, "Enemy action should enter Startup, not direct Active") + enemy.queue_free() + await process_frame + + +func _check_chart_event_reaches_boss_driver() -> void: + started_actions.clear() + var actors := Node2D.new() + actors.name = "ActorsContainer" + root.add_child(actors) + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + if boss_scene == null: + actors.queue_free() + return + var boss := boss_scene.instantiate() + boss.name = "Boss" + actors.add_child(boss) + await process_frame + boss.get_node("ActionController").connect("action_started", _on_action_started) + + var chart_script: Script = load("res://resources/beat_chart.gd") + var track_script: Script = load("res://resources/chart_track.gd") + var chart: Resource = chart_script.new() + var track: Resource = track_script.new() + chart.set("chart_id", &"boss_dispatch_chart") + track.set("track_id", &"track_boss_melee") + track.set("track_type", &"boss") + track.set("events", [_make_event(1, &"Boss", &"boss_combo_1")]) + chart.set("tracks", [track]) + + var runner: Node = load("res://scenes/chart/chart_runner.gd").new() + root.add_child(runner) + runner.set("beat_time_override", 0.5) + runner.set("actors_container_path", runner.get_path_to(actors)) + runner.call("set_chart", chart) + runner.call("update_for_song_time", 0.5) + _expect(started_actions.has(&"boss_combo_1"), "ChartRunner should dispatch Boss action_id to Boss EnemyActionDriver") + runner.queue_free() + actors.queue_free() + await process_frame + + +func _check_chart_event_action_id() -> void: + var event_script: Script = load("res://resources/chart_event.gd") + var event: Resource = event_script.new() + _expect(_has_property(event, "action_id"), "ChartEvent should expose action_id for AIIntent dispatch") + event.set("action_id", &"boss_2way_shoot") + _expect_equal(event.get("action_id"), &"boss_2way_shoot", "ChartEvent action_id should be data-driven") + + +func _make_event(beat: int, target_id: StringName, action_id: StringName) -> Resource: + var event: Resource = load("res://resources/chart_event.gd").new() + event.set("beat_index", beat) + event.set("event_type", &"enemy_action") + event.set("target_id", target_id) + event.set("lead_beats", 0.0) + event.set("action_id", action_id) + return event + + +func _boss_action_ids() -> Array[StringName]: + return [ + &"boss_combo_1", + &"boss_combo_2", + &"boss_combo_3", + &"boss_lunging_stab", + &"boss_dash", + &"boss_shoot_1", + &"boss_shoot_2", + &"boss_2way_shoot", + ] + + +func _on_action_started(action: Resource, _intent) -> void: + started_actions.append(StringName(str(action.get("id")))) + + +func _has_property(object: Object, property_name: String) -> bool: + for property: Dictionary in object.get_property_list(): + if str(property.get("name", "")) == property_name: + return true + return false + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS enemy action pipeline") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_enemy_action_pipeline.gd.uid b/tests/test_enemy_action_pipeline.gd.uid new file mode 100644 index 0000000..0f972a1 --- /dev/null +++ b/tests/test_enemy_action_pipeline.gd.uid @@ -0,0 +1 @@ +uid://dk2khyitxxfpg diff --git a/tests/test_frame_collision_driver.gd b/tests/test_frame_collision_driver.gd new file mode 100644 index 0000000..940ea91 --- /dev/null +++ b/tests/test_frame_collision_driver.gd @@ -0,0 +1,195 @@ +extends SceneTree + +const DriverScript := preload("res://scenes/components/frame_collision_driver.gd") +const EmitterScript := preload("res://scenes/components/damage_emitter.gd") +const ReceiverScript := preload("res://scenes/components/damage_receiver.gd") +const ActionControllerScript := preload("res://scenes/components/action_controller.gd") +const ActionDataScript := preload("res://resources/action_data.gd") + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var actor := _build_actor() + root.add_child(actor) + await process_frame + + var driver: Node = actor.get_node("FrameCollisionDriver") + var receiver := actor.get_node("DamageReceiver") as Area2D + var receiver_shape := receiver.get_node("CollisionShape2D") as CollisionShape2D + var emitter := actor.get_node("DamageEmitter") as Area2D + var emitter_shape := emitter.get_node("CollisionShape2D") as CollisionShape2D + var sprite := actor.get_node("Visual/CharacterSprite") as Sprite2D + var controller: Node = actor.get_node("ActionController") + + # --- hurtbox follows the displayed frame's opaque bounds --- + sprite.frame = 0 + driver.call("refresh_now") + var body_size := (receiver_shape.shape as RectangleShape2D).size + _expect_float(body_size.x, 24.0, "Frame 0 hurtbox width should match the small pose") + _expect_float(body_size.y, 40.0, "Frame 0 hurtbox height should match the pose") + _expect_vector(receiver_shape.position, Vector2(0.0, -20.0), "Frame 0 hurtbox should centre on the pose") + + sprite.frame = 2 + driver.call("refresh_now") + var extended_size := (receiver_shape.shape as RectangleShape2D).size + _expect_float(extended_size.x, 44.0, "Frame 2 hurtbox should widen with the extended pose") + _expect_bool(extended_size.x > body_size.x, true, "Damage-receive matrix must change per frame") + + # --- hitbox only monitors on reaching ACTIVE frames --- + var action: Resource = ActionDataScript.new() + action.set("hit_type", &"melee") + action.set("range", 40.0) + emitter.call("configure_hit", action, {"label": "perfect"}) + controller.set("phase", ActionControllerScript.Phase.ACTIVE) + sprite.frame = 0 + driver.call("refresh_now") + _expect_bool(emitter.monitoring, false, "Windup frame (no forward reach) must not send damage") + sprite.frame = 2 + driver.call("refresh_now") + _expect_bool(emitter.monitoring, true, "Weapon-extended frame must send damage") + _expect_bool(emitter_shape.position.x < 0.0, true, "Facing left, the hit shape extends left") + _expect_bool((emitter_shape.shape as RectangleShape2D).size.x >= 30.0, true, "Hit reach should cover max(pose reach, action range)") + + # recovery: leaving ACTIVE closes the window on the same frame + controller.set("phase", ActionControllerScript.Phase.RECOVERY) + driver.call("refresh_now") + _expect_bool(emitter.monitoring, false, "Leaving ACTIVE must close the hit window") + controller.set("phase", ActionControllerScript.Phase.ACTIVE) + + # --- mirroring follows heading/Visual flip --- + actor.set("heading", Vector2.RIGHT) + (actor.get_node("Visual") as Node2D).scale.x = -2.0 + driver.call("refresh_now") + _expect_bool(emitter.monitoring, true, "Mirrored reach frame still sends damage") + _expect_bool(emitter_shape.position.x > 0.0, true, "Facing right, the hit shape extends right") + + # --- body matrix is re-asserted every tick --- + actor.collision_layer = 5 + actor.collision_mask = 99 + driver.call("refresh_now") + _expect_int(actor.collision_layer, 32, "Body collision layer must be re-applied per frame") + _expect_int(actor.collision_mask, 65, "Body collision mask must be re-applied per frame") + + # --- one hit per swing even with per-frame windows --- + var target := Area2D.new() + target.add_to_group("damage_receivers") + root.add_child(target) + emitter.call("_on_area_entered", target) + emitter.call("_on_area_entered", target) + _expect_bool(emitter.call("has_already_hit", target), true, "Emitter should remember receivers hit this swing") + emitter.call("clear_hit") + _expect_bool(emitter.call("has_already_hit", target), false, "clear_hit should reset the per-swing memory") + + target.free() + actor.free() + _finish() + + +func _build_actor() -> CharacterBody2D: + var actor_script := GDScript.new() + actor_script.source_code = "extends CharacterBody2D\nvar heading := Vector2.LEFT\n" + actor_script.reload() + var actor := CharacterBody2D.new() + actor.set_script(actor_script) + actor.collision_layer = 32 + actor.collision_mask = 65 + + var visual := Node2D.new() + visual.name = "Visual" + visual.scale = Vector2(2, 2) + actor.add_child(visual) + + var sprite := Sprite2D.new() + sprite.name = "CharacterSprite" + sprite.centered = false + sprite.offset = Vector2(-16, -32) + sprite.hframes = 4 + sprite.vframes = 1 + sprite.texture = _build_sheet() + visual.add_child(sprite) + + var emitter := Area2D.new() + emitter.name = "DamageEmitter" + emitter.set_script(EmitterScript) + emitter.collision_layer = 8 + emitter.collision_mask = 4 + emitter.monitoring = false + var emitter_shape := CollisionShape2D.new() + emitter_shape.name = "CollisionShape2D" + emitter_shape.shape = RectangleShape2D.new() + emitter.add_child(emitter_shape) + actor.add_child(emitter) + + var receiver := Area2D.new() + receiver.name = "DamageReceiver" + receiver.set_script(ReceiverScript) + receiver.collision_layer = 2 + receiver.collision_mask = 16 + var receiver_shape := CollisionShape2D.new() + receiver_shape.name = "CollisionShape2D" + receiver_shape.shape = RectangleShape2D.new() + receiver.add_child(receiver_shape) + actor.add_child(receiver) + + var controller_script := GDScript.new() + controller_script.source_code = "extends Node\nvar phase := 0\n" + controller_script.reload() + var controller := Node.new() + controller.name = "ActionController" + controller.set_script(controller_script) + actor.add_child(controller) + + var driver := Node.new() + driver.name = "FrameCollisionDriver" + driver.set_script(DriverScript) + driver.set("hurt_margin", Vector2.ZERO) + actor.add_child(driver) + return actor + + +func _build_sheet() -> ImageTexture: + # 4 frames of 32x32. Frame 0: compact body (x 10..21). Frame 2: weapon + # extended to the native-left edge (x 0..21). Frames 1/3 stay empty. + var image := Image.create(128, 32, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + for y: int in range(12, 32): + for x: int in range(10, 22): + image.set_pixel(x, y, Color.WHITE) + for x: int in range(64, 86): + image.set_pixel(x, y, Color.WHITE) + return ImageTexture.create_from_image(image) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.5: + failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.75: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS frame collision driver") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_frame_collision_driver.gd.uid b/tests/test_frame_collision_driver.gd.uid new file mode 100644 index 0000000..ce6c4ff --- /dev/null +++ b/tests/test_frame_collision_driver.gd.uid @@ -0,0 +1 @@ +uid://dfswm2py74msn diff --git a/tests/test_game_flow.gd b/tests/test_game_flow.gd new file mode 100644 index 0000000..d303f4b --- /dev/null +++ b/tests/test_game_flow.gd @@ -0,0 +1,148 @@ +extends SceneTree + +## Game-flow state machine + boss-room entry rules (AnchorV1.0 §4.5 / §19.5). + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var flow: Node = load("res://autoload/game_flow_manager.gd").new() + flow.set("manage_scene", false) + root.add_child(flow) + await process_frame + + # --- screens exist for every menu state --- + for state_name: StringName in [&"Title", &"DifficultySelect", &"Paused", &"VolumeSettings", &"ExitGameConfirm", &"ReturnTitleConfirm", &"Defeat", &"Victory", &"Result"]: + var screens: Dictionary = flow.get("_screens") + _expect_bool(screens.has(state_name), true, "screen for %s exists" % state_name) + var flow_ui := flow.get_node_or_null("FlowUI") + _expect_bool(flow_ui != null, true, "flow UI layer exists") + if flow_ui != null: + _expect_bool(flow_ui.get_node_or_null("PauseButton") == null, true, "HUD should not show a top-right pause button; pause is Esc-only") + + # --- pause / resume drive the SceneTree pause + rhythm clock --- + flow.set("state", &"Gameplay_FrontArea") + var esc := InputEventAction.new() + esc.action = &"ui_cancel" + esc.pressed = true + flow.call("_unhandled_input", esc) + _expect_string(str(flow.get("state")), "Paused", "Esc enters Paused") + _expect_bool(root.get_tree().paused, true, "pause stops the SceneTree") + flow.call("_unhandled_input", esc) + _expect_string(str(flow.get("state")), "Gameplay_FrontArea", "Esc resumes gameplay") + _expect_bool(root.get_tree().paused, false, "resume releases the SceneTree") + + # --- boss-room entry rules on a synthetic scene (§4.5) --- + var scene := _build_fake_scene() + root.add_child(scene) + current_scene = scene + await process_frame + var player := scene.get_node("Stage/ActorsContainer/Player") + var streak := player.get_node("StreakCounter") + streak.set("streak", 9) + var combo := player.get_node("ComboWindow") + combo.call("record", &"A") + combo.call("record", &"A") + var buff := player.get_node("AttackBuffComponent") + var container := player.get_node("EffectContainer") + for index: int in range(6): + container.call("add_effect", buff.get("buff_definition"), &"test") + _expect_int(int(buff.call("attack_buff_stacks")), 6, "test setup should reach 6 buff stacks") + + flow.set("state", &"Gameplay_FrontArea") + flow.set("_boss_room_entered", false) + flow.call("enter_boss_room") + + _expect_string(str(flow.get("state")), "Gameplay_BossRoom", "entering the boss room switches state") + _expect_int(int(streak.get("streak")), 0, "boss entry clears the combo counter") + _expect_int((combo.call("get_slots") as Array).size(), 0, "boss entry clears the four-slot window") + _expect_int(int(buff.call("attack_buff_stacks")), 3, "boss entry caps the attack buff at 3 stacks") + var boss := scene.get_node("Stage/ActorsContainer/Boss") + _expect_bool(bool(boss.get("combat_enabled")), true, "boss combat unlocks on entry") + _expect_bool(bool(boss.get("stationary")), false, "boss leaves training-dummy mode on entry") + + # --- re-entering is a no-op --- + streak.set("streak", 5) + flow.call("enter_boss_room") + _expect_int(int(streak.get("streak")), 5, "boss entry rules apply only once") + + # --- rank appears only on victory (§19.6) --- + flow.set("_victory", true) + flow.set("state", &"Result") + flow.call("_populate_result_screen") + var labels: Dictionary = flow.get("_result_labels") + _expect_bool((labels.get("rank") as Label).visible, true, "victory result shows the rank") + flow.set("_victory", false) + flow.call("_populate_result_screen") + _expect_bool((labels.get("rank") as Label).visible, false, "defeat result hides the rank") + + current_scene = null + scene.free() + flow.free() + _finish() + + +func _build_fake_scene() -> Node: + var scene := Node.new() + scene.name = "Main" + var stage := Node2D.new() + stage.name = "Stage" + scene.add_child(stage) + var actors := Node2D.new() + actors.name = "ActorsContainer" + stage.add_child(actors) + + var player := CharacterBody2D.new() + player.name = "Player" + actors.add_child(player) + var streak: Node = load("res://scenes/components/streak_counter.gd").new() + streak.name = "StreakCounter" + player.add_child(streak) + var combo: Node = load("res://scenes/components/combo_window.gd").new() + combo.name = "ComboWindow" + player.add_child(combo) + var container: Node = load("res://scenes/components/effect_container.gd").new() + container.name = "EffectContainer" + player.add_child(container) + var buff: Node = load("res://scenes/components/attack_buff_component.gd").new() + buff.name = "AttackBuffComponent" + player.add_child(buff) + + var boss_script := GDScript.new() + boss_script.source_code = "extends Node2D\nvar combat_enabled := false\nvar stationary := true\n" + boss_script.reload() + var boss := Node2D.new() + boss.name = "Boss" + boss.set_script(boss_script) + actors.add_child(boss) + return scene + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS game flow") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_game_flow.gd.uid b/tests/test_game_flow.gd.uid new file mode 100644 index 0000000..a9199f7 --- /dev/null +++ b/tests/test_game_flow.gd.uid @@ -0,0 +1 @@ +uid://3pufq1dg46jr diff --git a/tests/test_game_stats_rank.gd b/tests/test_game_stats_rank.gd new file mode 100644 index 0000000..eb00364 --- /dev/null +++ b/tests/test_game_stats_rank.gd @@ -0,0 +1,98 @@ +extends SceneTree + +## Result-screen statistics + Rank formula (AnchorV1.0 §19.6 / §21.10). + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var stats: Node = load("res://autoload/game_stats.gd").new() + root.add_child(stats) + await process_frame + + stats.call("start_run") + + # --- fact subscription through a synthetic bus feed --- + stats.call("_on_judgement_made", &"perfect", 0.0, 1) + stats.call("_on_judgement_made", &"good", 20.0, 2) + stats.call("_on_judgement_made", &"bad", 90.0, 3) + stats.call("_on_judgement_made", &"miss", 300.0, 4) + stats.call("_on_time_anchor_resolved", {"beat": 4}, true, {"label": "perfect"}) + stats.call("_on_time_anchor_resolved", {"beat": 8}, false, {}) + stats.call("_on_time_phase_changed", &"past", &"future", &"time_anchor_broken") + stats.call("_on_time_phase_changed", &"past", &"future", &"chart_init") + stats.call("_on_streak_changed", 12) + stats.call("_on_streak_changed", 3) + + _expect_int(int(stats.get("perfect_count")), 1, "perfect count") + _expect_int(int(stats.get("good_count")), 1, "good count") + _expect_int(int(stats.get("bad_count")), 1, "bad count") + _expect_int(int(stats.get("miss_count")), 1, "miss count") + _expect_int(int(stats.get("anchor_success_count")), 1, "anchor success only on held") + _expect_int(int(stats.get("phase_shift_count")), 1, "chart_init phase change must not count as a shift") + _expect_int(int(stats.get("max_combo")), 12, "max combo keeps the highest streak") + + # --- accuracy: (1*1.00 + 1*0.85 + 1*0.55 + 0) / 4 * 65 --- + _expect_float(float(stats.call("accuracy_score")), (1.0 + 0.85 + 0.55) / 4.0 * 65.0, "accuracy score formula") + # --- anchor: 1 success / (1 + 1 shift) * 20 --- + _expect_float(float(stats.call("anchor_score")), 10.0, "anchor control score formula") + # --- combo: min(12/80, 1) * 15 --- + _expect_float(float(stats.call("combo_score")), 12.0 / 80.0 * 15.0, "combo stability score formula") + + # --- rank bands (§19.6) --- + _expect_string(str(stats.call("rank_for_score", 95.0)), "S", "rank S from 90") + _expect_string(str(stats.call("rank_for_score", 90.0)), "S", "rank S boundary") + _expect_string(str(stats.call("rank_for_score", 85.0)), "A", "rank A band") + _expect_string(str(stats.call("rank_for_score", 70.0)), "B", "rank B band") + _expect_string(str(stats.call("rank_for_score", 55.0)), "C", "rank C band") + _expect_string(str(stats.call("rank_for_score", 20.0)), "D", "rank D band") + + # --- anchor_total = 0 → full anchor score (§19.6) --- + stats.call("reset") + _expect_float(float(stats.call("anchor_score")), 20.0, "no anchors at all should give the full 20 anchor points") + + # --- perfect run: full 100 / S --- + stats.call("start_run") + for index: int in range(80): + stats.call("_on_judgement_made", &"perfect", 0.0, index) + stats.call("_on_streak_changed", 80) + stats.call("_on_time_anchor_resolved", {"beat": 4}, true, {"label": "perfect"}) + _expect_float(float(stats.call("total_score")), 100.0, "perfect run should score 100") + _expect_string(str(stats.call("current_rank")), "S", "perfect run rank") + + # --- tracking stops after stop_tracking --- + stats.call("stop_tracking") + stats.call("_on_judgement_made", &"miss", 0.0, 99) + _expect_int(int(stats.get("miss_count")), 0, "no counting after stop_tracking") + + stats.free() + _finish() + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.001: + failures.append("%s: expected %f, got %f" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS game stats rank") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_game_stats_rank.gd.uid b/tests/test_game_stats_rank.gd.uid new file mode 100644 index 0000000..4989c1c --- /dev/null +++ b/tests/test_game_stats_rank.gd.uid @@ -0,0 +1 @@ +uid://bwcdrkap0rgax diff --git a/tests/test_hurt_buff_drop_and_super_armor.gd b/tests/test_hurt_buff_drop_and_super_armor.gd new file mode 100644 index 0000000..cb3c323 --- /dev/null +++ b/tests/test_hurt_buff_drop_and_super_armor.gd @@ -0,0 +1,117 @@ +extends SceneTree + +## 受伤掉层与霸体(修正案): +## 进入受伤状态掉 floor(层数/2) 层攻击 Buff,每掉 1 层获得 0.5s SuperArmor; +## 霸体期间伤害照吃、不被打断、无击退,因此不会连锁掉层。 + +const DefenseResolverScript := preload("res://scripts/resolvers/defense_resolver.gd") + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(player_scene != null, "player scene should load") + if player_scene == null: + _finish() + return + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + + var buff: Node = player.get_node("AttackBuffComponent") + var container: Node = player.get_node("EffectContainer") + var state_machine: Node = player.get_node("StateMachine") + var health: Node = player.get_node("HealthComponent") + health.call("set_values", 1000, 1000) + + # --- 攒满 7 层,受击进入受伤状态:掉 3 层(floor(7/2)),获得 1.5s 霸体 --- + for index: int in range(7): + container.call("add_effect", buff.get("buff_definition"), &"test") + _expect_int(int(buff.call("attack_buff_stacks")), 7, "test setup reaches 7 stacks") + + health.call("receive_hit", {"damage": 10, "interrupts": true}) + _expect_equal(state_machine.call("get_life_state"), &"Hitstun", "interrupting hit enters the hurt state") + _expect_int(int(buff.call("attack_buff_stacks")), 4, "hurt state drops floor(7/2)=3 stacks") + _expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 1, "hurt grants the super-armor effect") + _expect_float(_armor_remaining(container), 1.5, "3 dropped stacks buy 3 x 0.5s of armor") + + # --- 霸体期间:防御结算变为 SuperArmor,不打断、伤害照吃 --- + var defense: Dictionary = DefenseResolverScript.resolve_effective_defense({ + "defense_state": &"Vulnerable", + "effect_container": container, + }) + _expect_equal(defense.get("defense_state"), &"SuperArmor", "armor window resolves as SuperArmor") + _expect(not bool(defense.get("interrupts", true)), "armor window blocks interrupts") + _expect_float(float(defense.get("damage_mult", 0.0)), 1.0, "armor does not reduce damage") + + # --- 霸体期间的后续命中(interrupts 已被防御结算拦下)不再触发掉层 --- + state_machine.call("set_life_state", &"Alive") + health.call("receive_hit", {"damage": 10, "interrupts": bool(defense.get("interrupts", true))}) + _expect_equal(state_machine.call("get_life_state"), &"Alive", "armored hit does not re-enter the hurt state") + _expect_int(int(buff.call("attack_buff_stacks")), 4, "armored hit does not drop further stacks") + + # --- 霸体 + 格挡被背刺:背刺只破格挡姿态本身,霸体仍要兜底 --- + var back_hit_defense: Dictionary = DefenseResolverScript.resolve_effective_defense({ + "defense_state": &"Parrying", + "attack_from_front": false, + "effect_container": container, + }) + _expect_equal(back_hit_defense.get("defense_state"), &"SuperArmor", "a back-hit on a blocking, armored player falls back to SuperArmor") + _expect(not bool(back_hit_defense.get("interrupts", true)), "the armored back-hit still cannot interrupt") + + # --- 霸体按秒到期 --- + container.call("tick_time", 1.6) + _expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 0, "armor expires after its duration") + + # --- 层数不足:1 层掉 floor(0.5)=0 层,不给霸体 --- + container.call("set_effect_stacks", &"time_anchor_attack_buff", 1) + state_machine.call("set_life_state", &"Hitstun") + _expect_int(int(buff.call("attack_buff_stacks")), 1, "a single stack is too few to drop") + _expect_int(int(container.call("effect_stacks", &"hurt_super_armor")), 0, "no dropped stacks means no armor") + + player.free() + _finish() + + +func _armor_remaining(container: Node) -> float: + var summaries: Array = container.call("active_effect_summaries") + for summary: Dictionary in summaries: + if StringName(str(summary.get("id"))) == &"hurt_super_armor": + return float(summary.get("remaining", -1.0)) + return -1.0 + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String, tolerance := 0.05) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS hurt buff drop and super armor") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_hurt_buff_drop_and_super_armor.gd.uid b/tests/test_hurt_buff_drop_and_super_armor.gd.uid new file mode 100644 index 0000000..966738f --- /dev/null +++ b/tests/test_hurt_buff_drop_and_super_armor.gd.uid @@ -0,0 +1 @@ +uid://qthttlqas1cf diff --git a/tests/test_input_component_intents.gd b/tests/test_input_component_intents.gd new file mode 100644 index 0000000..2c4a3dc --- /dev/null +++ b/tests/test_input_component_intents.gd @@ -0,0 +1,100 @@ +extends SceneTree + +var failures: Array[String] = [] +var intents: Array = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var component_script: Script = load("res://scenes/components/input_component.gd") + _expect(component_script != null, "InputComponent script should load") + if component_script == null: + _finish() + return + + var component: Node = component_script.new() + root.add_child(component) + await process_frame + _expect(component.has_signal("intent_created"), "InputComponent should expose intent_created") + if component.has_signal("intent_created"): + component.connect("intent_created", _on_intent_created) + + var expected := { + KEY_A: [&"A", &"a"], + KEY_D: [&"D", &"d"], + KEY_W: [&"W", &"w"], + KEY_S: [&"S", &"s"], + KEY_SPACE: [&"SP", &"space"], + } + for key: Key in expected: + var before := intents.size() + var handled: bool = component.call("handle_input_event", _key_event(key, true, false, false)) + _expect_bool(handled, true, "%s keycode press should be handled" % OS.get_keycode_string(key)) + _expect_int(intents.size(), before + 1, "%s press intent count" % OS.get_keycode_string(key)) + if intents.size() == before + 1: + _expect_string(str(intents[before].get("symbol")), str(expected[key][0]), "%s symbol" % OS.get_keycode_string(key)) + _expect_string(str(intents[before].get("rhythm_action")), str(expected[key][1]), "%s rhythm action" % OS.get_keycode_string(key)) + _expect_string(str(intents[before].get("event_type")), "pressed", "%s event type" % OS.get_keycode_string(key)) + _expect_bool(float(intents[before].get("timestamp_ms")) > 0.0, true, "%s timestamp" % OS.get_keycode_string(key)) + before = intents.size() + handled = component.call("handle_input_event", _key_event(key, true, false, true)) + _expect_bool(handled, true, "%s physical key press should be handled" % OS.get_keycode_string(key)) + _expect_int(intents.size(), before + 1, "%s physical press intent count" % OS.get_keycode_string(key)) + + var echo_count := intents.size() + var echo_handled: bool = component.call("handle_input_event", _key_event(KEY_A, true, true, false)) + _expect_bool(echo_handled, false, "echo press should not be handled") + _expect_int(intents.size(), echo_count, "echo press should not emit intent") + + var release_handled: bool = component.call("handle_input_event", _key_event(KEY_A, false, false, false)) + _expect_bool(release_handled, true, "A release should be handled") + _expect_string(str(intents[intents.size() - 1].get("event_type")), "released", "release event type") + _expect_string(str(intents[intents.size() - 1].get("symbol")), "A", "release symbol") + component.free() + _finish() + + +func _on_intent_created(intent) -> void: + intents.append(intent) + + +func _key_event(key: Key, pressed: bool, echo: bool, physical_only: bool) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = KEY_NONE if physical_only else key + event.physical_keycode = key if physical_only else KEY_NONE + event.pressed = pressed + event.echo = echo + return event + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS input component intents") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_input_component_intents.gd.uid b/tests/test_input_component_intents.gd.uid new file mode 100644 index 0000000..44b6b49 --- /dev/null +++ b/tests/test_input_component_intents.gd.uid @@ -0,0 +1 @@ +uid://1ouwybdpl40q diff --git a/tests/test_input_gate.gd b/tests/test_input_gate.gd new file mode 100644 index 0000000..c45af99 --- /dev/null +++ b/tests/test_input_gate.gd @@ -0,0 +1,229 @@ +extends SceneTree + +## new1 定案 (2026-07-05): 节奏点一次性消耗 + 双击容错 + 空按 MISS 触发 0.5s +## 输入锁定,锁定期间按键完全无效;预置判定(测试/AI)绕过门控。 + +var failures: Array[String] = [] +var started: Array[StringName] = [] +var rejected: Array[StringName] = [] +var judged: Array[Dictionary] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _run_rhythm_gate_unit() + await _run_controller_integration() + _finish() + + +func _run_rhythm_gate_unit() -> void: + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + _expect(rhythm_script != null, "RhythmManager script should load") + if rhythm_script == null: + return + var rhythm: Node = rhythm_script.new() + rhythm.set("starts_on_ready", false) + # 钉住时钟:running + 冻结 song_position,锁定时长可被推进的时间跨过。 + rhythm.set("running", true) + rhythm.set("_clock_paused", true) + rhythm.set("_paused_song_position", 2.0) + rhythm.set("input_lockout_seconds", 0.5) + + _expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 4))), "ok", "first press on beat 4 should pass") + _expect_string(str(rhythm.call("gate_judged_input", _rating("good", 4))), "ignored", "double-tap on consumed beat 4 should be ignored once") + _expect_string(str(rhythm.call("gate_judged_input", _rating("good", 4))), "miss", "third press on consumed beat 4 should downgrade to miss") + _expect_bool(bool(rhythm.call("is_input_locked")), true, "downgraded miss should start the input lockout") + _expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 5))), "ignored", "press during lockout should be ignored even if on-time") + rhythm.set("_paused_song_position", 2.6) + _expect_bool(bool(rhythm.call("is_input_locked")), false, "lockout should expire after input_lockout_seconds") + _expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 5))), "ok", "beat 5 should be consumable after lockout expires") + _expect_string(str(rhythm.call("gate_judged_input", _rating("miss", 6))), "miss", "off-window press should stay miss") + _expect_bool(bool(rhythm.call("is_input_locked")), true, "off-window miss should start the input lockout") + rhythm.set("_paused_song_position", 3.2) + _expect_string(str(rhythm.call("gate_judged_input", _rating("bad", 7))), "ok", "fresh beat after lockout should pass") + rhythm.call("start") + rhythm.set("running", true) + rhythm.set("_clock_paused", true) + rhythm.set("_paused_song_position", 2.0) + _expect_string(str(rhythm.call("gate_judged_input", _rating("perfect", 4))), "ok", "start() should clear consumed beats and lockout") + rhythm.free() + + +func _run_controller_integration() -> void: + var autoload_rhythm := root.get_node_or_null("RhythmManager") + _expect(autoload_rhythm != null, "RhythmManager autoload should exist") + if autoload_rhythm == null: + return + # 钉住全局时钟,让门控/锁定完全确定。 + if bool(autoload_rhythm.get("playing")): + autoload_rhythm.call("stop") + autoload_rhythm.set("running", true) + autoload_rhythm.set("_clock_paused", true) + autoload_rhythm.set("_paused_song_position", 10.0) + autoload_rhythm.set("_lockout_until", -1.0) + autoload_rhythm.call("_prune_consumed_beats", 0) + (autoload_rhythm.get("_consumed_beats") as Dictionary).clear() + autoload_rhythm.set("input_lockout_seconds", 0.5) + + var fixture := await _controller_fixture() + if fixture.is_empty(): + return + var controller: Node = fixture["controller"] + var combo: Node = fixture["combo"] + controller.connect("action_started", _on_action_started) + controller.connect("action_rejected", _on_action_rejected) + _event_bus().connect("judgement_made", _on_judgement_made) + + controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20)) + _expect_array(combo.call("get_slots"), [&"A"], "live perfect should enter ComboWindow") + _expect_int(started.size(), 1, "live perfect should start an action") + + controller.call("_reset_to_idle") + var judged_before := judged.size() + controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20)) + _expect_array(combo.call("get_slots"), [&"A"], "double-tap on consumed beat should leave slots untouched") + _expect_int(started.size(), 1, "double-tap on consumed beat should not start an action") + _expect_string(str(rejected[rejected.size() - 1]), "input_ignored", "double-tap should reject as input_ignored") + _expect_int(judged.size(), judged_before, "ignored input should emit no judgement feedback") + + controller.call("_reset_to_idle") + controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 20)) + _expect_array(combo.call("get_slots"), [&"A", &"Ø"], "third press on consumed beat should record a miss slot") + _expect_string(str(judged[judged.size() - 1].get("quality")), "miss", "third press should emit a miss judgement") + _expect_bool(bool(autoload_rhythm.call("is_input_locked")), true, "downgraded miss should lock input") + + controller.call("_reset_to_idle") + controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 21)) + _expect_string(str(rejected[rejected.size() - 1]), "input_locked", "press during lockout should reject as input_locked") + _expect_array(combo.call("get_slots"), [&"A", &"Ø"], "locked press should leave no trace in ComboWindow") + _expect_int(started.size(), 1, "locked press should not start an action") + + autoload_rhythm.set("_paused_song_position", 10.6) + controller.call("_reset_to_idle") + controller.call("submit_intent", _live_intent(&"A", &"a", "perfect", 21)) + _expect_int(started.size(), 2, "press after lockout expiry should start an action") + + # 预置判定(无 live 标记)必须绕过门控:已消耗的拍也照常执行。 + controller.call("_reset_to_idle") + controller.call("submit_intent", _pinned_intent(&"A", &"a", "perfect", 20)) + _expect_int(started.size(), 3, "pinned judgement should bypass the gate even on a consumed beat") + + autoload_rhythm.set("_lockout_until", -1.0) + (autoload_rhythm.get("_consumed_beats") as Dictionary).clear() + fixture["root"].free() + + +func _rating(label: String, beat: int) -> Dictionary: + return {"label": label, "diff": 0.0, "abs_diff": 0.0, "nearest_beat": beat} + + +func _live_intent(symbol: StringName, rhythm_action: StringName, label: String, beat: int) -> RefCounted: + var intent := _pinned_intent(symbol, rhythm_action, label, beat) + var judgement: Dictionary = intent.get("judgement") + judgement["live"] = true + intent.set("judgement", judgement) + return intent + + +func _pinned_intent(symbol: StringName, rhythm_action: StringName, label: String, beat: int) -> RefCounted: + var intent_script: Script = load("res://scenes/components/input_intent.gd") + var intent: RefCounted = intent_script.call("create", symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) + intent.set("judgement", _rating(label, beat)) + return intent + + +func _controller_fixture() -> Dictionary: + var combo_script: Script = load("res://scenes/components/combo_window.gd") + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + var state_script: Script = load("res://scenes/components/state_machine.gd") + var energy_script: Script = load("res://scenes/components/energy_component.gd") + var controller_script: Script = load("res://scenes/components/action_controller.gd") + if combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null: + _expect(false, "fixture scripts should load") + return {} + var fixture_root := Node.new() + root.add_child(fixture_root) + var combo: Node = combo_script.new() + combo.name = "ComboWindow" + fixture_root.add_child(combo) + var resolver: Node = resolver_script.new() + resolver.name = "ActionResolver" + fixture_root.add_child(resolver) + var state: Node = state_script.new() + state.name = "StateMachine" + fixture_root.add_child(state) + var energy: Node = energy_script.new() + energy.name = "EnergyComponent" + fixture_root.add_child(energy) + var controller: Node = controller_script.new() + controller.name = "ActionController" + controller.set("combo_window_path", NodePath("../ComboWindow")) + controller.set("action_resolver_path", NodePath("../ActionResolver")) + controller.set("state_machine_path", NodePath("../StateMachine")) + fixture_root.add_child(controller) + await process_frame + energy.call("set_values", 99, 99) + return { + "root": fixture_root, + "combo": combo, + "controller": controller, + } + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _on_action_rejected(_intent, reason: StringName) -> void: + rejected.append(reason) + + +func _on_judgement_made(quality: StringName, offset_ms: float, beat_index: int) -> void: + judged.append({"quality": quality, "offset_ms": offset_ms, "beat": beat_index}) + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS input gate (new1 consume/lockout)") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_input_gate.gd.uid b/tests/test_input_gate.gd.uid new file mode 100644 index 0000000..70d29d1 --- /dev/null +++ b/tests/test_input_gate.gd.uid @@ -0,0 +1 @@ +uid://bah2t3bddtgea diff --git a/tests/test_judgement_payload.gd b/tests/test_judgement_payload.gd new file mode 100644 index 0000000..e15d8ce --- /dev/null +++ b/tests/test_judgement_payload.gd @@ -0,0 +1,65 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var bus_script: Script = load("res://autoload/event_bus.gd") + _expect(bus_script != null, "EventBus script should load") + if bus_script != null: + var legacy_signal := StringName("rhythm" + "_action_requested") + var bus: Node = bus_script.new() + _expect_int(_signal_arg_count(bus, "judgement_made"), 3, "EventBus.judgement_made should expose quality, offset_ms, beat_index") + _expect(not bus.has_signal(legacy_signal), "EventBus should not expose the legacy rhythm action request signal") + bus.free() + + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + _expect(rhythm_script != null, "RhythmManager script should load") + if rhythm_script != null: + var legacy_method := StringName("judge" + "_action") + var rhythm: Node = rhythm_script.new() + _expect(not rhythm.has_method(legacy_method), "RhythmManager should not expose the legacy action judgement method") + _expect(_has_property(rhythm, "clock_volume_db"), "RhythmManager should expose clock_volume_db") + var rating: Dictionary = rhythm.call("judge", 0.0) + _expect(rating.has("nearest_beat"), "RhythmManager.judge should return nearest_beat") + _expect(typeof(rating.get("nearest_beat")) == TYPE_INT, "nearest_beat should be an int") + rhythm.free() + _finish() + + +func _signal_arg_count(node: Node, signal_name: StringName) -> int: + for signal_info: Dictionary in node.get_signal_list(): + if StringName(str(signal_info.get("name"))) == signal_name: + return (signal_info.get("args") as Array).size() + return -1 + + +func _has_property(object: Object, property_name: StringName) -> bool: + for property: Dictionary in object.get_property_list(): + if StringName(str(property.get("name"))) == property_name: + return true + return false + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS judgement payload") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_judgement_payload.gd.uid b/tests/test_judgement_payload.gd.uid new file mode 100644 index 0000000..ea2b575 --- /dev/null +++ b/tests/test_judgement_payload.gd.uid @@ -0,0 +1 @@ +uid://7e5r7oommir5 diff --git a/tests/test_level1_melee_contact.gd b/tests/test_level1_melee_contact.gd new file mode 100644 index 0000000..cdf096c --- /dev/null +++ b/tests/test_level1_melee_contact.gd @@ -0,0 +1,132 @@ +extends SceneTree + +## Regression for the first Level 1 contact: body blocking must not leave the +## opening melee minion outside the player's basic attack hit window. + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + + await _check_inactive_boss_is_not_front_area_body_wall() + await _check_body_blocked_contact_hits() + await _check_opening_sequence_can_reach_minion() + _finish() + + +func _check_inactive_boss_is_not_front_area_body_wall() -> void: + var stage := await _make_stage() + if stage == null: + return + var boss := stage.get_node("ActorsContainer/Boss") as CharacterBody2D + var receiver := boss.get_node("DamageReceiver") as Area2D + + _expect( + (int(boss.collision_layer) & (1 << 6)) == 0, + "Inactive Level 1 Boss should not occupy the enemy_body layer before boss-room entry" + ) + _expect( + not receiver.monitorable, + "Inactive Level 1 Boss should not expose a damage receiver before boss-room entry" + ) + + boss.set("combat_enabled", true) + await physics_frame + _expect( + (int(boss.collision_layer) & (1 << 6)) != 0, + "Boss should restore enemy_body collision when boss combat unlocks" + ) + _expect(receiver.monitorable, "Boss damage receiver should restore when boss combat unlocks") + stage.free() + + +func _check_body_blocked_contact_hits() -> void: + var stage := await _make_stage() + if stage == null: + return + var container := stage.get_node("ActorsContainer") + var player := container.get_node("Player") as Node2D + var minion := container.get_node("JinZhanMinion") as Node2D + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + var health := minion.get_node("HealthComponent") + + player.global_position = Vector2(minion.global_position.x - 20.0, minion.global_position.y) + player.set("heading", Vector2.RIGHT) + await physics_frame + var start_health := int(health.get("current")) + + player.call("submit_combo_input", "D", "perfect") + await create_timer(0.8).timeout + await physics_frame + + _expect( + int(health.get("current")) < start_health, + "Body-blocked Level 1 melee contact should still be hit by D basic attack" + ) + + stage.free() + + +func _check_opening_sequence_can_reach_minion() -> void: + var stage := await _make_stage() + if stage == null: + return + var container := stage.get_node("ActorsContainer") + var player := container.get_node("Player") as Node2D + var minion := container.get_node("JinZhanMinion") as Node2D + var health := minion.get_node("HealthComponent") + var start_health := int(health.get("current")) + + # new2 出生点改到地图最左侧后,玩家需要先推进一段路才遇敌;这里直接把 + # 玩家放到近战怪的追击停步距离(120px)上,验证接敌后的连招能够命中。 + player.global_position = Vector2(minion.global_position.x - 120.0, minion.global_position.y) + player.set("heading", Vector2.RIGHT) + await physics_frame + + for _index: int in range(5): + player.call("submit_combo_input", "D", "perfect") + await create_timer(0.65).timeout + await physics_frame + + _expect( + int(health.get("current")) < start_health, + "Approach-range Level 1 sequence should be able to damage the first minion" + ) + stage.free() + + +func _make_stage() -> Node: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(stage_scene != null, "stage.tscn should load") + if stage_scene == null: + return null + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + await physics_frame + return stage + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS level1 melee contact") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_level1_melee_contact.gd.uid b/tests/test_level1_melee_contact.gd.uid new file mode 100644 index 0000000..57b985f --- /dev/null +++ b/tests/test_level1_melee_contact.gd.uid @@ -0,0 +1 @@ +uid://c3uno3t3v06al diff --git a/tests/test_level1_spawn_flow.gd b/tests/test_level1_spawn_flow.gd new file mode 100644 index 0000000..837597e --- /dev/null +++ b/tests/test_level1_spawn_flow.gd @@ -0,0 +1,182 @@ +extends SceneTree + +## 第一关出怪流程(关卡设计指南 §7/§8): +## 阶段一预置近战怪 → 击杀后阶段二在玩家附近刷远程怪 → 再击杀后阶段三 +## 左右交替增援 2近战+2远程 → 全灭 6 只解封 Boss 房门。 + +var failures: Array[String] = [] +var cleared_signals := 0 + +const MINION_GATE_CLEARANCE := 220.0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + var bus := _ensure_event_bus() + if bus.has_signal("front_area_cleared"): + bus.connect("front_area_cleared", func() -> void: cleared_signals += 1) + + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(stage_scene != null, "stage.tscn should load") + if stage_scene == null: + _finish() + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + await process_frame + + var director: Node = stage.get_node_or_null("LevelDirector") + var gate: Node = stage.get_node_or_null("BossRoomGate") + var container: Node = stage.get_node("ActorsContainer") + var player: Node2D = container.get_node("Player") + _expect(director != null, "Stage should carry a LevelDirector") + _expect(gate != null, "Stage should carry a BossRoomGate") + if director == null or gate == null: + stage.free() + _finish() + return + director.set("reinforcement_time_scale", 0.01) + + # ---- 阶段一:只有 1 只预置近战怪,Boss 房门封锁 ---- + var initial := _alive_minions(container) + _expect_int(initial.size(), 1, "Phase 1 should start with exactly one pre-placed minion") + if initial.size() == 1: + _expect_equal(initial[0].get("strength_profile"), &"past_strong", "The pre-placed minion should be the melee (past-strong) one") + _expect_minions_left_of_gate(initial, gate, "Initial pre-placed minion") + var initial_behavior := initial[0].get_node_or_null("MinionBehavior") + if initial_behavior != null: + _expect_float(float(initial_behavior.get("arena_max_x")), float(gate.get("gate_x")) - MINION_GATE_CLEARANCE, "Initial pre-placed minion should use the front-area safe patrol max") + _expect(bool(gate.get("sealed")), "Gate should start sealed") + var wall_shape := gate.get_node("GateWall").get_child(0) as CollisionShape2D + _expect(not wall_shape.disabled, "Sealed gate should enable its blocking wall") + _expect_int(int(director.get("phase")), 1, "Director should start in phase 1") + + # ---- 击杀近战怪 → 阶段二远程怪在玩家附近刷新(可感知、不贴脸)---- + _kill(initial[0]) + await process_frame + _expect_int(int(director.get("phase")), 2, "First kill should advance to phase 2") + var phase2 := _alive_minions(container) + _expect_int(phase2.size(), 1, "Phase 2 should spawn exactly one new minion") + if phase2.size() == 1: + var ranged: Node2D = phase2[0] + _expect_equal(ranged.get("strength_profile"), &"future_strong", "Phase 2 minion should be the ranged (future-strong) one") + _expect_minions_left_of_gate(phase2, gate, "Phase 2 minion") + var distance := absf(ranged.global_position.x - player.global_position.x) + _expect(distance >= 200.0 and distance <= 480.0, "Ranged minion should spawn near but not on the player (got %.0f px)" % distance) + var behavior := ranged.get_node_or_null("MinionBehavior") + _expect(behavior != null and behavior.get("role") == &"ranged", "Phase 2 minion behavior should use the ranged role") + + # ---- 击杀远程怪 → 阶段三增援:近战L → 远程R → 近战R → 远程L ---- + _kill(ranged) + await process_frame + _expect_int(int(director.get("phase")), 3, "Second kill should advance to phase 3") + # 缩放后的增援间隔与 headless 帧长同数量级:只断言 0 秒那只已经 + # 立即出现,后续数量交给下面“全部四只”断言覆盖。 + var first_wave := _alive_minions(container) + _expect(first_wave.size() >= 1, "First reinforcement should spawn immediately (got %d)" % first_wave.size()) + if first_wave.size() >= 1: + _expect_equal(first_wave[0].get("strength_profile"), &"past_strong", "First reinforcement should be melee") + _expect(first_wave[0].global_position.x < 1500.0, "First reinforcement should enter from the LEFT edge") + # 其余三只按 0.01 缩放的间隔陆续出现。 + await create_timer(0.5).timeout + await process_frame + var reinforcements := _alive_minions(container) + _expect_int(reinforcements.size(), 4, "All four reinforcements should have spawned") + _expect_minions_left_of_gate(reinforcements, gate, "Phase 3 reinforcement") + _expect_int(int(director.get("total_spawned")), 6, "Level should field six minions in total") + var melee_count := 0 + var ranged_count := 0 + for minion: Node2D in reinforcements: + if minion.get("strength_profile") == &"past_strong": + melee_count += 1 + else: + ranged_count += 1 + _expect_int(melee_count, 2, "Reinforcements should include two melee minions") + _expect_int(ranged_count, 2, "Reinforcements should include two ranged minions") + + # ---- 全灭 → 解封 Boss 房门 ---- + _expect(bool(gate.get("sealed")), "Gate must stay sealed while reinforcements live") + for minion: Node2D in reinforcements: + _kill(minion) + await process_frame + await process_frame + _expect(bool(director.get("cleared")), "Director should report the front area cleared") + _expect(not bool(gate.get("sealed")), "Clearing all six minions should unseal the gate") + _expect(wall_shape.disabled, "Unsealed gate should disable its blocking wall") + _expect_int(cleared_signals, 1, "front_area_cleared should broadcast exactly once") + + stage.free() + _finish() + + +func _alive_minions(container: Node) -> Array[Node2D]: + var result: Array[Node2D] = [] + for child: Node in container.get_children(): + if not child.is_in_group("enemies"): + continue + var health := child.get_node_or_null("HealthComponent") + if health != null and int(health.get("current")) > 0: + result.append(child as Node2D) + return result + + +func _kill(minion: Node) -> void: + var health := minion.get_node_or_null("HealthComponent") + if health != null: + health.call("apply_damage", 999999) + + +func _expect_minions_left_of_gate(minions: Array[Node2D], gate: Node, label: String) -> void: + var limit := float(gate.get("gate_x")) - MINION_GATE_CLEARANCE + for minion: Node2D in minions: + _expect( + minion.global_position.x <= limit, + "%s should stay left of the boss-room air wall safety line %.0f, got %.0f" % [label, limit, minion.global_position.x] + ) + + +func _ensure_event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS level1 spawn flow") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_level1_spawn_flow.gd.uid b/tests/test_level1_spawn_flow.gd.uid new file mode 100644 index 0000000..e9317fa --- /dev/null +++ b/tests/test_level1_spawn_flow.gd.uid @@ -0,0 +1 @@ +uid://hyqpwd2tiigq diff --git a/tests/test_main_ui_camera_canvas.gd b/tests/test_main_ui_camera_canvas.gd new file mode 100644 index 0000000..972dbfc --- /dev/null +++ b/tests/test_main_ui_camera_canvas.gd @@ -0,0 +1,51 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var main_scene: PackedScene = load("res://scenes/main/main.tscn") + if main_scene == null: + failures.append("main.tscn should load") + _finish() + return + var main := main_scene.instantiate() + root.add_child(main) + await process_frame + + _expect_bool(main.get_node_or_null("UILayer") is CanvasLayer, true, "Main UI should stay in the x1 CanvasLayer") + var ui := main.get_node_or_null("UILayer/UI") as Control + _expect_bool(ui != null, true, "Main UI should be under UILayer/UI") + if ui != null: + _expect_vector(ui.position, Vector2.ZERO, "Main UI should start at the viewport origin inside the CanvasLayer") + _expect_vector(ui.size, Vector2(1152.0, 648.0), "Main UI should fill the viewport") + _expect_vector(ui.scale, Vector2.ONE, "Main UI should not counter-scale against the world camera") + + main.queue_free() + await process_frame + _finish() + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.03: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS main ui camera canvas") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_main_ui_camera_canvas.gd.uid b/tests/test_main_ui_camera_canvas.gd.uid new file mode 100644 index 0000000..81631cd --- /dev/null +++ b/tests/test_main_ui_camera_canvas.gd.uid @@ -0,0 +1 @@ +uid://c24pli2m867qe diff --git a/tests/test_menu_difficulty_confirm.gd b/tests/test_menu_difficulty_confirm.gd new file mode 100644 index 0000000..33448ba --- /dev/null +++ b/tests/test_menu_difficulty_confirm.gd @@ -0,0 +1,99 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var settings := _ensure_game_settings() + settings.call("set_difficulty", &"normal") + var flow: Node = load("res://autoload/game_flow_manager.gd").new() + flow.name = "FlowUnderTest" + flow.set("manage_scene", false) + root.add_child(flow) + await process_frame + flow.call("_set_state", &"Title") + await process_frame + _expect_equal(StringName(str(flow.get("state"))), &"Title", "Flow should start on title") + var title_screen := flow.get("_screens").get(&"Title", null) as Control + _expect(title_screen != null, "Title screen should exist") + if title_screen != null: + _expect(title_screen.get_node_or_null("MenuArt") is TextureRect, "Title should use the panel frame art") + _expect(title_screen.get_node_or_null("TitleLogo") is TextureRect, "Title should show the game logo art") + _expect(title_screen.get_node_or_null("TitleDifficultyValue") is Label, "Title should show the current-difficulty footer (2026-07-05 mockup)") + _expect(title_screen.get_node_or_null("StartGameButton") is Button, "Title should expose the start button") + _expect(title_screen.get_node_or_null("DifficultyButton") is Button, "Title should expose the difficulty button") + _expect(title_screen.get_node_or_null("QuitGameButton") is Button, "Title should expose the quit button") + + await _click(Vector2(191, 355)) + _expect_equal(StringName(str(flow.get("state"))), &"DifficultySelect", "Clicking difficulty opens the difficulty page") + var difficulty_screen := flow.get("_screens").get(&"DifficultySelect", null) as Control + _expect(difficulty_screen != null, "Difficulty screen should exist") + if difficulty_screen != null: + _expect(difficulty_screen.get_node_or_null("MenuArt") is TextureRect, "Difficulty should use the panel frame art") + _expect(difficulty_screen.get_node_or_null("DifficultyFooterValue") is Label, "Difficulty screen should show the current-difficulty footer (2026-07-05 mockup)") + _expect(difficulty_screen.get_node_or_null("ConfirmStartButton") is Button, "Difficulty should expose the start command") + _expect(difficulty_screen.get_node_or_null("BackTitleButton") is Button, "Difficulty should expose the back command") + + await _click(Vector2(191, 221)) + _expect_equal(StringName(str(flow.get("_pending_difficulty"))), &"easy", "Clicking easy selects the easy difficulty") + _expect_equal(StringName(str(settings.get("difficulty"))), &"easy", "Clicking easy applies the selected difficulty immediately") + + await _click(Vector2(191, 558)) + _expect_equal(StringName(str(flow.get("state"))), &"Title", "Clicking the reference back button returns to title") + + flow.free() + _finish() + + +func _click(position: Vector2) -> void: + var motion := InputEventMouseMotion.new() + motion.position = position + root.push_input(motion) + await process_frame + var press := InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.position = position + press.pressed = true + root.push_input(press) + await process_frame + var release := InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + release.position = position + release.pressed = false + root.push_input(release) + await process_frame + + +func _ensure_game_settings() -> Node: + var settings := root.get_node_or_null("GameSettings") + if settings != null: + return settings + settings = load("res://autoload/game_settings.gd").new() + settings.name = "GameSettings" + root.add_child(settings) + return settings + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS menu difficulty confirm") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_menu_difficulty_confirm.gd.uid b/tests/test_menu_difficulty_confirm.gd.uid new file mode 100644 index 0000000..d050984 --- /dev/null +++ b/tests/test_menu_difficulty_confirm.gd.uid @@ -0,0 +1 @@ +uid://doye5hjetb20k diff --git a/tests/test_migration_phase0_1.gd b/tests/test_migration_phase0_1.gd new file mode 100644 index 0000000..61a99eb --- /dev/null +++ b/tests/test_migration_phase0_1.gd @@ -0,0 +1,147 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_phase0_files() + _check_directories() + _check_project_settings() + _check_phase1_files() + _finish() + + +func _check_phase0_files() -> void: + _expect(_file_contains("res://.gitignore", ".godot/"), ".gitignore should ignore .godot/") + _expect(_file_contains("res://.gitignore", ".DS_Store"), ".gitignore should ignore .DS_Store") + _expect(_file_contains("res://.gitignore", "*.import.tmp"), ".gitignore should ignore *.import.tmp") + _expect(FileAccess.file_exists("res://README.md"), "README.md should exist") + _expect(_file_contains("res://docs/decisions.md", "body collision audit"), "docs/decisions.md should record the body collision audit") + _expect(_file_contains("res://docs/decisions.md", "player_body"), "body collision audit should record player_body layer decision") + _expect(_file_contains("res://docs/decisions.md", "enemy_body"), "body collision audit should record enemy_body layer decision") + + +func _check_directories() -> void: + for dir_path: String in [ + "res://autoload", + "res://scripts/resolvers", + "res://resources/actions", + "res://resources/actions/enemies", + "res://resources/effects", + "res://resources/charts", + "res://scenes/characters", + "res://scenes/chart", + "res://scenes/combat", + "res://scenes/components", + "res://scenes/enemies", + "res://scenes/ground", + "res://scenes/main", + "res://scenes/stage", + "res://scenes/ui", + "res://assets/art", + "res://assets/audio", + "res://assets/ui", + "res://tools", + "res://tests", + "res://docs", + ]: + _expect(DirAccess.open(dir_path) != null, "%s should exist" % dir_path) + _expect(DirAccess.open("res://resources/effects/time_phase") != null, "resources/effects/time_phase should contain implemented time-phase effects") + + +func _check_project_settings() -> void: + var expected_layers := { + "layer_names/2d_physics/layer_1": "world", + "layer_names/2d_physics/layer_2": "player_hurtbox", + "layer_names/2d_physics/layer_3": "enemy_hurtbox", + "layer_names/2d_physics/layer_4": "player_hitbox", + "layer_names/2d_physics/layer_5": "enemy_hitbox", + "layer_names/2d_physics/layer_6": "player_body", + "layer_names/2d_physics/layer_7": "enemy_body", + } + for key: String in expected_layers: + _expect(ProjectSettings.has_setting(key), "%s should be configured" % key) + if ProjectSettings.has_setting(key): + _expect_equal(str(ProjectSettings.get_setting(key)), expected_layers[key], "%s name" % key) + + for autoload_name: String in ["EventBus", "RhythmManager"]: + var key := "autoload/%s" % autoload_name + _expect(ProjectSettings.has_setting(key), "%s should be registered as an autoload" % autoload_name) + if ProjectSettings.has_setting("autoload/EventBus"): + _expect(str(ProjectSettings.get_setting("autoload/EventBus")).contains("res://autoload/event_bus.gd"), "EventBus autoload path") + if ProjectSettings.has_setting("autoload/RhythmManager"): + _expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload path") + + _expect_action_unbound("move_left") + _expect_action_unbound("move_right") + _expect_action_key("combo_a", KEY_A) + _expect_action_key("combo_d", KEY_D) + _expect_action_key("combo_w", KEY_W) + _expect_action_key("combo_s", KEY_S) + _expect_action_key("combo_space", KEY_SPACE) + + +func _check_phase1_files() -> void: + _expect(FileAccess.file_exists("res://autoload/event_bus.gd"), "event_bus.gd should be migrated") + _expect(FileAccess.file_exists("res://autoload/event_bus.gd.uid"), "event_bus.gd.uid should be migrated") + _expect(FileAccess.file_exists("res://autoload/rhythm_manager.gd"), "rhythm_manager.gd should be migrated") + _expect(FileAccess.file_exists("res://autoload/rhythm_manager.gd.uid"), "rhythm_manager.gd.uid should be migrated") + _expect(FileAccess.file_exists("res://assets/audio/song.ogg"), "song.ogg should be migrated") + _expect(FileAccess.file_exists("res://assets/audio/song.ogg.import"), "song.ogg.import should be migrated") + + +func _expect_action_unbound(action_name: StringName) -> void: + _expect(InputMap.has_action(action_name), "%s action should exist" % action_name) + if InputMap.has_action(action_name): + _expect_int(InputMap.action_get_events(action_name).size(), 0, "%s should be registered but physically unbound" % action_name) + + +func _expect_action_key(action_name: StringName, keycode: Key) -> void: + _expect(InputMap.has_action(action_name), "%s action should exist" % action_name) + var found := false + if InputMap.has_action(action_name): + for event: InputEvent in InputMap.action_get_events(action_name): + var key := event as InputEventKey + if key != null and (key.physical_keycode == keycode or key.keycode == keycode): + found = true + _expect(found, "%s should bind %s" % [action_name, OS.get_keycode_string(keycode)]) + + +func _file_contains(path: String, needle: String) -> bool: + if not FileAccess.file_exists(path): + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return false + var text := file.get_as_text() + file.close() + return text.contains(needle) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual, expected, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, str(expected), str(actual)]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS migration phase 0/1") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_migration_phase0_1.gd.uid b/tests/test_migration_phase0_1.gd.uid new file mode 100644 index 0000000..494fd29 --- /dev/null +++ b/tests/test_migration_phase0_1.gd.uid @@ -0,0 +1 @@ +uid://la6ssnq52fkv diff --git a/tests/test_minion_behavior_states.gd b/tests/test_minion_behavior_states.gd new file mode 100644 index 0000000..d623a4c --- /dev/null +++ b/tests/test_minion_behavior_states.gd @@ -0,0 +1,115 @@ +extends SceneTree + +## 小怪行为状态机运行时验证(docs/Level1Design.md §2): +## 近战怪:玩家在警戒外巡逻 → 玩家进入警戒后下一拍追击 → 进入攻击距离按拍出手。 +## 远程怪:站位固定 → 玩家近身过久触发后撤 → 拉开距离后回到站位固定。 + +var failures: Array[String] = [] +var started_actions := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + var bus := _ensure_event_bus() + var manager := _ensure_time_phase_manager() + + var stage: Node = (load("res://scenes/stage/stage.tscn") as PackedScene).instantiate() + root.add_child(stage) + await process_frame + manager.call("reset_to_initial", &"past") + await process_frame + + var container: Node = stage.get_node("ActorsContainer") + var player: Node2D = container.get_node("Player") + var melee: Node2D = container.get_node("JinZhanMinion") + var behavior: Node = melee.get_node("MinionBehavior") + var director: Node = stage.get_node("LevelDirector") + var ground_y := float(director.get("ground_y")) + + # ---- 巡逻:玩家在警戒范围外 ---- + player.global_position = Vector2(1250.0, ground_y) + for i: int in range(8): + await physics_frame + _expect_equal(behavior.get("state"), &"Patrol", "Melee minion should patrol while the player is outside alert range") + _expect(absf(float(melee.get("approach_direction"))) > 0.0, "Patrol should keep the minion strolling") + + # ---- 警戒 → 下一拍进入追击 ---- + player.global_position = Vector2(melee.global_position.x - 300.0, ground_y) + await physics_frame + await physics_frame + _expect_equal(behavior.get("state"), &"Patrol", "Alert alone must not change state before the next beat") + bus.emit_signal("beat_ticked", 100) + await physics_frame + _expect_equal(behavior.get("state"), &"Chase", "Melee minion should switch to Chase on the beat after alert") + + # ---- 进入攻击距离 → 按拍出手 ---- + var controller: Node = melee.get_node("ActionController") + controller.connect("action_started", func(_action: Resource, _intent: Variant) -> void: started_actions += 1) + player.global_position = Vector2(melee.global_position.x - 80.0, ground_y) + await physics_frame + bus.emit_signal("beat_ticked", 101) + await process_frame + _expect(started_actions >= 1, "Melee minion should attack on the beat once in range") + + # ---- 远程怪:站位固定 → 近身触发后撤 → 重新固定 ---- + var ranged: Node2D = director.call("spawn_minion", &"ranged", Vector2(2050.0, ground_y)) + var ranged_behavior: Node = ranged.get_node("MinionBehavior") + ranged_behavior.set("too_close_seconds", 0.2) + await process_frame + _expect_equal(ranged_behavior.get("state"), &"Hold", "Ranged minion should hold its station after spawning") + var station_x := ranged.global_position.x + player.global_position = Vector2(station_x - 60.0, ground_y) + await create_timer(0.5).timeout + var retreat_state: StringName = ranged_behavior.get("state") + _expect(retreat_state == &"Retreat" or ranged.global_position.x > station_x + 40.0, "Crowded ranged minion should retreat (state %s)" % retreat_state) + await create_timer(1.4).timeout + _expect_equal(ranged_behavior.get("state"), &"Hold", "Ranged minion should settle on a new station after retreating") + _expect(ranged.global_position.x > station_x + 100.0, "Retreat should actually gain distance (moved %.0f px)" % (ranged.global_position.x - station_x)) + + stage.free() + _finish() + + +func _ensure_event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _ensure_time_phase_manager() -> Node: + var manager := root.get_node_or_null("TimePhaseManager") + if manager == null: + manager = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + return manager + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS minion behavior states") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_minion_behavior_states.gd.uid b/tests/test_minion_behavior_states.gd.uid new file mode 100644 index 0000000..2ee2525 --- /dev/null +++ b/tests/test_minion_behavior_states.gd.uid @@ -0,0 +1 @@ +uid://ebs6yqox0x02 diff --git a/tests/test_minion_cadence_and_facing.gd b/tests/test_minion_cadence_and_facing.gd new file mode 100644 index 0000000..ece0653 --- /dev/null +++ b/tests/test_minion_cadence_and_facing.gd @@ -0,0 +1,374 @@ +extends SceneTree + +var failures: Array[String] = [] + +const FORM_NATIVE_FACING := { + &"jin_zhan_1": Vector2.LEFT, + &"jin_zhan_3": Vector2.RIGHT, + # 小女孩素材原生朝右:配置成 LEFT 曾导致她背对玩家攻击(镜像反了)。 + &"yuan_cheng_1": Vector2.RIGHT, + &"yuan_cheng_2": Vector2.RIGHT, +} + +## 远程弹道离 actor 原点的高度:可见脚底约 -40px,再加出手高度,对齐手部/法球。 +const RANGED_PROJECTILE_HEIGHT := 86.0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_attack_intervals() + await _check_form_native_facing() + await _check_animation_feet_are_ground_locked() + await _check_witch_projectile_stays_horizontal() + await _check_little_girl_projectile_stays_horizontal() + await _check_ranged_form_visual_heights_match() + await _check_projectile_visual_rotates_with_direction() + _finish() + + +func _check_attack_intervals() -> void: + var manager := _ensure_time_phase_manager() + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + _expect(scene != null, "Minion scene should load") + if scene == null: + return + + var past_strong := _spawn_form(scene, &"jin_zhan_3", &"jin_zhan_1", &"past_strong") + manager.call("reset_to_initial", &"past") + await process_frame + _expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "Strong minion phase should attack once every two beats") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "Weak minion phase should attack once every four beats") + past_strong.queue_free() + + var future_strong := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_2", &"future_strong") + manager.call("set_time_phase", &"past", &"debug") + await process_frame + _expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "Future-strong minion should be weak in Past and attack once every four beats") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "Future-strong minion should be strong in Future and attack once every two beats") + future_strong.queue_free() + + +func _check_form_native_facing() -> void: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + if scene == null: + return + for form_id: StringName in FORM_NATIVE_FACING.keys(): + var minion := _spawn_form(scene, form_id, form_id, &"past_strong") + await process_frame + var target := Node2D.new() + target.name = "Target" + root.add_child(target) + + minion.global_position = Vector2(100.0, 100.0) + target.global_position = Vector2(40.0, 100.0) + minion.call("look_at_target", target) + minion.call("flip_sprites") + _expect_visual_scale_for_heading(minion, form_id, Vector2.LEFT, "%s should face left when target is left" % form_id) + + target.global_position = Vector2(160.0, 100.0) + minion.call("look_at_target", target) + minion.call("flip_sprites") + _expect_visual_scale_for_heading(minion, form_id, Vector2.RIGHT, "%s should face right when target is right" % form_id) + + target.queue_free() + minion.queue_free() + + +func _check_animation_feet_are_ground_locked() -> void: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + if scene == null: + return + for form_id: StringName in FORM_NATIVE_FACING.keys(): + var minion := _spawn_form(scene, form_id, form_id, &"past_strong") + await process_frame + minion.set_physics_process(false) + var animation_player := minion.get_node("AnimationPlayer") as AnimationPlayer + var sprite := minion.get_node("Visual/CharacterSprite") as Sprite2D + var baseline := _animation_baseline(animation_player, sprite, StringName("%s_idle" % form_id)) + _expect(baseline < INF, "%s should have a measurable idle baseline" % form_id) + for animation_name: StringName in animation_player.get_animation_list(): + if not str(animation_name).begins_with(str(form_id)): + continue + _expect_animation_feet(animation_player, sprite, animation_name, baseline) + minion.queue_free() + + +func _check_witch_projectile_stays_horizontal() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn") + var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_2_attack.tres") + _expect(player_scene != null, "Player scene should load for witch projectile") + _expect(minion_scene != null, "Minion scene should load for witch projectile") + _expect(action != null, "Witch projectile action should load") + if player_scene == null or minion_scene == null or action == null: + return + + var container := Node2D.new() + container.name = "ActorsContainer" + root.add_child(container) + var player := player_scene.instantiate() as Node2D + player.name = "Player" + container.add_child(player) + var witch := _spawn_form_under(container, minion_scene, &"yuan_cheng_2", &"yuan_cheng_2", &"future_strong") + await process_frame + await process_frame + + player.global_position = Vector2(100.0, 560.0) + witch.global_position = Vector2(360.0, 560.0) + witch.call("look_at_target", player) + var left_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0] + var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO) + _expect_vector(left_direction, Vector2.LEFT, "Witch projectile should stay horizontal when firing left") + + player.global_position = Vector2(620.0, 560.0) + witch.call("look_at_target", player) + var right_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0] + var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO) + _expect_vector(right_direction, Vector2.RIGHT, "Witch projectile should stay horizontal when firing right") + container.queue_free() + + +func _check_little_girl_projectile_stays_horizontal() -> void: + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn") + var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres") + _expect(player_scene != null, "Player scene should load for little girl projectile aim") + _expect(minion_scene != null, "Minion scene should load for little girl projectile aim") + _expect(action != null, "Little girl projectile action should load") + if player_scene == null or minion_scene == null or action == null: + return + + var container := Node2D.new() + container.name = "ActorsContainer" + root.add_child(container) + var player := player_scene.instantiate() as Node2D + player.name = "Player" + container.add_child(player) + var girl := _spawn_form_under(container, minion_scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong") + await process_frame + await process_frame + + player.global_position = Vector2(100.0, 560.0) + girl.global_position = Vector2(360.0, 560.0) + girl.call("look_at_target", player) + var left_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0] + var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO) + _expect_vector(left_direction, Vector2.LEFT, "Little girl projectile should stay horizontal when firing left") + + player.global_position = Vector2(620.0, 560.0) + girl.call("look_at_target", player) + var right_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0] + var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO) + _expect_vector(right_direction, Vector2.RIGHT, "Little girl projectile should stay horizontal when firing right") + container.queue_free() + + +func _check_ranged_form_visual_heights_match() -> void: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + _expect(scene != null, "Minion scene should load for ranged height check") + if scene == null: + return + var girl := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong") + var witch := _spawn_form(scene, &"yuan_cheng_2", &"yuan_cheng_2", &"past_strong") + await process_frame + var girl_height := _visible_sprite_height(girl) + var witch_height := _visible_sprite_height(witch) + _expect(girl_height > 0.0, "Little girl visible height should be measurable") + _expect(witch_height > 0.0, "Witch visible height should be measurable") + _expect_float(witch_height, girl_height, "Witch visible height should match little girl height after normalization", 8.0) + _expect_float((witch.call("projectile_spawn_position", null) as Vector2).y, (girl.call("projectile_spawn_position", null) as Vector2).y, "Witch projectile lane should match little girl lane", 1.0) + _expect_float( + (girl.call("projectile_spawn_position", null) as Vector2).y - girl.global_position.y, + -RANGED_PROJECTILE_HEIGHT, + "Ranged projectiles should launch from hand/orb height, not the ankles", + 0.5 + ) + girl.queue_free() + witch.queue_free() + + +func _check_projectile_visual_rotates_with_direction() -> void: + var projectile_scene: PackedScene = load("res://scenes/combat/player_projectile.tscn") + _expect(projectile_scene != null, "Projectile scene should load") + if projectile_scene == null: + return + var projectile := projectile_scene.instantiate() as Area2D + var direction := Vector2(-0.8, -0.2).normalized() + projectile.set("direction", direction) + root.add_child(projectile) + await process_frame + var sprite := projectile.get_node_or_null("Sprite") as Sprite2D + _expect(sprite != null, "Projectile should create a Sprite") + if sprite != null: + _expect_float(float(sprite.rotation), direction.angle(), "Projectile Sprite should rotate along its travel direction") + _expect_equal(sprite.flip_h, false, "Projectile Sprite should use rotation rather than horizontal flip for aiming") + projectile.queue_free() + + +func _spawn_form(scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D: + return _spawn_form_under(root, scene, past_form, future_form, strength_profile) + + +func _spawn_form_under(parent: Node, scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D: + var minion := scene.instantiate() as Node2D + minion.set("past_form_id", past_form) + minion.set("future_form_id", future_form) + minion.set("strength_profile", strength_profile) + parent.add_child(minion) + return minion + + +func _expect_visual_scale_for_heading(minion: Node, form_id: StringName, heading: Vector2, label: String) -> void: + _expect_equal(minion.get("heading"), heading, label) + var native_facing: Vector2 = FORM_NATIVE_FACING.get(form_id, Vector2.RIGHT) + var expected_sign := 1.0 if heading == native_facing else -1.0 + var scale_x := float(minion.get_node("Visual").scale.x) + if signf(scale_x) != expected_sign: + failures.append("%s: expected visual scale sign %.0f, got %.3f" % [label, expected_sign, scale_x]) + + +func _animation_baseline(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName) -> float: + if not animation_player.has_animation(animation_name): + return INF + animation_player.play(animation_name) + animation_player.seek(0.0, true) + animation_player.advance(0.0) + return _visible_sprite_bottom_global_y(sprite) + + +func _expect_animation_feet(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName, baseline: float) -> void: + var animation := animation_player.get_animation(animation_name) + if animation == null: + return + var frame_track := _frame_track_index(animation) + if frame_track < 0: + return + for key_index: int in range(animation.track_get_key_count(frame_track)): + var key_time := float(animation.track_get_key_time(frame_track, key_index)) + animation_player.play(animation_name) + animation_player.seek(key_time, true) + animation_player.advance(0.0) + var feet := _visible_sprite_bottom_global_y(sprite) + if absf(feet - baseline) > 0.5: + failures.append("%s frame %d should keep feet on %.2f, got %.2f" % [animation_name, key_index, baseline, feet]) + + +func _frame_track_index(animation: Animation) -> int: + for track_index: int in range(animation.get_track_count()): + if str(animation.track_get_path(track_index)).ends_with(":frame"): + return track_index + return -1 + + +func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float: + if sprite == null or sprite.texture == null: + return INF + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return INF + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y + + +func _visible_sprite_height(minion: Node2D) -> float: + var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D + var visual := minion.get_node_or_null("Visual") as Node2D + if sprite == null or visual == null or sprite.texture == null: + return 0.0 + var bounds := _visible_sprite_bounds_local(sprite) + if bounds.size == Vector2.ZERO: + return 0.0 + return bounds.size.y * absf(visual.scale.y) + + +func _visible_sprite_bounds_local(sprite: Sprite2D) -> Rect2: + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return Rect2() + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var left := frame_width + var right := -1 + var top := frame_height + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + left = mini(left, x) + right = maxi(right, x) + top = mini(top, y) + bottom = maxi(bottom, y) + if right < left or bottom < top: + return Rect2() + return Rect2(Vector2(left, top), Vector2(right - left, bottom - top)) + + +func _ensure_time_phase_manager() -> Node: + var manager := root.get_node_or_null("TimePhaseManager") + if manager == null: + manager = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + return manager + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.01: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS minion cadence and facing") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_minion_cadence_and_facing.gd.uid b/tests/test_minion_cadence_and_facing.gd.uid new file mode 100644 index 0000000..4102d48 --- /dev/null +++ b/tests/test_minion_cadence_and_facing.gd.uid @@ -0,0 +1 @@ +uid://cikycp4tmve5h diff --git a/tests/test_minion_phase_system.gd b/tests/test_minion_phase_system.gd new file mode 100644 index 0000000..214a45b --- /dev/null +++ b/tests/test_minion_phase_system.gd @@ -0,0 +1,307 @@ +extends SceneTree + +var failures: Array[String] = [] +var started_actions: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + # 难度会缩放小怪血量(普通 ×3 / 困难 ×5);本测试关注相位系统, + # 钉住 easy 以保持 650 基线断言(直接写字段、不落盘)。 + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.set("difficulty", &"easy") + await _check_minion_scene_phase_swap_preserves_state() + await _check_minion_strength_profiles_and_action_pools() + await _check_stage_instances_phase_minions() + await _check_boss_phase_action_restrictions() + _finish() + + +func _check_minion_scene_phase_swap_preserves_state() -> void: + var bus := _ensure_event_bus() + var manager := _ensure_time_phase_manager() + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + _expect(scene != null, "Minion scene should load") + if scene == null: + return + var minion: Node2D = scene.instantiate() + minion.name = "TestPhaseMinion" + minion.set("past_form_id", &"jin_zhan_3") + minion.set("future_form_id", &"jin_zhan_1") + root.add_child(minion) + await process_frame + # 本检查只关心相位切换本身不移动小怪;关掉巡逻 AI,避免它在 + # 等待帧里合法行走造成位置漂移误报。父节点先于子节点跑物理帧, + # 因此还要清掉上一帧遗留的 approach_direction,否则仍会漂移一步。 + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + minion.set("approach_direction", 0.0) + var health: Node = minion.get_node_or_null("HealthComponent") + _expect(health != null, "Minion should have a HealthComponent") + if health != null: + _expect_int(int(health.get("maximum")), 650, "Minion max HP should follow the latest design baseline") + health.call("set_values", 321, 650) + minion.global_position = Vector2(222.0, 333.0) + manager.call("reset_to_initial", &"past") + await process_frame + _expect_equal(minion.get("form_id"), &"jin_zhan_3", "Past phase should use jin_zhan_3 for this slot") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_equal(minion.get("form_id"), &"jin_zhan_1", "Future phase should swap this slot to jin_zhan_1") + _expect_equal(minion.global_position, Vector2(222.0, 333.0), "Phase swap should preserve minion position") + if health != null: + _expect_int(int(health.get("current")), 321, "Phase swap should preserve minion current HP") + manager.call("set_time_phase", &"past", &"debug") + await process_frame + _expect_equal(minion.get("form_id"), &"jin_zhan_3", "Switching back should restore the Past form") + minion.queue_free() + _cleanup_node(bus) + _cleanup_node(manager) + await process_frame + + +func _check_minion_strength_profiles_and_action_pools() -> void: + var bus := _ensure_event_bus() + var manager := _ensure_time_phase_manager() + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + if scene == null: + return + var past_strong: Node = scene.instantiate() + past_strong.name = "PastStrongMinion" + past_strong.set("past_form_id", &"jin_zhan_3") + past_strong.set("future_form_id", &"jin_zhan_1") + past_strong.set("strength_profile", &"past_strong") + root.add_child(past_strong) + await process_frame + manager.call("reset_to_initial", &"past") + await process_frame + _expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "PastStrongEnemy should attack every two beats in Past") + _expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_3"), "Past form should use jin_zhan_3 actions") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "PastStrongEnemy should attack every four beats in Future") + _expect(_all_action_ids_begin_with(past_strong.call("current_action_ids"), "minion_jin_zhan_1"), "Future form should use jin_zhan_1 actions") + past_strong.queue_free() + + var future_strong: Node = scene.instantiate() + future_strong.name = "FutureStrongMinion" + future_strong.set("past_form_id", &"yuan_cheng_1") + future_strong.set("future_form_id", &"yuan_cheng_2") + future_strong.set("strength_profile", &"future_strong") + root.add_child(future_strong) + await process_frame + manager.call("set_time_phase", &"past", &"debug") + await process_frame + _expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "FutureStrongEnemy should attack every four beats in Past") + _expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_1"), "Past form should use yuan_cheng_1 actions") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "FutureStrongEnemy should attack every two beats in Future") + _expect(_all_action_ids_begin_with(future_strong.call("current_action_ids"), "minion_yuan_cheng_2"), "Future form should use yuan_cheng_2 actions") + future_strong.queue_free() + _cleanup_node(bus) + _cleanup_node(manager) + await process_frame + + +func _check_stage_instances_phase_minions() -> void: + var bus := _ensure_event_bus() + var manager := _ensure_time_phase_manager() + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(stage_scene != null, "Stage scene should load") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var jin := stage.get_node_or_null("ActorsContainer/JinZhanMinion") + # 当前关卡设计只预置近战怪;远程怪由 LevelDirector 在阶段二动态生成, + # 这里直接让导演生成一只来验证双形态相位切换。 + var yuan: Node = null + var director := stage.get_node_or_null("LevelDirector") + if director != null: + # 出生点左移(new2)后玩家在 x=1180:远程怪要放在新射程 450 之外, + # 避免它在测试等待帧里开打、把形态切换推迟到攻击结束。 + yuan = director.call("spawn_minion", &"ranged", Vector2(2100.0, 560.0)) + _expect(jin != null, "Stage should instance JinZhanMinion") + _expect(yuan != null, "LevelDirector should spawn a ranged YuanCheng minion") + if yuan != null: + await process_frame + var yuan_behavior := yuan.get_node_or_null("MinionBehavior") + if yuan_behavior != null: + yuan_behavior.set("enabled", false) + yuan.set("approach_direction", 0.0) + if jin != null: + var jin_behavior := jin.get_node_or_null("MinionBehavior") + if jin_behavior != null: + jin_behavior.set("enabled", false) + jin.set("approach_direction", 0.0) + if jin != null and yuan != null: + manager.call("reset_to_initial", &"past") + await process_frame + _expect_equal(jin.get("form_id"), &"jin_zhan_3", "Map A/Past should contain jin_zhan_3") + _expect_equal(yuan.get("form_id"), &"yuan_cheng_1", "Map A/Past should contain yuan_cheng_1") + var jin_health: Node = jin.get_node("HealthComponent") + var yuan_health: Node = yuan.get_node("HealthComponent") + jin_health.call("set_values", 444, 650) + yuan_health.call("set_values", 333, 650) + var jin_pos: Vector2 = (jin as Node2D).global_position + var yuan_pos: Vector2 = (yuan as Node2D).global_position + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect_equal(jin.get("form_id"), &"jin_zhan_1", "Map B/Future should swap jin_zhan_3 to jin_zhan_1") + _expect_equal(yuan.get("form_id"), &"yuan_cheng_2", "Map B/Future should swap yuan_cheng_1 to yuan_cheng_2") + _expect_equal((jin as Node2D).global_position, jin_pos, "JinZhan phase swap should preserve position") + _expect_equal((yuan as Node2D).global_position, yuan_pos, "YuanCheng phase swap should preserve position") + _expect_int(int(jin_health.get("current")), 444, "JinZhan phase swap should preserve HP") + _expect_int(int(yuan_health.get("current")), 333, "YuanCheng phase swap should preserve HP") + stage.queue_free() + _cleanup_node(bus) + _cleanup_node(manager) + await process_frame + + +func _check_boss_phase_action_restrictions() -> void: + var bus := _ensure_event_bus() + var manager := _ensure_time_phase_manager() + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + var player: Node2D = stage.get_node("ActorsContainer/Player") + var boss: Node2D = stage.get_node("ActorsContainer/Boss") + var controller: Node = boss.get_node("ActionController") + var tree: Node = boss.get_node("BossBehaviorTree") + player.global_position = Vector2(100.0, 360.0) + boss.global_position = Vector2(170.0, 360.0) + boss.set("stationary", false) + # Boss 战入场后行为树才会出招(BossRoomGate 会置 combat_enabled)。 + boss.set("combat_enabled", true) + tree.set("enabled", true) + tree.set("decision_interval_beats", 1.0) + controller.connect("action_started", _on_action_started) + + started_actions.clear() + manager.call("reset_to_initial", &"past") + await process_frame + bus.emit_signal("beat_ticked", 20) + await process_frame + _expect(not started_actions.is_empty(), "Boss should act in Past") + if not started_actions.is_empty(): + _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Past Boss action should be melee-only") + _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Past Boss action should not be ranged") + + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + started_actions.clear() + manager.call("set_time_phase", &"future", &"debug") + await process_frame + # new3 §6.4:相位切换后 Boss 停顿一拍,不立刻出招。 + bus.emit_signal("beat_ticked", 21) + await process_frame + _expect(started_actions.is_empty(), "Boss should pause one beat right after the phase switch") + bus.emit_signal("beat_ticked", 22) + await process_frame + _expect(not started_actions.is_empty(), "Boss should act after the phase-switch pause") + if not started_actions.is_empty(): + # new3 §6.7:远程相位被近身(≤240px)时优先后撤拉开距离,后撤不造成伤害。 + _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"retreat"), "Point-blank Future Boss should retreat first") + _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss action should not be melee") + controller.call("_reset_to_idle") + boss.set("state", StringName("idle")) + started_actions.clear() + bus.emit_signal("beat_ticked", 23) + await process_frame + _expect(not started_actions.is_empty(), "Boss should attack after the retreat") + if not started_actions.is_empty(): + _expect(_action_has_tag(started_actions[started_actions.size() - 1], &"ranged"), "Future Boss attack should be ranged-only") + _expect(not _action_has_tag(started_actions[started_actions.size() - 1], &"melee"), "Future Boss attack should not be melee") + stage.queue_free() + _cleanup_node(bus) + _cleanup_node(manager) + await process_frame + + +func _on_action_started(action: Resource, _intent) -> void: + if action != null: + started_actions.append(StringName(str(action.get("id")))) + + +func _all_action_ids_begin_with(ids: Variant, prefix: String) -> bool: + if not ids is Array or (ids as Array).is_empty(): + return false + for id_value: Variant in ids: + if not str(id_value).begins_with(prefix): + return false + return true + + +func _action_has_tag(action_id: StringName, tag: StringName) -> bool: + var resolver: Script = load("res://scenes/combat/action_resolver.gd") + if resolver == null: + return false + var action: Resource = resolver.call("get_action", action_id) + if action == null or not action.get("action_tags") is Array: + return false + return (action.get("action_tags") as Array).has(tag) + + +func _ensure_event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _ensure_time_phase_manager() -> Node: + var manager := root.get_node_or_null("TimePhaseManager") + if manager == null: + manager = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + return manager + + +func _cleanup_node(node: Node) -> void: + if node == null or not is_instance_valid(node): + return + if node.name == "EventBus" or node.name == "TimePhaseManager": + return + if node != null and is_instance_valid(node) and node.get_parent() == root: + root.remove_child(node) + node.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS minion phase system") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_minion_phase_system.gd.uid b/tests/test_minion_phase_system.gd.uid new file mode 100644 index 0000000..cc95365 --- /dev/null +++ b/tests/test_minion_phase_system.gd.uid @@ -0,0 +1 @@ +uid://djoccr2u4i0qh diff --git a/tests/test_new3_enemy_rules.gd b/tests/test_new3_enemy_rules.gd new file mode 100644 index 0000000..6d0bdb0 --- /dev/null +++ b/tests/test_new3_enemy_rules.gd @@ -0,0 +1,166 @@ +extends SceneTree + +## new3 定案 (2026-07-05) 专项回归: +## 1. Boss 对不耗能量的攻击恒霸体(掉血但不打断、不击退);耗能技能可压制。 +## 2. 近战小怪基础攻击 50 / 远程 25(LevelDirector 按职责配置)。 +## 3. 远程小怪连续受击 4 次触发无敌闪烁 + 跳跃脱离。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + await _check_boss_super_armor_rule() + await _check_role_based_base_attack() + await _check_ranged_hit_escape() + _finish() + + +func _check_boss_super_armor_rule() -> void: + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + _expect(boss_scene != null, "boss.tscn should load") + if boss_scene == null: + return + var boss: Node = boss_scene.instantiate() + root.add_child(boss) + await process_frame + + var normal_action: Resource = load("res://resources/actions/ground_attack_left_1.tres") + var energy_action: Resource = load("res://resources/actions/dash_slash_left.tres") + _expect_bool(bool(boss.call("shrugs_off_hit", null)), true, "Boss should shrug off action-less hits") + _expect_bool(bool(boss.call("shrugs_off_hit", normal_action)), true, "Boss should shrug off zero-cost normal attacks") + _expect_bool(bool(boss.call("shrugs_off_hit", energy_action)), false, "Boss should be suppressed by energy-cost skills") + + var receiver := boss.get_node("DamageReceiver") as Area2D + var emitter_script: Script = load("res://scenes/components/damage_emitter.gd") + var emitter: Area2D = emitter_script.new() + emitter.set("damage", 100) + root.add_child(emitter) + + var resolver: Script = load("res://scripts/resolvers/combat_resolver.gd") + + emitter.set("action_context", normal_action) + emitter.set("judgement_context", {"label": "perfect"}) + var normal_result: Dictionary = resolver.call("resolve_hit", emitter, receiver) + _expect_bool(int(normal_result.get("damage", 0)) > 0, true, "Normal attack should still damage the Boss") + _expect_bool(bool(normal_result.get("interrupts", true)), false, "Normal attack should not interrupt the Boss") + _expect_bool((normal_result.get("knockback", Vector2.ZERO) as Vector2) == Vector2.ZERO, true, "Normal attack should not knock the Boss back") + + emitter.set("action_context", energy_action) + emitter.set("judgement_context", {"label": "perfect"}) + var skill_result: Dictionary = resolver.call("resolve_hit", emitter, receiver) + _expect_bool(int(skill_result.get("damage", 0)) > 0, true, "Energy skill should damage the Boss") + _expect_bool(bool(skill_result.get("interrupts", true)), true, "Energy skill should interrupt the Boss") + _expect_bool((skill_result.get("knockback", Vector2.ZERO) as Vector2).x != 0.0, true, "Energy skill should knock the Boss back") + + emitter.free() + boss.free() + + +func _check_role_based_base_attack() -> void: + var stage_scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(stage_scene != null, "stage.tscn should load") + if stage_scene == null: + return + var stage: Node = stage_scene.instantiate() + root.add_child(stage) + await process_frame + await process_frame + + var jin := stage.get_node_or_null("ActorsContainer/JinZhanMinion") + _expect(jin != null, "Stage should keep the preplaced melee minion") + if jin != null: + var emitter := jin.get_node("DamageEmitter") + _expect_int(int(emitter.get("damage")), 50, "Melee minion base attack should be doubled to 50") + + var director := stage.get_node_or_null("LevelDirector") + if director != null: + var yuan: Node = director.call("spawn_minion", &"ranged", Vector2(2100.0, 560.0)) + if yuan != null: + var yuan_emitter := yuan.get_node("DamageEmitter") + _expect_int(int(yuan_emitter.get("damage")), 25, "Ranged minion base attack should stay 25") + var behavior := yuan.get_node("MinionBehavior") + _expect_bool(float(behavior.get("attack_range")) <= 460.0, true, "Ranged minion must not fire from off-screen (attack range within camera half-width)") + stage.free() + + +func _check_ranged_hit_escape() -> void: + var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn") + _expect(minion_scene != null, "minion.tscn should load") + if minion_scene == null: + return + var minion: Node = minion_scene.instantiate() + minion.set("past_form_id", &"yuan_cheng_1") + minion.set("future_form_id", &"yuan_cheng_2") + minion.set("strength_profile", &"future_strong") + root.add_child(minion) + await process_frame + + var behavior := minion.get_node("MinionBehavior") + behavior.set("role", &"ranged") + behavior.set("state", &"Hold") + var receiver := minion.get_node("DamageReceiver") as Area2D + + # 连续受击 3 次仍不脱离;第 4 次触发。 + for _index: int in range(3): + behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO) + _expect_bool(bool(behavior.call("_should_escape")), false, "Three consecutive hits should not trigger the escape yet") + behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO) + _expect_bool(bool(behavior.call("_should_escape")), true, "Fourth consecutive hit should trigger the escape") + + behavior.call("_begin_escape") + _expect_equal(behavior.get("state"), &"Retreat", "Escape should enter the Retreat state") + _expect_bool(bool(behavior.get("_escaping")), true, "Escape retreat should use the jump-out mode") + await physics_frame + await physics_frame + _expect_bool(receiver.monitoring, false, "Escape should grant brief invulnerability (receiver off)") + + # 无敌结束后恢复可受击。 + behavior.call("_tick_escape_invulnerability", 1.0) + await physics_frame + await physics_frame + _expect_bool(receiver.monitoring, true, "Invulnerability should expire and restore the receiver") + + # 触发后进入冷却:不能连续无敌脱离。 + for _index: int in range(4): + behavior.call("_on_damage_received", 10, &"melee", Vector2.ZERO) + _expect_bool(bool(behavior.call("_should_escape")), false, "Escape must not chain while the retreat cooldown is running") + + minion.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS new3 enemy rules") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_new3_enemy_rules.gd.uid b/tests/test_new3_enemy_rules.gd.uid new file mode 100644 index 0000000..3b6d3b6 --- /dev/null +++ b/tests/test_new3_enemy_rules.gd.uid @@ -0,0 +1 @@ +uid://cgoqj5ta5vv1b diff --git a/tests/test_phase2_schema_layer.gd b/tests/test_phase2_schema_layer.gd new file mode 100644 index 0000000..7ece859 --- /dev/null +++ b/tests/test_phase2_schema_layer.gd @@ -0,0 +1,98 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_action_data_schema() + _check_chart_schema() + _check_effect_schema() + _finish() + + +func _check_action_data_schema() -> void: + var action_script: Script = load("res://resources/action_data.gd") + _expect(action_script != null, "ActionData script should load") + if action_script == null: + return + + var action: Resource = action_script.new() + _expect(_has_property(action, "input_pattern"), "ActionData should expose input_pattern") + _expect(_has_property(action, "startup_beats"), "ActionData should expose startup_beats") + _expect(_has_property(action, "active_beats"), "ActionData should expose active_beats") + _expect(_has_property(action, "recovery_beats"), "ActionData should expose recovery_beats") + _expect(_has_property(action, "knockback_mult_x"), "ActionData should expose knockback_mult_x") + _expect(_has_property(action, "knockback_mult_y"), "ActionData should expose knockback_mult_y") + _expect_float(float(action.get("knockback_mult_x")), 0.0, "knockback_mult_x should default to zero") + _expect_float(float(action.get("knockback_mult_y")), 0.0, "knockback_mult_y should default to zero") + _expect(not _has_property(action, "allowed_time_phases"), "ActionData should keep action execution independent from time phase fields") + + +func _check_chart_schema() -> void: + var beat_chart_script: Script = load("res://resources/beat_chart.gd") + var chart_track_script: Script = load("res://resources/chart_track.gd") + var chart_event_script: Script = load("res://resources/chart_event.gd") + _expect(beat_chart_script != null, "BeatChart script should load") + _expect(chart_track_script != null, "ChartTrack script should load") + _expect(chart_event_script != null, "ChartEvent script should load") + if beat_chart_script == null or chart_track_script == null or chart_event_script == null: + return + + var chart: Resource = beat_chart_script.new() + var track: Resource = chart_track_script.new() + var event: Resource = chart_event_script.new() + + _expect(_has_property(chart, "tracks"), "BeatChart should expose tracks") + _expect(_has_property(track, "events"), "ChartTrack should expose events") + _expect(_has_property(event, "event_type"), "ChartEvent should expose event_type") + _expect(_has_property(event, "payload"), "ChartEvent should expose payload") + _expect(_has_property(event, "lead_beats"), "ChartEvent should expose lead_beats") + _expect(_has_property(event, "time_phase_mask"), "ChartEvent should expose time_phase_mask") + _expect(_has_property(chart, "initial_time_phase"), "BeatChart should expose initial_time_phase") + + event.set("beat_index", 3) + event.set("subdivision", 1) + event.set("subdivisions_per_beat", 4) + _expect_float(float(event.call("beat_position")), 3.25, "ChartEvent beat_position should include subdivisions") + + +func _check_effect_schema() -> void: + for path: String in [ + "res://resources/effects/effect_definition.gd", + "res://resources/effects/effect_instance.gd", + "res://resources/effects/stat_modifier.gd", + "res://resources/effects/defense_modifier.gd", + "res://resources/effects/action_rule_modifier.gd", + ]: + _expect(load(path) != null, "%s should load" % path) + + +func _has_property(object: Object, property_name: StringName) -> bool: + for property: Dictionary in object.get_property_list(): + if StringName(str(property.get("name"))) == property_name: + return true + return false + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS phase 2 schema layer") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_phase2_schema_layer.gd.uid b/tests/test_phase2_schema_layer.gd.uid new file mode 100644 index 0000000..783ae17 --- /dev/null +++ b/tests/test_phase2_schema_layer.gd.uid @@ -0,0 +1 @@ +uid://r6pge647kdht diff --git a/tests/test_phase9_boss_health_ui.gd b/tests/test_phase9_boss_health_ui.gd new file mode 100644 index 0000000..6ca2dc8 --- /dev/null +++ b/tests/test_phase9_boss_health_ui.gd @@ -0,0 +1,113 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var ui_scene := load("res://scenes/ui/main_ui.tscn") as PackedScene + _expect(ui_scene != null, "Main UI scene should load") + if ui_scene == null: + _finish() + return + var ui := ui_scene.instantiate() + root.add_child(ui) + await process_frame + _expect(ui.get_node_or_null("StatusBars/HealthBar") is ProgressBar, "UI should keep Player HealthBar") + _expect(ui.get_node_or_null("BossStatus/BossHealthBar") is ProgressBar, "UI should include BossHealthBar") + _expect(ui.get_node_or_null("BossStatus/BossPortrait") is TextureRect, "BossStatus should include the Boss portrait art") + _expect(ui.has_method("bind_boss_actor"), "MainUI should expose bind_boss_actor") + var boss_status := ui.get_node_or_null("BossStatus") as Control + var player_health_bar := ui.get_node_or_null("StatusBars/HealthBar") as ProgressBar + var boss_health_bar := ui.get_node_or_null("BossStatus/BossHealthBar") as ProgressBar + var boss_portrait := ui.get_node_or_null("BossStatus/BossPortrait") as TextureRect + if boss_status == null or player_health_bar == null or boss_health_bar == null or boss_portrait == null: + ui.free() + _finish() + return + _expect_equal(boss_portrait.texture.resource_path if boss_portrait.texture != null else "", "res://assets/ui/panels/boss_portrait.png", "Boss portrait should use the supplied boss art") + _expect_float(boss_portrait.size.x, 57.0, "Boss portrait should keep the supplied art width") + _expect_float(boss_portrait.size.y, 58.0, "Boss portrait should keep the supplied art height") + _expect_bool(boss_status.visible, false, "BossStatus should stay hidden before boss-room entry") + var player := _actor_with_health("Player", 1000, 1000) + var boss := _actor_with_health("Boss", 28000, 28000) + root.add_child(player) + root.add_child(boss) + await process_frame + if ui.has_method("bind_boss_actor"): + ui.call("bind_boss_actor", boss) + await process_frame + _expect_bool(boss_status.visible, false, "Binding a Boss actor should not show BossStatus before boss-room entry") + var bus := root.get_node_or_null("EventBus") + _expect(bus != null and bus.has_signal("flow_state_changed"), "EventBus should expose flow_state_changed") + if bus != null and bus.has_signal("flow_state_changed"): + bus.emit_signal("flow_state_changed", &"Title", &"Gameplay_FrontArea") + await process_frame + _expect_bool(boss_status.visible, false, "BossStatus should remain hidden in the front area") + bus.emit_signal("flow_state_changed", &"Gameplay_FrontArea", &"Gameplay_BossRoom") + await process_frame + _expect_bool(boss_status.visible, true, "BossStatus should show after boss-room entry") + bus.emit_signal("flow_state_changed", &"Gameplay_BossRoom", &"Title") + await process_frame + _expect_bool(boss_status.visible, false, "BossStatus should hide outside boss-room gameplay") + _expect_float(float(player_health_bar.max_value), 1000.0, "Player HealthBar should initialize from Player HealthComponent") + _expect_float(float(boss_health_bar.max_value), 28000.0, "BossHealthBar max should follow Boss HealthComponent") + _expect_float(float(boss_health_bar.value), 28000.0, "BossHealthBar value should follow Boss HealthComponent") + boss.get_node("HealthComponent").call("apply_damage", 1200) + await process_frame + _expect_float(float(boss_health_bar.value), 26800.0, "BossHealthBar should follow Boss damage") + _expect_float(float(player_health_bar.value), 1000.0, "Boss damage should not change Player HealthBar") + player.get_node("HealthComponent").call("set_values", 640, 1000) + await process_frame + _expect_float(float(player_health_bar.max_value), 1000.0, "Player HealthBar should follow Player HealthComponent when bound by event") + _expect_float(float(player_health_bar.value), 640.0, "Player HealthBar should follow Player damage") + _expect_float(float(boss_health_bar.value), 26800.0, "Player damage should not change BossHealthBar") + ui.free() + player.free() + boss.free() + _finish() + + +func _actor_with_health(actor_name: String, current: int, maximum: int) -> Node: + var actor := Node2D.new() + actor.name = actor_name + var health_script: Script = load("res://scenes/components/health_component.gd") + var health: Node = health_script.new() + health.name = "HealthComponent" + actor.add_child(health) + health.set("maximum", maximum) + health.set("current", current) + return actor + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.001: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, str(expected), str(actual)]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS phase9 boss health ui") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_phase9_boss_health_ui.gd.uid b/tests/test_phase9_boss_health_ui.gd.uid new file mode 100644 index 0000000..ee4985d --- /dev/null +++ b/tests/test_phase9_boss_health_ui.gd.uid @@ -0,0 +1 @@ +uid://devpxc3mhfk2a diff --git a/tests/test_phase9_content_completion.gd b/tests/test_phase9_content_completion.gd new file mode 100644 index 0000000..ed1d699 --- /dev/null +++ b/tests/test_phase9_content_completion.gd @@ -0,0 +1,220 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var resolver: Script = load("res://scenes/combat/action_resolver.gd") + if resolver != null and resolver.has_method("clear_cache"): + resolver.call("clear_cache") + _check_player_action_set() + _check_pattern_export() + _check_charge_bindings() + await _check_player_animation_coverage() + _check_reserved_terms() + _finish() + + +func _check_player_action_set() -> void: + var expected := { + &"ground_attack_left_1": {"pattern": [&"A"], "damage": 1.0, "reward": 10.0, "cost": 0.0, "move_x": -1.0, "move_y": 0.0, "animation": &"atk_ground_1"}, + &"ground_attack_left_2": {"pattern": [&"A", &"A"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": -1.1, "move_y": 0.0, "animation": &"atk_ground_2"}, + &"ground_attack_left_3": {"pattern": [&"A", &"A", &"A"], "damage": 1.5, "reward": 15.0, "cost": 0.0, "move_x": -1.2, "move_y": 0.0, "animation": &"atk_ground_3"}, + &"dash_slash_left": {"pattern": [&"A", &"SP"], "damage": 1.6, "reward": 0.0, "cost": 20.0, "move_x": -2.6, "move_y": 0.0, "animation": &"dash_slash", "tag": &"dash_through"}, + &"combo_finisher_left": {"pattern": [&"A", &"A", &"SP"], "damage": 2.2, "reward": 0.0, "cost": 30.0, "move_x": -1.3, "move_y": 0.0, "animation": &"combo_finisher"}, + &"ground_smash_left": {"pattern": [&"A", &"A", &"A", &"SP"], "damage": 2.8, "reward": 0.0, "cost": 40.0, "move_x": -0.8, "move_y": 0.0, "knock_y": 1.8, "animation": &"ground_smash", "tag": &"knockup"}, + &"ground_attack_right_1": {"pattern": [&"D"], "damage": 1.0, "reward": 10.0, "cost": 0.0, "move_x": 1.0, "move_y": 0.0, "animation": &"atk_ground_1"}, + &"ground_attack_right_2": {"pattern": [&"D", &"D"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": 1.1, "move_y": 0.0, "animation": &"atk_ground_2"}, + &"ground_attack_right_3": {"pattern": [&"D", &"D", &"D"], "damage": 1.5, "reward": 15.0, "cost": 0.0, "move_x": 1.2, "move_y": 0.0, "animation": &"atk_ground_3"}, + &"dash_slash_right": {"pattern": [&"D", &"SP"], "damage": 1.6, "reward": 0.0, "cost": 20.0, "move_x": 2.6, "move_y": 0.0, "animation": &"dash_slash", "tag": &"dash_through"}, + &"combo_finisher_right": {"pattern": [&"D", &"D", &"SP"], "damage": 2.2, "reward": 0.0, "cost": 30.0, "move_x": 1.3, "move_y": 0.0, "animation": &"combo_finisher"}, + &"ground_smash_right": {"pattern": [&"D", &"D", &"D", &"SP"], "damage": 2.8, "reward": 0.0, "cost": 40.0, "move_x": 0.8, "move_y": 0.0, "knock_y": 1.8, "animation": &"ground_smash", "tag": &"knockup"}, + &"launcher_up": {"pattern": [&"W"], "damage": 1.2, "reward": 12.0, "cost": 0.0, "move_x": 0.6, "move_y": 1.8, "knock_y": 1.8, "animation": &"rising_slash"}, + &"air_attack_left": {"pattern": [&"A"], "damage": 1.3, "reward": 10.0, "cost": 0.0, "move_x": -5.8, "move_y": -1.6, "animation": &"atk_air", "ground": [&"Airborne"]}, + &"air_attack_right": {"pattern": [&"D"], "damage": 1.3, "reward": 10.0, "cost": 0.0, "move_x": 5.8, "move_y": -1.6, "animation": &"atk_air", "ground": [&"Airborne"]}, + &"plunging_strike": {"pattern": [&"S"], "damage": 1.8, "reward": 15.0, "cost": 0.0, "move_x": 0.0, "move_y": -2.6, "knock_y": 1.4, "animation": &"plunge_start", "ground": [&"Airborne"], "tag": &"knockup"}, + &"block_start": {"pattern": [&"S"], "damage": 0.0, "reward": 0.0, "cost": 0.0, "move_x": 0.0, "move_y": 0.0, "animation": &"block_start", "ground": [&"Grounded"], "hit_type": &"defense", "defense": &"parry"}, + &"jian_yu_left_lv1": {"pattern": [], "damage": 1.8, "reward": 0.0, "cost": 20.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"jian_yu_left_lv2": {"pattern": [], "damage": 2.8, "reward": 0.0, "cost": 35.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"jian_yu_left_lv3": {"pattern": [], "damage": 4.2, "reward": 0.0, "cost": 50.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"jian_yu_right_lv1": {"pattern": [], "damage": 1.8, "reward": 0.0, "cost": 20.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"jian_yu_right_lv2": {"pattern": [], "damage": 2.8, "reward": 0.0, "cost": 35.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"jian_yu_right_lv3": {"pattern": [], "damage": 4.2, "reward": 0.0, "cost": 50.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_rain_cast", "phase": [&"Charging"], "defense": &"super_armor"}, + &"zhan_bo_lv1": {"pattern": [], "damage": 1.2, "reward": 0.0, "cost": 15.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"}, + &"zhan_bo_lv2": {"pattern": [], "damage": 2.0, "reward": 0.0, "cost": 25.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"}, + &"zhan_bo_lv3": {"pattern": [], "damage": 3.2, "reward": 0.0, "cost": 40.0, "move_x": 0.0, "move_y": 0.0, "animation": &"blade_wave_cast", "phase": [&"Charging"], "hit_type": &"projectile"}, + } + for action_id: StringName in expected.keys(): + var action := _load_action(action_id) + _expect(action != null, "%s should exist as phase-9 ActionData" % action_id) + if action == null: + continue + var spec: Dictionary = expected[action_id] + _expect_array(action.get("input_pattern"), spec.get("pattern", []), "%s input_pattern" % action_id) + _expect_float(float(action.get("damage_mult")), float(spec["damage"]), "%s damage_mult" % action_id) + _expect_float(float(action.get("base_reward")), float(spec["reward"]), "%s base_reward" % action_id) + _expect_float(float(action.get("base_cost")), float(spec["cost"]), "%s base_cost" % action_id) + _expect_float(float(action.get("move_mult_x")), float(spec["move_x"]), "%s move_mult_x" % action_id) + _expect_float(float(action.get("move_mult_y")), float(spec["move_y"]), "%s move_mult_y" % action_id) + if spec.has("knock_y"): + _expect_float(float(action.get("knockback_mult_y")), float(spec["knock_y"]), "%s knockback_mult_y" % action_id) + _expect_equal(StringName(str(action.get("animation"))), spec["animation"], "%s animation" % action_id) + if spec.has("ground"): + _expect_array(action.get("allowed_ground_states"), spec["ground"], "%s allowed_ground_states" % action_id) + if spec.has("phase"): + _expect_array(action.get("allowed_action_phases"), spec["phase"], "%s allowed_action_phases" % action_id) + if spec.has("hit_type"): + _expect_equal(StringName(str(action.get("hit_type"))), spec["hit_type"], "%s hit_type" % action_id) + if spec.has("tag"): + _expect((action.get("action_tags") as Array).has(spec["tag"]), "%s should include action tag %s" % [action_id, spec["tag"]]) + if spec.has("defense"): + _expect((action.get("defense_tags") as Array).has(spec["defense"]), "%s should include defense tag %s" % [action_id, spec["defense"]]) + + +func _check_pattern_export() -> void: + var exporter: Script = load("res://tools/export_action_patterns.gd") + _expect(exporter != null, "ActionPatternExporter should load") + if exporter == null: + return + var patterns: Dictionary = exporter.call("export_patterns") + var expected := { + "A": &"ground_attack_left_1", + "AA": &"ground_attack_left_2", + "AAA": &"ground_attack_left_3", + "ASP": &"dash_slash_left", + "AASP": &"combo_finisher_left", + "AAASP": &"ground_smash_left", + "D": &"ground_attack_right_1", + "DD": &"ground_attack_right_2", + "DDD": &"ground_attack_right_3", + "DSP": &"dash_slash_right", + "DDSP": &"combo_finisher_right", + "DDDSP": &"ground_smash_right", + "W": &"launcher_up", + "S": &"block_start", + } + for key: String in expected: + _expect_equal(patterns.get(key), expected[key], "Pattern %s" % key) + for forbidden: String in ["AD", "DA", "ADSP", "DASP", "ASPSP", "SS"]: + _expect(not patterns.has(forbidden), "Forbidden pattern %s should not exist" % forbidden) + + +func _check_charge_bindings() -> void: + var bindings := load("res://resources/player_action_bindings.tres") + _expect(bindings != null, "PlayerSkillBindings resource should exist") + if bindings == null: + return + var entries: Array = bindings.get("charge_entries") + _expect_int(entries.size(), 3, "charge_entries size") + _expect_charge_entry(entries, &"A", &"after_tap", &"on_release", [&"jian_yu_left_lv1", &"jian_yu_left_lv2", &"jian_yu_left_lv3"]) + _expect_charge_entry(entries, &"D", &"after_tap", &"on_release", [&"jian_yu_right_lv1", &"jian_yu_right_lv2", &"jian_yu_right_lv3"]) + _expect_charge_entry(entries, &"S", &"after_tap", &"on_secondary_key", [&"zhan_bo_lv1", &"zhan_bo_lv2", &"zhan_bo_lv3"]) + + +func _check_player_animation_coverage() -> void: + var scene := load("res://scenes/characters/player.tscn") as PackedScene + _expect(scene != null, "Player scene should load") + if scene == null: + return + var player := scene.instantiate() + root.add_child(player) + await process_frame + _expect(player.has_method("has_player_animation"), "Player should expose animation coverage checks") + if player.has_method("has_player_animation"): + for animation_name: StringName in [&"atk_ground_1", &"atk_ground_2", &"atk_ground_3", &"dash_slash", &"combo_finisher", &"ground_smash", &"rising_slash", &"block_start", &"blade_rain_cast", &"blade_wave_cast", &"atk_air", &"plunge_start", &"idle", &"turn"]: + _expect(bool(player.call("has_player_animation", animation_name)), "Player should be able to play %s" % animation_name) + player.free() + + +func _check_reserved_terms() -> void: + var required := ["TimePhaseManager", "TimeAnchorSystem"] + var files := _gd_files("res://autoload") + _gd_files("res://resources") + _gd_files("res://scenes") + _gd_files("res://scripts") + var corpus := "" + for path: String in files: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + continue + corpus += file.get_as_text() + for needle: String in required: + _expect(corpus.contains(needle), "implemented time systems should reference %s" % needle) + + +func _expect_charge_entry(entries: Array, symbol: StringName, mode: StringName, trigger: StringName, ids: Array[StringName]) -> void: + for entry: Variant in entries: + if entry is Dictionary and StringName(str(entry.get("symbol", &""))) == symbol: + _expect_equal(StringName(str(entry.get("entry_mode", &""))), mode, "%s charge entry mode" % symbol) + _expect_equal(StringName(str(entry.get("cast_trigger", &""))), trigger, "%s charge trigger" % symbol) + var cast_ids: Dictionary = entry.get("cast_action_ids", {}) + for index: int in range(ids.size()): + _expect_equal(cast_ids.get(index + 1), ids[index], "%s charge level %d" % [symbol, index + 1]) + return + failures.append("Missing charge entry %s" % symbol) + + +func _load_action(action_id: StringName) -> Resource: + var files := _tres_files("res://resources/actions") + for path: String in files: + var resource := load(path) as Resource + if resource != null and StringName(str(resource.get("id"))) == action_id: + return resource + return null + + +func _tres_files(path: String) -> Array[String]: + return _files_with_extension(path, ".tres") + + +func _gd_files(path: String) -> Array[String]: + return _files_with_extension(path, ".gd") + + +func _files_with_extension(path: String, extension: String) -> Array[String]: + var result: Array[String] = [] + var dir := DirAccess.open(path) + if dir == null: + return result + for subdir: String in dir.get_directories(): + result.append_array(_files_with_extension("%s/%s" % [path, subdir], extension)) + for file_name: String in dir.get_files(): + if file_name.ends_with(extension): + result.append("%s/%s" % [path, file_name]) + return result + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Variant, expected: Array, label: String) -> void: + if not actual is Array or actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.001: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS phase9 content completion") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_phase9_content_completion.gd.uid b/tests/test_phase9_content_completion.gd.uid new file mode 100644 index 0000000..c81ecf2 --- /dev/null +++ b/tests/test_phase9_content_completion.gd.uid @@ -0,0 +1 @@ +uid://c2f71vfa3qspv diff --git a/tests/test_player2_fx_and_witch_projectile.gd b/tests/test_player2_fx_and_witch_projectile.gd new file mode 100644 index 0000000..2e1f2f5 --- /dev/null +++ b/tests/test_player2_fx_and_witch_projectile.gd @@ -0,0 +1,249 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_player_attack_buff_visual_scales_with_stacks() + await _check_minion_strong_buff_visual_and_health_position() + await _check_ranged_forms_projectiles_stay_horizontal() + await _check_witch_visual_height_matches_little_girl() + _finish() + + +func _check_player_attack_buff_visual_scales_with_stacks() -> void: + _expect(ResourceLoader.exists("res://scenes/components/attack_buff_visual.gd"), "AttackBuffVisual script should exist") + var scene := load("res://scenes/characters/player.tscn") as PackedScene + _expect(scene != null, "Player scene should load for attack buff visual") + if scene == null: + return + var player := scene.instantiate() + root.add_child(player) + await process_frame + var visual := player.get_node_or_null("Visual/AttackBuffVisual") as Node2D + _expect(visual != null, "Player should have AttackBuffVisual under Visual") + if visual != null: + _expect_int(int(visual.call("frame_count")), 24, "AttackBuffVisual should load the original 24 buff frames") + visual.call("set_attack_buff", 0, 7) + _expect_bool(visual.visible, false, "AttackBuffVisual should hide at zero stacks") + visual.call("set_attack_buff", 1, 7) + await process_frame + var sprite := visual.get_node_or_null("BuffEffect") as Sprite2D + _expect(sprite != null and sprite.visible, "AttackBuffVisual should show a sprite when stacks are present") + var one_stack_scale := sprite.scale.x if sprite != null else 0.0 + var one_stack_alpha := sprite.modulate.a if sprite != null else 0.0 + visual.call("set_attack_buff", 7, 7) + await process_frame + if sprite != null: + _expect(sprite.scale.x > one_stack_scale, "AttackBuffVisual should get larger as buff stacks grow") + _expect(sprite.modulate.a > one_stack_alpha, "AttackBuffVisual should get more opaque as buff stacks grow") + player.free() + + +func _check_minion_strong_buff_visual_and_health_position() -> void: + _expect(ResourceLoader.exists("res://scenes/components/strong_buff_visual.gd"), "StrongBuffVisual script should exist") + var manager := _ensure_time_phase_manager() + var scene := load("res://scenes/enemies/minion.tscn") as PackedScene + _expect(scene != null, "Minion scene should load for strong buff visual") + if scene == null: + return + var minion := scene.instantiate() as Node2D + minion.set("past_form_id", &"yuan_cheng_1") + minion.set("future_form_id", &"yuan_cheng_2") + minion.set("strength_profile", &"future_strong") + root.add_child(minion) + await process_frame + var strong_visual := minion.get_node_or_null("Visual/StrongBuffVisual") as Node2D + _expect(strong_visual != null, "Minion should have StrongBuffVisual under Visual") + if strong_visual != null: + _expect_int(int(strong_visual.call("frame_count")), 30, "StrongBuffVisual should load the original 30 strong-state frames") + manager.call("reset_to_initial", &"past") + await process_frame + if strong_visual != null: + _expect_bool(bool(strong_visual.call("is_active")), false, "Future-strong witch should hide strong effect in Past") + var health_bar := minion.get_node_or_null("OverheadHealthBar") as Node2D + _expect(health_bar != null, "Minion should have OverheadHealthBar") + if health_bar != null: + # 血条在物理帧刷新、动画 offset 在渲染帧变化;断言前强制同步一次, + # 消除两者错开一个动画帧时的偏差误报。 + minion.call("_refresh_overhead_health_bar_position") + _expect_float(health_bar.position.y, _expected_health_bar_y(minion), "OverheadHealthBar should sit above the current sprite") + manager.call("set_time_phase", &"future", &"debug") + await process_frame + if strong_visual != null: + _expect_bool(bool(strong_visual.call("is_active")), true, "Future-strong witch should show strong effect in Future") + if health_bar != null: + minion.call("_refresh_overhead_health_bar_position") + _expect_float(health_bar.position.y, _expected_health_bar_y(minion), "OverheadHealthBar should move with the swapped sprite") + minion.free() + _cleanup_node(manager) + + +func _check_ranged_forms_projectiles_stay_horizontal() -> void: + var player_scene := load("res://scenes/characters/player.tscn") as PackedScene + var minion_scene := load("res://scenes/enemies/minion.tscn") as PackedScene + var girl_action := load("res://resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres") as Resource + var witch_action := load("res://resources/actions/enemies/minion_yuan_cheng_2_attack.tres") as Resource + _expect(player_scene != null, "Player scene should load for ranged projectile") + _expect(minion_scene != null, "Minion scene should load for ranged projectile") + _expect(girl_action != null, "Little girl projectile action should load") + _expect(witch_action != null, "Witch projectile action should load") + if player_scene == null or minion_scene == null or girl_action == null or witch_action == null: + return + var container := Node2D.new() + container.name = "ActorsContainer" + root.add_child(container) + var player := player_scene.instantiate() as Node2D + player.name = "Player" + container.add_child(player) + await _expect_form_horizontal_shot(container, minion_scene, player, &"yuan_cheng_1", girl_action, "Little girl") + await _expect_form_horizontal_shot(container, minion_scene, player, &"yuan_cheng_2", witch_action, "Witch") + container.free() + + +func _expect_form_horizontal_shot(container: Node, minion_scene: PackedScene, player: Node2D, form_id: StringName, action: Resource, label: String) -> void: + var shooter := minion_scene.instantiate() as Node2D + shooter.set("past_form_id", form_id) + shooter.set("future_form_id", form_id) + shooter.set("strength_profile", &"past_strong") + container.add_child(shooter) + await process_frame + player.global_position = Vector2(100.0, 560.0) + shooter.global_position = Vector2(360.0, 560.0) + shooter.call("look_at_target", player) + var left_request: Dictionary = (shooter.call("projectile_requests_for_action", action) as Array)[0] + var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO) + _expect_vector(left_direction, Vector2.LEFT, "%s projectile should fire horizontally left" % label) + player.global_position = Vector2(620.0, 560.0) + shooter.call("look_at_target", player) + var right_request: Dictionary = (shooter.call("projectile_requests_for_action", action) as Array)[0] + var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO) + _expect_vector(right_direction, Vector2.RIGHT, "%s projectile should fire horizontally right" % label) + shooter.queue_free() + + +func _check_witch_visual_height_matches_little_girl() -> void: + var scene := load("res://scenes/enemies/minion.tscn") as PackedScene + _expect(scene != null, "Minion scene should load for ranged visual height") + if scene == null: + return + var girl := _spawn_single_form(scene, &"yuan_cheng_1") + var witch := _spawn_single_form(scene, &"yuan_cheng_2") + await process_frame + var girl_height := _visible_sprite_height(girl) + var witch_height := _visible_sprite_height(witch) + _expect(girl_height > 0.0, "Little girl visible height should be measurable") + _expect(witch_height > 0.0, "Witch visible height should be measurable") + _expect_float(witch_height, girl_height, "Witch visible height should match little girl height after anchor normalization", 8.0) + _expect_float((witch.call("projectile_spawn_position", null) as Vector2).y, (girl.call("projectile_spawn_position", null) as Vector2).y, "Witch projectile lane should match little girl lane", 1.0) + girl.queue_free() + witch.queue_free() + + +func _spawn_single_form(scene: PackedScene, form_id: StringName) -> Node2D: + var minion := scene.instantiate() as Node2D + minion.set("past_form_id", form_id) + minion.set("future_form_id", form_id) + minion.set("strength_profile", &"past_strong") + root.add_child(minion) + return minion + + +func _visible_sprite_height(minion: Node2D) -> float: + var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D + var visual := minion.get_node_or_null("Visual") as Node2D + if sprite == null or visual == null or sprite.texture == null: + return 0.0 + var bounds := _visible_sprite_bounds_local(sprite) + if bounds.size == Vector2.ZERO: + return 0.0 + return bounds.size.y * absf(visual.scale.y) + + +func _visible_sprite_bounds_local(sprite: Sprite2D) -> Rect2: + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return Rect2() + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var left := frame_width + var right := -1 + var top := frame_height + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + left = mini(left, x) + right = maxi(right, x) + top = mini(top, y) + bottom = maxi(bottom, y) + if right < left or bottom < top: + return Rect2() + return Rect2(Vector2(left, top), Vector2(right - left, bottom - top)) + + +func _expected_health_bar_y(minion: Node2D) -> float: + var visual := minion.get_node_or_null("Visual") as Node2D + var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D + if visual == null or sprite == null: + return -118.0 + return visual.position.y + sprite.offset.y * absf(visual.scale.y) - 10.0 + + +func _ensure_time_phase_manager() -> Node: + var manager := root.get_node_or_null("TimePhaseManager") + if manager == null: + manager = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + return manager + + +func _cleanup_node(node: Node) -> void: + if node != null and is_instance_valid(node): + node.queue_free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String, tolerance := 0.5) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.02: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS player2 fx and witch projectile") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_player2_fx_and_witch_projectile.gd.uid b/tests/test_player2_fx_and_witch_projectile.gd.uid new file mode 100644 index 0000000..2353908 --- /dev/null +++ b/tests/test_player2_fx_and_witch_projectile.gd.uid @@ -0,0 +1 @@ +uid://b3gcc6571kmxx diff --git a/tests/test_player_action_animation_tempo.gd b/tests/test_player_action_animation_tempo.gd new file mode 100644 index 0000000..ed1af43 --- /dev/null +++ b/tests/test_player_action_animation_tempo.gd @@ -0,0 +1,66 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var player := await _player_fixture() + if player == null: + _finish(null) + return + _set_beat_time(0.2) + + var basic_action: Resource = load("res://resources/actions/ground_attack_left_1.tres") + player.call("_play_action_animation", "atk_ground_1", basic_action) + var animation_player := player.get_node_or_null("AnimationPlayer") as AnimationPlayer + if animation_player == null: + failures.append("Player should include AnimationPlayer") + else: + _expect_float(float(animation_player.speed_scale), 1.75, 0.01, "AnimationPlayer actions should speed up to fit shortened beat durations") + + var smash_action: Resource = load("res://resources/actions/ground_smash_left.tres") + player.call("_play_action_animation", "ground_smash", smash_action) + player.call("_tick_manual_animation", 0.05) + _expect_float(float(player.get("_manual_animation_time")), 0.1, 0.01, "Manual player action animation time should advance on the tempo-scaled action clock") + + _finish(player) + + +func _player_fixture() -> Node: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + failures.append("Player scene should load") + return null + var player := scene.instantiate() + root.add_child(player) + await process_frame + return player + + +func _set_beat_time(next_beat_time: float) -> void: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm == null: + failures.append("RhythmManager autoload should exist") + return + rhythm.set("beat_time", next_beat_time) + + +func _expect_float(actual: float, expected: float, tolerance: float, label: String) -> void: + if absf(actual - expected) > tolerance: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish(player: Node) -> void: + if player != null: + player.free() + if failures.is_empty(): + print("PASS player action animation tempo") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_player_action_animation_tempo.gd.uid b/tests/test_player_action_animation_tempo.gd.uid new file mode 100644 index 0000000..024b981 --- /dev/null +++ b/tests/test_player_action_animation_tempo.gd.uid @@ -0,0 +1 @@ +uid://b3ujromburlym diff --git a/tests/test_player_combo_input.gd b/tests/test_player_combo_input.gd new file mode 100644 index 0000000..adaf26b --- /dev/null +++ b/tests/test_player_combo_input.gd @@ -0,0 +1,169 @@ +extends SceneTree + +var failures: Array[String] = [] +var started: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var fixture := await _fixture() + if fixture.is_empty(): + _finish() + return + var input_component: Node = fixture["input"] + var controller: Node = fixture["controller"] + var combo: Node = fixture["combo"] + controller.connect("action_started", _on_action_started) + input_component.connect("intent_created", controller.submit_intent) + + # 每次按键前把节奏钟钉到一个新的拍心:判定确定为 perfect(消除旧的 + # 墙钟 flake),且不与 new1 的"节奏点一次性消耗"规则冲突。 + _pin_beat(100) + input_component.call("handle_input_event", _key_event(KEY_A)) + _expect_bool(started.is_empty(), false, "physical A should start an action") + _expect_string(_last_started(), "ground_attack_left_1", "physical A should start left attack") + controller.call("_reset_to_idle") + _pin_beat(101) + input_component.call("handle_input_event", _key_event(KEY_A)) + _expect_array(combo.call("get_slots"), [&"A", &"A"], "physical A A should fill two slots") + _expect_string(_last_started(), "ground_attack_left_2", "physical A A should start left second attack") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + _pin_beat(102) + input_component.call("handle_input_event", _key_event(KEY_D)) + controller.call("_reset_to_idle") + _pin_beat(103) + input_component.call("handle_input_event", _key_event(KEY_D)) + _expect_array(combo.call("get_slots"), [&"D", &"D"], "physical D D should fill two slots") + _expect_string(_last_started(), "ground_attack_right_2", "physical D D should start right second attack") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + _pin_beat(104) + input_component.call("handle_input_event", _key_event(KEY_W)) + _expect_string(_last_started(), "launcher_up", "physical W should start launcher") + _expect_array(combo.call("get_slots"), [&"W"], "physical W should enter ComboWindow") + + controller.call("_reset_to_idle") + combo.call("clear", &"test-reset") + started.clear() + _pin_beat(105) + input_component.call("handle_input_event", _key_event(KEY_S)) + _expect_string(_last_started(), "block_start", "physical S should start temporary block animation") + _expect_array(combo.call("get_slots"), [&"S"], "physical S should enter ComboWindow") + + controller.call("_reset_to_idle") + started.clear() + _pin_beat(106) + input_component.call("handle_input_event", _key_event(KEY_A)) + controller.call("_reset_to_idle") + _pin_beat(107) + input_component.call("handle_input_event", _key_event(KEY_S)) + _expect_string(_last_started(), "block_start", "physical S should still block after a previous basic input remains in ComboWindow") + + fixture["root"].free() + _finish() + + +func _last_started() -> String: + if started.is_empty(): + return "" + return str(started[started.size() - 1]) + + +## 把 RhythmManager 的时钟偏移钉到指定拍心:紧随其后的实时按键判定为 perfect。 +func _pin_beat(beat: int) -> void: + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm == null: + failures.append("RhythmManager autoload should exist for beat pinning") + return + rhythm.set("running", true) + var beat_time := float(rhythm.get("beat_time")) + var beat_offset := float(rhythm.get("beat_offset")) + rhythm.set("_clock_offset", float(beat) * beat_time - beat_offset - Time.get_ticks_msec() / 1000.0) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _fixture() -> Dictionary: + var input_script: Script = load("res://scenes/components/input_component.gd") + var combo_script: Script = load("res://scenes/components/combo_window.gd") + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + var state_script: Script = load("res://scenes/components/state_machine.gd") + var energy_script: Script = load("res://scenes/components/energy_component.gd") + var controller_script: Script = load("res://scenes/components/action_controller.gd") + if input_script == null or combo_script == null or resolver_script == null or state_script == null or energy_script == null or controller_script == null: + failures.append("Phase 3 input/action scripts should load") + return {} + var fixture_root := Node.new() + root.add_child(fixture_root) + var input_component: Node = input_script.new() + input_component.name = "InputComponent" + fixture_root.add_child(input_component) + var combo: Node = combo_script.new() + combo.name = "ComboWindow" + fixture_root.add_child(combo) + var resolver: Node = resolver_script.new() + resolver.name = "ActionResolver" + fixture_root.add_child(resolver) + var state: Node = state_script.new() + state.name = "StateMachine" + fixture_root.add_child(state) + var energy: Node = energy_script.new() + energy.name = "EnergyComponent" + fixture_root.add_child(energy) + var controller: Node = controller_script.new() + controller.name = "ActionController" + controller.set("combo_window_path", NodePath("../ComboWindow")) + controller.set("action_resolver_path", NodePath("../ActionResolver")) + controller.set("state_machine_path", NodePath("../StateMachine")) + fixture_root.add_child(controller) + await process_frame + energy.call("set_values", 99, 99) + return { + "root": fixture_root, + "input": input_component, + "combo": combo, + "controller": controller, + } + + +func _key_event(key: Key) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = key + event.physical_keycode = key + event.pressed = true + return event + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS player combo input") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_player_combo_input.gd.uid b/tests/test_player_combo_input.gd.uid new file mode 100644 index 0000000..dc58980 --- /dev/null +++ b/tests/test_player_combo_input.gd.uid @@ -0,0 +1 @@ +uid://bqoowxp1n1jgi diff --git a/tests/test_rhythm_action_architecture.gd b/tests/test_rhythm_action_architecture.gd new file mode 100644 index 0000000..d7d80e4 --- /dev/null +++ b/tests/test_rhythm_action_architecture.gd @@ -0,0 +1,112 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + for path: String in [ + "res://scenes/components/input_intent.gd", + "res://scenes/components/input_component.gd", + "res://scenes/components/combo_window.gd", + "res://scenes/components/state_machine.gd", + "res://scenes/components/energy_component.gd", + "res://scenes/components/burst_component.gd", + "res://scenes/components/charge_component.gd", + "res://scenes/components/action_controller.gd", + "res://scenes/combat/action_resolver.gd", + "res://resources/player_action_bindings.gd", + "res://resources/player_action_bindings.tres", + ]: + _expect(load(path) != null, "%s should load" % path) + + var resolver_script: Script = load("res://scenes/combat/action_resolver.gd") + if resolver_script != null: + if resolver_script.has_method("clear_cache"): + resolver_script.call("clear_cache") + var expected := { + "A": &"ground_attack_left_1", + "AA": &"ground_attack_left_2", + "D": &"ground_attack_right_1", + "DD": &"ground_attack_right_2", + "W": &"launcher_up", + "S": &"block_start", + "SS": &"block_start", + } + for pattern: String in expected: + var action: Resource = resolver_script.call("resolve_pattern", pattern) + _expect(action != null, "ActionResolver should resolve %s" % pattern) + if action != null: + _expect(StringName(str(action.get("id"))) == expected[pattern], "%s should resolve to %s" % [pattern, expected[pattern]]) + _check_s_basic_fallback_keeps_exact_combos(resolver_script) + + var gd_files := _all_gd_files(["res://scenes/components", "res://scenes/combat", "res://resources"]) + for path: String in gd_files: + var text := _read_text(path) + _expect(not text.contains("legacy_skill_"), "%s should not hardcode old skill ids" % path) + _expect(not text.contains("legacy_time_phase"), "%s should not keep legacy time phase placeholders" % path) + _expect(not text.contains("legacy_time_anchor"), "%s should not keep legacy time anchor placeholders" % path) + _finish() + + +func _check_s_basic_fallback_keeps_exact_combos(resolver_script: Script) -> void: + resolver_script.call("reload", "res://tests/fixtures/actions") + var s_action: Resource = resolver_script.call("resolve_pattern", "S") + var combo_action: Resource = resolver_script.call("resolve_pattern", "SD") + var fallback_action: Resource = resolver_script.call("resolve_pattern", "AS") + _expect(s_action != null, "S fixture action should resolve") + if s_action != null: + _expect(StringName(str(s_action.get("id"))) == &"test_block_start", "S alone should resolve to the immediate block action") + _expect(combo_action != null, "S follow-up combo fixture should resolve") + if combo_action != null: + _expect(StringName(str(combo_action.get("id"))) == &"test_s_followup_combo", "S follow-up combo should beat trailing S fallback") + _expect(fallback_action != null, "Unmatched pattern ending in S should still fall back to immediate block") + if fallback_action != null: + _expect(StringName(str(fallback_action.get("id"))) == &"test_block_start", "Only unmatched S suffixes should use block fallback") + resolver_script.call("clear_cache") + + +func _all_gd_files(dirs: Array[String]) -> Array[String]: + var result: Array[String] = [] + for dir_path: String in dirs: + _collect_gd_files(dir_path, result) + return result + + +func _collect_gd_files(dir_path: String, result: Array[String]) -> void: + var dir := DirAccess.open(dir_path) + if dir == null: + return + for subdir: String in dir.get_directories(): + _collect_gd_files("%s/%s" % [dir_path, subdir], result) + for file_name: String in dir.get_files(): + if file_name.ends_with(".gd"): + result.append("%s/%s" % [dir_path, file_name]) + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + var text := file.get_as_text() + file.close() + return text + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm action architecture") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_action_architecture.gd.uid b/tests/test_rhythm_action_architecture.gd.uid new file mode 100644 index 0000000..de7472c --- /dev/null +++ b/tests/test_rhythm_action_architecture.gd.uid @@ -0,0 +1 @@ +uid://b27nn83sjvaug diff --git a/tests/test_rhythm_feedback_and_move_list.gd b/tests/test_rhythm_feedback_and_move_list.gd new file mode 100644 index 0000000..cc5263f --- /dev/null +++ b/tests/test_rhythm_feedback_and_move_list.gd @@ -0,0 +1,119 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _pin_rhythm_clock() + await _check_time_anchor_movers_are_visibly_larger() + await _check_center_hit_feedback_is_visibly_larger() + await _check_e_toggles_move_list() + _finish() + + +func _pin_rhythm_clock() -> void: + # RhythmManager 自启动的墙钟会让 _upcoming_beat_index 随启动耗时漂移 + # (启动超过一个拍长 0.46s 时 upcoming 越过第 1 拍,特殊拍断言偶发失败)。 + # 钉停时钟,让 upcoming 走确定性的 _last_beat_index+1 兜底路径。 + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + + +func _check_time_anchor_movers_are_visibly_larger() -> void: + var scene := load("res://scenes/ui/rhythm_track.tscn") as PackedScene + _expect(scene != null, "RhythmTrack scene should load") + if scene == null: + return + var track := scene.instantiate() as Control + track.size = Vector2(1152.0, 648.0) + root.add_child(track) + await process_frame + track.size = Vector2(1152.0, 648.0) + track.call("_apply_responsive_layout") + var left_mover := track.get_node_or_null("LeftMover") as Control + _expect(left_mover != null, "RhythmTrack should have LeftMover") + if left_mover == null: + track.queue_free() + return + var normal_size := left_mover.size + track.call("_on_time_anchor_scheduled", {"beat": 1}) + track.call("_on_beat_ticked", 0) + await process_frame + var special_size := left_mover.size + _expect(special_size.x >= normal_size.x * 1.5, "Special rhythm point should be much wider than normal point") + _expect(special_size.y >= normal_size.y * 1.5, "Special rhythm point should be much taller than normal point") + track.queue_free() + + +func _check_center_hit_feedback_is_visibly_larger() -> void: + var scene := load("res://scenes/ui/rhythm_track.tscn") as PackedScene + if scene == null: + return + var track := scene.instantiate() as Control + track.size = Vector2(1152.0, 648.0) + root.add_child(track) + await process_frame + track.size = Vector2(1152.0, 648.0) + track.call("_apply_responsive_layout") + var center_flash := track.get_node_or_null("CenterFlash") as Control + _expect(center_flash != null, "RhythmTrack should have CenterFlash") + if center_flash == null: + track.queue_free() + return + var normal_size := center_flash.size + track.call("_show_center_hit", true) + await process_frame + var hit_size := center_flash.size + _expect(hit_size.x >= normal_size.x * 1.45, "Center hit effect should be much wider than idle flash") + _expect(hit_size.y >= normal_size.y * 1.45, "Center hit effect should be much taller than idle flash") + track.queue_free() + + +func _check_e_toggles_move_list() -> void: + var scene := load("res://scenes/ui/main_ui.tscn") as PackedScene + _expect(scene != null, "MainUI scene should load") + if scene == null: + return + var ui := scene.instantiate() as Control + root.add_child(ui) + await process_frame + _expect(ui.has_method("toggle_move_list"), "MainUI should expose toggle_move_list") + _expect(InputMap.has_action("toggle_move_list"), "E key action should be registered as toggle_move_list") + var panel := ui.get_node_or_null("MoveListPanel") as Control + _expect(panel != null, "MainUI should build a MoveListPanel") + if panel != null and ui.has_method("toggle_move_list"): + _expect_bool(panel.visible, false, "Move list should start hidden") + ui.call("toggle_move_list") + _expect_bool(panel.visible, true, "toggle_move_list should show the move list") + var event := InputEventKey.new() + event.keycode = KEY_E + event.physical_keycode = KEY_E + event.pressed = true + ui.call("_unhandled_input", event) + _expect_bool(panel.visible, false, "Pressing E should hide the visible move list") + ui.queue_free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm feedback and move list") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_feedback_and_move_list.gd.uid b/tests/test_rhythm_feedback_and_move_list.gd.uid new file mode 100644 index 0000000..803e600 --- /dev/null +++ b/tests/test_rhythm_feedback_and_move_list.gd.uid @@ -0,0 +1 @@ +uid://c3ekprrba3j6t diff --git a/tests/test_rhythm_mover_smoothness.gd b/tests/test_rhythm_mover_smoothness.gd new file mode 100644 index 0000000..28fa418 --- /dev/null +++ b/tests/test_rhythm_mover_smoothness.gd @@ -0,0 +1,84 @@ +extends SceneTree + +## 节奏点从两侧向中心的移动必须由音乐时钟连续驱动: +## 进度 = RhythmManager 当前拍内进度,不允许 beat_flash 期间瞬移到中心。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var rhythm := root.get_node_or_null("RhythmManager") + _expect(rhythm != null, "RhythmManager autoload should exist") + if rhythm == null: + _finish() + return + + var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn") + _expect(scene != null, "rhythm_track.tscn should load") + if scene == null: + _finish() + return + var track := scene.instantiate() as Control + root.add_child(track) + await process_frame + + var beat_time := float(rhythm.get("beat_time")) + var left_mover := track.get_node("LeftMover") as Control + var right_mover := track.get_node("RightMover") as Control + var left_start := track.get("left_mover_start") as Vector2 + var right_start := track.get("right_mover_start") as Vector2 + var center := track.get("track_center") as Vector2 + + # 把音乐时钟冻结在拍内 37% 处,节奏点必须停在 37% 的插值位置。 + rhythm.call("pause_clock") + rhythm.set("_paused_song_position", beat_time * 0.37) + # beat_flash 拉满:旧实现会在此瞬移到中心,新实现必须继续跟随时钟。 + track.set("beat_flash", 1.0) + track.call("_update_movers") + _expect_mover_at(left_mover, left_start.lerp(center, 0.37), "left mover follows the musical clock at 37%") + _expect_mover_at(right_mover, right_start.lerp(center, 0.37), "right mover follows the musical clock at 37%") + + # 时钟推进到 82%:节奏点连续前进,不回跳。 + rhythm.set("_paused_song_position", beat_time * 0.82) + track.call("_update_movers") + _expect_mover_at(left_mover, left_start.lerp(center, 0.82), "left mover keeps gliding at 82%") + _expect_mover_at(right_mover, right_start.lerp(center, 0.82), "right mover keeps gliding at 82%") + + # 拍点整数时刻(进度 0):节奏点回到两侧起点,开始下一拍收束。 + rhythm.set("_paused_song_position", beat_time * 1.0) + track.call("_update_movers") + _expect_mover_at(left_mover, left_start, "left mover restarts from the edge on the beat") + _expect_mover_at(right_mover, right_start, "right mover restarts from the edge on the beat") + + rhythm.call("resume_clock") + track.free() + _finish() + + +func _expect_mover_at(mover: Control, expected_center: Vector2, label: String) -> void: + var actual := Vector2( + (mover.offset_left + mover.offset_right) * 0.5, + (mover.offset_top + mover.offset_bottom) * 0.5 + ) + if actual.distance_to(expected_center) > 0.75: + failures.append("%s: expected %s, got %s" % [label, expected_center, actual]) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm mover smoothness") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_mover_smoothness.gd.uid b/tests/test_rhythm_mover_smoothness.gd.uid new file mode 100644 index 0000000..9585f01 --- /dev/null +++ b/tests/test_rhythm_mover_smoothness.gd.uid @@ -0,0 +1 @@ +uid://b800h1hmixxyv diff --git a/tests/test_rhythm_scene.gd b/tests/test_rhythm_scene.gd new file mode 100644 index 0000000..a2de05b --- /dev/null +++ b/tests/test_rhythm_scene.gd @@ -0,0 +1,81 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_rhythm_autoload() + _check_rhythm_manager_contract() + _check_debug_scene() + _finish() + + +func _check_rhythm_autoload() -> void: + _expect(ProjectSettings.has_setting("autoload/EventBus"), "EventBus autoload should be configured") + _expect(ProjectSettings.has_setting("autoload/RhythmManager"), "RhythmManager autoload should be configured") + if ProjectSettings.has_setting("autoload/RhythmManager"): + _expect(str(ProjectSettings.get_setting("autoload/RhythmManager")).contains("res://autoload/rhythm_manager.gd"), "RhythmManager autoload path") + + +func _check_rhythm_manager_contract() -> void: + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + _expect(rhythm_script != null, "RhythmManager script should load") + if rhythm_script == null: + return + + var rhythm: Node = rhythm_script.new() + _expect_float(float(rhythm.get("bpm")), 129.2, "RhythmManager default BPM should match ev_past1.mp3") + _expect(_file_contains("res://autoload/rhythm_manager.gd", "res://assets/audio/ev_past1.mp3"), "RhythmManager should load ev_past1.mp3 by default") + _expect(rhythm.has_method("input_to_song_time"), "RhythmManager should expose input_to_song_time") + _expect(rhythm.has_method("judge"), "RhythmManager should expose judge") + var rating: Dictionary = rhythm.call("judge", 0.0) + _expect(rating.has("nearest_beat"), "judge result should include nearest_beat") + _expect(rating.has("diff"), "judge result should include diff") + rhythm.free() + + +func _check_debug_scene() -> void: + var debug_scene: PackedScene = load("res://scenes/main/clock_debug.tscn") + _expect(debug_scene != null, "clock_debug scene should load") + if debug_scene == null: + return + + var debug_root := debug_scene.instantiate() + _expect(debug_root.get_script() != null, "clock_debug root script should load") + _expect(debug_root.has_method("_print_clock_sample"), "clock_debug should expose manual sample printing") + debug_root.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _file_contains(path: String, needle: String) -> bool: + if not FileAccess.file_exists(path): + return false + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return false + var text := file.get_as_text() + file.close() + return text.contains(needle) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.001: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm scene") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_scene.gd.uid b/tests/test_rhythm_scene.gd.uid new file mode 100644 index 0000000..fe63d3c --- /dev/null +++ b/tests/test_rhythm_scene.gd.uid @@ -0,0 +1 @@ +uid://bvcxr7cxyewo8 diff --git a/tests/test_rhythm_ui.gd b/tests/test_rhythm_ui.gd new file mode 100644 index 0000000..322e174 --- /dev/null +++ b/tests/test_rhythm_ui.gd @@ -0,0 +1,129 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if scene == null: + push_error("Could not load main_ui.tscn") + quit(1) + return + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + for node_path: String in [ + "RhythmTrack", + "RhythmTrack/JudgementLabel", + "RhythmTrack/HudReadoutRow/ComboCountLabel", + "RhythmTrack/HudReadoutRow/AttackBuffValueLabel", + "RhythmTrack/BuffPipRow", + "RhythmTrack/JudgementArt", + "RhythmTrack/ChartMarkerContainer", + "ComboWindow", + "StatusBars/HealthBar", + "StatusBars/EnergyBar", + "StatusBars/ChargeBar", + ]: + if not ui.has_node(node_path): + failures.append("Missing rhythm UI node: %s" % node_path) + + var bus := _event_bus() + bus.emit_signal("judgement_made", &"perfect", 0.0, 12) + bus.emit_signal("streak_changed", 20) + bus.emit_signal("attack_buff_changed", 5, 7) + await process_frame + var label := ui.get_node("RhythmTrack/JudgementLabel") as Label + if not label.text.contains("PERFECT"): + failures.append("RhythmTrack should render EventBus judgement text") + var judgement_art := ui.get_node_or_null("RhythmTrack/JudgementArt") as TextureRect + var combo_label := ui.get_node_or_null("RhythmTrack/HudReadoutRow/ComboCountLabel") as Label + var buff_label := ui.get_node_or_null("RhythmTrack/HudReadoutRow/AttackBuffValueLabel") as Label + var buff_row := ui.get_node_or_null("RhythmTrack/BuffPipRow") + if judgement_art != null: + _expect_bool(judgement_art.visible, true, "RhythmTrack should show judgement art after a judgement") + _expect_string(_texture_file(judgement_art.texture), "perfect.png", "Perfect judgement should use the r/perfect art") + if combo_label != null: + _expect_string(combo_label.text, "combo 20", "RhythmTrack should render combo count under the track") + if buff_label != null: + _expect_string(buff_label.text, "ATK x 500%", "RhythmTrack should render buff stacks as ATK multiplier text") + if buff_row != null: + _expect_buff_pips(buff_row, 5) + bus.emit_signal("judgement_made", &"miss", 0.0, 13) + await process_frame + if judgement_art != null: + _expect_string(_texture_file(judgement_art.texture), "miss.png", "Miss judgement should use the r/miss art") + + var player_scene: PackedScene = load("res://scenes/characters/player.tscn") + if player_scene == null: + failures.append("Player scene should load for EventBus skill facts") + else: + var player := player_scene.instantiate() + root.add_child(player) + await process_frame + player.call("submit_combo_input", "A", "perfect") + await process_frame + var skill_label := ui.get_node("ComboSkillLabel") as Label + _expect_bool(skill_label.visible, false, "MainUI should keep ComboSkillLabel hidden in the reference HUD") + _expect_string(skill_label.text, "", "Hidden ComboSkillLabel should not render skill_executed facts") + player.free() + + ui.free() + _finish() + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect_buff_pips(row: Node, lit_count: int) -> void: + _expect_int(row.get_child_count(), 7, "BuffPipRow should always render seven buff slots") + for index: int in range(row.get_child_count()): + var pip := row.get_child(index) as TextureRect + if pip == null: + failures.append("BuffPipRow child %d should be a TextureRect" % index) + continue + var expected_file := "buff_on.png" if index < lit_count else "buff_off.png" + _expect_string(_texture_file(pip.texture), expected_file, "Buff pip %d should use %s" % [index, expected_file]) + + +func _texture_file(texture: Texture2D) -> String: + if texture == null: + return "" + return texture.resource_path.get_file() + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm ui") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_ui.gd.uid b/tests/test_rhythm_ui.gd.uid new file mode 100644 index 0000000..f7558c8 --- /dev/null +++ b/tests/test_rhythm_ui.gd.uid @@ -0,0 +1 @@ +uid://gc0e04kmqlxa diff --git a/tests/test_rhythm_ui_layout.gd b/tests/test_rhythm_ui_layout.gd new file mode 100644 index 0000000..c317669 --- /dev/null +++ b/tests/test_rhythm_ui_layout.gd @@ -0,0 +1,231 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + await _check_main_ui_authored_layout() + await _check_stage_x1_camera_composition() + _finish() + + +func _check_main_ui_authored_layout() -> void: + var scene: PackedScene = load("res://scenes/ui/rhythm_track.tscn") + if scene == null: + failures.append("Could not load rhythm_track.tscn") + return + + # Movers glide with the live rhythm clock; freeze it so this layout check + # reads the authored start positions deterministically. + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + + var track := scene.instantiate() as Control + root.add_child(track) + await process_frame + + _expect_float(track.anchor_left, 0.5, "RhythmTrack should stay centered") + _expect_float(track.anchor_right, 0.5, "RhythmTrack should stay centered") + _expect_bool(track.has_node("JudgementLabel"), true, "RhythmTrack should own its judgement label") + var track_rect := _control_rect(track) + _expect_float(track_rect.size.x, 864.0, "RhythmTrack should keep the authored scene width") + _expect_float(track_rect.size.y, 240.0, "RhythmTrack should keep the x1 authored scene height") + _expect_float(track_rect.position.y, 54.0, "RhythmTrack should keep the x1 authored scene y") + var readout_row := track.get_node_or_null("HudReadoutRow") as Control + var buff_row := track.get_node_or_null("BuffPipRow") as Control + var judgement_art := track.get_node_or_null("JudgementArt") as Control + _expect_bool(readout_row != null, true, "RhythmTrack should own the combo / ATK readout row") + _expect_bool(buff_row != null, true, "RhythmTrack should own the seven-slot buff row") + _expect_bool(judgement_art != null, true, "RhythmTrack should own the judgement art node") + if readout_row != null: + _expect_float(_control_rect(readout_row).position.y, 97.2, "Combo and ATK readouts should use the x1 responsive layout") + if buff_row != null: + _expect_float(_control_rect(buff_row).position.y, 95.04, "Buff pips should use the x1 responsive layout") + if judgement_art != null: + _expect_float(_control_rect(judgement_art).position.y, 108.48, "Judgement art should use the x1 responsive layout") + var line := track.get_node("TrackLine") as Control + var center_base := track.get_node("CenterBase") as Control + var center_flash := track.get_node("CenterFlash") as Control + var left_mover := track.get_node("LeftMover") as Control + var right_mover := track.get_node("RightMover") as Control + var line_center_y := _control_rect(line).get_center().y + _expect_float((_control_rect(center_base).get_center()).y, line_center_y, "Center anchor should sit on the authored rhythm track horizontal axis") + _expect_float((_control_rect(left_mover).get_center()).y, line_center_y, "Left rhythm point should start on the authored horizontal axis") + _expect_float((_control_rect(right_mover).get_center()).y, line_center_y, "Right rhythm point should start on the authored horizontal axis") + _expect_float((_control_rect(left_mover).get_center()).x, 174.41, "Left rhythm point should start at the x1 responsive position") + _expect_float((_control_rect(right_mover).get_center()).x, 689.59, "Right rhythm point should start at the x1 responsive position") + _expect_float((track.get("track_center") as Vector2).y, line_center_y, "Rhythm point convergence should use the authored track horizontal axis") + _expect_bool(center_flash.z_index > left_mover.z_index, true, "Center hit effect should render above moving rhythm points") + _expect_bool(center_base.z_index > center_flash.z_index, true, "Center icon should render above the hit effect") + _expect_bool(center_base.z_index > left_mover.z_index, true, "Center icon should cover rhythm points when they reach the middle") + + track.free() + + var ui_scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if ui_scene == null: + failures.append("Could not load main_ui.tscn") + return + var ui := ui_scene.instantiate() + root.add_child(ui) + await process_frame + _expect_bool(ui is Control, true, "MainUI should stay as the x1 Control scene hosted by Main/UILayer") + var status_node := ui.get_node("StatusBars") as Control + var rhythm_node := ui.get_node("RhythmTrack") as Control + var boss_node := ui.get_node("BossStatus") as Control + var combo_node := ui.get_node("ComboWindow") as Control + _expect_vector(status_node.scale, Vector2(1.125, 1.125), "Player status HUD runtime scale should match x1") + _expect_vector(status_node.position, Vector2(30.6, 25.2), "Player status HUD runtime position should match x1") + _expect_vector(rhythm_node.scale, Vector2(1.0, 1.0), "Rhythm HUD runtime scale should match x1") + _expect_vector(rhythm_node.pivot_offset, Vector2(432.0, 0.0), "Rhythm HUD should keep its top-center pivot") + _expect_vector(boss_node.scale, Vector2(1.062, 1.062), "Boss status HUD runtime scale should match x1") + _expect_vector(boss_node.pivot_offset, Vector2.ZERO, "Boss status HUD should keep the x1 top-left pivot") + _expect_vector(combo_node.scale, Vector2(1.152, 1.152), "Combo slot HUD runtime scale should match x1") + _expect_vector(combo_node.position, Vector2(37.8, 369.0), "Combo slots runtime position should match x1") + _expect_bool(not bool(ui.get_node("TimePhaseHud").visible), true, "TimePhaseHud should use the authored hidden default") + _expect_bool(not bool(ui.get_node("DebugPanel").visible), true, "DebugPanel should use the authored hidden default") + ui.free() + + +func _check_stage_x1_camera_composition() -> void: + var scene: PackedScene = load("res://scenes/stage/stage.tscn") + if scene == null: + failures.append("Could not load stage.tscn") + return + var stage := scene.instantiate() + var initial_player_position := (stage.get_node("ActorsContainer/Player") as Node2D).position + var initial_melee_position := (stage.get_node("ActorsContainer/JinZhanMinion") as Node2D).position + var initial_boss_position := (stage.get_node("ActorsContainer/Boss") as Node2D).position + root.add_child(stage) + await process_frame + _expect_bool(bool(stage.get("use_authored_camera_view")), false, "Stage should use the x1 follow-camera baseline") + var player := stage.get_node("ActorsContainer/Player") as Node2D + var melee := stage.get_node("ActorsContainer/JinZhanMinion") as Node2D + var boss := stage.get_node("ActorsContainer/Boss") as Node2D + var camera := stage.get_node("Camera2D") as Camera2D + var gate := stage.get_node("BossRoomGate") + _expect_vector(camera.global_position, Vector2(1180, 395), "Camera2D should start over the left-spawn player (new2)") + _expect_vector(camera.zoom, Vector2(1.25, 1.25), "Camera2D should keep the x1 reference zoom") + _expect_vector(initial_player_position, Vector2(1180, 560), "Player should spawn at the map's left side (new2)") + _expect_vector(initial_melee_position, Vector2(2200, 560), "Initial minion should use the left-shifted safe authored anchor") + _expect_vector(initial_boss_position, Vector2(2520, 560), "Boss should keep the x1 authored anchor") + _expect_float(player.global_position.y, melee.global_position.y, "Player and minion anchors should share one horizontal line") + _expect_float(player.global_position.y, boss.global_position.y, "Player and Boss anchors should share one horizontal line") + player.global_position = Vector2(float(gate.get("gate_x")), player.global_position.y) + stage.call("_update_camera_follow") + var boss_entry_x := float(gate.get("gate_x")) + float(gate.get("wall_thickness")) + var half_view_width := float(root.size.x) / (2.0 * camera.zoom.x) + var sealed_camera_max_x := boss_entry_x - half_view_width + _expect_float(camera.global_position.x, sealed_camera_max_x, "Sealed Boss room should clamp camera before the boss-room entry line") + _expect_float(camera.global_position.x + half_view_width, boss_entry_x, "Sealed camera view should not reveal the boss-room entry area") + gate.call("unseal") + await process_frame + stage.call("_update_camera_follow") + _expect_vector(camera.global_position, player.global_position + Vector2(0.0, -165.0), "Camera2D should follow freely after the Boss room path unlocks") + var projectile_screen_y := _world_to_screen(boss.call("projectile_spawn_position", null), camera.global_position, camera.zoom, Vector2(1152, 648)).y + var boss_bounds := _visible_sprite_bounds_global(boss.get_node("Visual/CharacterSprite") as Sprite2D) + var boss_top_screen_y := _world_to_screen(Vector2(boss.global_position.x, boss_bounds.position.y), camera.global_position, camera.zoom, Vector2(1152, 648)).y + var boss_bottom_screen_y := _world_to_screen(Vector2(boss.global_position.x, boss_bounds.end.y), camera.global_position, camera.zoom, Vector2(1152, 648)).y + var boss_height_screen := boss_bottom_screen_y - boss_top_screen_y + _expect_ratio(projectile_screen_y - boss_top_screen_y, boss_height_screen, 0.30, 0.62, "Boss projectile should spawn from the upper body, not below the feet") + _expect_float(absf(float(player.get_node("Visual").scale.x)), 1.0, "Player visual scale should match x1") + _expect_float(float(player.get_node("Visual").scale.y), 1.0, "Player visual scale should match x1") + _expect_float(absf(float(melee.get_node("Visual").scale.x)), 2.0, "Minion visual scale should match x1") + _expect_float(float(melee.get_node("Visual").scale.y), 2.0, "Minion visual scale should match x1") + _expect_float(absf(float(boss.get_node("Visual").scale.x)), 2.0, "Boss visual scale should match x1") + _expect_float(float(boss.get_node("Visual").scale.y), 2.0, "Boss visual scale should match x1") + for sprite_path: String in [ + "Ground/ArtLayer/PastBackground", + "Ground/ArtLayer/FutureBackground", + "ActorsContainer/Player/Visual/CharacterSprite", + "ActorsContainer/Player/Visual/FxOverlay", + "ActorsContainer/Boss/Visual/CharacterSprite", + "ActorsContainer/Boss/Visual/FxOverlay", + ]: + var sprite := stage.get_node_or_null(sprite_path) as Sprite2D + _expect_bool(sprite != null, true, "%s should exist for texture filtering" % sprite_path) + if sprite != null: + _expect_bool(sprite.texture_filter == CanvasItem.TEXTURE_FILTER_LINEAR, true, "%s should use linear texture interpolation when scaled" % sprite_path) + stage.free() + + +func _control_rect(control: Control) -> Rect2: + return Rect2( + Vector2(control.offset_left, control.offset_top), + Vector2(control.offset_right - control.offset_left, control.offset_bottom - control.offset_top) + ) + + +func _world_to_screen(world_position: Vector2, camera_position: Vector2, zoom: Vector2, viewport_size: Vector2) -> Vector2: + return (world_position - camera_position) * zoom + viewport_size * 0.5 + + +func _visible_sprite_bounds_global(sprite: Sprite2D) -> Rect2: + if sprite == null or sprite.texture == null: + return Rect2() + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return Rect2() + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var left := frame_width + var right := -1 + var top := frame_height + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + left = mini(left, x) + right = maxi(right, x) + top = mini(top, y) + bottom = maxi(bottom, y) + if right < left or bottom < top: + return Rect2() + var top_left := sprite.to_global(sprite.offset + Vector2(left, top)) + var bottom_right := sprite.to_global(sprite.offset + Vector2(right, bottom)) + return Rect2( + Vector2(minf(top_left.x, bottom_right.x), minf(top_left.y, bottom_right.y)), + Vector2(absf(bottom_right.x - top_left.x), absf(bottom_right.y - top_left.y)) + ) + + +func _expect_ratio(actual: float, basis: float, minimum: float, maximum: float, label: String) -> void: + var ratio := actual / maxf(1.0, basis) + if ratio < minimum or ratio > maximum: + failures.append("%s: expected ratio %.3f..%.3f, got %.3f" % [label, minimum, maximum, ratio]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.02: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.03: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS rhythm ui layout") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_rhythm_ui_layout.gd.uid b/tests/test_rhythm_ui_layout.gd.uid new file mode 100644 index 0000000..bbf55b6 --- /dev/null +++ b/tests/test_rhythm_ui_layout.gd.uid @@ -0,0 +1 @@ +uid://cxckkauqhu7gh diff --git a/tests/test_round2_changes.gd b/tests/test_round2_changes.gd new file mode 100644 index 0000000..baa1440 --- /dev/null +++ b/tests/test_round2_changes.gd @@ -0,0 +1,183 @@ +extends SceneTree + +## 2026-07-05 第二轮策划需求契约:剑雨放大(范围/视觉翻倍、伤害不变)、 +## 敌弹降速 80%(玩家斩波不变)、受击不转身 + 击退背离攻击者、 +## 胜负停留时间 ×3、结算中文 + RANK 只留等级。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"normal") + + _check_jian_yu_resources() + _check_projectile_speeds() + _check_knockback_direction() + _check_heading_gate() + _check_flow_delays() + await _check_result_screen() + + if settings != null: + settings.call("set_difficulty", &"normal") + _finish() + + +func _check_jian_yu_resources() -> void: + var expected := { + 1: {"damage_mult": 1.8, "base_cost": 20.0}, + 2: {"damage_mult": 2.8, "base_cost": 35.0}, + 3: {"damage_mult": 4.2, "base_cost": 50.0}, + } + for side: String in ["left", "right"]: + for level: int in [1, 2, 3]: + var path := "res://resources/actions/jian_yu_%s_lv%d.tres" % [side, level] + var action := load(path) as Resource + if action == null: + failures.append("jian_yu resource missing: %s" % path) + continue + _expect_float(float(action.get("range")), 280.0, "%s range doubled to 280" % path) + _expect_float(float(action.get("damage_mult")), expected[level]["damage_mult"], "%s damage unchanged" % path) + _expect_float(float(action.get("base_cost")), expected[level]["base_cost"], "%s cost unchanged" % path) + + +func _check_projectile_speeds() -> void: + var container: Node2D = load("res://scenes/stage/actors_container.gd").new() + container.name = "ActorsContainerUnderTest" + root.add_child(container) + container.call("_on_projectile_requested", null, Vector2.ZERO, Vector2.LEFT, {"team": &"enemy"}) + container.call("_on_projectile_requested", null, Vector2.ZERO, Vector2.RIGHT, {"team": &"player"}) + var children := container.get_children() + _expect_int(children.size(), 2, "both projectiles spawn") + if children.size() == 2: + _expect_float(float(children[0].get("speed")), 416.0, "enemy bullets fly at 80% speed (416)") + _expect_float(float(children[1].get("speed")), 520.0, "player wave keeps full speed (520)") + container.free() + + +func _check_knockback_direction() -> void: + var combat := root.get_node_or_null("CombatManager") + if combat == null: + failures.append("CombatManager autoload missing") + return + var receiver := Node2D.new() + receiver.name = "KnockbackReceiver" + root.add_child(receiver) + receiver.global_position = Vector2(100.0, 0.0) + var from_right := {"from": Vector2(200.0, 0.0)} + var from_left := {"from": Vector2(0.0, 0.0)} + _expect_float(float(combat.call("_knockback_x_away_from_attacker", receiver, from_right, 120.0)), -120.0, "attacker on the right pushes receiver left") + _expect_float(float(combat.call("_knockback_x_away_from_attacker", receiver, from_left, 120.0)), 120.0, "attacker on the left pushes receiver right") + _expect_float(float(combat.call("_knockback_x_away_from_attacker", receiver, {}, 120.0)), 120.0, "missing attacker position keeps legacy +X") + receiver.free() + + +func _check_heading_gate() -> void: + var actor_script := GDScript.new() + actor_script.source_code = "extends CharacterBody2D\nvar heading := Vector2.LEFT\nvar speed := 180.0\nvar height := 0.0\nvar height_speed := 0.0\nvar state := &\"idle\"\n" + actor_script.reload() + var actor := CharacterBody2D.new() + actor.name = "HeadingActor" + actor.set_script(actor_script) + var motor: Node = load("res://scenes/components/movement_motor.gd").new() + motor.name = "MovementMotor" + actor.add_child(motor) + root.add_child(actor) + + actor.set("heading", Vector2.LEFT) + motor.call("apply_knockback", Vector2(120.0, 0.0)) + motor.call("set_heading") + _expect_bool(Vector2(actor.get("heading")) == Vector2.LEFT, true, "knockback velocity must not flip heading (受击不转身)") + + # 残速衰减到 0 后门禁解除,自主移动照常改朝向。 + motor.call("_decay_stray_velocity", 1.0) + _expect_float(actor.velocity.x, 0.0, "knockback stray velocity decays to zero") + actor.velocity.x = 50.0 + motor.call("set_heading") + _expect_bool(Vector2(actor.get("heading")) == Vector2.RIGHT, true, "voluntary velocity flips heading again after decay") + actor.free() + + +func _check_flow_delays() -> void: + var flow_script := load("res://autoload/game_flow_manager.gd") as GDScript + var constants := flow_script.get_script_constant_map() + _expect_float(float(constants.get("VICTORY_SCREEN_DELAY", 0.0)), 3.6, "victory delay tripled to 3.6s") + _expect_float(float(constants.get("DEFEAT_SCREEN_DELAY", 0.0)), 4.8, "defeat delay tripled to 4.8s") + + +func _check_result_screen() -> void: + var flow: Node = load("res://autoload/game_flow_manager.gd").new() + flow.name = "FlowRound2" + flow.set("manage_scene", false) + root.add_child(flow) + await process_frame + + var labels: Dictionary = flow.get("_result_labels") + flow.set("_victory", true) + flow.call("_populate_result_screen") + var rank_label := labels.get("rank", null) as Label + if rank_label == null: + failures.append("rank label missing") + else: + _expect_bool(rank_label.text.begins_with("RANK "), true, "rank text keeps the RANK prefix") + _expect_bool(rank_label.text.contains("/"), false, "rank text drops the score suffix (只留等级)") + + var result_screen := (flow.get("_screens") as Dictionary).get(&"Result", null) as Control + if result_screen == null: + failures.append("result screen missing") + else: + var texts := _collect_label_texts(result_screen) + for expected_text: String in ["最大连击", "完美", "良好", "勉强", "失误", "锚点成功", "相位切换"]: + _expect_bool(texts.has(expected_text), true, "result stats show Chinese label %s" % expected_text) + + # 页脚实时反映当前难度(标题页 + 难度页)。 + flow.call("_select_difficulty", &"hard") + _expect_string(str((flow.get("_title_difficulty_value") as Label).text), "困难", "title footer tracks difficulty") + _expect_string(str((flow.get("_difficulty_footer_value") as Label).text), "当前难度:困难", "difficulty footer tracks difficulty") + flow.call("_select_difficulty", &"normal") + flow.free() + + +func _collect_label_texts(node: Node) -> Array[String]: + var texts: Array[String] = [] + if node is Label: + texts.append((node as Label).text) + for child: Node in node.get_children(): + texts.append_array(_collect_label_texts(child)) + return texts + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_string(actual: String, expected: String, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS round2 changes") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_round2_changes.gd.uid b/tests/test_round2_changes.gd.uid new file mode 100644 index 0000000..47d3031 --- /dev/null +++ b/tests/test_round2_changes.gd.uid @@ -0,0 +1 @@ +uid://doa52mam1gn3h diff --git a/tests/test_round3_fixes.gd b/tests/test_round3_fixes.gd new file mode 100644 index 0000000..1d3b185 --- /dev/null +++ b/tests/test_round3_fixes.gd @@ -0,0 +1,174 @@ +extends SceneTree + +## 第三轮修复契约(2026-07-05): +## 1. 标题/难度背景改用无 Logo 的干净关卡原画 + 独立悬浮 Logo, +## 背景以 2 秒为单位渐变轮换,渐变期间 UI 不再重叠。 +## 2. 判定字样(JudgementArt/Label)显示 1 秒后自动消失,新判定顶替重置计时。 +## 3. Boss 被击退:击退残速不再被 handle_movement 清零,滑行距离 ×3 +## (距离 ∝ 初速²,由 CombatManager 按 √3 换算初速)。 + +const CLEAN_PAST_ART := "res://assets/art/ground/past/past.png" +const CLEAN_FUTURE_ART := "res://assets/art/ground/future/future.png" + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + await _check_title_composition() + await _check_judgement_auto_hide() + await _check_boss_knockback() + _finish() + + +func _check_title_composition() -> void: + var flow: Node = load("res://autoload/game_flow_manager.gd").new() + flow.name = "FlowUnderTestRound3" + flow.set("manage_scene", false) + root.add_child(flow) + await process_frame + + var consts: Dictionary = (flow.get_script() as Script).get_script_constant_map() + _expect_float(float(consts.get("TITLE_BG_HOLD_SECONDS", 0.0)), 2.0, "标题背景以 2 秒为单位轮流切换") + _expect_float(float(consts.get("TITLE_BG_FADE_SECONDS", 0.0)), 0.8, "轮换带渐入过渡") + + var title_screen := (flow.get("_screens") as Dictionary).get(&"Title", null) as Control + _expect(title_screen != null, "标题屏存在") + if title_screen != null: + var past := title_screen.get_node_or_null("SceneBackgroundPast") as TextureRect + var future := title_screen.get_node_or_null("SceneBackgroundFuture") as TextureRect + _expect(past != null and past.texture != null and past.texture.resource_path == CLEAN_PAST_ART, + "标题底层背景使用干净的过去原画(无 Logo 烙印)") + _expect(future != null and future.texture != null and future.texture.resource_path == CLEAN_FUTURE_ART, + "标题叠层背景使用干净的未来原画(无 Logo 烙印)") + var logo := title_screen.get_node_or_null("TitleLogo") as TextureRect + _expect(logo != null, "标题持有独立悬浮 Logo 节点") + if logo != null: + _expect(logo.position.x > 340.0, "Logo 悬浮在背景中央区域(面板之外),渐变时保持固定") + _expect(logo.size.is_equal_approx(Vector2(616.0, 300.0)), "Logo 按原生尺寸显示") + _expect(logo.mouse_filter == Control.MOUSE_FILTER_IGNORE, "悬浮 Logo 不得拦截鼠标点击") + + var difficulty_screen := (flow.get("_screens") as Dictionary).get(&"DifficultySelect", null) as Control + _expect(difficulty_screen != null, "难度屏存在") + if difficulty_screen != null: + var bg := difficulty_screen.get_node_or_null("SceneBackground") as TextureRect + _expect(bg != null and bg.texture != null and bg.texture.resource_path == CLEAN_FUTURE_ART, + "难度屏背景使用干净的未来原画") + _expect(difficulty_screen.get_node_or_null("TitleLogo") is TextureRect, + "难度屏同样叠加悬浮 Logo") + + flow.free() + + +func _check_judgement_auto_hide() -> void: + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if scene == null: + failures.append("main_ui.tscn 应可加载") + return + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + var track := ui.get_node_or_null("RhythmTrack") as Control + var art := track.get_node_or_null("JudgementArt") as TextureRect if track != null else null + if art == null: + failures.append("RhythmTrack 应持有 JudgementArt") + ui.free() + return + + var bus := _event_bus() + bus.emit_signal("judgement_made", &"perfect", 0.0, 12) + await process_frame + _expect(art.visible, "判定字样在判定后立即可见") + await create_timer(0.5).timeout + _expect(art.visible, "判定字样满 1 秒前保持可见") + await create_timer(0.85).timeout + _expect(not art.visible, "判定字样满 1 秒后自动消失") + + # 顶替语义:1 秒内出现新判定则重置计时,从新判定起再算 1 秒。 + bus.emit_signal("judgement_made", &"good", 20.0, 13) + await process_frame + _expect(art.visible, "新判定重新点亮字样") + await create_timer(0.6).timeout + bus.emit_signal("judgement_made", &"perfect", 0.0, 14) + await process_frame + await create_timer(0.6).timeout + _expect(art.visible, "被顶替后计时重置:距新判定不足 1 秒仍可见") + await create_timer(0.7).timeout + _expect(not art.visible, "顶替后的判定同样满 1 秒消失") + + ui.free() + + +func _check_boss_knockback() -> void: + var combat := root.get_node_or_null("CombatManager") + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + if boss_scene == null or combat == null: + failures.append("boss.tscn 与 CombatManager 应可用") + return + var boss := boss_scene.instantiate() as Node2D + root.add_child(boss) + await process_frame + boss.global_position = Vector2(500.0, 300.0) + + # 2026-07-05 策划两轮调参:首轮 ×3 试玩仍嫌短,加码到 ×9(初速 ×3)。 + _expect_float(float(boss.call("knockback_distance_taken_mult")), 9.0, "Boss 被击退距离倍数为 9") + _expect_float(float(combat.call("_knockback_velocity_scale_for_receiver", boss)), 3.0, + "CombatManager 将距离 ×9 换算为初速 ×3") + var plain := Node2D.new() + root.add_child(plain) + _expect_float(float(combat.call("_knockback_velocity_scale_for_receiver", plain)), 1.0, + "无钩子的受击者保持原始击退") + plain.free() + + # 结构性修复:击退残速必须在 Boss 的 handle_movement 里活下来并产生滑行。 + var motor := boss.get_node("MovementMotor") + var start_x := boss.global_position.x + motor.call("apply_knockback", Vector2(120.0, 0.0)) + await physics_frame + await physics_frame + await physics_frame + _expect((boss as CharacterBody2D).velocity.x > 0.0, "击退残速不再被 Boss 每帧清零") + for _index: int in range(60): + await physics_frame + var displacement := boss.global_position.x - start_x + _expect(displacement > 5.0, "Boss 击退产生实际水平滑行(got %.2f px)" % displacement) + + boss.free() + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.4f, got %.4f" % [label, expected, actual]) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS round3 fixes") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_round3_fixes.gd.uid b/tests/test_round3_fixes.gd.uid new file mode 100644 index 0000000..6471a03 --- /dev/null +++ b/tests/test_round3_fixes.gd.uid @@ -0,0 +1 @@ +uid://c2s1kfic6et7t diff --git a/tests/test_round4_fixes.gd b/tests/test_round4_fixes.gd new file mode 100644 index 0000000..61ff018 --- /dev/null +++ b/tests/test_round4_fixes.gd @@ -0,0 +1,196 @@ +extends SceneTree + +## 第四轮修复契约(2026-07-05,07-06 修订): +## 1. 小怪击退:残速不再被 AI 每帧接管清零,产生实际水平滑行; +## 垂直击退后假高度轴正常积分并落地复位 Grounded(修复"被剑雨/下砸 +## 打过一次后 ground_state 永久 Airborne、小怪从此不能出招"的隐藏 bug)。 +## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver +## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉 +## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。 +## 3. 关卡背景单层视差:背景水平速度为相机 0.8 倍(0.92 肉眼难辨已加强), +## 锚点 2047,无相机保持原位;脚下地砖轻微滑动是单张烙死原画的已知 +## 取舍,无伤视差待美术拆层。 +## (Boss 击退 ×9 的加码钉在 test_round3_fixes。) + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + await _check_minion_horizontal_knockback() + await _check_minion_vertical_knockback_recovers() + await _check_stagger_gates_enemy_actions() + await _check_boss_to_player_knockback() + await _check_background_parallax() + _finish() + + +func _make_minion() -> Node2D: + var scene: PackedScene = load("res://scenes/enemies/minion.tscn") + var minion := scene.instantiate() as Node2D + root.add_child(minion) + # 与 test_minion_phase_system 同款:关 AI,排除追击/巡逻对位移的污染。 + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + minion.set("approach_direction", 0.0) + return minion + + +func _check_minion_horizontal_knockback() -> void: + var minion := _make_minion() + await process_frame + minion.global_position = Vector2(500.0, 300.0) + var motor := minion.get_node("MovementMotor") + var start_x := minion.global_position.x + motor.call("apply_knockback", Vector2(120.0, 0.0)) + await physics_frame + await physics_frame + await physics_frame + _expect((minion as CharacterBody2D).velocity.x > 0.0, "小怪击退残速不再被 AI 每帧清零") + for _index: int in range(40): + await physics_frame + var displacement := minion.global_position.x - start_x + _expect(displacement > 5.0, "小怪击退产生实际水平滑行(got %.2f px)" % displacement) + _expect(not bool(motor.call("has_knockback_stray")), "残速衰减完后 stray 标志自清(AI 可恢复接管)") + minion.free() + + +func _check_minion_vertical_knockback_recovers() -> void: + var minion := _make_minion() + await process_frame + minion.global_position = Vector2(500.0, 300.0) + var motor := minion.get_node("MovementMotor") + var state_machine := minion.get_node("StateMachine") + motor.call("apply_knockback", Vector2(0.0, 300.0)) + await physics_frame + _expect_equal(state_machine.call("get_ground_state"), &"Airborne", "垂直击退令小怪进入 Airborne") + var landed := false + for _index: int in range(180): + await physics_frame + if state_machine.call("get_ground_state") == &"Grounded" \ + and is_zero_approx(float(minion.get("height"))) \ + and is_zero_approx(float(minion.get("height_speed"))): + landed = true + break + _expect(landed, "垂直击退后小怪应自然落地复位 Grounded(旧版永久卡 Airborne 导致无法出招)") + minion.free() + + +func _check_stagger_gates_enemy_actions() -> void: + # Boss:击退滑行期间不得起手新动作;强制撤退清残速后恢复。 + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + var boss := boss_scene.instantiate() as Node2D + root.add_child(boss) + await process_frame + boss.set("combat_enabled", true) + boss.set("stationary", false) + # 用不解析的动作 ID 旁路 BossBehaviorTree 的相位限型分支(melee/ranged + # 按时间相位限制与本契约无关),只隔离趔趄门禁本身。 + var boss_motor := boss.get_node("MovementMotor") + _expect(bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), "无击退时 Boss 可正常起手") + boss_motor.call("apply_knockback", Vector2(120.0, 0.0)) + _expect(not bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), + "击退滑行(趔趄)期间 Boss 不得起手新动作——否则突进会吃掉击退位移") + boss_motor.call("clear_knockback_stray") + _expect(bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), + "清残速标志后恢复起手(受击链强制撤退走此豁免)") + boss.free() + + # 小怪:同款门禁。 + var minion := _make_minion() + await process_frame + var minion_motor := minion.get_node("MovementMotor") + _expect(bool(minion.call("can_start_enemy_action", &"__stagger_probe__")), "无击退时小怪可正常起手") + minion_motor.call("apply_knockback", Vector2(120.0, 0.0)) + _expect(not bool(minion.call("can_start_enemy_action", &"__stagger_probe__")), + "击退滑行期间小怪不得起手新动作") + minion.free() + + +func _check_boss_to_player_knockback() -> void: + # 2026-07-06 策划(连续三轮):"Boss 对玩家没有任何击退效果"。两个根因: + # ①Boss 近战沿用默认基准 120,普通连段倍率 0.4/0.5 → 位移仅 2~3px,且相机 + # 1:1 跟随玩家把位移完全吃进视觉;②敌方弹丸基准击退 (0,0),未来相位 + # Boss 只用远程 → 恒为零。修复=Boss 近战基准 ×3 初速 + 敌弹补基准。 + var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") + var boss := boss_scene.instantiate() + root.add_child(boss) + await process_frame + var emitter := boss.get_node("DamageEmitter") + _expect((emitter.get("base_knockback") as Vector2).is_equal_approx(Vector2(360.0, 304.056)), + "Boss 近战基准击退 ×3 初速(普通连段 2px→20~32px、重击 190~343px)") + boss.free() + + var container: Node2D = (load("res://scenes/stage/actors_container.gd") as GDScript).new() + root.add_child(container) + container.call("_on_projectile_requested", null, Vector2.ZERO, Vector2.LEFT, {"team": &"enemy"}) + await process_frame + var bullet := container.get_child(0) + _expect((bullet.get("base_knockback") as Vector2).is_equal_approx(Vector2(360.0, 0.0)), + "敌方弹丸补上基准击退(未来相位不再恒为零,弹丸不击飞)") + _expect_float(float(bullet.get("speed")), 416.0, "敌弹速度保持 416 不受击退注入影响") + container.free() + + +func _check_background_parallax() -> void: + var scene: PackedScene = load("res://scenes/ground/ground1.tscn") + var ground := scene.instantiate() + root.add_child(ground) + await process_frame + await process_frame + var art_layer := ground.get_node("ArtLayer") as Node2D + + # 无相机:背景保持原位(headless/菜单兜底)。 + _expect_float(art_layer.position.x, 896.5, "无相机时背景保持基准位置") + + # 有相机:偏移 = (相机中心 - 1734.5) × 0.8——"远景天幕"语义,背景 80% + # 跟随相机,仅 0.2 倍速随世界滚动,保证远景夕阳时刻在画面内(策划定案)。 + var camera := Camera2D.new() + root.add_child(camera) + camera.make_current() + camera.global_position = Vector2(2547.0, 395.0) + await process_frame + await process_frame + _expect_float(art_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "背景 80% 跟随相机(天幕视差)") + + camera.global_position = Vector2(1547.0, 395.0) + await process_frame + await process_frame + _expect_float(art_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时天幕对称跟随") + + camera.free() + ground.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.4f, got %.4f" % [label, expected, actual]) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS round4 fixes") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_round4_fixes.gd.uid b/tests/test_round4_fixes.gd.uid new file mode 100644 index 0000000..1c8b772 --- /dev/null +++ b/tests/test_round4_fixes.gd.uid @@ -0,0 +1 @@ +uid://bsg34311s4xkm diff --git a/tests/test_stage4_assets.gd b/tests/test_stage4_assets.gd new file mode 100644 index 0000000..e2e6683 --- /dev/null +++ b/tests/test_stage4_assets.gd @@ -0,0 +1,97 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_player_asset_paths() + _check_boss_asset_paths() + _check_reference_and_credits() + _check_no_ds_store("res://assets/art/characters/player") + _check_no_ds_store("res://assets/art/characters/boss") + _check_key_dimensions() + _finish() + + +func _check_player_asset_paths() -> void: + for path: String in [ + "res://assets/art/characters/player/01_basic_attack_i/air_prep.png", + "res://assets/art/characters/player/01_basic_attack_i/air_swing.png", + "res://assets/art/characters/player/01_basic_attack_i/slash_overlay_fx.png", + "res://assets/art/characters/player/02_basic_attack_ii/air_prep.png", + "res://assets/art/characters/player/02_basic_attack_ii/air_swing.png", + "res://assets/art/characters/player/07_rising_slash/prep.png", + "res://assets/art/characters/player/07_rising_slash/swing.png", + "res://assets/art/characters/player/18_block/air_block_start.png", + "res://assets/art/characters/player/16_idle/idle_loop.png", + "res://assets/art/characters/player/17_turn_around/turn.png", + ]: + _expect(FileAccess.file_exists(path), "Missing player asset: %s" % path) + + +func _check_boss_asset_paths() -> void: + for path: String in [ + "res://assets/art/characters/boss/idle.png", + "res://assets/art/characters/boss/ground_combo_1.png", + "res://assets/art/characters/boss/lunging_stab.png", + "res://assets/art/characters/boss/2_way_shoot_gun_1.png", + ]: + _expect(FileAccess.file_exists(path), "Missing boss asset: %s" % path) + + +func _check_reference_and_credits() -> void: + _expect(FileAccess.file_exists("res://assets/art/characters/player/credits_blair_ceradsky.txt"), "Player asset credits should be copied") + _expect(FileAccess.file_exists("res://assets/art/characters/player/reference/suggested_collision_box.png"), "Suggested collision reference should be copied") + _expect(FileAccess.file_exists("res://assets/art/characters/player/reference/suggested_gameobject_center.png"), "Suggested center reference should be copied") + + +func _check_no_ds_store(dir_path: String) -> void: + var dir := DirAccess.open(dir_path) + _expect(dir != null, "%s should exist" % dir_path) + if dir == null: + return + for file_name: String in dir.get_files(): + _expect(file_name != ".DS_Store", "%s should not contain .DS_Store" % dir_path) + for subdir: String in dir.get_directories(): + _check_no_ds_store("%s/%s" % [dir_path, subdir]) + + +func _check_key_dimensions() -> void: + _expect_image_size("res://assets/art/characters/player/16_idle/idle_loop.png", Vector2i(512, 256)) + _expect_image_size("res://assets/art/characters/player/01_basic_attack_i/air_prep.png", Vector2i(384, 128)) + _expect_image_size("res://assets/art/characters/player/07_rising_slash/swing.png", Vector2i(640, 256)) + _expect_image_size("res://assets/art/characters/player/18_block/air_block_start.png", Vector2i(640, 128)) + _expect_image_size("res://assets/art/characters/boss/lunging_stab.png", Vector2i(1200, 48)) + + +func _expect_image_size(path: String, expected: Vector2i) -> void: + if not FileAccess.file_exists(path): + failures.append("Cannot inspect missing image: %s" % path) + return + var image := Image.new() + var error := image.load(ProjectSettings.globalize_path(path)) + if error != OK: + failures.append("Could not load %s: error %s" % [path, error]) + return + var actual := image.get_size() + if actual != expected: + failures.append("%s expected %s, got %s" % [path, expected, actual]) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS stage 4 assets") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_stage4_assets.gd.uid b/tests/test_stage4_assets.gd.uid new file mode 100644 index 0000000..9a17a25 --- /dev/null +++ b/tests/test_stage4_assets.gd.uid @@ -0,0 +1 @@ +uid://cooq78k0hkmvx diff --git a/tests/test_stage4_movement_physics.gd b/tests/test_stage4_movement_physics.gd new file mode 100644 index 0000000..fe2de7c --- /dev/null +++ b/tests/test_stage4_movement_physics.gd @@ -0,0 +1,220 @@ +extends SceneTree + +const ENEMY_BODY_MASK := 1 << 6 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _check_scripts_load() + await _check_action_motion_direction() + await _check_block_has_no_displacement() + await _check_launcher_sets_airborne() + await _check_launcher_returns_to_ground() + await _check_air_followup_has_long_horizontal_arc("A", -1.0) + await _check_air_followup_has_long_horizontal_arc("D", 1.0) + await _check_dash_through_restores_mask_on_cancel() + _check_no_free_movement_bindings() + _check_jump_input_removed() + _finish() + + +func _check_scripts_load() -> void: + for path: String in [ + "res://scenes/components/movement_motor.gd", + "res://scenes/components/motion_executor.gd", + "res://scenes/characters/character.gd", + "res://scenes/characters/player.gd", + "res://scenes/ground/ground.tscn", + ]: + _expect(load(path) != null, "%s should load" % path) + + +func _check_action_motion_direction() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(scene != null, "player.tscn should load for motion direction") + if scene == null: + return + # 起手默认 ANCHOR_ACTIVE 会按墙钟相位拉伸对齐拍格,固定秒数等待会偶发 + # 在位移相位前超时;与本文件其它检查一致,按物理帧轮询到出现位移为止。 + var left_player := await _make_player(scene) + var left_start := (left_player as Node2D).global_position.x + left_player.call("submit_combo_input", "A", "perfect") + var moved_left := false + for _index: int in range(90): + await physics_frame + if (left_player as Node2D).global_position.x < left_start: + moved_left = true + break + _expect(moved_left, "A action should move player left") + left_player.free() + + var right_player := await _make_player(scene) + var right_start := (right_player as Node2D).global_position.x + right_player.call("submit_combo_input", "D", "perfect") + var moved_right := false + for _index: int in range(90): + await physics_frame + if (right_player as Node2D).global_position.x > right_start: + moved_right = true + break + _expect(moved_right, "D action should move player right") + right_player.free() + + +func _check_block_has_no_displacement() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + return + var player := await _make_player(scene) + var start_position := (player as Node2D).global_position + player.call("submit_combo_input", "S", "perfect") + await create_timer(0.35).timeout + await physics_frame + _expect((player as Node2D).global_position.is_equal_approx(start_position), "S block should not displace the player") + _expect(is_zero_approx(float(player.get("height"))), "S block should not change height") + _expect(is_zero_approx(float(player.get("height_speed"))), "S block should not add vertical speed") + player.free() + + +func _check_launcher_sets_airborne() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + return + var player := await _make_player(scene) + var state_machine: Node = player.get_node("StateMachine") + player.call("submit_combo_input", "W", "perfect") + var saw_airborne := false + var saw_height_motion := false + for _index: int in range(90): + await physics_frame + saw_airborne = saw_airborne or state_machine.call("get_ground_state") == &"Airborne" + saw_height_motion = saw_height_motion or float(player.get("height_speed")) > 0.0 or float(player.get("height")) > 0.0 + if saw_airborne and saw_height_motion: + break + _expect(saw_airborne, "W launcher should set GroundState Airborne") + _expect(saw_height_motion, "W launcher should start vertical height motion") + player.free() + + +func _check_launcher_returns_to_ground() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + return + var player := await _make_player(scene) + var state_machine: Node = player.get_node("StateMachine") + player.call("submit_combo_input", "W", "perfect") + var saw_airborne := false + var landed_after_airborne := false + for _index: int in range(240): + await physics_frame + if state_machine.call("get_ground_state") == &"Airborne": + saw_airborne = true + elif saw_airborne and state_machine.call("get_ground_state") == &"Grounded" and is_zero_approx(float(player.get("height"))) and is_zero_approx(float(player.get("height_speed"))): + landed_after_airborne = true + break + _expect(saw_airborne, "W launcher should enter Airborne before landing") + _expect(landed_after_airborne, "W launcher should naturally return to Grounded with zero height") + player.free() + + +func _check_air_followup_has_long_horizontal_arc(symbol: String, direction: float) -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + return + var player := await _make_player(scene) + var state_machine: Node = player.get_node("StateMachine") + var action_controller: Node = player.get_node("ActionController") + player.call("submit_combo_input", "W", "perfect") + var ready_for_air_followup := false + for _index: int in range(140): + await physics_frame + if state_machine.call("get_ground_state") == &"Airborne" and int(action_controller.get("phase")) == 0 and float(player.get("height")) > 40.0: + ready_for_air_followup = true + break + _expect(ready_for_air_followup, "W should leave enough airtime for W+%s follow-up" % symbol) + if not ready_for_air_followup: + player.free() + return + var start_x := (player as Node2D).global_position.x + var start_height := float(player.get("height")) + var min_height := start_height + var max_displacement := 0.0 + player.call("submit_combo_input", symbol, "perfect") + for _index: int in range(90): + await physics_frame + var displacement := ((player as Node2D).global_position.x - start_x) * direction + max_displacement = maxf(max_displacement, displacement) + min_height = minf(min_height, float(player.get("height"))) + _expect(max_displacement >= 240.0, "W+%s should carry a long horizontal air arc, got %.1f px" % [symbol, max_displacement]) + _expect(min_height <= start_height - 20.0, "W+%s should visibly descend during the horizontal air arc" % symbol) + player.free() + + +func _check_dash_through_restores_mask_on_cancel() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + if scene == null: + return + var player := await _make_player(scene) + _expect((int(player.get("collision_mask")) & ENEMY_BODY_MASK) != 0, "Player body should normally collide with enemy body") + var motion: Node = player.get_node("MotionExecutor") + var action := _dash_through_action() + motion.call("execute", action, Vector2.RIGHT, 0.5, 220.0) + _expect((int(player.get("collision_mask")) & ENEMY_BODY_MASK) == 0, "dash_through should disable enemy body collision") + motion.call("cancel") + _expect((int(player.get("collision_mask")) & ENEMY_BODY_MASK) != 0, "dash_through cancel should restore enemy body collision") + player.free() + + +func _dash_through_action() -> Resource: + var script: Script = load("res://resources/action_data.gd") + var action: Resource = script.new() + var action_tags: Array[StringName] = [&"dash_through"] + action.set("id", &"test_dash_through") + action.set("action_tags", action_tags) + action.set("move_mult_x", 1.0) + action.set("startup_beats", 0.25) + action.set("active_beats", 0.25) + action.set("recovery_beats", 0.25) + return action + + +func _check_no_free_movement_bindings() -> void: + for action_name: StringName in [&"move_left", &"move_right"]: + _expect(InputMap.has_action(action_name), "%s action should remain declared" % action_name) + _expect_int(InputMap.action_get_events(action_name).size(), 0, "%s should stay unbound in rhythm migration" % action_name) + + +func _check_jump_input_removed() -> void: + _expect(not InputMap.has_action(&"jump"), "There should be no jump input action in the rhythm-only player") + + +func _make_player(scene: PackedScene) -> Node: + var player := scene.instantiate() + root.add_child(player) + await process_frame + return player + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS stage 4 movement physics") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_stage4_movement_physics.gd.uid b/tests/test_stage4_movement_physics.gd.uid new file mode 100644 index 0000000..a5f901b --- /dev/null +++ b/tests/test_stage4_movement_physics.gd.uid @@ -0,0 +1 @@ +uid://btuqu7l33o7nr diff --git a/tests/test_stage4_player_scene.gd b/tests/test_stage4_player_scene.gd new file mode 100644 index 0000000..c698438 --- /dev/null +++ b/tests/test_stage4_player_scene.gd @@ -0,0 +1,229 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene: PackedScene = load("res://scenes/characters/player.tscn") + _expect(scene != null, "player.tscn should load") + if scene == null: + _finish() + return + + var player := scene.instantiate() + root.add_child(player) + await process_frame + _check_scene_nodes(player) + _check_animation_table(player) + _check_action_animation_names() + _check_initial_native_facing(player) + await _check_directional_heading_and_mirror(scene) + player.free() + _finish() + + +func _check_scene_nodes(player: Node) -> void: + _expect(player is CharacterBody2D, "Player root should be CharacterBody2D") + _expect(player.get_node_or_null("Visual") is Node2D, "Player should have Visual mirror node") + _expect(player.get_node_or_null("Visual/CharacterSprite") is Sprite2D, "Player should have Visual/CharacterSprite") + _expect(player.get_node_or_null("Visual/FxOverlay") is Sprite2D, "Player should have Visual/FxOverlay") + _expect(player.get_node_or_null("Visual/FxAnimationPlayer") is AnimationPlayer, "Player should have Visual/FxAnimationPlayer") + _expect(player.get_node_or_null("AnimationPlayer") is AnimationPlayer, "Player should have AnimationPlayer") + _expect(player.get_node_or_null("ContactFxSpawner") != null, "Player should have ContactFxSpawner placeholder") + _expect(player.get_node_or_null("MovementMotor") != null, "Player should have MovementMotor") + _expect(player.get_node_or_null("MotionExecutor") != null, "Player should have MotionExecutor") + _expect(player.get_node_or_null("ActionExecutor") != null, "Player should have stage-4 ActionExecutor") + _expect(player.get_node_or_null("HealthComponent") != null, "Player should keep phase-5 HealthComponent after combat migration") + if player.get_node_or_null("HealthComponent") != null: + _expect_int(int(player.get_node("HealthComponent").get("maximum")), 1000, "Hiro HealthComponent maximum should match the Boss design baseline") + _expect_int(int(player.get_node("HealthComponent").get("current")), 1000, "Hiro HealthComponent current should start at the Boss design baseline") + _expect(player.get_node_or_null("DamageEmitter") is Area2D, "Player should keep phase-5 DamageEmitter after combat migration") + _expect(player.get_node_or_null("DamageReceiver") is Area2D, "Player should keep phase-5 DamageReceiver after combat migration") + var sprite := player.get_node_or_null("Visual/CharacterSprite") as Sprite2D + if sprite != null: + _expect(sprite.texture != null and sprite.texture.resource_path == "res://assets/art/characters/player/16_idle/idle_loop.png", "CharacterSprite should use Hiro idle sheet") + _expect_int(sprite.hframes, 4, "Hiro idle sheet hframes") + _expect_int(sprite.vframes, 2, "Hiro idle sheet vframes") + + +func _check_animation_table(player: Node) -> void: + var animation_player := player.get_node("AnimationPlayer") as AnimationPlayer + for animation_name: StringName in [&"idle", &"atk_ground_1", &"atk_ground_2", &"rising_slash", &"block_start", &"turn"]: + _expect(animation_player.has_animation(animation_name), "Missing Hiro animation: %s" % animation_name) + _expect_uses_visual_sprite(animation_player.get_animation(animation_name), str(animation_name)) + _expect_overlay_skips_reference_frame(animation_player, "atk_ground_1") + _expect_overlay_offset(animation_player, "atk_ground_1", Vector2(-147, -191)) + _expect_overlay_offset(animation_player, "atk_ground_2", Vector2(-147, -191)) + _expect_overlay_offset(animation_player, "rising_slash", Vector2(-111, -156)) + _expect_frame_sequence(animation_player, "idle", [0, 1, 2, 3, 4, 5, 6, 7]) + _expect_frame_sequence(animation_player, "block_start", [0, 1, 2, 3, 4]) + _expect_sprite_sheet(animation_player, "block_start", "res://assets/art/characters/player/18_block/air_block_start.png", 5, 1) + + +func _expect_uses_visual_sprite(animation: Animation, animation_name: String) -> void: + if animation == null: + return + var found_sprite_frame := false + for track_index: int in range(animation.get_track_count()): + var path := animation.track_get_path(track_index) + if path == NodePath("Visual/CharacterSprite:frame"): + found_sprite_frame = true + if str(path).begins_with("CharacterSprite:"): + failures.append("%s should target Visual/CharacterSprite, not root CharacterSprite" % animation_name) + _expect(found_sprite_frame, "%s should key Visual/CharacterSprite frames" % animation_name) + + +func _expect_overlay_skips_reference_frame(animation_player: AnimationPlayer, animation_name: String) -> void: + var animation := animation_player.get_animation(animation_name) + _expect(animation != null, "%s should exist for overlay check" % animation_name) + if animation == null: + return + for track_index: int in range(animation.get_track_count()): + if animation.track_get_path(track_index) != NodePath("Visual/FxOverlay:frame"): + continue + if animation.track_get_key_count(track_index) == 0: + failures.append("%s overlay frame track should have keys" % animation_name) + return + var first_frame := int(animation.track_get_key_value(track_index, 0)) + _expect_int(first_frame, 1, "%s overlay should skip reference frame 0" % animation_name) + return + failures.append("%s should key Visual/FxOverlay frames" % animation_name) + + +func _check_action_animation_names() -> void: + var expected := { + "res://resources/actions/ground_attack_left_1.tres": &"atk_ground_1", + "res://resources/actions/ground_attack_right_1.tres": &"atk_ground_1", + "res://resources/actions/ground_attack_left_2.tres": &"atk_ground_2", + "res://resources/actions/ground_attack_right_2.tres": &"atk_ground_2", + "res://resources/actions/launcher_up.tres": &"rising_slash", + "res://resources/actions/block_start.tres": &"block_start", + } + for path: String in expected: + var action: Resource = load(path) + _expect(action != null, "%s should load" % path) + if action != null: + _expect(action.get("animation") == expected[path], "%s animation should be %s" % [path, expected[path]]) + + +func _check_initial_native_facing(player: Node) -> void: + _expect_vector(player.get("heading"), Vector2.LEFT, "Player should start facing Hiro's native left direction") + _expect_float(float(player.get_node("Visual").scale.x), 2.0, "Initial Visual scale should keep Hiro's native left-facing art at reference size") + _expect_float(float(player.get_node("Visual").scale.y), 2.0, "Initial Visual scale should keep Hiro at reference size") + _expect_float(float(player.get("visual_ground_offset")), 32.0, "Player Visual should expose the editor/runtime feet offset") + _expect_vector(player.get_node("Visual").position, Vector2(0, 32), "Player Visual should preview at the same feet offset in editor") + + +func _check_directional_heading_and_mirror(scene: PackedScene) -> void: + var left_player := await _make_player(scene) + left_player.call("submit_combo_input", "A", "perfect") + await physics_frame + _expect_vector(left_player.get("heading"), Vector2.LEFT, "A intent should face left") + _expect_float(float(left_player.get_node("Visual").scale.x), 2.0, "A intent should keep Hiro's native left-facing art at reference size") + _expect_float(float(left_player.get_node("Visual").scale.y), 2.0, "A intent should keep Hiro at reference size") + left_player.free() + + var right_player := await _make_player(scene) + right_player.call("submit_combo_input", "D", "perfect") + await physics_frame + _expect_vector(right_player.get("heading"), Vector2.RIGHT, "D intent should face right") + _expect_float(float(right_player.get_node("Visual").scale.x), -2.0, "D intent should mirror Hiro to face right at reference size") + _expect_float(float(right_player.get_node("Visual").scale.y), 2.0, "D intent should keep Hiro at reference size") + right_player.free() + + +func _make_player(scene: PackedScene) -> Node: + var player := scene.instantiate() + root.add_child(player) + await process_frame + return player + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_overlay_offset(animation_player: AnimationPlayer, animation_name: String, expected: Vector2) -> void: + var animation := animation_player.get_animation(animation_name) + _expect(animation != null, "%s should exist for overlay offset check" % animation_name) + if animation == null: + return + for track_index: int in range(animation.get_track_count()): + if animation.track_get_path(track_index) != NodePath("Visual/FxOverlay:offset"): + continue + if animation.track_get_key_count(track_index) == 0: + failures.append("%s overlay offset track should have keys" % animation_name) + return + var actual: Vector2 = animation.track_get_key_value(track_index, 0) + _expect_vector(actual, expected, "%s overlay offset should align the author reference frame" % animation_name) + return + failures.append("%s should key Visual/FxOverlay offset" % animation_name) + + +func _expect_frame_sequence(animation_player: AnimationPlayer, animation_name: String, expected: Array[int]) -> void: + var animation := animation_player.get_animation(animation_name) + _expect(animation != null, "%s should exist for frame sequence check" % animation_name) + if animation == null: + return + for track_index: int in range(animation.get_track_count()): + if animation.track_get_path(track_index) != NodePath("Visual/CharacterSprite:frame"): + continue + var actual: Array[int] = [] + for key_index: int in range(animation.track_get_key_count(track_index)): + actual.append(int(animation.track_get_key_value(track_index, key_index))) + if actual != expected: + failures.append("%s frame sequence expected %s, got %s" % [animation_name, expected, actual]) + return + failures.append("%s should key Visual/CharacterSprite frame sequence" % animation_name) + + +func _expect_sprite_sheet(animation_player: AnimationPlayer, animation_name: String, expected_path: String, expected_hframes: int, expected_vframes: int) -> void: + var animation := animation_player.get_animation(animation_name) + _expect(animation != null, "%s should exist for sprite sheet check" % animation_name) + if animation == null: + return + var texture_path := "" + var hframes := -1 + var vframes := -1 + for track_index: int in range(animation.get_track_count()): + var path := animation.track_get_path(track_index) + if path == NodePath("Visual/CharacterSprite:texture") and animation.track_get_key_count(track_index) > 0: + var texture: Texture2D = animation.track_get_key_value(track_index, 0) + texture_path = texture.resource_path if texture != null else "" + elif path == NodePath("Visual/CharacterSprite:hframes") and animation.track_get_key_count(track_index) > 0: + hframes = int(animation.track_get_key_value(track_index, 0)) + elif path == NodePath("Visual/CharacterSprite:vframes") and animation.track_get_key_count(track_index) > 0: + vframes = int(animation.track_get_key_value(track_index, 0)) + _expect(texture_path == expected_path, "%s texture should be %s, got %s" % [animation_name, expected_path, texture_path]) + _expect_int(hframes, expected_hframes, "%s hframes" % animation_name) + _expect_int(vframes, expected_vframes, "%s vframes" % animation_name) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Variant, expected: Vector2, label: String) -> void: + if not (actual is Vector2) or not (actual as Vector2).is_equal_approx(expected): + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS stage 4 player scene") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_stage4_player_scene.gd.uid b/tests/test_stage4_player_scene.gd.uid new file mode 100644 index 0000000..8dc6f7c --- /dev/null +++ b/tests/test_stage4_player_scene.gd.uid @@ -0,0 +1 @@ +uid://co1c8i7l553up diff --git a/tests/test_stage4_playtest_scene.gd b/tests/test_stage4_playtest_scene.gd new file mode 100644 index 0000000..228d391 --- /dev/null +++ b/tests/test_stage4_playtest_scene.gd @@ -0,0 +1,55 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + var scene_path := "res://scenes/main/stage4_playtest.tscn" + var scene: PackedScene = load(scene_path) + _expect(scene != null, "Stage 4 playtest scene should load") + if scene == null: + _finish() + return + var root_node := scene.instantiate() + root.add_child(root_node) + await process_frame + var player := root_node.get_node_or_null("Player") as Node2D + var ground := root_node.get_node_or_null("Ground") + var camera := root_node.get_node_or_null("Camera2D") as Camera2D + _expect(player != null, "Stage 4 playtest scene should instance Player") + _expect(ground != null, "Stage 4 playtest scene should instance Ground") + _expect(camera != null, "Stage 4 playtest scene should include Camera2D") + if player != null and ground != null: + var anchor := ground.get_node_or_null("GroundAnchor") as Node2D + _expect(anchor != null, "Ground should expose GroundAnchor") + if anchor != null: + _expect_vector(player.global_position, anchor.global_position, "Player should stand at GroundAnchor") + if player != null and camera != null: + _expect(camera.enabled, "Camera2D should be enabled") + _expect(camera.global_position.y < player.global_position.y, "Camera should frame above the player's feet") + root_node.free() + _finish() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if not actual.is_equal_approx(expected): + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS stage 4 playtest scene") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_stage4_playtest_scene.gd.uid b/tests/test_stage4_playtest_scene.gd.uid new file mode 100644 index 0000000..b88ff38 --- /dev/null +++ b/tests/test_stage4_playtest_scene.gd.uid @@ -0,0 +1 @@ +uid://b3nkrudw5lsdm diff --git a/tests/test_startup_fix_and_fx_sync.gd b/tests/test_startup_fix_and_fx_sync.gd new file mode 100644 index 0000000..4536e1f --- /dev/null +++ b/tests/test_startup_fix_and_fx_sync.gd @@ -0,0 +1,120 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + # 血条基线按 easy(小怪血量倍率 1.0)取值;显式钉住,不依赖残留的 settings.cfg。 + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + await _check_player_foot_charge_segments() + await _check_minion_overhead_health_bar() + _check_charge_timing_unchanged() + _finish() + + +func _check_player_foot_charge_segments() -> void: + _expect(ResourceLoader.exists("res://scenes/components/overhead_charge_segments.gd"), "OverheadChargeSegments script should exist") + var source := _read_text("res://scenes/components/overhead_charge_segments.gd") + _expect(source.find("var _ready") == -1, "OverheadChargeSegments should not declare var _ready beside func _ready()") + _expect(source.find("_is_charge_ready") != -1, "OverheadChargeSegments should use _is_charge_ready for readiness state") + + var scene := load("res://scenes/characters/player.tscn") as PackedScene + _expect(scene != null, "player.tscn should load with foot charge segment script") + if scene == null: + return + var player := scene.instantiate() + root.add_child(player) + await process_frame + var segments := player.get_node_or_null("OverheadChargeSegments") as Node2D + _expect(segments != null, "Player should have a foot-level three-segment charge bar") + if segments != null: + _expect_float(segments.position.y, -18.0, "Player foot charge bar should sit just above the feet") + _expect_int(int(segments.z_index), 96, "Player foot charge bar should draw above character effects") + _expect_int(int(segments.get("segment_count")), 3, "Player foot charge bar should have three segments") + var segment_size: Vector2 = segments.get("segment_size") + _expect_float(segment_size.x, 42.0, "Player foot charge bar should use longer segments") + _expect_float(segment_size.y, 12.0, "Player foot charge bar should use taller segments") + _expect_float(float(segments.get("inactive_linger_seconds")), 0.22, "Player foot charge bar should linger after release") + _expect_bool(segments.visible, false, "Player foot charge bar should start hidden") + segments.call("_on_charge_changed", 0.0, 2.0, false, true) + _expect_bool(segments.visible, true, "Player foot charge bar should show while charging") + _expect_float(float(segments.call("current_progress_segments")), 1.0, "Fresh charge should light the first segment") + segments.call("_on_charge_changed", 2.0, 2.0, true, true) + _expect_float(float(segments.call("current_progress_segments")), 3.0, "Ready charge should fill all three segments") + segments.call("_on_charge_changed", 0.0, 2.0, false, false) + _expect_bool(segments.visible, true, "Player foot charge bar should briefly linger after charge ends") + player.free() + + +func _check_minion_overhead_health_bar() -> void: + _expect(ResourceLoader.exists("res://scenes/components/overhead_health_bar.gd"), "OverheadHealthBar script should exist") + var scene := load("res://scenes/enemies/minion.tscn") as PackedScene + _expect(scene != null, "minion.tscn should load with overhead health bar script") + if scene == null: + return + var minion := scene.instantiate() + root.add_child(minion) + await process_frame + var health_bar := minion.get_node_or_null("OverheadHealthBar") as Node2D + _expect(health_bar != null, "Minion should have an overhead health bar") + if health_bar != null: + _expect(minion.get("overhead_health_bar") is Node2D, "Minion script should cache OverheadHealthBar as Node2D") + _expect_float(float(health_bar.call("health_ratio")), 1.0, "Minion overhead health bar should start full") + minion.get_node("HealthComponent").call("apply_damage", 325) + await process_frame + _expect_float(float(health_bar.call("health_ratio")), 0.5, "Minion overhead health bar should follow HealthComponent damage") + minion.free() + + +func _check_charge_timing_unchanged() -> void: + var controller_script: Script = load("res://scenes/components/action_controller.gd") + var controller: Node = controller_script.new() + _expect_float(float(controller.get("charge_level_beats")), 1.0, "Charge level timing should remain one beat per level") + controller.free() + + +func _read_text(path: String) -> String: + if not FileAccess.file_exists(path): + return "" + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return "" + var text := file.get_as_text() + file.close() + return text + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.02: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS startup fix and fx sync") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_startup_fix_and_fx_sync.gd.uid b/tests/test_startup_fix_and_fx_sync.gd.uid new file mode 100644 index 0000000..b605dff --- /dev/null +++ b/tests/test_startup_fix_and_fx_sync.gd.uid @@ -0,0 +1 @@ +uid://digr7ajw8qwqi diff --git a/tests/test_streak_counter.gd b/tests/test_streak_counter.gd new file mode 100644 index 0000000..3712c0c --- /dev/null +++ b/tests/test_streak_counter.gd @@ -0,0 +1,65 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var counter: Node = load("res://scenes/components/streak_counter.gd").new() + counter.name = "StreakCounter" + root.add_child(counter) + await process_frame + + var broadcasts: Array = [] + bus.connect("streak_changed", func(count: int) -> void: + broadcasts.append(count) + ) + + bus.emit_signal("skill_executed", null, &"perfect") + bus.emit_signal("skill_executed", null, &"good") + _expect_int(int(counter.get("streak")), 2, "skill_executed should increment the streak") + _expect_equal(str(broadcasts), str([1, 2]), "each increment should broadcast streak_changed") + + bus.emit_signal("judgement_made", &"good", 12.0, 4) + _expect_int(int(counter.get("streak")), 2, "non-miss judgements must not clear the streak") + + bus.emit_signal("judgement_made", &"miss", 300.0, 5) + _expect_int(int(counter.get("streak")), 0, "miss should clear the streak") + _expect_equal(str(broadcasts), str([1, 2, 0]), "miss clear should broadcast zero") + + bus.emit_signal("judgement_made", &"miss", 300.0, 6) + _expect_equal(str(broadcasts), str([1, 2, 0]), "already-zero streak should not rebroadcast on miss") + + bus.emit_signal("skill_executed", null, &"perfect") + bus.emit_signal("chart_reset", &"test") + _expect_int(int(counter.get("streak")), 0, "chart_reset should clear the streak") + _expect_equal(str(broadcasts), str([1, 2, 0, 1, 0]), "chart_reset clear should broadcast zero") + + _finish() + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS streak counter") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_streak_counter.gd.uid b/tests/test_streak_counter.gd.uid new file mode 100644 index 0000000..07e132f --- /dev/null +++ b/tests/test_streak_counter.gd.uid @@ -0,0 +1 @@ +uid://d4mmrqr57m2ut diff --git a/tests/test_strong_interrupt_rule.gd b/tests/test_strong_interrupt_rule.gd new file mode 100644 index 0000000..7acf30e --- /dev/null +++ b/tests/test_strong_interrupt_rule.gd @@ -0,0 +1,169 @@ +extends SceneTree + +## 关卡规则:只有处于强势状态的怪才会打断玩家当前的状态。 +## 弱势怪的攻击只结算伤害 —— 不造成 Hitstun、不取消动作、不产生击退; +## 强弱在动作开始时快照(§17.4);Boss 恒定视为强势。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var rhythm := root.get_node_or_null("RhythmManager") + if rhythm != null and rhythm.has_method("stop_manager"): + rhythm.call("stop_manager") + _ensure_event_bus() + var manager := _ensure_time_phase_manager() + var combat := _ensure_combat_manager() + + # 受击方:最小可受击体(StateMachine + HealthComponent + DamageReceiver)。 + var victim := Node2D.new() + victim.name = "Player" + root.add_child(victim) + var state_machine: Node = load("res://scenes/components/state_machine.gd").new() + state_machine.name = "StateMachine" + victim.add_child(state_machine) + var health: Node = load("res://scenes/components/health_component.gd").new() + health.name = "HealthComponent" + victim.add_child(health) + var receiver: Area2D = load("res://scenes/components/damage_receiver.gd").new() + receiver.name = "DamageReceiver" + victim.add_child(receiver) + health.call("set_values", 300, 300) + + # 攻击方:真实小怪场景(近战 / past_strong)。 + var minion: Node2D = (load("res://scenes/enemies/minion.tscn") as PackedScene).instantiate() + minion.name = "AttackerMinion" + minion.global_position = Vector2(4000.0, 0.0) + root.add_child(minion) + await process_frame + var behavior := minion.get_node_or_null("MinionBehavior") + if behavior != null: + behavior.set("enabled", false) + var emitter: Area2D = minion.get_node("DamageEmitter") + + # ---- 弱势(近战怪 × Future):只掉血,不打断,不击退 ---- + manager.call("reset_to_initial", &"past") + await process_frame + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect(not bool(minion.call("is_strong_in_current_time_phase")), "past_strong melee minion should be WEAK in Future") + var weak_action: Resource = load("res://resources/actions/enemies/minion_jin_zhan_1_attack.tres") + emitter.call("configure_hit", weak_action, {"label": "perfect"}) + var weak_result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect(int(weak_result.get("damage", 0)) > 0, "Weak hit should still deal damage") + _expect(int(health.get("current")) < 300, "Weak hit should reduce player HP") + _expect(not bool(weak_result.get("interrupts", true)), "Weak-state attacker must NOT interrupt") + _expect_equal(weak_result.get("knockback", Vector2.ONE), Vector2.ZERO, "Weak-state attacker must not knock the player back") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Alive", "Weak hit must not put the player into Hitstun") + + # ---- 快照规则(§17.4):出手时弱势,命中前切回强势相位仍不打断 ---- + emitter.call("configure_hit", weak_action, {"label": "perfect"}) + manager.call("set_time_phase", &"past", &"debug") + await physics_frame + await process_frame + var snapshot_result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect(not bool(snapshot_result.get("interrupts", true)), "Interrupt authority must snapshot at action START, not at hit time") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Alive", "Snapshot-weak hit must not stun the player") + + # ---- 强势(近战怪 × Past):正常打断 ---- + health.call("set_values", 300, 300) + state_machine.call("set_life_state", &"Alive") + await physics_frame + await process_frame + _expect(bool(minion.call("is_strong_in_current_time_phase")), "past_strong melee minion should be STRONG in Past") + var strong_action: Resource = load("res://resources/actions/enemies/minion_jin_zhan_3_attack_1.tres") + emitter.call("configure_hit", strong_action, {"label": "perfect"}) + var strong_result: Dictionary = combat.call("resolve_hit", emitter, receiver) + _expect(bool(strong_result.get("interrupts", false)), "Strong-state attacker keeps interrupt rights") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "Strong hit should put the player into Hitstun") + + # ---- 弹丸同样遵守规则(弱势远程怪的子弹) ---- + health.call("set_values", 300, 300) + state_machine.call("set_life_state", &"Alive") + await physics_frame + await process_frame + var projectile: Area2D = load("res://scenes/combat/player_projectile.gd").new() + root.add_child(projectile) + projectile.set("damage", 25) + projectile.set("attacker_interrupts", false) + projectile.set("judgement_context", {"label": "perfect"}) + var weak_shot: Dictionary = combat.call("resolve_hit", projectile, receiver) + _expect(int(weak_shot.get("damage", 0)) > 0, "Weak projectile should still deal damage") + _expect(not bool(weak_shot.get("interrupts", true)), "Weak-state projectile must not interrupt") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Alive", "Weak projectile must not stun the player") + projectile.set("attacker_interrupts", true) + await physics_frame + await process_frame + var strong_shot: Dictionary = combat.call("resolve_hit", projectile, receiver) + _expect(bool(strong_shot.get("interrupts", false)), "Strong-state projectile keeps interrupt rights") + _expect_equal(state_machine.call("build_context").get("life_state"), &"Hitstun", "Strong projectile should stun the player") + + # ---- Boss 不分强弱:两个相位都视为强势 ---- + var boss: Node = (load("res://scenes/enemies/boss.tscn") as PackedScene).instantiate() + boss.set("combat_enabled", false) + root.add_child(boss) + await process_frame + manager.call("set_time_phase", &"future", &"debug") + await process_frame + _expect(bool(boss.call("is_strong_in_current_time_phase")), "Boss must count as strong in Future") + manager.call("set_time_phase", &"past", &"debug") + await process_frame + _expect(bool(boss.call("is_strong_in_current_time_phase")), "Boss must count as strong in Past") + + boss.free() + projectile.free() + minion.free() + victim.free() + _finish() + + +func _ensure_event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _ensure_time_phase_manager() -> Node: + var manager := root.get_node_or_null("TimePhaseManager") + if manager == null: + manager = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + return manager + + +func _ensure_combat_manager() -> Node: + var combat := root.get_node_or_null("CombatManager") + if combat == null: + combat = load("res://autoload/combat_manager.gd").new() + combat.name = "CombatManager" + root.add_child(combat) + return combat + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS strong interrupt rule") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_strong_interrupt_rule.gd.uid b/tests/test_strong_interrupt_rule.gd.uid new file mode 100644 index 0000000..e429486 --- /dev/null +++ b/tests/test_strong_interrupt_rule.gd.uid @@ -0,0 +1 @@ +uid://dr2x2fnjggbkr diff --git a/tests/test_time_anchor_resolution.gd b/tests/test_time_anchor_resolution.gd new file mode 100644 index 0000000..b4ceb54 --- /dev/null +++ b/tests/test_time_anchor_resolution.gd @@ -0,0 +1,115 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var manager: Node = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + var system: Node = load("res://autoload/time_anchor_system.gd").new() + system.name = "TimeAnchorSystem" + system.set("periodic_scheduling_enabled", false) + root.add_child(system) + await process_frame + + var scheduled: Array = [] + var resolved: Array = [] + bus.connect("time_anchor_scheduled", func(anchor: Dictionary) -> void: + scheduled.append(anchor) + ) + bus.connect("time_anchor_resolved", func(anchor: Dictionary, held: bool, judgement: Dictionary) -> void: + resolved.append([anchor, held, judgement]) + ) + + # --- Perfect input on the anchor beat holds the phase. --- + system.call("schedule_time_anchor", 4, &"chart") + _expect_int(scheduled.size(), 1, "scheduling should broadcast time_anchor_scheduled") + bus.emit_signal("judgement_made", &"perfect", 0.0, 4) + _expect_int(resolved.size(), 1, "qualifying judgement should resolve the anchor") + if resolved.size() >= 1: + _expect_bool(bool(resolved[0][1]), true, "perfect on the anchor beat should hold") + _expect_equal(manager.get("current_time_phase"), &"past", "held anchor should not switch the phase") + + # --- Early miss does not break; a later qualifying fact rescues. --- + system.call("schedule_time_anchor", 8, &"chart") + bus.emit_signal("judgement_made", &"miss", 120.0, 8) + _expect_int(resolved.size(), 1, "miss must not resolve the anchor early") + bus.emit_signal("judgement_made", &"good", 40.0, 8) + _expect_int(resolved.size(), 2, "later qualifying input should rescue the anchor") + if resolved.size() >= 2: + _expect_bool(bool(resolved[1][1]), true, "rescued anchor should be held") + + # --- Off-beat judgements are ignored; deadline breaks and switches phase. --- + system.call("schedule_time_anchor", 12, &"chart") + bus.emit_signal("judgement_made", &"good", 0.0, 11) + _expect_int(resolved.size(), 2, "judgement on a non-anchor beat should not resolve") + system.call("check_deadlines_for_song_time", 12 * 0.5 + 0.5) + _expect_int(resolved.size(), 3, "passing the deadline should break the anchor") + if resolved.size() >= 3: + _expect_bool(bool(resolved[2][1]), false, "deadline resolution should be broken") + _expect_equal(manager.get("current_time_phase"), &"future", "broken anchor should toggle the phase") + + # --- min_grade tightening. --- + system.call("schedule_time_anchor", 16, &"chart", null, &"good") + bus.emit_signal("judgement_made", &"bad", 90.0, 16) + _expect_int(resolved.size(), 3, "bad should not satisfy a min_grade good anchor") + bus.emit_signal("judgement_made", &"perfect", 5.0, 16) + _expect_int(resolved.size(), 4, "perfect should satisfy a min_grade good anchor") + + # --- Resolved anchors never respond again. --- + bus.emit_signal("judgement_made", &"perfect", 0.0, 4) + _expect_int(resolved.size(), 4, "resolved anchors must not resolve twice") + + # --- chart_reset discards armed anchors without resolving. --- + system.call("schedule_time_anchor", 20, &"chart") + var phase_before_reset: StringName = manager.get("current_time_phase") + bus.emit_signal("chart_reset", &"test_chart") + _expect_int(int(system.call("anchored_beat_count")), 0, "chart_reset should discard all anchors") + _expect_int(resolved.size(), 4, "chart_reset must not resolve anchors") + _expect_equal(manager.get("current_time_phase"), phase_before_reset, "chart_reset alone must not switch the phase") + + # --- Same-beat dedup: second schedule returns the existing anchor. --- + system.call("schedule_time_anchor", 24, &"periodic") + var scheduled_count := scheduled.size() + system.call("schedule_time_anchor", 24, &"chart") + _expect_int(scheduled.size(), scheduled_count, "same-beat schedule should dedup, not double-broadcast") + var summaries: Array = system.call("armed_anchor_summaries") + _expect_int(summaries.size(), 1, "one armed anchor expected after dedup") + if summaries.size() == 1: + _expect_equal((summaries[0] as Dictionary).get("source"), &"chart", "explicit chart anchor should win the collision") + + _finish() + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS time anchor resolution") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_time_anchor_resolution.gd.uid b/tests/test_time_anchor_resolution.gd.uid new file mode 100644 index 0000000..e5652d4 --- /dev/null +++ b/tests/test_time_anchor_resolution.gd.uid @@ -0,0 +1 @@ +uid://bjkir4kcd620w diff --git a/tests/test_time_anchor_scheduling.gd b/tests/test_time_anchor_scheduling.gd new file mode 100644 index 0000000..09bec5f --- /dev/null +++ b/tests/test_time_anchor_scheduling.gd @@ -0,0 +1,120 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + _check_frequency_profile_lookup() + var first_run := await _run_scheduling_scenario() + var second_run := await _run_scheduling_scenario() + _expect_equal(str(first_run), str(second_run), "identical fact sequences should produce identical anchor layouts") + _finish() + + +func _check_frequency_profile_lookup() -> void: + var profile: Resource = load("res://resources/time_anchor_frequency_default.tres") + if profile == null: + failures.append("default frequency profile should load") + return + var expectations := {0: 1, 7: 1, 8: 2, 15: 2, 16: 4, 120: 4} + for streak: int in expectations.keys(): + _expect_int(int(profile.call("anchors_per_window_for_streak", streak)), expectations[streak], "anchors per 4-beat window for streak %d" % streak) + _expect_equal(str(profile.call("anchor_offsets_for_streak", 0)), str([4]), "base tier should place one anchor at the end of the 4-beat window") + _expect_equal(str(profile.call("anchor_offsets_for_streak", 8)), str([2, 4]), "middle tier should place two anchors in the 4-beat window") + _expect_equal(str(profile.call("anchor_offsets_for_streak", 16)), str([1, 2, 3, 4]), "top tier should upgrade every beat in the 4-beat window") + + +func _run_scheduling_scenario() -> Array: + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var manager: Node = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + var system: Node = load("res://autoload/time_anchor_system.gd").new() + system.name = "TimeAnchorSystem" + root.add_child(system) + await process_frame + + var scheduled_beats: Array = [] + bus.connect("time_anchor_scheduled", func(anchor: Dictionary) -> void: + scheduled_beats.append([int(anchor.get("beat", -1)), StringName(str(anchor.get("source", &"-")))]) + ) + + # Base tier (streak 0): one anchor per 4-beat window, candidate 4 locks when the lead window opens. + system.call("update_periodic_scheduling", 0.0) + _expect_int(scheduled_beats.size(), 0, "candidate outside the lead window must not lock") + system.call("update_periodic_scheduling", 2.1) + _expect_int(scheduled_beats.size(), 1, "candidate entering the lead window should lock") + if not scheduled_beats.is_empty(): + _expect_int(scheduled_beats[0][0], 4, "first periodic anchor should land on beat 4") + _expect_equal(scheduled_beats[0][1], &"periodic", "periodic anchor should carry the periodic source") + + # Locked anchors do not retro-change when the streak tier jumps. + bus.emit_signal("streak_changed", 16) + system.call("update_periodic_scheduling", 2.2) + _expect_int(int(system.call("anchored_beat_count")), 1, "tier change must not retro-modify locked anchors") + + # Top tier: every beat in the next 4-beat window can become an anchor. + system.call("update_periodic_scheduling", 3.5) + _expect_bool(beats_only_of(scheduled_beats).has(5), true, "top tier should lock beat 5 in the next window") + system.call("update_periodic_scheduling", 4.5) + _expect_bool(beats_only_of(scheduled_beats).has(6), true, "top tier should lock beat 6 in the next window") + + # Explicit chart anchor restarts the next 4-beat window from its beat. + bus.emit_signal("streak_changed", 0) + system.call("schedule_time_anchor", 10, &"chart") + system.call("update_periodic_scheduling", 8.5) + var beats_only: Array = scheduled_beats.map(func(entry: Array) -> int: return entry[0]) + _expect_bool(beats_only.count(10) == 1, true, "explicit anchor beat should appear exactly once") + system.call("update_periodic_scheduling", 12.1) + _expect_bool(beats_only_of(scheduled_beats).has(14), true, "periodic count should restart from the explicit anchor's 4-beat window (10 + 4 = 14)") + + # chart_reset clears the schedule and the streak. + bus.emit_signal("chart_reset", &"scenario") + _expect_int(int(system.call("anchored_beat_count")), 0, "chart_reset should clear scheduled anchors") + _expect_int(int(system.get("current_streak")), 0, "chart_reset should clear the cached streak") + + var result := beats_only_of(scheduled_beats) + root.remove_child(system) + system.free() + root.remove_child(manager) + manager.free() + root.remove_child(bus) + bus.free() + await process_frame + return result + + +func beats_only_of(entries: Array) -> Array: + return entries.map(func(entry: Array) -> int: return entry[0]) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS time anchor scheduling") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_time_anchor_scheduling.gd.uid b/tests/test_time_anchor_scheduling.gd.uid new file mode 100644 index 0000000..3eb5285 --- /dev/null +++ b/tests/test_time_anchor_scheduling.gd.uid @@ -0,0 +1 @@ +uid://b5a4dob3e8h8m diff --git a/tests/test_time_phase_adapter.gd b/tests/test_time_phase_adapter.gd new file mode 100644 index 0000000..4ad5a39 --- /dev/null +++ b/tests/test_time_phase_adapter.gd @@ -0,0 +1,106 @@ +extends SceneTree + +const PAST_STRONG := &"effect_tp_past_enemy_strong" +const PAST_WEAK := &"effect_tp_past_enemy_weak" + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + # 伤害基线按 easy 难度取值;显式钉住,不依赖残留的 settings.cfg。 + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var manager: Node = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + + var actor := Node.new() + actor.name = "PhaseActor" + var container: Node = load("res://scenes/components/effect_container.gd").new() + container.name = "EffectContainer" + actor.add_child(container) + var adapter: Node = load("res://scenes/components/time_phase_adapter.gd").new() + adapter.name = "TimePhaseAdapter" + adapter.set("profile", load("res://resources/time_phase/profile_past_strong_enemy.tres")) + actor.add_child(adapter) + root.add_child(actor) + await process_frame + + # --- Idempotent init applies the current (past) phase effects. --- + var ids: Array = container.call("active_effect_ids") + _expect_bool(ids.has(PAST_STRONG), true, "past phase should mount the strong effect") + _expect_bool(ids.has(PAST_WEAK), false, "past phase should not mount the weak effect") + _expect_int(int(container.call("active_count")), 1, "exactly one time phase effect expected after init") + + # --- Switching phases swaps the effect set. --- + manager.call("toggle_time_phase", &"time_anchor_broken") + ids = container.call("active_effect_ids") + _expect_bool(ids.has(PAST_WEAK), true, "future phase should mount the weak effect") + _expect_bool(ids.has(PAST_STRONG), false, "future phase should unmount the strong effect") + _expect_int(int(container.call("active_count")), 1, "effect balance: exactly one phase effect after a switch") + + # --- Switching back restores the original set with no residue. --- + manager.call("toggle_time_phase", &"time_anchor_broken") + ids = container.call("active_effect_ids") + _expect_bool(ids.has(PAST_STRONG), true, "back in past the strong effect should return") + _expect_int(int(container.call("active_count")), 1, "effect balance holds across repeated switches") + + # --- Damage numbers actually flip with the phase. --- + # 强势 ×1.4 / 弱势 ×0.7 以 resources/effects/time_phase/ 四个资源现值为准 + # (2026-07-04 校准批次;旧口径 ×1.2 已废弃)。 + var stat_resolver: Script = load("res://scripts/resolvers/stat_resolver.gd") + var past_damage := float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null)) + _expect_float(past_damage, 140.0, "past-strong enemy should deal x1.4 damage in past") + manager.call("toggle_time_phase", &"time_anchor_broken") + var future_damage := float(stat_resolver.call("resolve_damage", 100.0, null, {"label": "perfect"}, container, null)) + _expect_float(future_damage, 70.0, "past-strong enemy should deal x0.7 damage in future") + + # --- Actors without a profile are untouched by phase switches. --- + var plain_actor := Node.new() + plain_actor.name = "PlainActor" + var plain_container: Node = load("res://scenes/components/effect_container.gd").new() + plain_container.name = "EffectContainer" + plain_actor.add_child(plain_container) + var plain_adapter: Node = load("res://scenes/components/time_phase_adapter.gd").new() + plain_adapter.name = "TimePhaseAdapter" + plain_actor.add_child(plain_adapter) + root.add_child(plain_actor) + await process_frame + manager.call("toggle_time_phase", &"time_anchor_broken") + _expect_int(int(plain_container.call("active_count")), 0, "actors without a profile must be unaffected") + + _finish() + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS time phase adapter") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_time_phase_adapter.gd.uid b/tests/test_time_phase_adapter.gd.uid new file mode 100644 index 0000000..74addac --- /dev/null +++ b/tests/test_time_phase_adapter.gd.uid @@ -0,0 +1 @@ +uid://cnx3kui6n1frr diff --git a/tests/test_time_phase_manager.gd b/tests/test_time_phase_manager.gd new file mode 100644 index 0000000..ec9ac49 --- /dev/null +++ b/tests/test_time_phase_manager.gd @@ -0,0 +1,74 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + var bus: Node = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + var manager: Node = load("res://autoload/time_phase_manager.gd").new() + manager.name = "TimePhaseManager" + root.add_child(manager) + await process_frame + + var facts: Array = [] + bus.connect("time_phase_changed", func(previous: StringName, current: StringName, reason: StringName) -> void: + facts.append([previous, current, reason]) + ) + + _expect_equal(manager.get("current_time_phase"), &"past", "initial time phase should be past") + + manager.call("toggle_time_phase", &"time_anchor_broken") + _expect_equal(manager.get("current_time_phase"), &"future", "toggle should switch past to future") + _expect_int(facts.size(), 1, "toggle should broadcast one fact") + if facts.size() == 1: + _expect_equal(facts[0][0], &"past", "fact should carry previous phase") + _expect_equal(facts[0][1], &"future", "fact should carry current phase") + _expect_equal(facts[0][2], &"time_anchor_broken", "fact should carry reason") + + manager.call("set_time_phase", &"future", &"debug") + _expect_int(facts.size(), 1, "setting the same phase should not broadcast") + + manager.call("set_time_phase", &"bogus", &"debug") + _expect_equal(manager.get("current_time_phase"), &"future", "invalid phase values should be rejected") + _expect_int(facts.size(), 1, "invalid phase values should not broadcast") + + manager.call("reset_to_initial", &"past") + _expect_equal(manager.get("current_time_phase"), &"past", "reset_to_initial should apply the initial phase") + _expect_int(facts.size(), 2, "reset that actually switches should broadcast") + if facts.size() == 2: + _expect_equal(facts[1][2], &"chart_init", "reset reason should be chart_init") + + manager.call("reset_to_initial", &"past") + _expect_int(facts.size(), 2, "reset to the current phase should not broadcast") + + manager.call("reset_to_initial", &"nonsense") + _expect_equal(manager.get("current_time_phase"), &"past", "invalid initial should fall back to past") + + _finish() + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS time phase manager") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_time_phase_manager.gd.uid b/tests/test_time_phase_manager.gd.uid new file mode 100644 index 0000000..65506cd --- /dev/null +++ b/tests/test_time_phase_manager.gd.uid @@ -0,0 +1 @@ +uid://jfb2juuguxtj diff --git a/tests/test_title_music_and_background.gd b/tests/test_title_music_and_background.gd new file mode 100644 index 0000000..9548fe7 --- /dev/null +++ b/tests/test_title_music_and_background.gd @@ -0,0 +1,107 @@ +extends SceneTree + +## 标题侧节奏音乐 + 标题背景「过去/现在」循环动画。 +## 标题、难度选择、退出确认期间音乐持续播放;进入 gameplay 后停止, +## 由 RhythmManager.start() + AudioLayerController.restart_layers() 从 0 重播。 + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + root.size = Vector2i(1152, 648) + _ensure_game_settings() + var flow: Node = load("res://autoload/game_flow_manager.gd").new() + flow.name = "FlowUnderTest" + flow.set("manage_scene", false) + root.add_child(flow) + await process_frame + + # --- 标题音乐播放器:常驻处理、走 Music 总线 --- + var player := flow.get_node_or_null("TitleMusicPlayer") as AudioStreamPlayer + _expect(player != null, "flow owns a TitleMusicPlayer") + if player != null: + _expect(player.process_mode == Node.PROCESS_MODE_ALWAYS, "title music must survive the SceneTree pause") + _expect_equal(player.bus, &"Music", "title music routes through the Music bus") + # headless 下不加载 mp3,注入无限长的生成器流来验证播放开关。 + player.stream = AudioStreamGenerator.new() + + # --- 标题界面同时持有两个时代背景,「现在」画在「过去」上层 --- + var title_screen := (flow.get("_screens") as Dictionary).get(&"Title", null) as Control + _expect(title_screen != null, "title screen exists") + if title_screen != null: + var past := title_screen.get_node_or_null("SceneBackgroundPast") as TextureRect + var future := title_screen.get_node_or_null("SceneBackgroundFuture") as TextureRect + _expect(past != null, "title keeps the past-era background") + _expect(future != null, "title keeps the future-era background") + if past != null and future != null: + _expect(past.texture != null and future.texture != null, "both era textures load") + _expect(past.get_index() < future.get_index(), "the future era paints above the past era") + + # --- 标题状态:音乐播放 + 时代循环动画运行 --- + flow.call("_set_state", &"Title") + flow.call("_update_title_music") + await process_frame + _expect(bool(flow.call("_should_play_title_music")), "title state wants the rhythm music") + if player != null: + _expect(player.playing, "title music plays on the title screen") + var tween := flow.get("_title_bg_tween") as Tween + _expect(tween != null and tween.is_valid(), "the era crossfade loop runs on the title screen") + + # --- 难度选择:音乐延续,标题动画停止 --- + flow.call("_set_state", &"DifficultySelect") + await process_frame + _expect(bool(flow.call("_should_play_title_music")), "difficulty select keeps the rhythm music") + if player != null: + _expect(player.playing, "title music keeps playing on difficulty select") + _expect(flow.get("_title_bg_tween") == null, "the era loop stops once the title screen hides") + + # --- 进入 gameplay:标题音乐停止 --- + flow.call("_set_state", &"Gameplay_FrontArea") + await process_frame + _expect(not bool(flow.call("_should_play_title_music")), "gameplay states drop the title music") + if player != null: + _expect(not player.playing, "title music stops when the run starts") + + # --- 回到标题:音乐从头再播 --- + flow.call("_set_state", &"Title") + await process_frame + if player != null: + _expect(player.playing, "returning to title restarts the music") + + flow.free() + _finish() + + +func _ensure_game_settings() -> Node: + var settings := root.get_node_or_null("GameSettings") + if settings != null: + return settings + settings = load("res://autoload/game_settings.gd").new() + settings.name = "GameSettings" + root.add_child(settings) + return settings + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish() -> void: + paused = false + if failures.is_empty(): + print("PASS title music and background loop") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_title_music_and_background.gd.uid b/tests/test_title_music_and_background.gd.uid new file mode 100644 index 0000000..34d9b9a --- /dev/null +++ b/tests/test_title_music_and_background.gd.uid @@ -0,0 +1 @@ +uid://2dpg1x6b21yd diff --git a/tests/test_ui_animation_regression.gd b/tests/test_ui_animation_regression.gd new file mode 100644 index 0000000..bec1364 --- /dev/null +++ b/tests/test_ui_animation_regression.gd @@ -0,0 +1,122 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + # 节奏标记/连击动画基线按 easy 判定窗取值;显式钉住,不依赖残留的 settings.cfg。 + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + var scene: PackedScene = load("res://scenes/ui/main_ui.tscn") + if scene == null: + push_error("Could not load main_ui.tscn") + quit(1) + return + + var ui := scene.instantiate() + root.add_child(ui) + await process_frame + + var track := ui.get_node_or_null("RhythmTrack") as Control + if track == null: + failures.append("Missing RhythmTrack") + _finish(ui) + return + + _expect_node(track, "LeftMover", "RhythmTrack should keep the animated left mover") + _expect_node(track, "RightMover", "RhythmTrack should keep the animated right mover") + _expect_node(track, "CenterFlash", "RhythmTrack should keep the center beat flash") + + var bus := _event_bus() + bus.emit_signal("beat_ticked", 1) + await process_frame + var center_flash := track.get_node_or_null("CenterFlash") as CanvasItem + if center_flash != null: + # Beat ticks now pulse the flash at BEAT_TICK_FLASH_PEAK (0.35) so full + # brightness stays reserved for judged hits; assert visibility, not peak. + _expect_bool(center_flash.modulate.a > 0.2, true, "CenterFlash should become visible on beat") + + var marker_container := track.get_node_or_null("ChartMarkerContainer") as Control + if marker_container == null: + failures.append("RhythmTrack should include ChartMarkerContainer") + else: + # 谱面预告标记已被有意停用(rhythm_track._on_chart_event_upcoming 为空操作); + # 钉住"不再生成标记"的现行为,防止无声复活。 + var event_script: Script = load("res://resources/chart_event.gd") + var event: Resource = event_script.new() + event.set("event_type", &"enemy_action") + event.set("action_id", &"enemy_slash") + bus.emit_signal("chart_event_upcoming", event, 0.5) + await process_frame + _expect_bool(marker_container.get_child_count() == 0, true, "Chart upcoming events must not spawn markers (feature deliberately disabled)") + + bus.emit_signal("judgement_made", &"perfect", 0.0, 12) + await process_frame + var label := track.get_node_or_null("JudgementLabel") as Label + if label != null: + _expect_bool(label.scale.x > 1.0, true, "JudgementLabel should pulse on judgement") + _expect_bool(label.modulate.is_equal_approx(Color("00f2ff")), true, "JudgementLabel should use judgement color") + + var combo_window := ui.get_node_or_null("ComboWindow") as Control + if combo_window == null: + failures.append("Missing ComboWindow") + else: + bus.emit_signal("combo_updated", [&"A"]) + await process_frame + # 改版后 child(0) 是外框 SlotFrame,真正脉冲的是命名槽位面板 Slot1。 + var first_slot := combo_window.get_node_or_null("Slot1") as Control + if first_slot == null: + failures.append("ComboWindow should expose the Slot1 panel") + else: + _expect_bool(first_slot.scale.x > 1.0, true, "Combo slot should pulse when filled") + bus.emit_signal("combo_cleared", &"full") + await process_frame + _expect_bool(first_slot.scale.x > 1.0, true, "Combo slots should flash when cleared") + + var charge_bar := ui.get_node_or_null("StatusBars/ChargeBar") as ProgressBar + if charge_bar == null: + failures.append("Missing ChargeBar") + else: + bus.emit_signal("player_charge_changed", 1.1, 1.1, true, true) + await create_timer(0.05).timeout + var first_alpha := charge_bar.modulate.a + await create_timer(0.05).timeout + var second_alpha := charge_bar.modulate.a + _expect_bool(not is_equal_approx(first_alpha, second_alpha), true, "ChargeBar should flash while ready") + + _finish(ui) + + +func _event_bus() -> Node: + var bus := root.get_node_or_null("EventBus") + if bus == null: + bus = load("res://autoload/event_bus.gd").new() + bus.name = "EventBus" + root.add_child(bus) + return bus + + +func _expect_node(node: Node, path: String, label: String) -> void: + if node.get_node_or_null(path) == null: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _finish(ui: Node) -> void: + ui.free() + if failures.is_empty(): + print("PASS ui animation regression") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_ui_animation_regression.gd.uid b/tests/test_ui_animation_regression.gd.uid new file mode 100644 index 0000000..6bb5a02 --- /dev/null +++ b/tests/test_ui_animation_regression.gd.uid @@ -0,0 +1 @@ +uid://d1w1q6tgde103 diff --git a/tests/test_v2_core_contracts.gd b/tests/test_v2_core_contracts.gd new file mode 100644 index 0000000..c53a436 --- /dev/null +++ b/tests/test_v2_core_contracts.gd @@ -0,0 +1,360 @@ +extends SceneTree + +const InputIntentScript := preload("res://scenes/components/input_intent.gd") + +var failures: Array[String] = [] +var started: Array[StringName] = [] +var rejected: Array[StringName] = [] +var finished: Array[StringName] = [] +var cancelled: Array[StringName] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await process_frame + # 相位时长快照基线按 easy 难度取值;显式钉住,不依赖残留的 settings.cfg。 + var settings := root.get_node_or_null("GameSettings") + if settings != null: + settings.call("set_difficulty", &"easy") + _check_rhythm_input_clock_contract() + _check_event_bus_v2_fact_signals() + _check_action_data_v2_schema() + _check_state_machine_four_axis_context() + _check_state_machine_is_storage_only() + _check_action_rule_resolver() + _check_action_rule_resolver_reads_effect_modifiers() + await _check_action_phase_duration_uses_stat_snapshot() + await _check_miss_does_not_cancel_current_action() + await _check_chain_cancel_is_not_action_finished() + await _check_insufficient_energy_rolls_back_window_record() + await _check_undefined_plain_pattern_uses_trailing_basic_action() + _finish() + + +func _check_rhythm_input_clock_contract() -> void: + var rhythm_script: Script = load("res://autoload/rhythm_manager.gd") + _expect(rhythm_script != null, "RhythmManager script should load") + if rhythm_script == null: + return + var rhythm: Node = rhythm_script.new() + _expect(rhythm.has_method("input_to_song_time"), "RhythmManager should expose input_to_song_time(timestamp_ms)") + if rhythm.has_method("input_to_song_time"): + rhythm.set("_clock_offset", 1.25) + _expect_float(float(rhythm.call("input_to_song_time", 2000.0)), 3.25, "input_to_song_time should apply the shared ticks-to-song offset") + rhythm.free() + + +func _check_event_bus_v2_fact_signals() -> void: + var event_bus_script: Script = load("res://autoload/event_bus.gd") + _expect(event_bus_script != null, "EventBus script should load") + if event_bus_script == null: + return + var event_bus: Node = event_bus_script.new() + for signal_name: String in ["intent_replaced", "action_started", "action_cancelled", "judgement_made"]: + _expect(event_bus.has_signal(signal_name), "EventBus should expose %s fact signal" % signal_name) + _expect_signal_arg_count(event_bus, "judgement_made", 3, "EventBus judgement_made should keep the phase-1 three-argument contract") + event_bus.free() + + +func _check_action_data_v2_schema() -> void: + var action_script: Script = load("res://resources/action_data.gd") + _expect(action_script != null, "ActionData script should load") + if action_script == null: + return + var action: Resource = action_script.new() + for property_name: String in [ + "allowed_ground_states", + "allowed_action_phases", + "action_tags", + "defense_tags", + "cancel_into_tags", + ]: + _expect(_has_property(action, property_name), "ActionData should expose %s" % property_name) + action.set("startup_beats", 0.5) + action.set("active_beats", 0.25) + action.set("recovery_beats", 0.75) + _expect_float(float(action.get("action_beats")), 1.5, "ActionData.action_beats should be derived from startup + active + recovery") + + +func _check_state_machine_four_axis_context() -> void: + var machine_script: Script = load("res://scenes/components/state_machine.gd") + _expect(machine_script != null, "StateMachine script should load") + if machine_script == null: + return + var machine: Node = machine_script.new() + _expect(machine.has_method("build_context"), "StateMachine should expose build_context()") + _expect(machine.has_signal("axis_changed"), "StateMachine should emit axis_changed") + if machine.has_method("build_context"): + var context: Dictionary = machine.call("build_context") + _expect_equal(context.get("life_state"), &"Alive", "Default LifeState") + _expect_equal(context.get("ground_state"), &"Grounded", "Default GroundState") + _expect_equal(context.get("action_phase"), &"Neutral", "Default ActionPhaseState") + _expect_equal(context.get("defense_state"), &"Vulnerable", "Default DefenseState") + _expect(machine.has_method("set_action_phase"), "StateMachine should allow ActionController to mirror ActionPhaseState") + if machine.has_method("set_action_phase"): + machine.call("set_action_phase", &"Startup") + _expect_equal(machine.call("build_context").get("action_phase"), &"Startup", "Action phase should update independently") + machine.free() + + +func _check_state_machine_is_storage_only() -> void: + var machine_script: Script = load("res://scenes/components/state_machine.gd") + _expect(machine_script != null, "StateMachine script should load for storage-only check") + if machine_script == null: + return + var machine: Node = machine_script.new() + _expect(not machine.has_method("can_start_action"), "StateMachine should not decide whether actions can start") + _expect(not machine.has_method("can_move_freely"), "StateMachine should not decide whether actors can move freely") + machine.free() + + +func _check_action_rule_resolver() -> void: + var resolver_script: Script = load("res://scripts/resolvers/action_rule_resolver.gd") + _expect(resolver_script != null, "ActionRuleResolver should exist under scripts/resolvers") + if resolver_script == null: + return + var action_script: Script = load("res://resources/action_data.gd") + if action_script == null: + return + var action: Resource = action_script.new() + var allowed_ground_states: Array[StringName] = [&"Grounded"] + var allowed_action_phases: Array[StringName] = [&"Neutral"] + action.set("allowed_ground_states", allowed_ground_states) + action.set("allowed_action_phases", allowed_action_phases) + var grounded_context := { + "life_state": &"Alive", + "ground_state": &"Grounded", + "action_phase": &"Neutral", + } + var airborne_context := grounded_context.duplicate() + airborne_context["ground_state"] = &"Airborne" + _expect(bool(resolver_script.call("can_execute", grounded_context, action)), "ActionRuleResolver should allow matching state axes") + _expect(not bool(resolver_script.call("can_execute", airborne_context, action)), "ActionRuleResolver should reject non-matching ground state") + + +func _check_action_rule_resolver_reads_effect_modifiers() -> void: + var resolver_script: Script = load("res://scripts/resolvers/action_rule_resolver.gd") + var action_script: Script = load("res://resources/action_data.gd") + var definition_script: Script = load("res://resources/effects/effect_definition.gd") + var modifier_script: Script = load("res://resources/effects/action_rule_modifier.gd") + var container_script: Script = load("res://scenes/components/effect_container.gd") + if resolver_script == null or action_script == null or definition_script == null or modifier_script == null or container_script == null: + return + var action: Resource = action_script.new() + var action_tags: Array[StringName] = [&"spell"] + action.set("action_tags", action_tags) + var definition: Resource = definition_script.new() + definition.set("id", &"test_silence") + definition.set("duration_type", &"infinite") + var modifier: Resource = modifier_script.new() + var blocked_tags: Array[StringName] = [&"spell"] + modifier.set("blocked_action_tags", blocked_tags) + var modifiers: Array[Resource] = [modifier] + definition.set("action_rule_modifiers", modifiers) + var container: Node = container_script.new() + root.add_child(container) + container.call("add_effect", definition) + var context := { + "life_state": &"Alive", + "ground_state": &"Grounded", + "action_phase": &"Neutral", + "effect_container": container, + } + _expect(not bool(resolver_script.call("can_execute", context, action)), "ActionRuleResolver should block actions tagged by active Effect modifiers") + container.free() + + +func _check_action_phase_duration_uses_stat_snapshot() -> void: + var player := await _make_player() + var controller: Node = player.get_node("ActionController") + var effect_container: Node = player.get_node("EffectContainer") + player.get_node("EnergyComponent").set_current(10) + controller.set("beat_anchor_policy", &"FREE") + effect_container.call("add_effect", _make_phase_modifier_effect(&"startup_beats", 0.5)) + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + # 契约:修正器把 0.25 拍起手减半为 0.125 拍;秒数随 RhythmManager 实际拍长走 + # (autoload 存在时为 60/129.2,不再假设 0.5s/拍)。 + var rhythm := root.get_node_or_null("RhythmManager") + var beat_time := float(rhythm.get("beat_time")) if rhythm != null else 0.5 + _expect_float(float(controller.get("phase_duration")), 0.125 * beat_time, "ActionController should snapshot startup duration through StatResolver modifiers") + player.free() + + +func _check_miss_does_not_cancel_current_action() -> void: + var player := await _make_player() + var controller: Node = player.get_node("ActionController") + _connect_action_signals(controller) + player.get_node("EnergyComponent").set_current(10) + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + controller.call("submit_intent", _miss_intent(&"D", &"d")) + _expect_int(int(controller.get("phase")), 1, "Miss during Startup should not reset the current action phase") + _expect_array(player.call("get_combo_slots"), [&"A", &"Ø"], "Miss should only append Ø to the ComboWindow") + _expect(not cancelled.has(&"interrupt"), "Miss should not emit an interrupt cancellation") + player.free() + + +func _check_chain_cancel_is_not_action_finished() -> void: + var player := await _make_player() + var controller: Node = player.get_node("ActionController") + _connect_action_signals(controller) + player.get_node("EnergyComponent").set_current(10) + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + controller.set("phase", 3) + controller.set("phase_duration", 0.1) + controller.set("phase_elapsed", 0.1) + controller.call("submit_intent", _perfect_intent(&"S", &"s")) + _expect(cancelled.has(&"chain"), "Chain cancel should emit action_cancelled(chain)") + _expect_int(finished.size(), 0, "Chain cancel should not emit action_finished for the interrupted action") + player.free() + + +func _check_insufficient_energy_rolls_back_window_record() -> void: + var player := await _make_player() + var controller: Node = player.get_node("ActionController") + _connect_action_signals(controller) + var action: Resource = load("res://resources/actions/ground_attack_left_1.tres") + var original_cost := float(action.get("base_cost")) + action.set("base_cost", 5.0) + player.get_node("EnergyComponent").set_current(0) + player.get_node("ComboWindow").record(&"S") + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + _expect(rejected.has(&"insufficient_energy"), "Insufficient energy should be reported as insufficient_energy") + _expect_array(player.call("get_combo_slots"), [&"S"], "Insufficient energy should roll back only the submitted input") + _expect(not started.has(&"ground_attack_left_1"), "Insufficient energy should reject before action_started") + action.set("base_cost", original_cost) + player.free() + + +func _check_undefined_plain_pattern_uses_trailing_basic_action() -> void: + var player := await _make_player() + var controller: Node = player.get_node("ActionController") + _connect_action_signals(controller) + player.get_node("EnergyComponent").set_current(10) + controller.call("submit_intent", _perfect_intent(&"A", &"a")) + controller.call("_reset_to_idle") + rejected.clear() + started.clear() + controller.call("submit_intent", _perfect_intent(&"W", &"w")) + _expect(not rejected.has(&"no_executable_action"), "Undefined A W pattern should not reject when trailing W exists") + _expect_array(player.call("get_combo_slots"), [&"A", &"W"], "Undefined A W pattern should keep the submitted W") + _expect(started.has(&"launcher_up"), "Undefined A W pattern should execute trailing standalone W") + player.free() + + +func _make_player() -> Node: + started.clear() + rejected.clear() + finished.clear() + cancelled.clear() + var scene: PackedScene = load("res://scenes/characters/player.tscn") + var player := scene.instantiate() + root.add_child(player) + await process_frame + return player + + +func _make_phase_modifier_effect(stat_name: StringName, value: float) -> Resource: + var definition: Resource = load("res://resources/effects/effect_definition.gd").new() + definition.set("id", StringName("test_%s_modifier" % stat_name)) + definition.set("duration_type", &"infinite") + var modifier: Resource = load("res://resources/effects/stat_modifier.gd").new() + modifier.set("stat", stat_name) + modifier.set("operation", "multiply") + modifier.set("value", value) + var modifiers: Array[Resource] = [modifier] + definition.set("stat_modifiers", modifiers) + return definition + + +func _connect_action_signals(controller: Node) -> void: + controller.connect("action_started", _on_action_started) + controller.connect("action_finished", _on_action_finished) + controller.connect("action_rejected", _on_action_rejected) + if controller.has_signal("action_cancelled"): + controller.connect("action_cancelled", _on_action_cancelled) + else: + failures.append("ActionController should emit action_cancelled(action, reason)") + + +func _on_action_started(action: Resource, _intent) -> void: + started.append(StringName(str(action.get("id")))) + + +func _on_action_finished(action: Resource) -> void: + if action != null: + finished.append(StringName(str(action.get("id")))) + + +func _on_action_rejected(_intent, reason: StringName) -> void: + rejected.append(reason) + + +func _on_action_cancelled(_action: Resource, reason: StringName) -> void: + cancelled.append(reason) + + +func _perfect_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: + var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) + intent.judgement = {"label": "perfect", "diff": 0.0, "abs_diff": 0.0} + return intent + + +func _miss_intent(symbol: StringName, rhythm_action: StringName) -> RefCounted: + var intent: RefCounted = InputIntentScript.create(symbol, rhythm_action, &"pressed", float(Time.get_ticks_msec())) + intent.judgement = {"label": "miss", "diff": 1.0, "abs_diff": 1.0} + return intent + + +func _has_property(object: Object, property_name: String) -> bool: + for property: Dictionary in object.get_property_list(): + if str(property.get("name", "")) == property_name: + return true + return false + + +func _expect_signal_arg_count(object: Object, signal_name: String, expected: int, label: String) -> void: + for signal_info: Dictionary in object.get_signal_list(): + if str(signal_info.get("name", "")) != signal_name: + continue + var args = signal_info.get("args", []) + _expect_int(args.size() if args is Array else 0, expected, label) + return + failures.append("%s: signal %s not found" % [label, signal_name]) + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_array(actual: Array, expected: Array, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_int(actual: int, expected: int, label: String) -> void: + if actual != expected: + failures.append("%s: expected %d, got %d" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s: expected %.4f, got %.4f" % [label, expected, actual]) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS v2 core contracts") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_v2_core_contracts.gd.uid b/tests/test_v2_core_contracts.gd.uid new file mode 100644 index 0000000..45be3cc --- /dev/null +++ b/tests/test_v2_core_contracts.gd.uid @@ -0,0 +1 @@ +uid://8tw6so4o50ax diff --git a/tests/test_v2_terms_cleanup.gd b/tests/test_v2_terms_cleanup.gd new file mode 100644 index 0000000..8d376d9 --- /dev/null +++ b/tests/test_v2_terms_cleanup.gd @@ -0,0 +1,125 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + var event_bus: Node = load("res://autoload/event_bus.gd").new() + _expect(not event_bus.has_signal("action_judged"), "EventBus should not expose legacy action_judged") + event_bus.free() + var rhythm: Node = load("res://autoload/rhythm_manager.gd").new() + _expect(not rhythm.has_signal("action_judged"), "RhythmManager should not expose legacy action_judged") + rhythm.free() + for runtime_path: String in [ + "res://autoload/rhythm_manager.gd", + "res://autoload/event_bus.gd", + "res://scenes/components/action_controller.gd", + "res://scenes/ui/rhythm_track.gd", + ]: + if not FileAccess.file_exists(runtime_path): + continue + _expect(not _read_text(runtime_path).contains("action_judged"), "%s should not reference action_judged" % runtime_path) + for rule_path: String in [ + "res://scenes/combat/action_resolver.gd", + "res://scenes/components/action_controller.gd", + ]: + _expect(not _read_text(rule_path).contains("get_current_state_name"), "%s should not use legacy state API for rule context" % rule_path) + var state_machine_source := _read_text("res://scenes/components/state_machine.gd") + _expect(not state_machine_source.contains("state_names"), "StateMachine should not keep legacy state_names") + _expect(not state_machine_source.contains("get_current_state_name"), "StateMachine should not expose legacy get_current_state_name") + _expect(not state_machine_source.contains("\"state\""), "StateMachine.build_context should not emit legacy state") + _expect(not state_machine_source.contains("func can_start_action"), "StateMachine should not own action permission rules") + _expect(not state_machine_source.contains("func can_move_freely"), "StateMachine should not own movement permission rules") + var action_rule_source := _read_text("res://scripts/resolvers/action_rule_resolver.gd") + _expect(not action_rule_source.contains("legacy_state"), "ActionRuleResolver should not fallback to legacy state context") + for marker_path: String in [ + "res://scenes/ui/rhythm_track.gd", + "res://tests/test_chart_layer.gd", + "res://tests/test_ui_animation_regression.gd", + ]: + if not FileAccess.file_exists(marker_path): + continue + var marker_source := _read_text(marker_path) + _expect(not marker_source.contains("enemy_prepare_attack"), "%s should not keep legacy enemy_prepare_attack marker" % marker_path) + _expect(not marker_source.contains("enemy_attack_active"), "%s should not keep legacy enemy_attack_active marker" % marker_path) + _expect(not marker_source.contains("enemy_recovery"), "%s should not keep legacy enemy_recovery marker" % marker_path) + var action_controller_source := _read_text("res://scenes/components/action_controller.gd") + _expect(not action_controller_source.contains("action_executor.execute(current_action, StringName(str(current_intent.judgement.get(\"label\", \"perfect\"))), burst_component)"), "ActionController should not pass BurstComponent as a stat provider") + _expect(not action_controller_source.contains("resolve_cost\", action, burst_component"), "ActionController cost resolution should use EffectContainer, not BurstComponent provider") + var burst_source := _read_text("res://scenes/components/burst_component.gd") + _expect(not burst_source.contains("judgement_scale"), "BurstComponent should not directly mutate RhythmManager.judgement_scale") + for presentation_path: String in [ + "res://scenes/characters/character.gd", + "res://scenes/characters/player.gd", + "res://scenes/components/movement_motor.gd", + ]: + var presentation_source := _read_text(presentation_path) + _expect(not presentation_source.contains("Character.State"), "%s should not reference legacy Character.State" % presentation_path) + _expect(not presentation_source.contains("enum State"), "%s should use explicit presentation constants, not legacy Character.State enum" % presentation_path) + _expect(not FileAccess.file_exists("res://scenes/rhythm/rhythm_conductor.gd"), "Legacy rhythm_conductor.gd should be removed") + _expect(not FileAccess.file_exists("res://tests/test_rhythm_conductor.gd"), "Legacy rhythm conductor test should be removed") + var charge_source := _read_text("res://scenes/components/charge_component.gd") + for charge_needle: String in ["release_requested", "begin_hold", "finish_hold", "skill_a_charge_release", "skill_d_charge_release"]: + _expect(not charge_source.contains(charge_needle), "ChargeComponent should not keep legacy charge API: %s" % charge_needle) + var player_source := _read_text("res://scenes/characters/player.gd") + for player_needle: String in ["_held_symbols", "_is_symbol_held"]: + _expect(not player_source.contains(player_needle), "Player should not keep legacy held-symbol cache: %s" % player_needle) + var action_script: Script = load("res://resources/action_data.gd") + if action_script == null: + failures.append("ActionData script should load for required_state cleanup") + else: + var action: Resource = action_script.new() as Resource + _expect(not _has_property(action, "required_state"), "ActionData should not expose legacy required_state") + for property_name: String in ["energy_cost", "energy_reward", "spawns_projectile", "projectile_scene", "damage", "cancel_window", "has_super_armor", "displacement"]: + _expect(not _has_property(action, property_name), "ActionData should not expose legacy field %s" % property_name) + for doc_path: String in [ + "res://AGENTS.md", + "res://docs/当前项目评估文档.md", + ]: + if not FileAccess.file_exists(doc_path): + continue + _expect(not _read_text(doc_path).contains("required_state"), "%s should not describe required_state as an active compatibility field" % doc_path) + for artifact_path: String in [ + "res://assets/art/characters/jump.png", + "res://assets/art/characters/jump.png.import", + "res://assets/art/characters/katana_attack_sheathe.png", + "res://assets/art/characters/katana_attack_sheathe.png.import", + "res://assets/art/characters/player_idle.png", + "res://assets/art/characters/player_idle.png.import", + "res://assets/art/characters/player_punch.png", + "res://assets/art/characters/player_punch.png.import", + "res://addons/limboai/bin/limboai.gdextension", + ]: + _expect(not FileAccess.file_exists(artifact_path), "Unused legacy artifact should be removed: %s" % artifact_path) + _expect(DirAccess.open("res://something") == null, "Placeholder something/ directory should be removed") + _finish() + + +func _read_text(path: String) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("Could not read %s" % path) + return "" + return file.get_as_text() + + +func _has_property(object: Object, property_name: String) -> bool: + for property: Dictionary in object.get_property_list(): + if property.get("name") == property_name: + return true + return false + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS v2 terms cleanup") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_v2_terms_cleanup.gd.uid b/tests/test_v2_terms_cleanup.gd.uid new file mode 100644 index 0000000..0139e64 --- /dev/null +++ b/tests/test_v2_terms_cleanup.gd.uid @@ -0,0 +1 @@ +uid://najngxhyqpa diff --git a/tests/test_x1_visual_baseline.gd b/tests/test_x1_visual_baseline.gd new file mode 100644 index 0000000..db85793 --- /dev/null +++ b/tests/test_x1_visual_baseline.gd @@ -0,0 +1,127 @@ +extends SceneTree + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_stage_matches_x1_visual_baseline() + await _check_hud_matches_x1_visual_baseline() + _finish() + + +func _check_stage_matches_x1_visual_baseline() -> void: + var scene: PackedScene = load("res://scenes/stage/stage.tscn") + _expect(scene != null, "Stage scene should load") + if scene == null: + return + var stage := scene.instantiate() + var initial_player_position := (stage.get_node("ActorsContainer/Player") as Node2D).position + var initial_melee_position := (stage.get_node("ActorsContainer/JinZhanMinion") as Node2D).position + var initial_boss_position := (stage.get_node("ActorsContainer/Boss") as Node2D).position + root.add_child(stage) + await process_frame + await process_frame + + var camera := stage.get_node_or_null("Camera2D") as Camera2D + var player := stage.get_node_or_null("ActorsContainer/Player") as Node2D + var melee := stage.get_node_or_null("ActorsContainer/JinZhanMinion") as Node2D + var boss := stage.get_node_or_null("ActorsContainer/Boss") as Node2D + _expect_bool(bool(stage.get("use_authored_camera_view")), false, "Stage should use the x1 follow-camera baseline") + _expect_vector(camera.global_position, Vector2(1180, 395), "Camera should follow the left-spawn player (new2)") + _expect_vector(camera.zoom, Vector2(1.25, 1.25), "Camera zoom should match x1") + _expect(stage.get_node_or_null("Ground/ArtLayer/PastBackground") is Sprite2D, "Stage should use the x1 ground1 background") + _expect(stage.get_node_or_null("Ground/ArtLayer/FutureBackground") is Sprite2D, "Stage should keep the x1 future background") + + _expect_vector(initial_player_position, Vector2(1180, 560), "Player should spawn at the map's left side (new2)") + _expect_vector(initial_melee_position, Vector2(2200, 560), "Initial minion anchor should use the left-shifted safe position") + _expect_vector(initial_boss_position, Vector2(2520, 560), "Boss anchor should match x1") + _expect_float(player.global_position.y, melee.global_position.y, "Player and minion anchors should share one horizontal line") + _expect_float(player.global_position.y, boss.global_position.y, "Player and Boss anchors should share one horizontal line") + _expect_float(_visible_sprite_bottom_global_y(player.get_node("Visual/CharacterSprite") as Sprite2D), 520.0, "Player visible feet should match Rthythm_archor_game") + _expect_float(_visible_sprite_bottom_global_y(melee.get_node("Visual/CharacterSprite") as Sprite2D), 520.0, "Initial minion visible feet should match Rthythm_archor_game") + _expect_float(_visible_sprite_bottom_global_y(boss.get_node("Visual/CharacterSprite") as Sprite2D), 520.0, "Boss visible feet should match Rthythm_archor_game") + _expect_vector(player.get_node("Visual").scale, Vector2(1, 1), "Player visual scale should match x1") + _expect_vector(melee.get_node("Visual").scale, Vector2(2, 2), "Minion visual scale should match x1") + _expect_float(float(melee.get("visual_ground_offset")), -38.0, "Minion visual offset should match Rthythm_archor_game anchor baseline") + var boss_scale: Vector2 = boss.get_node("Visual").scale + _expect_vector(Vector2(absf(boss_scale.x), boss_scale.y), Vector2(2, 2), "Boss visual scale should match x1") + _expect_float(float(boss.get("visual_ground_offset")), -40.0, "Boss visual offset should match Rthythm_archor_game so the visible body lines up") + _expect_float(float(boss.get("projectile_spawn_height")), 74.0, "Boss projectile lane should match Rthythm_archor_game after the visual offset is restored") + stage.free() + + +func _check_hud_matches_x1_visual_baseline() -> void: + var main_scene: PackedScene = load("res://scenes/main/main.tscn") + _expect(main_scene != null, "Main scene should load") + if main_scene == null: + return + var main := main_scene.instantiate() + root.add_child(main) + await process_frame + _expect(main.get_node_or_null("UILayer") is CanvasLayer, "HUD should stay in the x1 CanvasLayer") + var ui := main.get_node_or_null("UILayer/UI") as Control + _expect(ui != null, "Main UI should be under UILayer/UI") + if ui != null: + _expect_vector((ui.get_node("StatusBars") as Control).scale, Vector2(1.125, 1.125), "Player HUD runtime scale should match x1") + _expect_vector((ui.get_node("RhythmTrack") as Control).scale, Vector2(1, 1), "Rhythm HUD scale should match x1") + _expect_vector((ui.get_node("BossStatus") as Control).scale, Vector2(1.062, 1.062), "Boss HUD runtime scale should match x1") + _expect_vector((ui.get_node("ComboWindow") as Control).scale, Vector2(1.152, 1.152), "Combo HUD runtime scale should match x1") + main.free() + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _expect_bool(actual: bool, expected: bool, label: String) -> void: + if actual != expected: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _expect_float(actual: float, expected: float, label: String) -> void: + if absf(actual - expected) > 0.03: + failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) + + +func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: + if actual.distance_to(expected) > 0.03: + failures.append("%s: expected %s, got %s" % [label, expected, actual]) + + +func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float: + if sprite == null or sprite.texture == null: + return INF + var image := sprite.texture.get_image() + if image == null or image.is_empty(): + return INF + var hframes := maxi(1, sprite.hframes) + var vframes := maxi(1, sprite.vframes) + var frame_width := image.get_width() / hframes + var frame_height := image.get_height() / vframes + var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) + var column := frame_index % hframes + var row := int(frame_index / hframes) + var bottom := -1 + for y: int in range(frame_height): + for x: int in range(frame_width): + var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) + if pixel.a > 0.01: + bottom = y + if bottom < 0: + return INF + return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y + + +func _finish() -> void: + if failures.is_empty(): + print("PASS x1 visual baseline") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_x1_visual_baseline.gd.uid b/tests/test_x1_visual_baseline.gd.uid new file mode 100644 index 0000000..b28c0a0 --- /dev/null +++ b/tests/test_x1_visual_baseline.gd.uid @@ -0,0 +1 @@ +uid://3b1bdfo73es1 diff --git a/tests/test_zhan_bo_release_facing.gd b/tests/test_zhan_bo_release_facing.gd new file mode 100644 index 0000000..4056948 --- /dev/null +++ b/tests/test_zhan_bo_release_facing.gd @@ -0,0 +1,105 @@ +extends SceneTree + +## zhan_bo (S charge + Space) must release along the player's current facing — +## never auto-aim at the nearest enemy — and projectile collision circles must +## stay inside the visible bullet/wave art. + +var failures: Array[String] = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + await _check_release_follows_facing() + await _check_projectile_collision_fits_visual() + _finish() + + +func _check_release_follows_facing() -> void: + var parent := Node2D.new() + root.add_child(parent) + var player_scene := load("res://scenes/characters/player.tscn") as PackedScene + var action := load("res://resources/actions/zhan_bo_lv1.tres") as Resource + _expect(player_scene != null, "player scene should load") + _expect(action != null, "zhan_bo action should load") + if player_scene == null or action == null: + parent.free() + return + var player := player_scene.instantiate() as Node2D + parent.add_child(player) + player.global_position = Vector2(1000.0, 560.0) + # An enemy on the RIGHT must not pull the release direction anymore. + var decoy := Node2D.new() + decoy.add_to_group("enemies") + parent.add_child(decoy) + decoy.global_position = Vector2(1400.0, 560.0) + await process_frame + + player.set("heading", Vector2.LEFT) + var left_direction := player.call("projectile_direction", action) as Vector2 + _expect(left_direction.x < 0.0, "facing left should release the wave to the left even with an enemy on the right") + var left_spawn := player.call("projectile_spawn_position", action) as Vector2 + _expect(left_spawn.x < player.global_position.x, "left release should spawn the wave on the player's left side") + + player.set("heading", Vector2.RIGHT) + var right_direction := player.call("projectile_direction", action) as Vector2 + _expect(right_direction.x > 0.0, "facing right should release the wave to the right") + var right_spawn := player.call("projectile_spawn_position", action) as Vector2 + _expect(right_spawn.x > player.global_position.x, "right release should spawn the wave on the player's right side") + parent.free() + + +func _check_projectile_collision_fits_visual() -> void: + var projectile_scene := load("res://scenes/combat/player_projectile.tscn") as PackedScene + _expect(projectile_scene != null, "projectile scene should load") + if projectile_scene == null: + return + + var bullet := projectile_scene.instantiate() as Area2D + root.add_child(bullet) + var wave := projectile_scene.instantiate() as Area2D + wave.set("visual_profile", &"wave") + root.add_child(wave) + await process_frame + + var bullet_shape := _circle_shape(bullet) + var wave_shape := _circle_shape(wave) + _expect(bullet_shape != null, "bullet projectile should keep a CircleShape2D") + _expect(wave_shape != null, "wave projectile should keep a CircleShape2D") + if bullet_shape != null: + # effect_sheet flight frames are ~32x18px visible at scale 2. + _expect(float(bullet_shape.radius) <= 9.0, "bullet collision radius (%s) must not exceed the visible bolt half-height 9" % str(bullet_shape.radius)) + if wave_shape != null: + # Smallest wave frame is ~66x65px visible at scale 0.55. + _expect(float(wave_shape.radius) <= 32.5, "wave collision radius (%s) must fit inside the smallest wave frame" % str(wave_shape.radius)) + if bullet_shape != null and wave_shape != null: + _expect(bullet_shape != wave_shape, "profiles must not share one CircleShape2D subresource") + _expect(float(wave_shape.radius) > float(bullet_shape.radius), "wave hit circle should stay larger than the small bullet's") + bullet.queue_free() + wave.queue_free() + + +func _circle_shape(projectile: Area2D) -> CircleShape2D: + if projectile == null: + return null + var shape_node := projectile.get_node_or_null("CollisionShape2D") as CollisionShape2D + if shape_node == null: + return null + return shape_node.shape as CircleShape2D + + +func _expect(condition: bool, label: String) -> void: + if not condition: + failures.append(label) + + +func _finish() -> void: + if failures.is_empty(): + print("PASS zhan_bo release facing and projectile collision fit") + quit(0) + else: + for failure: String in failures: + push_error(failure) + quit(1) diff --git a/tests/test_zhan_bo_release_facing.gd.uid b/tests/test_zhan_bo_release_facing.gd.uid new file mode 100644 index 0000000..d9ed218 --- /dev/null +++ b/tests/test_zhan_bo_release_facing.gd.uid @@ -0,0 +1 @@ +uid://r15l7mu6yiij diff --git a/tools/export_action_patterns.gd b/tools/export_action_patterns.gd new file mode 100644 index 0000000..070bc64 --- /dev/null +++ b/tools/export_action_patterns.gd @@ -0,0 +1,58 @@ +class_name ActionPatternExporter +extends RefCounted + +const ACTION_DIR := "res://resources/actions" + + +static func export_patterns(action_dir := ACTION_DIR) -> Dictionary: + var patterns := {} + _collect_patterns(action_dir, patterns) + return patterns + + +static func to_text(action_dir := ACTION_DIR) -> String: + var patterns := export_patterns(action_dir) + var keys := patterns.keys() + keys.sort() + var lines: Array[String] = [] + for key: String in keys: + lines.append("%s -> %s" % [key, patterns[key]]) + return "\n".join(lines) + + +static func _collect_patterns(action_dir: String, patterns: Dictionary) -> void: + var dir := DirAccess.open(action_dir) + if dir == null: + return + var subdirs := dir.get_directories() + subdirs.sort() + for subdir: String in subdirs: + _collect_patterns("%s/%s" % [action_dir, subdir], patterns) + var files := dir.get_files() + files.sort() + for file_name: String in files: + if not file_name.ends_with(".tres"): + continue + var action: Resource = load("%s/%s" % [action_dir, file_name]) + if action == null: + continue + if _is_air_only_action(action): + continue + var key := _pattern_key(action.get("input_pattern")) + if key.is_empty(): + continue + patterns[key] = StringName(str(action.get("id"))) + + +static func _pattern_key(pattern: Variant) -> String: + if not pattern is Array: + return "" + var key := "" + for symbol: Variant in pattern: + key += str(symbol) + return key.replace(" ", "").to_upper() + + +static func _is_air_only_action(action: Resource) -> bool: + var states = action.get("allowed_ground_states") + return states is Array and states.size() == 1 and states[0] == &"Airborne" diff --git a/tools/export_action_patterns.gd.uid b/tools/export_action_patterns.gd.uid new file mode 100644 index 0000000..b0d2966 --- /dev/null +++ b/tools/export_action_patterns.gd.uid @@ -0,0 +1 @@ +uid://c22iirhmkldt1 diff --git a/tools/time_anchor_chart_tool.gd b/tools/time_anchor_chart_tool.gd new file mode 100644 index 0000000..0e7ca2e --- /dev/null +++ b/tools/time_anchor_chart_tool.gd @@ -0,0 +1,102 @@ +class_name TimeAnchorChartTool +extends RefCounted + +## Chart-side tooling for time anchors (design doc 3.4): +## generate explicit periodic anchors, validate anchor/mask configuration, +## and simulate the runtime periodic scheduler for a constant streak. + +const ChartEventScript := preload("res://resources/chart_event.gd") + +const VALID_TIME_PHASES: Array[StringName] = [&"past", &"future"] +const VALID_TIME_PHASE_MASKS: Array[StringName] = [&"", &"both", &"past", &"future"] + + +static func validate_chart(chart: Resource, beat_time := 0.5, bad_window := 0.2, judgement_scale := 1.0) -> Array[String]: + var errors: Array[String] = [] + if chart == null: + errors.append("chart is null") + return errors + if bad_window * judgement_scale > beat_time * 0.5: + errors.append("bad window %.3f exceeds half a beat %.3f; overlapping anchor windows at the 1-beat tier" % [bad_window * judgement_scale, beat_time * 0.5]) + var initial = chart.get("initial_time_phase") + if initial != null and not VALID_TIME_PHASES.has(StringName(str(initial))): + errors.append("initial_time_phase must be past/future, got %s" % str(initial)) + var seen_anchor_beats: Dictionary = {} + for event: Resource in chart.call("all_events"): + var event_key := str(event.call("key")) + var mask = event.get("time_phase_mask") + if mask != null and not VALID_TIME_PHASE_MASKS.has(StringName(str(mask))): + errors.append("%s: invalid time_phase_mask %s" % [event_key, str(mask)]) + var event_type := StringName(str(event.get("event_type"))) + if event_type == &"time_anchor": + if int(event.get("subdivision")) != 0: + errors.append("%s: time_anchor must sit on an integer beat" % event_key) + var beat := int(event.get("beat_index")) + if seen_anchor_beats.has(beat): + errors.append("%s: duplicate time_anchor on beat %d" % [event_key, beat]) + seen_anchor_beats[beat] = true + elif event_type == &"force_time_phase": + var payload = event.get("payload") + var target := StringName(str(payload.get("target_time_phase", ""))) if payload is Dictionary else &"" + if not VALID_TIME_PHASES.has(target): + errors.append("%s: force_time_phase needs payload.target_time_phase past/future" % event_key) + return errors + + +static func validate_time_phase_profile(profile: Resource) -> Array[String]: + var errors: Array[String] = [] + if profile == null: + return errors + for property_name: String in ["past_effects", "future_effects"]: + var definitions = profile.get(property_name) + if not definitions is Array: + continue + for definition: Variant in definitions: + if definition is Resource and StringName(str((definition as Resource).get("duration_type"))) != &"infinite": + errors.append("%s: effect %s must be duration_type infinite (lifecycle owned by TimePhaseAdapter)" % [property_name, str((definition as Resource).get("id"))]) + return errors + + +static func generate_periodic_time_anchor_events(interval_beats: int, total_beats: int, lead_beats := 2.0) -> Array[Resource]: + var events: Array[Resource] = [] + var safe_interval := maxi(1, interval_beats) + var beat := safe_interval + while beat < total_beats: + var event: Resource = ChartEventScript.new() + event.set("event_id", StringName("time_anchor_%d" % beat)) + event.set("beat_index", beat) + event.set("event_type", &"time_anchor") + event.set("lead_beats", lead_beats) + events.append(event) + beat += safe_interval + return events + + +static func simulate_periodic_schedule(frequency_profile: Resource, streak: int, total_beats: int, explicit_beats: Array[int] = []) -> Array[int]: + var window_beats := 4 + if frequency_profile != null and frequency_profile.get("window_beats") != null: + window_beats = maxi(1, int(frequency_profile.get("window_beats"))) + var offsets: Array[int] = [window_beats] + if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"): + var profile_offsets = frequency_profile.call("anchor_offsets_for_streak", streak) + if profile_offsets is Array and not profile_offsets.is_empty(): + offsets = [] + for offset: Variant in profile_offsets: + offsets.append(clampi(int(offset), 1, window_beats)) + var scheduled: Array[int] = [] + var origin := 0 + while origin < total_beats: + var explicit_hit := false + for explicit_beat: int in explicit_beats: + if explicit_beat > origin and explicit_beat <= origin + window_beats: + origin = explicit_beat + explicit_hit = true + break + if explicit_hit: + continue + for offset: int in offsets: + var beat := origin + offset + if beat < total_beats: + scheduled.append(beat) + origin += window_beats + return scheduled diff --git a/tools/time_anchor_chart_tool.gd.uid b/tools/time_anchor_chart_tool.gd.uid new file mode 100644 index 0000000..629204d --- /dev/null +++ b/tools/time_anchor_chart_tool.gd.uid @@ -0,0 +1 @@ +uid://dwl1tj4dusq35