feat(m77-b): battle event beats, actor poses and accessibility switches

This commit is contained in:
2026-09-14 22:32:57 +08:00
parent 6c853c0807
commit eb324ecdc3
5 changed files with 306 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@
| 2026-09-13 | M60 可玩性真相(W1) | `e1cad068`/`dac5de40` | `m60-playthrough` `20260913T145904Z-38281` | runtime_verified | 34 步 0 阻断;视频与逐步截图 |
| 2026-09-13 | M61 设计/密度(W2) | `310d2620` | `m61-five-profile` `20260913T162625Z-2172` | simulation_verified | decisions=330, max_gap=31.3s, 反支配/存档/可达通过 |
| 2026-09-13 | M62 组件统一(W3) | `2e5540ed` | `m62-ink-baselines` `20260913T164335Z-38711` | runtime_verified | 组件库+矩阵+探针接入;基线 Guild 0 / Contract 1 / Readiness+Result 6 / Market 严格 0 |
| 2026-09-14 | M77-B 战斗事件节拍 + 无障碍开关真实生效 | `feat(m77-b)` | runtime_verified | 9 类节拍(预警/格挡/弹道/法术/治疗/伤害/击倒/恢复/开场)+ 演员姿态;`ABG_M77_BATTLE_BEATS_OK` |
| 2026-09-14 | M77-A 战场构图(底图 + 敌我演员 + 队形/走廊 + 计数/队伍条) | `feat(m77-a)` | runtime_verified | 全流程实拍 + `ABG_M77_BATTLE_FIELD_OK`;区域 1 全部敌人立绘入库 |
| 2026-09-14 | M76-D 整机试听带(4 段合成试听) | `feat(m76-d)` | runtime_pending(Owner 听感)| guild/market/risk/result 四段;音乐床 + 真实音效时序 |
| 2026-09-14 | M76-C 音频接线(服务/总线/事件/音乐族)+ 卡内模块点击缺陷 | `feat(m76-c)`/`fix(m76-c)` `b0db3b46` | `m76-audio` `20260914T131826Z-19299` | runtime_verified(待整机听感)| 144 事件、16 总线;全流程 34 步 0 阻断且音频门通过;修复市场卡无法点击 |
+13 -1
View File
@@ -22,7 +22,7 @@
1. **M77-A(COMPLETE)战场构图**:Windglass Verge 手绘战场底图(生成)+ 敌我演员落位(左公司线/右敌线/走廊)
+ 顶部计数与控制簇 + 单位血条/状态 + 底部队伍条 + 计时/回合徽记;与采纳方向图做**尺度实测对比**。
2. **M77-B 事件节拍**:行动/命中/受击/格挡/技能/击倒/撤退的地面预警圈、位移、命中停顿与 VFX(VFX 语言见 phase6_vfx_language_05)。
2. **M77-B(COMPLETE)事件节拍**:行动/命中/受击/格挡/技能/击倒/撤退的地面预警圈、位移、命中停顿与 VFX(VFX 语言见 phase6_vfx_language_05)。
3. **M77-C 音频**:BAT 60 条音效生成(ElevenLabs,常设授权)+ 区域 1 战斗音乐套件(MUS-R01-BATTLE/BOSS)+ 接线。
4. **M77-D 控制与无障碍**:暂停/1x/2x/4x/搜索、战斗日志/字幕、`reduced_motion`/`reduced_flashes`/`color_cues`/`hit_pause` 开关真实生效。
5. **M77-E 门**:新战斗探针(1.0+1.3 × zh/en;墨迹门 + 尺度门 + 事件播放断言)+ 全流程 attempt(含战斗段)+ 实拍。
@@ -38,6 +38,18 @@
- 机器门:驱动脚本新增 `ABG_M77_BATTLE_FIELD_OK allies=4 enemies=2 ally_art=4 corridor=0.53..0.60`
(人数、真实贴纸、尺寸带、走廊不被穿越、敌线不重复)。
## M77-B 产出(2026-09-14)
- 战场美术节点新增**节拍系统**(自有时钟,无 Tween 生命周期):预警脉冲圈 + 落下箭头、格挡弧、
弹道飞行 + 命中爆闪、法术蓄能环 + 光束、治疗光点、伤害冲击波、击倒标记、恢复光环、开场走廊微光。
- 演员姿态(壳侧、随事件持久):击倒的同伴**倒地旋转 + 半透明**;出手方冲步位移;受击方染色;
`reduced_motion` 时位移/旋转减半或取消。
- 无障碍:`reduced_motion`(大位移抑制)、`reduced_flashes`(白闪改为描边圈)、`hit_pause`(命中冻结 0.1s)
由设置传入美术节点并真实生效。
- 机器门:驱动脚本新增 `ABG_M77_BATTLE_BEATS_OK beats=32 kinds=damage,guard,guarded_recovery,healing,magic,ranged,result,telegraph`
(节拍 ≥3、种类覆盖、计数来自壳侧持久计数而非每帧新建的美术节点)。
- 便于人工查看:`ABG_PLAYTHROUGH_BEAT_CAPTURE=1` + `ABG_PLAYTHROUGH_BATTLE_SPEED=<n>` 可逐拍截图。
## Acceptance checks
- 尺度:公司线可容纳 4 单位、敌线 3 单位,走廊不断裂;单位高度/间距与采纳方向图同比例(±10%)
+210
View File
@@ -60,6 +60,7 @@ func _draw() -> void:
return
_draw_top_rails()
_draw_field_language()
_draw_beat()
_draw_bottom_rail()
@@ -447,3 +448,212 @@ func _plate(background: Color, border: Color, border_width: int, radius: int) ->
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
return style
# --- M77-B event beats -------------------------------------------------------
# The approved field language (path, rings, urgent marker) stays; these add the
# readable beat: telegraph pulses, projectile travel, impact bursts, guard arc,
# healing motes, knockout mark. Sprites stay with the shell actor nodes (pose
# and fade are set there); the art owns the temporal VFX and its own clock, so
# no Tween lifecycle exists to leak.
const BEAT_DURATIONS := {
"opening": 1.2,
"telegraph": 1.2,
"guard": 0.7,
"ranged": 0.9,
"magic": 1.0,
"healing": 1.0,
"damage": 0.9,
"knockout": 1.4,
"guarded_recovery": 1.0,
}
const BEAT_IMPACT := {
"ranged": 0.40,
"magic": 0.45,
"damage": 0.32,
"knockout": 0.34,
"guard": 0.15,
"healing": 0.20,
}
var beat_kind := ""
var beat_key := ""
var beat_time := 0.0
var beat_duration := 0.0
var beat_amount := 0
var beat_source := Vector2.ZERO
var beat_target := Vector2.ZERO
var beat_played := 0
var beat_kinds_seen: Dictionary = {}
var reduce_motion := false
var reduce_flashes := false
var hit_pause_enabled := true
var _pause_consumed := true
func set_accessibility(presentation: Dictionary) -> void:
reduce_motion = bool(presentation.get("reduced_motion", false))
reduce_flashes = bool(presentation.get("reduced_flashes", false))
hit_pause_enabled = bool(presentation.get("hit_pause", true))
queue_redraw()
func set_beat(key: String, kind: String, source: Vector2, target: Vector2, amount := 0) -> void:
if key == beat_key and kind == beat_kind:
return
beat_key = key
beat_kind = kind
beat_source = source
beat_target = target
beat_amount = amount
beat_time = 0.0
beat_duration = float(BEAT_DURATIONS.get(kind, 0.9))
_pause_consumed = not hit_pause_enabled
beat_played += 1
beat_kinds_seen[kind] = int(beat_kinds_seen.get(kind, 0)) + 1
set_process(true)
queue_redraw()
func beat_progress() -> float:
if beat_duration <= 0.0:
return 1.0
return clampf(beat_time / beat_duration, 0.0, 1.0)
func _process(delta: float) -> void:
if beat_kind.is_empty():
set_process(false)
return
beat_time += delta
var impact := float(BEAT_IMPACT.get(beat_kind, 1.0))
if not _pause_consumed and beat_time >= beat_duration * impact:
# A short impact freeze; the flight already happened before it.
_pause_consumed = true
beat_time -= 0.10
queue_redraw()
if beat_time > beat_duration * 1.4:
set_process(false)
func _draw_beat() -> void:
if beat_kind.is_empty() or size.x <= 0.0:
return
var progress := beat_progress()
var source := _to_px(beat_source)
var target := _to_px(beat_target)
match beat_kind:
"opening":
_draw_opening_beat(progress)
"telegraph":
_draw_telegraph_beat(progress, target)
"guard":
_draw_guard_beat(progress, target)
"ranged":
_draw_projectile_beat(progress, source, target, Color("#f2d27a"))
"magic":
_draw_magic_beat(progress, source, target)
"healing":
_draw_healing_beat(progress, target)
"damage":
_draw_impact_beat(progress, target, Color("#ffd9a0"))
"knockout":
_draw_impact_beat(progress, target, Color("#e0695f"))
_draw_knockout_mark(progress, target)
"guarded_recovery":
_draw_recovery_beat(progress, target)
func _draw_opening_beat(progress: float) -> void:
var center := Vector2(size.x * 0.50, size.y * 0.345)
var alpha := 0.35 + 0.45 * sin(progress * PI)
draw_arc(center, size.x * 0.022, 0.0, TAU, 30, Color("#efd694", alpha), 2.0)
draw_line(Vector2(size.x * 0.34, size.y * 0.56), Vector2(size.x * 0.66, size.y * 0.56), Color("#e8d39d", alpha * 0.5), 1.5)
func _draw_telegraph_beat(progress: float, target: Vector2) -> void:
var base := maxf(14.0, size.x * 0.014)
for index in 3:
var local := fmod(progress * 2.2 + float(index) * 0.33, 1.0)
var radius := lerpf(base, base * 5.0, local)
draw_arc(target, radius, 0.0, TAU, 40, Color("#e0575f", (1.0 - local) * 0.55), 2.5)
draw_circle(target, base * 0.55, Color("#e0575f", 0.30 * (1.0 - progress)))
func _draw_guard_beat(progress: float, target: Vector2) -> void:
var alpha := sin(progress * PI)
var center := target - Vector2(0.0, size.y * 0.045)
var radius := maxf(18.0, size.x * 0.018)
draw_arc(center, radius, PI, TAU, 26, Color("#84d7e6", 0.90 * alpha), 4.0)
draw_arc(center, radius * 0.78, PI, TAU, 22, Color("#d8f3f8", 0.55 * alpha), 1.5)
func _draw_projectile_beat(progress: float, source: Vector2, target: Vector2, tint: Color) -> void:
var impact := float(BEAT_IMPACT.get("ranged", 0.4))
if progress < impact:
var local := progress / impact
var position := source.lerp(target, local)
var direction := (target - source).normalized()
for index in 4:
var trail := position - direction * float(index) * size.x * 0.006
draw_circle(trail, maxf(1.5, size.x * 0.0022) * (1.0 - float(index) * 0.2), Color(tint, 0.55 - float(index) * 0.12))
draw_circle(position, maxf(3.0, size.x * 0.0045), tint)
return
_draw_impact_beat((progress - impact) / maxf(0.001, 1.0 - impact), target, tint)
func _draw_magic_beat(progress: float, source: Vector2, target: Vector2) -> void:
var impact := float(BEAT_IMPACT.get("magic", 0.45))
var violet := Color("#b98ce0")
if progress < impact:
var local := progress / impact
draw_arc(source, lerpf(size.x * 0.012, size.x * 0.030, local), 0.0, TAU, 30, Color(violet, 0.35 + 0.5 * local), 2.5)
draw_arc(source, lerpf(size.x * 0.006, size.x * 0.018, local), 0.0, TAU, 22, Color("#e8d6f6", 0.5 * local), 1.5)
return
var local := (progress - impact) / maxf(0.001, 1.0 - impact)
draw_line(source, target, Color(violet, 0.55 * (1.0 - local)), maxf(2.0, size.x * 0.004))
_draw_impact_beat(local, target, violet)
func _draw_healing_beat(progress: float, target: Vector2) -> void:
var green := Color("#95d9a0")
draw_arc(target, lerpf(size.x * 0.010, size.x * 0.030, progress), 0.0, TAU, 30, Color(green, (1.0 - progress) * 0.7), 2.0)
for index in 5:
var offset := (float(index) - 2.0) * size.x * 0.010
var rise := progress * size.y * 0.065
var mote := target + Vector2(offset, -size.y * 0.010 - rise)
draw_circle(mote, maxf(1.6, size.x * 0.0024), Color(green, (1.0 - progress) * 0.85))
func _draw_impact_beat(progress: float, target: Vector2, tint: Color) -> void:
if progress <= 0.0 or progress >= 1.0:
return
var fade := 1.0 - progress
if reduce_flashes:
draw_arc(target, lerpf(size.x * 0.012, size.x * 0.034, progress), 0.0, TAU, 30, Color(tint, fade * 0.85), 3.0)
else:
draw_circle(target, lerpf(size.x * 0.012, size.x * 0.026, progress), Color(tint, fade * 0.5))
for index in 8:
var angle := TAU * float(index) / 8.0
var direction := Vector2(cos(angle), sin(angle))
var inner := target + direction * size.x * 0.010
var outer := target + direction * size.x * lerpf(0.020, 0.040, progress)
draw_line(inner, outer, Color(tint, fade * 0.8), 2.0)
func _draw_knockout_mark(progress: float, target: Vector2) -> void:
var fade := clampf(1.0 - progress * 0.6, 0.0, 1.0)
draw_arc(target, size.x * 0.036, PI * 0.15, PI * 0.85, 24, Color("#8c3f47", fade), 3.0)
var glyph := target - Vector2(0.0, size.y * 0.075)
draw_line(glyph + Vector2(-size.x * 0.008, -size.x * 0.008), glyph + Vector2(size.x * 0.008, size.x * 0.008), Color("#f2c7c1", fade), 3.0)
draw_line(glyph + Vector2(size.x * 0.008, -size.x * 0.008), glyph + Vector2(-size.x * 0.008, size.x * 0.008), Color("#f2c7c1", fade), 3.0)
func _draw_recovery_beat(progress: float, target: Vector2) -> void:
var gold := Color("#f0cd83")
draw_arc(target, lerpf(size.x * 0.012, size.x * 0.032, progress), 0.0, TAU, 30, Color(gold, (1.0 - progress) * 0.75), 2.5)
for index in 4:
var offset := (float(index) - 1.5) * size.x * 0.012
var mote := target + Vector2(offset, -progress * size.y * 0.055)
draw_circle(mote, maxf(1.6, size.x * 0.0022), Color(gold, (1.0 - progress) * 0.8))
+53 -2
View File
@@ -240,6 +240,8 @@ var risk_commitment_receipt: Dictionary = {}
var battle_operation_id := ""
var battle_paused := false
var battle_speed := 1.0
var battle_beats_played := 0
var battle_beat_kinds: Dictionary = {}
var battle_notice := ""
var battle_playback_generation := 0
var battle_playback_timer: Timer
@@ -3328,6 +3330,7 @@ func _battle_opening_actor(position: int, recruit: Dictionary, battle: Dictionar
var portrait := TextureRect.new()
portrait.name = "BattlePortrait"
portrait.texture = _battle_opening_actor_texture(recruit)
_apply_battle_actor_pose(portrait, recruit, battle, true)
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
@@ -3379,6 +3382,7 @@ func _battle_opening_enemy(index: int, enemy_id: String, battle: Dictionary, rec
var portrait := TextureRect.new()
portrait.name = "BattleEnemyPortrait"
portrait.texture = _battle_enemy_texture(enemy_id)
_apply_battle_enemy_pose(portrait, enemy_id, battle)
portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
@@ -3520,6 +3524,44 @@ func _battle_profession_parent(profession_id: String) -> String:
return found.get_string() if found != null else ""
func _apply_battle_actor_pose(portrait: TextureRect, recruit: Dictionary, battle: Dictionary, ally: bool) -> void:
# M77-B: persistent down pose for knocked-out allies, plus a per-event
# lunge/target read. Large motion is suppressed by the reduced-motion
# accessibility switch; the event ring still reads the beat.
var reduced_motion := bool(settings.get("reduced_motion", false))
var recruit_id := String(recruit.get("recruit_id", ""))
if ally and recruit_id in Array(battle.get("knocked_out_ids", [])):
portrait.rotation = deg_to_rad(72.0 if not reduced_motion else 45.0)
portrait.modulate = Color(1.0, 0.86, 0.84, 0.45)
return
var event := battle_live_event
if event.is_empty():
return
var kind := String(event.get("kind", ""))
if not kind in ["ranged", "magic", "damage", "guard", "healing"]:
return
var is_source := String(event.get("actor_id", "")) == recruit_id
var is_target := String(event.get("target_id", "")) == recruit_id
if is_source and not reduced_motion:
portrait.position.x += 10.0
elif is_target:
portrait.modulate = Color(1.0, 0.92, 0.88, 1.0) if kind in ["damage", "knockout"] else Color(1.0, 1.0, 1.0, 1.0)
func _apply_battle_enemy_pose(portrait: TextureRect, enemy_id: String, battle: Dictionary) -> void:
var reduced_motion := bool(settings.get("reduced_motion", false))
var event := battle_live_event
if event.is_empty():
return
var kind := String(event.get("kind", ""))
var is_source := String(event.get("actor_id", "")) == enemy_id
var is_target := String(event.get("target_id", "")) == enemy_id
if is_source and not reduced_motion and kind in ["damage", "telegraph"]:
portrait.position.x -= 12.0
elif is_target and kind in ["ranged", "magic", "damage", "knockout"]:
portrait.modulate = Color(1.0, 0.90, 0.86, 1.0)
func _battle_enemy_texture(enemy_id: String) -> Texture2D:
if BATTLE_ENEMY_ART.has(enemy_id):
return BATTLE_ENEMY_ART[enemy_id]
@@ -3770,6 +3812,8 @@ func _build_battle_live_overlay(company: Dictionary, battle: Dictionary, view_da
var source_id := String(view_event.get("actor_id", ""))
var target_id := String(view_event.get("target_id", ""))
var view_state := String(Dictionary(view_data.get("state", {})).get("name", battle_live_state))
var live_source_anchor := _battle_live_actor_anchor(source_id, view_battle, true)
var live_target_anchor := _battle_live_actor_anchor(target_id, view_battle, false)
art.set_event_state(
view_state,
String(view_event.get("kind", "")),
@@ -3781,9 +3825,16 @@ func _build_battle_live_overlay(company: Dictionary, battle: Dictionary, view_da
float(Dictionary(view_data.get("state", {})).get("speed", battle_speed)),
_battle_live_marker_anchors(view_battle, true),
_battle_live_marker_anchors(view_battle, false),
_battle_live_actor_anchor(source_id, view_battle, true),
_battle_live_actor_anchor(target_id, view_battle, false)
live_source_anchor,
live_target_anchor
)
art.set_accessibility(settings)
var live_event_key := "%d|%s" % [int(view_event.get("phase", -1)), String(view_event.get("event_hash", ""))]
if not view_event.is_empty() and String(view_event.get("kind", "")) != "opening":
var live_kind := String(view_event.get("kind", ""))
art.set_beat(live_event_key, live_kind, live_source_anchor, live_target_anchor, int(view_event.get("amount", 0)))
battle_beats_played += 1
battle_beat_kinds[live_kind] = int(battle_beat_kinds.get(live_kind, 0)) + 1
battle_live_canvas.add_child(art)
var overlay := Control.new()
overlay.name = "BattleLiveLayout"
+29 -2
View File
@@ -27,6 +27,7 @@ var parsed_touches := 0
var shell
var game_node: Node
var settled := false
var _beats_checked := false
func _initialize() -> void:
@@ -108,7 +109,7 @@ func _run() -> void:
# presentation-only (the UI ships 1x/2x/4x); the driver raises the playback
# divisor so the same deterministic timeline completes inside the run.
if "battle_speed" in shell:
shell.battle_speed = 60.0
shell.battle_speed = float(OS.get_environment("ABG_PLAYTHROUGH_BATTLE_SPEED") or "60")
await _press("battle_start_observation", "BattleStartObservation", "")
await _check_battle_field()
await _wait_for_outcome()
@@ -340,7 +341,15 @@ func _verify(label: String, condition: bool, note: String) -> void:
func _wait_for_outcome() -> void:
var deadline := 3600
var elapsed := 0
var beat_capture := OS.get_environment("ABG_PLAYTHROUGH_BEAT_CAPTURE") == "1"
var capture_index := 0
while elapsed < deadline:
if beat_capture and elapsed % 2 == 0:
await RenderingServer.frame_post_draw
var frame := root.get_texture().get_image()
if frame != null and not frame.is_empty():
capture_index += 1
frame.save_png(frames_dir.path_join("beat-%03d.png" % capture_index))
var live := String(shell.battle_live_state) if "battle_live_state" in shell else ""
if live == "outcome_locked":
break
@@ -352,7 +361,8 @@ func _wait_for_outcome() -> void:
elapsed += 1
var status := "pass" if elapsed < deadline else "blocked"
var live_state := String(shell.battle_live_state) if "battle_live_state" in shell else "-"
var note := "waited=%d frames, screen=%s live=%s" % [elapsed * 10, shell.current_screen, live_state]
var beats_note := _check_battle_beats()
var note := "waited=%d frames, screen=%s live=%s %s" % [elapsed * 10, shell.current_screen, live_state, beats_note]
await _step("battle_outcome", "result or recovery", String(shell.current_screen), status, note)
if status == "blocked":
_finish(1)
@@ -551,3 +561,20 @@ func _check_battle_field() -> void:
print("ABG_M77_BATTLE_FIELD_OK %s" % summary)
return
push_error("M77-A battle field: %s | %s" % [str(problems), summary])
func _check_battle_beats() -> String:
var art: Node = shell.find_child("BattleLiveArt", true, false) if shell != null else null
if art == null:
return "beats=none"
var played := int(shell.battle_beats_played) if "battle_beats_played" in shell else int(art.beat_played)
var kinds: Dictionary = Dictionary(shell.battle_beat_kinds) if "battle_beat_kinds" in shell else art.beat_kinds_seen
if played < 3:
push_error("M77-B beats: expected at least 3 event beats, got %d" % played)
return "beats=%d FAILED" % played
var names: Array = kinds.keys()
names.sort()
var beats_seen := ",".join(names)
if not _beats_checked:
_beats_checked = true
print("ABG_M77_BATTLE_BEATS_OK beats=%d kinds=%s" % [played, beats_seen])
return "beats=%d kinds=%s" % [played, beats_seen]