读完这份策划书,先说结论:前两轮的架构骨架(Autoload 总线 + 组件化 + Resource 数据驱动)完全适用,但策划书里有三个事实会**反过来修正**我之前的一些具体建议,先把它们摆清楚,因为整个架构都要围绕它们展开。 **第一,拍(Beat)是全游戏唯一的时间单位。** 动作持续用 ActionBeats,Buff 持续 8 拍,爆气 16 拍、冷却 4 拍,蓄力按拍分级,敌人每 N 拍行动,连段窗口是 4 拍。这意味着 RhythmManager 不只是"判定服务",它是**全局主时钟**,所有系统的计时都应该订阅它的 `beat_ticked`,而不是各自开 Timer 数秒——这样换 BPM 时整个游戏自动变速,这正是节奏游戏该有的性质。 **第二,我要收回上一轮"输入缓冲"的建议。** InputBuffer 是格斗游戏的手感工具,但在你这个设计里它是有害的:判定的本质是测量按键时刻与拍点的偏差,缓冲会把"提前按"偷偷延后消费,等于篡改了玩家的真实节奏。而且策划书的 Miss 机制天然解决了连打问题——乱按的人会大量 Miss,Miss 清空窗口、动作失败,惩罚是玩法自带的,不需要程序层再去限速。保留的只有 echo 过滤、去重和时间戳这些"让输入干净"的部分。 **第三,策划书 9.1 节其实已经替你把数据模型设计好了。** 那个统一技能字段表(inputPattern、requiredState、baseCost、各倍率、hitType、clearWindow、canChain……)就是 `ActionData extends Resource` 的字段清单,而且基础动作和技能同构——A 一段斩就是一个 cost=0、pattern=[A] 的 ActionData。全游戏 30 多个动作全部变成 `.tres` 文件,代码里没有任何一张硬编码技能表。 ## 一、全局层:RhythmManager 作为节拍中枢RhythmManager(Autoload)的接口对应策划书 2.1 的全局单位表: ```gdscript # autoload/rhythm_manager.gd signal beat_ticked(beat_index: int) var bpm: float var beat_time: float # 60.0 / bpm var beat_index: int var judgement_scale := 1.0 # 爆气时 > 1,放宽判定窗口 func song_position() -> float: return music.get_playback_position() \ + AudioServer.get_time_since_last_mix() \ - AudioServer.get_output_latency() func judge(input_timestamp_ms: float) -> Judgement: # 换算到歌曲时间,求与最近拍点的 BeatOffset # 与 RhythmConfig 中的阈值比较 → perfect / good / bad / miss # 阈值乘以 judgement_scale 实现爆气放宽 ``` 判定阈值放进 `rhythm_config.tres` 资源(Perfect/Good/Bad 各自的偏差上限)——**策划书目前没有给这组数字**,这是要找策划补的第一个洞。爆气的"判定窗口适度放宽"通过 `judgement_scale` 一个字段实现,BurstComponent 进入爆气时改它,退出时还原。 CombatManager(Autoload)则是四条乘区公式的唯一归属地。策划书 2.3 的公式结构完全一致(基础值 × 动作乘区 × 节奏乘区 × Buff 乘区 × 爆气乘区),所以做一个统一的结算管线: ```gdscript # autoload/combat_manager.gd func resolve_damage(atk: float, action: ActionData, j: Judgement, buffs: BuffContainer, burst: BurstComponent) -> float: return atk * action.damage_mult * j.damage_mult \ * buffs.damage_mult(action) * burst.damage_mult() # resolve_move_x / resolve_move_y / resolve_cost 同构,只换字段 ``` 各系统只负责"贡献自己的乘数"(比如强袭乐句 Buff 只对同方向技能生效,所以 `buffs.damage_mult(action)` 要传入动作),永远不要在 Player 或技能代码里手写 `damage * 1.25 * 1.2` 这种散落的乘法——否则第 4 条公式改一次你要全项目搜一遍。 ## 二、数据层:ActionData 照抄策划书 9.1 ```gdscript # resources/action_data.gd class_name ActionData extends Resource @export var id: StringName @export var display_name: String @export var input_pattern: Array[StringName] # [&"A", &"A", &"space"] @export_enum("ground", "air", "guarding", "any") var required_state: String @export var base_cost := 0 # 基础动作恒为 0 @export var damage_mult := 1.0 @export var move_mult_x := 0.0 @export var move_mult_y := 0.0 @export var action_beats := 1.0 # W 为 2.0 @export_enum("melee", "projectile", "circle", "counter") var hit_type: String @export var range := 0.0 @export_enum("single", "area") var target_type: String @export var armor_level := 0 @export var clear_window := true # 音刃前两段为 false @export var can_chain := false # 音刃族为 true @export var special: StringName # 破霸体、浮空等特效钩子 ``` A/D 三段、W、招架、下劈、W 派生、四个方向技能、三段音刃、反击音刃、三级蓄力——全部是这个类的 `.tres` 实例,放在 `res://resources/actions/` 下。第一版验收标准 17.4 里"A Space、AA Space、AAA Space 功能不同"这种需求,变成纯粹的资源文件差异。 ## 三、玩家实体:组件划分随策划书调整 玩家场景树在上一轮基础上,按这份策划书的系统重新切分: ``` Player (CharacterBody2D) ├── Sprite / 骨骼动画 ├── StateMachine # 8 个状态,照抄 3.2 节 │ └── ground / air / guarding / charging / │ bladeChain / burstCharge / bursting / hitstun ├── InputComponent # 按下+松开事件、时间戳、长按检测 ├── ComboWindow # 四槽连段窗口,只记录显式输入/Miss,不自动补空拍 ├── ActionResolver # Space 优先级链 + 动作表匹配(纯逻辑) ├── MotionExecutor # 把位移乘区结果变成 ActionBeats 内的实际位移 ├── EnergyComponent # 回能规则 + 空挥计数器 ├── BuffContainer # 四个 Buff 的触发、拍计时、乘区供给 ├── BurstComponent # 爆气条件、四态、效果开关 ├── DamageEmitter (Area2D) └── DamageReceiver (Area2D) ``` 几个组件值得单独说透。 **ComboWindow:它是连段窗口的领域对象,不是输入缓冲,也不负责空拍补位。** 这里按当前设计修正:ComboWindow 不订阅 `beat_ticked` 来自动补 Ø,某一拍没有输入就什么都不记录。它只记录两类内容:通过节奏判定的显式输入(A/D/W/S/Space),以及 Miss 时由裁决层显式写入的 Ø 占位。Miss 只是正常槽位输入,不因自身触发清空;满 4 槽清空;受击清空(监听 DamageReceiver);`clear_window == true` 的动作释放后清空;bladeChain 期间按专门规则决定是否保留窗口。识别连段时对外暴露过滤掉 Ø 的有效序列,但正常玩法里 Ø 只作为 Miss 反馈,不会因为空拍自动出现: ```gdscript # components/combo_window.gd class_name ComboWindow extends Node signal cleared(reason: StringName) var slots: Array[StringName] = [] func record(action: StringName) -> void: # 判定非 Miss 后调用 slots.append(action) if slots.size() >= 4: clear(&"window_full") func record_miss() -> void: # 仅 Miss 裁决显式调用 slots.append(&"Ø") clear(&"miss") func pattern() -> Array[StringName]: # 供 ActionResolver 匹配 return slots.filter(func(s): return s != &"Ø") ``` 这样窗口的语义会更干净:连段只由玩家真实输入构成;Miss 是显式失败反馈并清空窗口;空拍不会污染连段,也不会制造需要额外解释的隐藏槽位。 **ActionResolver:Space 的六步优先级链是全项目最容易腐坏的逻辑,必须独立成模块。** 而且注意策划书 10.3 说反击音刃"优先级高于普通 S Space 音刃",但第 6 节的解析顺序里没写它——合并后的完整链条是七步:实现上把这七步写成一个有序规则数组,每条规则是"条件函数 + 产出动作",Resolver 逐条尝试、命中即返回。这样以后加 Q/E、装备技能替换(策划书 18 节的暂缓内容)只是往数组里插规则,不用动主干。 **InputComponent:长按检测暴露了一个策划书没定义的关键时序问题。** S Space 短按是音刃、长按是爆气——但按下的瞬间程序不可能知道玩家会不会长按,而策划书 4.1 又要求"按键时立刻判定"。这两条规则在 Space 上是冲突的,必须由程序定义结算时机。我建议的方案是**按下即判定、延迟结算**: ```gdscript # InputComponent 对 Space 的处理 # 按下: 立即调用 RhythmManager.judge() 并暂存结果(pending) # 若在 hold_threshold(建议 0.3~0.5 拍,需策划确认)内松开: # → 按短按结算,使用按下那一刻的判定结果(节奏不失真) # 若超过阈值仍按住: # → 丢弃 pending,升级为 charging / burstCharge 状态 # → 松开时重新判定(策划书 11.1 / 12.4 本来就要求松开判定) ``` 代价是短按的动作会比按键晚约三分之一拍才出招——对音刃这种远程投射物,可以让动画前段先演出、投射物在结算点生成来掩盖。这个点务必和策划当面对齐,它直接影响手感验收标准 17.1 的"按下后立即出动作"在 Space 上如何解释。 **StateMachine 直接照抄 3.2 节的八个状态**,每个 ActionData 的 `required_state` 字段由状态机门控(W 派生只在 air 状态可解析,招架结算只在 guarding)。charging、bladeChain、burstCharge 这三个状态的本质是"改变 Space 解析结果的模式",所以 ActionResolver 每次解析都要先问状态机当前状态——优先级链的①②③本质上就是状态查询。 **MotionExecutor 是这个游戏区别于普通横版的组件。** 玩家不能自由走路,意味着 Player 里**不存在**常规的"读方向键改 velocity"代码;所有位移都是动作的产物:接到一个动作的 `FinalMoveX/Y`(CombatManager 算好的乘区结果),在 `action_beats × beat_time` 的时长内用 tween 或速度曲线执行完,期间仍走 `move_and_slide` 保证碰撞。W 的 T0–T1 上升、T1 高点、T1–T2 下落也由它按拍切分,并在 T1 通知状态机开放空中派生窗口。 **资源三件套(Energy / Buff / Burst)全部是 EventBus 的订阅者。** EnergyComponent 的空挥限制需要一条此前没有的反馈链:DamageEmitter 命中时经 CombatManager 广播 `hit_confirmed`,EnergyComponent 据此区分"命中回能 100% / 有效位移未命中 50% / 连续三次空挥后归零"。BuffContainer 监听判定流水(合拍、完美律动看连击流)、连段事件(强袭乐句看 AAA 终结)、招架结果(守拍反击),持续时间订阅 `beat_ticked` 递减,并暴露一个 `ticking_paused` 开关给爆气用。BurstComponent 自己是个小状态机(off/ready/active/cooldown),每次能量或连击变化时检查 12.3 的三选一条件点亮 ready,激活时做四件事:改 CombatManager 乘区、把技能 cost 乘区归零、调 `RhythmManager.judgement_scale`、暂停 BuffContainer 计时,16 拍后统一还原并清资源。 ## 四、世界层与敌人 Stage 负责装载关卡、把曲目和 BPM 交给 RhythmManager 开始播放。ActorsContainer 统一生成三种测试敌人和**音刃投射物**(音刃是远程投射物,正好落在上一轮"实体不自己 new 实体"的规则里)。敌人 = Character 基类 + DamageReceiver(带 ArmorLevel)+ 一个极简的 EnemyBrain: ```gdscript # EnemyBrain: 敌人和玩家订阅同一个时钟,这是设计对称性所在 @export var data: EnemyData # beats_per_action、行为类型 func _on_beat(i: int) -> void: if i % data.beats_per_action == 0: _act() # 接近 / 攻击 / 远程射击 ``` 节奏型(1 拍、2 拍,以及未来的半拍、切分)就是 EnemyData 上的一个数字或节拍掩码,15.1 节的扩展方向零成本预留。破霸体走 DamageContext:W 派生的 ActionData 带 `armor_level` 和 `special = &"armor_break"`,Receiver 拿自己的 ArmorLevel 比较后决定是硬直、浮空还是只吃伤害。 UI 层强烈建议第一版就做**四槽窗口可视化**(四个格子实时显示 A/D/W/S/Space/Ø),它同时是玩家理解连段系统的核心界面和你调试 ComboWindow 的工具。这里的 Ø 只代表显式 Miss 反馈,不是空拍占位;UI 监听 ComboWindow 的信号即可。 ## 五、目录结构与实施映射 ``` res:// ├── autoload/ # rhythm_manager.gd / event_bus.gd / combat_manager.gd ├── resources/ │ ├── actions/ # 30+ 个 ActionData .tres(基础动作与技能同构) │ ├── buffs/ # 4 个 BuffData │ ├── enemies/ # EnemyData(节奏型、数值) │ └── rhythm_config.tres ├── components/ # input / combo_window / resolver / motion / energy / buff / burst / damage_* ├── scenes/ │ ├── characters/ # player.tscn、enemy 基类、三种测试敌人 │ ├── stage/ # stage.tscn、actors_container.tscn │ ├── combat/ # 音刃投射物等 │ └── ui/ # hud.tscn、combo_slots.tscn、energy_bar.tscn └── tests/ # 对 ComboWindow / Resolver / CombatManager 的纯逻辑单元测试 ``` 对应策划书 16 节的五个阶段:阶段 1 = RhythmManager + RhythmConfig + InputComponent + ComboWindow(这四个是地基,而且全部可脱离画面单元测试);阶段 2 = StateMachine + MotionExecutor + 基础动作的 ActionData;阶段 3 = ActionResolver 优先级链 + 全部技能资源 + DamageEmitter/投射物 + ActorsContainer;阶段 4 = Energy/Buff/Burst 三组件 + 乘区管线补全;阶段 5 = EnemyBrain + 三种敌人。每个阶段结束,对应的验收标准(17 节)恰好都有明确的架构归属可以逐条勾选。 ## 六、需要回去和策划确认的三个洞 架构做到这里,策划书有三处未定义的行为必须补上,否则程序只能自行拍板:一是**判定阈值的具体数字**(Perfect/Good/Bad 各多少毫秒或多少比例拍长),RhythmConfig 等着填;二是上面说的**长按阈值与短按结算时机**;三是**同一拍内的第二次输入**如何处理——ComboWindow 不再按拍自动补槽,但如果玩家在同一拍内按了两次,两次输入是否都独立判定、是否都进入窗口、第二次是否直接按 Miss 处理,仍需要定义。蓄力/爆气长按期间不补 Ø;这段时间窗口应冻结或在释放后按动作规则清空。 整体看,这份策划书和这套架构咬合得相当好——因为它本质上已经是一份"以拍为时基、以数据表驱动"的设计,你要做的就是让代码结构诚实地反映这个事实:一个时钟(RhythmManager)、一张表(ActionData 资源集)、一条链(Resolver 优先级)、一条管线(乘区结算),其余全是订阅者。如果你想,下一步我可以把阶段 1 的四个地基模块(含单元测试)完整写出来。