feat: tighten battle feedback cadence

This commit is contained in:
2026-08-12 23:40:12 +08:00
parent f30f0e8600
commit 3b26b3d671
4 changed files with 566 additions and 11 deletions
+168 -11
View File
@@ -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
@@ -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)
@@ -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
@@ -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