diff --git a/runtime/main.gd b/runtime/main.gd index acdc71d1..dd50e9a2 100644 --- a/runtime/main.gd +++ b/runtime/main.gd @@ -52,6 +52,9 @@ const VFX := { "heal": preload("res://assets/vfx/protect_gain.png"), "hit": preload("res://assets/vfx/damage_heavy.png"), } +const BATTLE_PHASE_DURATIONS := [1.60, 1.15, 1.05, 1.05, 1.05, 1.35] +const BATTLE_FEEDBACK_DURATION := 0.68 +const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24 const BOOT_REQUIRED_PATHS: Array[String] = [ "res://main.tscn", @@ -123,6 +126,7 @@ var locale := "zh_CN" var text_scale := 1.0 var reduced_motion := false var muted := false +var audio_enabled := true var boot_index := 0 var boot_verified := false @@ -146,11 +150,17 @@ var victory_events := 0 var phase_impact_committed: Dictionary = {} var animation_library: Dictionary = {} var victory_clock := 0.0 +var active_battle_feedback: Dictionary = {} +var battle_feedback_clock := 0.0 +var battle_feedback_history: Array[Dictionary] = [] var display_root: Control var front_sprite: TextureRect var rear_sprite: TextureRect var enemy_sprite: TextureRect +var battle_feedback_label: Label +var battle_feedback_vfx: TextureRect +var battle_feedback_origin := Vector2.ZERO var victory_front_sprite: TextureRect var victory_rear_sprite: TextureRect var victory_enemy_sprite: TextureRect @@ -172,7 +182,8 @@ func _ready() -> void: display_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) add_child(display_root) load_animation_library() - setup_audio() + if audio_enabled: + setup_audio() resized.connect(queue_render) render() if auto_boot: @@ -301,6 +312,8 @@ func render() -> void: front_sprite = null rear_sprite = null enemy_sprite = null + battle_feedback_label = null + battle_feedback_vfx = null victory_front_sprite = null victory_rear_sprite = null victory_enemy_sprite = null @@ -554,15 +567,11 @@ func render_battle() -> void: if battle_phase == 1: add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON) - elif battle_phase == 2: - add_vfx(VFX["guard"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.33), actor_height * 0.25) elif battle_phase == 3: if rear_choice == "Farstring": projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT) - else: - add_vfx(VFX["heal"], front_base_position + Vector2(actor_width * 0.55, actor_height * 0.3), actor_height * 0.25) - elif battle_phase == 4: - add_vfx(VFX["hit"], enemy_base_position + Vector2(actor_width * 0.28, actor_height * 0.25), actor_height * 0.27) + render_battle_feedback(actor_width, actor_height) + update_battle_feedback_visual() func render_victory() -> void: @@ -687,6 +696,7 @@ func depart() -> void: rear_events = 0 victory_events = 0 phase_impact_committed.clear() + reset_battle_feedback() victory_clock = 0.0 page = AppPage.BATTLE play_cue("depart") @@ -696,7 +706,7 @@ func depart() -> void: func advance_battle_phase() -> void: if page != AppPage.BATTLE or not battle_playing: return - commit_phase_impact(battle_phase) + commit_phase_impact(battle_phase, false) battle_phase += 1 battle_clock = 0.0 match battle_phase: @@ -720,30 +730,102 @@ func phase_impact_time(phase: int) -> float: return -1.0 -func commit_phase_impact(phase: int) -> void: +func phase_duration(phase: int) -> float: + if phase < 0 or phase >= BATTLE_PHASE_DURATIONS.size(): + return 0.0 + return float(BATTLE_PHASE_DURATIONS[phase]) + + +func commit_phase_impact(phase: int, refresh_runtime := true) -> void: if phase_impact_committed.get(phase, false): return + var target := "" + var delta := 0 + var qualifier_zh := "" + var qualifier_en := "" + var tier := "medium" match phase: 2: + var previous_front_hp := front_hp front_hp = 82 if front_choice == "Hearthguard" else 88 + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "格挡" if front_choice == "Hearthguard" else "闪避减伤" + qualifier_en = "blocked" if front_choice == "Hearthguard" else "evaded" guard_events += 1 play_cue("hit") 3: if rear_choice == "Farstring": + var previous_enemy_hp := enemy_hp enemy_hp = 64 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "后排命中" + qualifier_en = "rear hit" else: + var previous_front_hp := front_hp front_hp = mini(100, front_hp + 12) + target = "front" + delta = front_hp - previous_front_hp + qualifier_zh = "治疗" + qualifier_en = "healing" rear_events += 1 play_cue("role") 4: + var previous_enemy_hp := enemy_hp enemy_hp = 34 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "连携" + qualifier_en = "combo" play_cue("hit") 5: + var previous_enemy_hp := enemy_hp enemy_hp = 0 + target = "enemy" + delta = enemy_hp - previous_enemy_hp + qualifier_zh = "终结" + qualifier_en = "finisher" + tier = "large" play_cue("victory") _: return phase_impact_committed[phase] = true + emit_battle_feedback(phase, target, delta, qualifier_zh, qualifier_en, tier) + if refresh_runtime and page == AppPage.BATTLE: + render() + + +func emit_battle_feedback(phase: int, target: String, delta: int, qualifier_zh: String, qualifier_en: String, tier: String) -> void: + active_battle_feedback = { + "phase": phase, + "target": target, + "delta": delta, + "qualifier_zh": qualifier_zh, + "qualifier_en": qualifier_en, + "tier": tier, + } + battle_feedback_clock = 0.0 + battle_feedback_history.append(active_battle_feedback.duplicate(true)) + + +func reset_battle_feedback() -> void: + active_battle_feedback.clear() + battle_feedback_clock = 0.0 + battle_feedback_history.clear() + battle_feedback_label = null + battle_feedback_vfx = null + + +func expire_battle_feedback() -> void: + active_battle_feedback.clear() + battle_feedback_clock = 0.0 + if battle_feedback_label != null and is_instance_valid(battle_feedback_label): + battle_feedback_label.queue_free() + if battle_feedback_vfx != null and is_instance_valid(battle_feedback_vfx): + battle_feedback_vfx.queue_free() + battle_feedback_label = null + battle_feedback_vfx = null func _process(delta: float) -> void: @@ -757,11 +839,12 @@ func _process(delta: float) -> void: return if page != AppPage.BATTLE or not battle_playing or restart_confirmation: return + var feedback_was_active := not active_battle_feedback.is_empty() battle_clock += delta var impact_time := phase_impact_time(battle_phase) if impact_time >= 0.0 and battle_clock >= impact_time: commit_phase_impact(battle_phase) - var duration := 2.5 if battle_phase == 0 else 2.2 + var duration := phase_duration(battle_phase) if battle_clock >= duration: advance_battle_phase() return @@ -776,7 +859,14 @@ func _process(delta: float) -> void: rear_sprite.texture = animation_texture(rear_choice, rear_animation_state(), battle_clock) enemy_sprite.texture = animation_texture("Riftblade", enemy_animation_state(), battle_clock) if projectile != null and is_instance_valid(projectile) and battle_phase == 3 and rear_choice == "Farstring" and not reduced_motion: - projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, progress) + var projectile_start := 0.08 + var projectile_end := phase_impact_time(3) + 0.10 + var projectile_progress := clampf((battle_clock - projectile_start) / (projectile_end - projectile_start), 0.0, 1.0) + projectile.visible = battle_clock >= projectile_start and battle_clock <= projectile_end + projectile.position.x = lerp(size.x * 0.42, size.x * 0.64, projectile_progress) + if feedback_was_active: + battle_feedback_clock += delta + update_battle_feedback_visual() func request_restart() -> void: @@ -818,6 +908,7 @@ func clear_experience() -> void: rear_events = 0 victory_events = 0 phase_impact_committed.clear() + reset_battle_feedback() victory_clock = 0.0 @@ -937,6 +1028,72 @@ func add_vfx(texture: Texture2D, vfx_position: Vector2, diameter: float) -> Text return image +func render_battle_feedback(actor_width: float, actor_height: float) -> void: + if active_battle_feedback.is_empty(): + return + var target: String = active_battle_feedback["target"] + var delta: int = int(active_battle_feedback["delta"]) + var target_position := front_base_position if target == "front" else enemy_base_position + var target_scale := 1.0 if target == "front" else 0.82 + var feedback_width := clampf(size.x * 0.20, 170.0, 260.0) + battle_feedback_origin = Vector2( + clampf(target_position.x + actor_width * target_scale * 0.5 - feedback_width * 0.5, 12.0, size.x - feedback_width - 12.0), + maxf(174.0, target_position.y - actor_height * 0.10) + ) + var signed_value := ("+" if delta > 0 else "") + str(delta) + var qualifier: String = active_battle_feedback["qualifier_zh"] if locale == "zh_CN" else active_battle_feedback["qualifier_en"] + var feedback_color := COLOR_TEAL if delta > 0 else COLOR_GOLD_LIGHT + if active_battle_feedback["tier"] == "large": + feedback_color = Color("#ffcf57") + battle_feedback_label = centered_label("%s %s" % [signed_value, qualifier], 22 if active_battle_feedback["tier"] == "large" else 19, feedback_color) + battle_feedback_label.name = "BattleFeedbackLabel" + battle_feedback_label.position = battle_feedback_origin + battle_feedback_label.size = Vector2(feedback_width, 58.0) + battle_feedback_label.pivot_offset = battle_feedback_label.size * 0.5 + battle_feedback_label.add_theme_color_override("font_outline_color", Color(0.025, 0.035, 0.065, 0.98)) + battle_feedback_label.add_theme_constant_override("outline_size", 7) + battle_feedback_label.add_theme_color_override("font_shadow_color", Color(0.0, 0.0, 0.0, 0.55)) + battle_feedback_label.add_theme_constant_override("shadow_offset_x", 2) + battle_feedback_label.add_theme_constant_override("shadow_offset_y", 3) + display_root.add_child(battle_feedback_label) + + var vfx_texture: Texture2D = VFX["heal"] if delta > 0 else (VFX["guard"] if int(active_battle_feedback["phase"]) == 2 else VFX["hit"]) + var vfx_diameter := actor_height * (0.31 if active_battle_feedback["tier"] == "large" else 0.25) + battle_feedback_vfx = add_vfx( + vfx_texture, + target_position + Vector2(actor_width * target_scale * 0.40, actor_height * target_scale * 0.25), + vfx_diameter + ) + battle_feedback_vfx.name = "BattleFeedbackVFX" + + +func update_battle_feedback_visual() -> void: + if active_battle_feedback.is_empty(): + return + if battle_feedback_clock >= BATTLE_FEEDBACK_DURATION: + expire_battle_feedback() + return + var normalized := clampf(battle_feedback_clock / BATTLE_FEEDBACK_DURATION, 0.0, 1.0) + var fade := 1.0 if normalized < 0.58 else 1.0 - ((normalized - 0.58) / 0.42) + if battle_feedback_label != null and is_instance_valid(battle_feedback_label): + var rise := 0.0 if reduced_motion else 34.0 * (1.0 - pow(1.0 - normalized, 2.0)) + battle_feedback_label.position = battle_feedback_origin - Vector2(0.0, rise) + var pop_scale := 1.0 + if not reduced_motion: + pop_scale = lerpf(0.78, 1.12, battle_feedback_clock / 0.10) if battle_feedback_clock < 0.10 else lerpf(1.12, 1.0, clampf((battle_feedback_clock - 0.10) / 0.16, 0.0, 1.0)) + battle_feedback_label.scale = Vector2.ONE * pop_scale + battle_feedback_label.modulate.a = clampf(fade, 0.0, 1.0) + if battle_feedback_vfx != null and is_instance_valid(battle_feedback_vfx): + battle_feedback_vfx.visible = battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW + battle_feedback_vfx.modulate.a = clampf(1.0 - battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW, 0.0, 1.0) + if not reduced_motion and battle_feedback_clock <= BATTLE_FEEDBACK_IMPACT_WINDOW: + var recoil := sin(battle_feedback_clock / BATTLE_FEEDBACK_IMPACT_WINDOW * PI) * 8.0 + if active_battle_feedback["target"] == "front" and front_sprite != null and is_instance_valid(front_sprite): + front_sprite.position.x = front_base_position.x - recoil + elif active_battle_feedback["target"] == "enemy" and enemy_sprite != null and is_instance_valid(enemy_sprite): + enemy_sprite.position.x = enemy_base_position.x + recoil + + func add_signal_label(value: String, label_position: Vector2, color: Color) -> Label: var label := centered_label(value, 34, color) label.position = label_position diff --git a/runtime/tests/p9_0r3_battle_feedback_test.gd b/runtime/tests/p9_0r3_battle_feedback_test.gd new file mode 100644 index 00000000..e6e84e94 --- /dev/null +++ b/runtime/tests/p9_0r3_battle_feedback_test.gd @@ -0,0 +1,157 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + var game := MainScene.instantiate() + game.auto_boot = false + root.add_child(game) + await settle(10) + + expect(game.BATTLE_PHASE_DURATIONS.size() == 6, "six phases declare durations") + var total_duration := 0.0 + for phase in range(6): + var duration: float = game.phase_duration(phase) + total_duration += duration + var front_terminal := animation_terminal_time(game, game.front_choice if not game.front_choice.is_empty() else "Hearthguard", phase_front_state(phase)) + var rear_terminal := animation_terminal_time(game, "Farstring", phase_rear_state(phase)) + var enemy_terminal := animation_terminal_time(game, "Riftblade", phase_enemy_state(phase)) + var longest_terminal := maxf(front_terminal, maxf(rear_terminal, enemy_terminal)) + if longest_terminal > 0.0: + expect(duration >= longest_terminal, "phase %d reaches its terminal animation frame" % phase) + expect(duration - longest_terminal <= 0.70, "phase %d dead hold is at most 0.70 seconds" % phase) + else: + expect(phase == 0 and duration <= 1.60, "looping advance has one bounded movement phase") + expect(total_duration < 13.50, "battle clock is faster than P9.0R2") + + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.depart() + game.battle_phase = 2 + game.battle_clock = 0.0 + game.phase_impact_committed.clear() + game.front_hp = 100 + game.commit_phase_impact(2) + expect(game.front_hp == 82, "guard route preserves authoritative HP") + expect_feedback(game, 0, 2, "front", -18, "格挡") + game.commit_phase_impact(2) + expect(game.battle_feedback_history.size() == 1, "duplicate front commit creates no feedback") + expect(find_control(game, "BattleFeedbackLabel") != null, "committed hit renders number pop") + + game.battle_phase = 3 + game.phase_impact_committed.erase(3) + game.enemy_hp = 100 + game.commit_phase_impact(3) + expect_feedback(game, 1, 3, "enemy", -36, "后排命中") + game.battle_phase = 4 + game.phase_impact_committed.erase(4) + game.commit_phase_impact(4) + expect_feedback(game, 2, 4, "enemy", -30, "连携") + game.battle_phase = 5 + game.phase_impact_committed.erase(5) + game.commit_phase_impact(5) + expect_feedback(game, 3, 5, "enemy", -34, "终结") + + game.clear_experience() + game.front_choice = "Wayrunner" + game.rear_choice = "Kindhand" + game.depart() + game.battle_phase = 2 + game.phase_impact_committed.clear() + game.commit_phase_impact(2) + expect_feedback(game, 0, 2, "front", -12, "闪避减伤") + game.battle_phase = 3 + game.phase_impact_committed.erase(3) + game.commit_phase_impact(3) + expect(game.front_hp == 100, "heal remains capped at authoritative maximum") + expect_feedback(game, 1, 3, "front", 12, "治疗") + + game.battle_feedback_clock = 0.20 + game.render() + game.update_battle_feedback_visual() + var normal_label := find_control(game, "BattleFeedbackLabel") + expect(normal_label != null and normal_label.position.y < game.battle_feedback_origin.y, "normal mode number rises") + game.reduced_motion = true + game.render() + game.update_battle_feedback_visual() + var reduced_label := find_control(game, "BattleFeedbackLabel") + expect(reduced_label != null and is_equal_approx(reduced_label.position.y, game.battle_feedback_origin.y), "reduced motion keeps number stationary") + + game.battle_feedback_clock = game.BATTLE_FEEDBACK_DURATION + game.update_battle_feedback_visual() + expect(game.active_battle_feedback.is_empty(), "feedback expires at bounded duration") + expect(find_control(game, "BattleFeedbackLabel") == null, "expired number leaves the tree") + game.clear_experience() + expect(game.battle_feedback_history.is_empty() and game.active_battle_feedback.is_empty(), "clear removes feedback history and active payload") + + game.queue_free() + await settle(60) + if failures.is_empty(): + print("ABG_P9_0R3_BATTLE_FEEDBACK_OK phases=6 duration=%.2f values=-18,-12,-36,+12,-30,-34 once=true reduced_motion=true cleanup=true" % total_duration) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func animation_terminal_time(game: Control, character: String, state: String) -> float: + if state.is_empty() or not game.ANIMATION_SPECS.has(character): + return 0.0 + var states: Dictionary = game.ANIMATION_SPECS[character] + if not states.has(state): + return 0.0 + var spec: Dictionary = states[state] + if bool(spec["loop"]): + return 0.0 + return float(int(spec["frames"]) - 1) / float(spec["fps"]) + + +func phase_front_state(phase: int) -> String: + return {0: "advance", 2: "guard", 4: "basic_attack"}.get(phase, "") + + +func phase_rear_state(phase: int) -> String: + return {0: "advance", 3: "draw_release", 4: "draw_release"}.get(phase, "") + + +func phase_enemy_state(phase: int) -> String: + return {0: "advance", 1: "telegraph", 2: "attack", 3: "hit", 4: "hit", 5: "death"}.get(phase, "") + + +func expect_feedback(game: Control, index: int, phase: int, target: String, delta: int, qualifier: String) -> void: + expect(game.battle_feedback_history.size() == index + 1, "phase %d appends one feedback payload" % phase) + if game.battle_feedback_history.size() <= index: + return + var payload: Dictionary = game.battle_feedback_history[index] + expect(int(payload["phase"]) == phase, "feedback phase matches %d" % phase) + expect(payload["target"] == target, "phase %d target matches" % phase) + expect(int(payload["delta"]) == delta, "phase %d delta matches authoritative HP change" % phase) + expect(payload["qualifier_zh"] == qualifier, "phase %d qualifier is explicit" % phase) + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("P9.0R3 battle feedback: %s" % message) diff --git a/runtime/tests/p9_0r3_dynamic_slice_test.gd b/runtime/tests/p9_0r3_dynamic_slice_test.gd new file mode 100644 index 00000000..fb8174a5 --- /dev/null +++ b/runtime/tests/p9_0r3_dynamic_slice_test.gd @@ -0,0 +1,92 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +var game: Control +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + game = MainScene.instantiate() + game.auto_boot = false + game.audio_enabled = false + root.add_child(game) + await settle(12) + game.locale = "zh_CN" + game.text_scale = 1.0 + game.reduced_motion = false + game.muted = true + game.boot_verified = true + game.front_choice = read_string("ABG_P9_0R3_FRONT", "Hearthguard") + game.rear_choice = read_string("ABG_P9_0R3_REAR", "Farstring") + game.depart() + var seen_feedback_labels := 0 + var seen_values: Dictionary = {} + var elapsed := 0.0 + while game.page != game.AppPage.VICTORY and elapsed < 14.0: + await process_frame + elapsed += 1.0 / 30.0 + if find_control(game, "BattleFeedbackLabel") != null: + seen_feedback_labels += 1 + if not game.active_battle_feedback.is_empty(): + seen_values[int(game.active_battle_feedback["delta"])] = true + if game.page != game.AppPage.VICTORY: + failures.append("dynamic feedback slice did not reach victory") + if game.battle_feedback_history.size() != 4: + failures.append("dynamic feedback slice did not commit exactly four payloads") + if seen_feedback_labels < 12: + failures.append("dynamic feedback labels were not visible across enough rendered frames") + if seen_values.size() != 4: + failures.append("dynamic feedback values were not distinct across all phase commits") + if elapsed >= 8.0: + failures.append("dynamic feedback battle exceeded eight-second pacing target") + if game.guard_events != 1 or game.rear_events != 1 or game.victory_events != 1: + failures.append("dynamic feedback changed authoritative event counts") + await create_timer(1.0).timeout + game.queue_free() + game = null + await settle(120) + if failures.is_empty(): + print("ABG_P9_0R3_DYNAMIC_SLICE_OK duration=%.2f front=%s rear=%s feedback=4 values=%s visible_frames=%d impact_once=true renderer=normal" % [elapsed, read_string("ABG_P9_0R3_FRONT", "Hearthguard"), read_string("ABG_P9_0R3_REAR", "Farstring"), sorted_values(seen_values), seen_feedback_labels]) + call_deferred("finish", 0) + else: + for failure in failures: + push_error(failure) + call_deferred("finish", 1) + + +func sorted_values(values: Dictionary) -> String: + var keys := values.keys() + keys.sort() + var output: Array[String] = [] + for value in keys: + output.append(("+" if int(value) > 0 else "") + str(value)) + return ",".join(output) + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func read_string(key: String, fallback: String) -> String: + var value := OS.get_environment(key) + return value if not value.is_empty() else fallback + + +func finish(exit_code: int) -> void: + quit(exit_code) + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame diff --git a/runtime/tests/p9_0r3_feedback_capture_test.gd b/runtime/tests/p9_0r3_feedback_capture_test.gd new file mode 100644 index 00000000..6323cf37 --- /dev/null +++ b/runtime/tests/p9_0r3_feedback_capture_test.gd @@ -0,0 +1,149 @@ +extends SceneTree + +const MainScene = preload("res://main.tscn") + +const STATES := [ + "guard_damage", + "evade_damage", + "ranged_damage", + "healing", + "combo_damage", + "final_damage", +] + +var failures: Array[String] = [] +var output_root := "" +var viewport_size := Vector2i(1280, 720) +var locale := "zh_CN" +var text_scale := 1.0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + output_root = read_string("ABG_P9_0R3_CAPTURE_ROOT", "/tmp/abg-p9-0r3-feedback-captures") + viewport_size = Vector2i(read_int("ABG_P9_0R3_VIEWPORT_WIDTH", 1280), read_int("ABG_P9_0R3_VIEWPORT_HEIGHT", 720)) + locale = read_string("ABG_P9_0R3_LOCALE", "zh_CN") + text_scale = read_float("ABG_P9_0R3_TEXT_SCALE", 1.0) + DirAccess.make_dir_recursive_absolute(output_root) + var viewport := SubViewport.new() + viewport.size = viewport_size + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(viewport) + var game := MainScene.instantiate() + game.auto_boot = false + game.audio_enabled = false + viewport.add_child(game) + await settle(12) + game.locale = locale + game.text_scale = text_scale + game.reduced_motion = false + game.muted = true + game.boot_verified = true + for index in range(STATES.size()): + await capture_feedback(viewport, game, index + 1, STATES[index]) + game.queue_free() + viewport.queue_free() + await settle(120) + if failures.is_empty(): + print("ABG_P9_0R3_FEEDBACK_CAPTURE_OK frames=6 viewport=%sx%s locale=%s text_scale=%.1f renderer=normal root=%s" % [viewport_size.x, viewport_size.y, locale, text_scale, output_root]) + call_deferred("finish", 0) + else: + for failure in failures: + push_error(failure) + call_deferred("finish", 1) + + +func capture_feedback(viewport: SubViewport, game: Control, index: int, state_name: String) -> void: + game.clear_experience() + apply_feedback_state(game, state_name) + game.battle_feedback_clock = 0.16 + game.render() + game.update_battle_feedback_visual() + await settle(4) + await RenderingServer.frame_post_draw + var label := find_control(game, "BattleFeedbackLabel") + if label == null: + failures.append("capture %s has no feedback label" % state_name) + else: + var label_rect := Rect2(label.position, label.size * label.scale) + var health_band := Rect2(0, 0, viewport_size.x, 116) + var event_band := Rect2(viewport_size.x * 0.27, 116, viewport_size.x * 0.46, 48) + if label_rect.intersects(health_band) or label_rect.intersects(event_band): + failures.append("capture %s feedback overlaps HUD or event ribbon" % state_name) + var image := viewport.get_texture().get_image() + var path := output_root.path_join("%02d-%s.png" % [index, state_name]) + var error := image.save_png(path) + if error != OK: + failures.append("capture %s failed with %s" % [state_name, error]) + + +func apply_feedback_state(game: Control, state_name: String) -> void: + game.page = game.AppPage.BATTLE + game.battle_playing = false + game.front_choice = "Hearthguard" + game.rear_choice = "Farstring" + game.front_hp = 100 + game.enemy_hp = 100 + game.phase_impact_committed.clear() + match state_name: + "guard_damage": + game.battle_phase = 2 + game.commit_phase_impact(2, false) + "evade_damage": + game.front_choice = "Wayrunner" + game.battle_phase = 2 + game.commit_phase_impact(2, false) + "ranged_damage": + game.battle_phase = 3 + game.commit_phase_impact(3, false) + "healing": + game.front_choice = "Wayrunner" + game.rear_choice = "Kindhand" + game.front_hp = 88 + game.battle_phase = 3 + game.commit_phase_impact(3, false) + "combo_damage": + game.enemy_hp = 64 + game.battle_phase = 4 + game.commit_phase_impact(4, false) + "final_damage": + game.enemy_hp = 34 + game.battle_phase = 5 + game.commit_phase_impact(5, false) + + +func find_control(node: Node, node_name: String) -> Control: + if node.name == node_name and node is Control and not node.is_queued_for_deletion(): + return node + for child in node.get_children(): + var found := find_control(child, node_name) + if found != null: + return found + return null + + +func read_string(key: String, fallback: String) -> String: + var value := OS.get_environment(key) + return value if not value.is_empty() else fallback + + +func read_int(key: String, fallback: int) -> int: + var value := OS.get_environment(key) + return int(value) if not value.is_empty() else fallback + + +func read_float(key: String, fallback: float) -> float: + var value := OS.get_environment(key) + return float(value) if not value.is_empty() else fallback + + +func finish(exit_code: int) -> void: + quit(exit_code) + + +func settle(frames: int) -> void: + for frame in range(frames): + await process_frame