diff --git a/docs/EVIDENCE_LEDGER.md b/docs/EVIDENCE_LEDGER.md index 40a312d0..244ff7c6 100644 --- a/docs/EVIDENCE_LEDGER.md +++ b/docs/EVIDENCE_LEDGER.md @@ -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 阻断且音频门通过;修复市场卡无法点击 | diff --git a/docs/plans/M77_BATTLE_PRESENTATION.md b/docs/plans/M77_BATTLE_PRESENTATION.md index e16d5597..a537a14a 100644 --- a/docs/plans/M77_BATTLE_PRESENTATION.md +++ b/docs/plans/M77_BATTLE_PRESENTATION.md @@ -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=` 可逐拍截图。 + ## Acceptance checks - 尺度:公司线可容纳 4 单位、敌线 3 单位,走廊不断裂;单位高度/间距与采纳方向图同比例(±10%) diff --git a/runtime/entry/battle_live_art.gd b/runtime/entry/battle_live_art.gd index fe9bb126..00f8c0b1 100644 --- a/runtime/entry/battle_live_art.gd +++ b/runtime/entry/battle_live_art.gd @@ -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)) diff --git a/runtime/entry/entry_shell.gd b/runtime/entry/entry_shell.gd index 84647387..7a4c2f67 100644 --- a/runtime/entry/entry_shell.gd +++ b/runtime/entry/entry_shell.gd @@ -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" diff --git a/runtime/tools/m60_full_cycle_playthrough.gd b/runtime/tools/m60_full_cycle_playthrough.gd index 2e053db1..418f370d 100644 --- a/runtime/tools/m60_full_cycle_playthrough.gd +++ b/runtime/tools/m60_full_cycle_playthrough.gd @@ -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]