Files
Fighting_Rthythm_game/docs/架构方案.md
2026-07-02 09:47:52 -07:00

183 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
读完这份策划书,先说结论:前两轮的架构骨架(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 的 T0T1 上升、T1 高点、T1T2 下落也由它按拍切分,并在 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 的四个地基模块(含单元测试)完整写出来。