14 KiB
读完这份策划书,先说结论:前两轮的架构骨架(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 的全局单位表:
# 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 乘区 × 爆气乘区),所以做一个统一的结算管线:
# 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
# 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 反馈,不会因为空拍自动出现:
# 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 上是冲突的,必须由程序定义结算时机。我建议的方案是按下即判定、延迟结算:
# 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:
# 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 的四个地基模块(含单元测试)完整写出来。