feat: tighten battle feedback cadence
This commit is contained in:
+168
-11
@@ -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
|
||||
Reference in New Issue
Block a user