feat: complete first expedition session

This commit is contained in:
2026-08-13 02:05:57 +08:00
parent cbddf04319
commit 073ff690ea
7 changed files with 797 additions and 18 deletions
+410 -18
View File
@@ -57,7 +57,14 @@ const SECOND_BATTLE_PHASE_DURATIONS := {
"EQ-002": [1.35, 1.00, 1.10, 1.05, 1.30, 1.25],
"EQ-061": [1.20, 0.85, 0.95, 0.90, 1.00, 1.10],
}
const THIRD_BATTLE_PHASE_DURATIONS := {
"EQ-002": [1.20, 0.80, 1.00, 1.00, 1.10, 1.10, 1.40],
"EQ-061": [1.10, 0.70, 0.90, 0.90, 0.90, 0.90, 1.20],
"recruit": [1.10, 0.80, 0.90, 0.80, 0.80, 1.10, 1.50],
}
const FIRST_VICTORY_COIN := 20
const RETAIN_ITEM_COST := 12
const RECRUIT_REAR_COST := 14
const BATTLE_FEEDBACK_DURATION := 0.68
const BATTLE_FEEDBACK_IMPACT_WINDOW := 0.24
@@ -111,7 +118,7 @@ const CANDIDATES := {
},
}
enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT }
enum AppPage { BOOT, TITLE, YARD, BATTLE, VICTORY, ADAPTATION, SECOND_BATTLE, SECOND_RESULT, GUILD_RETURN, THIRD_BATTLE, SESSION_RESULT }
const COLOR_INK := Color("#10182c")
const COLOR_INDIGO := Color("#172745")
@@ -159,6 +166,12 @@ var adaptation_choice := ""
var adaptation_committed := false
var second_battle_elapsed := 0.0
var second_result_time := 0.0
var guild_choice := ""
var guild_committed := false
var retained_item := ""
var added_rear := ""
var third_battle_elapsed := 0.0
var third_result_time := 0.0
var animation_library: Dictionary = {}
var victory_clock := 0.0
var active_battle_feedback: Dictionary = {}
@@ -168,6 +181,7 @@ var battle_feedback_history: Array[Dictionary] = []
var display_root: Control
var front_sprite: TextureRect
var rear_sprite: TextureRect
var added_rear_sprite: TextureRect
var enemy_sprite: TextureRect
var battle_feedback_label: Label
var battle_feedback_vfx: TextureRect
@@ -178,6 +192,7 @@ var victory_enemy_sprite: TextureRect
var projectile: Control
var front_base_position := Vector2.ZERO
var rear_base_position := Vector2.ZERO
var added_rear_base_position := Vector2.ZERO
var enemy_base_position := Vector2.ZERO
var audio_player: AudioStreamPlayer
var sfx_playback: AudioStreamGeneratorPlayback
@@ -275,6 +290,8 @@ func animation_texture(character: String, state: String, elapsed: float) -> Text
func front_animation_state() -> String:
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1:
return "basic_attack" if front_choice == "Hearthguard" else "evade_counter"
match battle_phase:
@@ -288,6 +305,8 @@ func front_animation_state() -> String:
func rear_animation_state() -> String:
if page == AppPage.THIRD_BATTLE and battle_phase == 3:
return "draw_release" if rear_choice == "Farstring" else "lantern_heal"
match battle_phase:
0:
return "advance"
@@ -296,7 +315,29 @@ func rear_animation_state() -> String:
return "idle"
func added_rear_animation_state() -> String:
if page == AppPage.THIRD_BATTLE and battle_phase == 4:
return "draw_release" if added_rear == "Farstring" else "lantern_heal"
if battle_phase == 0:
return "advance"
return "idle"
func enemy_animation_state() -> String:
if page == AppPage.THIRD_BATTLE and retained_item == "EQ-061" and battle_phase == 1:
return "hit"
if page == AppPage.THIRD_BATTLE:
match battle_phase:
0:
return "advance"
1:
return "telegraph"
2:
return "attack"
3, 4, 5:
return "hit"
6:
return "death"
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061" and battle_phase == 1:
return "hit"
match battle_phase:
@@ -326,6 +367,7 @@ func render() -> void:
render_pending = false
front_sprite = null
rear_sprite = null
added_rear_sprite = null
enemy_sprite = null
battle_feedback_label = null
battle_feedback_vfx = null
@@ -335,7 +377,7 @@ func render() -> void:
projectile = null
for child in display_root.get_children():
child.free()
var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT] else YARD_BACKGROUND
var background_texture: Texture2D = FIELD_BACKGROUND if page in [AppPage.BATTLE, AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_BATTLE, AppPage.SECOND_RESULT, AppPage.THIRD_BATTLE, AppPage.SESSION_RESULT] else YARD_BACKGROUND
add_background(background_texture)
if portrait_layout():
render_portrait_notice()
@@ -357,6 +399,12 @@ func render() -> void:
render_battle()
AppPage.SECOND_RESULT:
render_second_result()
AppPage.GUILD_RETURN:
render_guild_return()
AppPage.THIRD_BATTLE:
render_battle()
AppPage.SESSION_RESULT:
render_session_result()
if restart_confirmation:
render_restart_confirmation()
@@ -564,20 +612,25 @@ func candidate_button(profession: String) -> Button:
func render_battle() -> void:
add_full_wash(Color(0.015, 0.025, 0.055, 0.1))
var second_cycle := page == AppPage.SECOND_BATTLE
render_hud(tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))
var third_cycle := page == AppPage.THIRD_BATTLE
var battle_title := tr_text("第三次远征 · 裂隙队长", "Third Expedition · Rift Captain") if third_cycle else (tr_text("第二次远征 · 老兵截道", "Second Expedition · Veteran Ambush") if second_cycle else tr_text("第一次远征 · 风环遗迹", "First Expedition · Windring Ruins"))
render_hud(battle_title)
var world_bottom := size.y - 58.0
var actor_height: float = clampf(size.y * 0.27, 92.0, 170.0)
var actor_width: float = actor_height
var contact_reached := battle_phase > 0 or reduced_motion
front_base_position = Vector2(size.x * (0.39 if contact_reached else 0.18), world_bottom - actor_height)
rear_base_position = Vector2(size.x * (0.24 if contact_reached else 0.07), world_bottom - actor_height * 0.92)
rear_base_position = Vector2(size.x * (0.26 if contact_reached else 0.08), world_bottom - actor_height * 0.92)
added_rear_base_position = Vector2(size.x * (0.12 if contact_reached else -0.06), world_bottom - actor_height * 0.86)
enemy_base_position = Vector2(size.x * (0.56 if contact_reached else 0.77), world_bottom - actor_height * 0.84)
front_sprite = actor_at(animation_texture(front_choice, front_animation_state(), battle_clock), front_base_position, Vector2(actor_width, actor_height), "FrontActor")
rear_sprite = actor_at(animation_texture(rear_choice, rear_animation_state(), battle_clock), rear_base_position, Vector2(actor_width * 0.9, actor_height * 0.9), "RearActor")
if third_cycle and not added_rear.is_empty():
added_rear_sprite = actor_at(animation_texture(added_rear, added_rear_animation_state(), battle_clock), added_rear_base_position, Vector2(actor_width * 0.86, actor_height * 0.86), "AddedRearActor")
enemy_sprite = actor_at(animation_texture("Riftblade", enemy_animation_state(), battle_clock), enemy_base_position, Vector2(actor_width * 0.82, actor_height * 0.82), "EnemyActor")
add_health_bar("FrontHealth", Vector2(22, 72), Vector2(size.x * 0.29, 14), front_hp, 100, candidate_name(front_choice))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 100, tr_text("裂影刃手", "Riftblade"))
add_health_bar("EnemyHealth", Vector2(size.x * 0.69 - 22, 72), Vector2(size.x * 0.29, 14), enemy_hp, 130 if third_cycle else 100, tr_text("裂隙队长", "Rift Captain") if third_cycle else tr_text("裂影刃手", "Riftblade"))
var event := battle_event_text()
var ribbon := PanelContainer.new()
ribbon.name = "BattleEvent"
@@ -586,21 +639,32 @@ func render_battle() -> void:
ribbon.add_theme_stylebox_override("panel", lacquer_style(Color(0.04, 0.07, 0.13, 0.94), COLOR_GOLD, 2))
ribbon.add_child(centered_label(event, 12, COLOR_WHITE))
display_root.add_child(ribbon)
if second_cycle:
var loan := centered_label(("▣ " if adaptation_choice == "EQ-002" else "◷ ") + adaptation_name(adaptation_choice), 11, COLOR_GOLD_LIGHT)
if second_cycle or (third_cycle and not retained_item.is_empty()):
var visible_item := retained_item if third_cycle else adaptation_choice
var loan := centered_label(("▣ " if visible_item == "EQ-002" else "◷ ") + adaptation_name(visible_item), 11, COLOR_GOLD_LIGHT)
loan.name = "ActiveAdaptation"
loan.position = Vector2(size.x * 0.02, 116)
loan.size = Vector2(size.x * 0.23, 48)
loan.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
display_root.add_child(loan)
elif third_cycle:
var company := centered_label(tr_text("◆ 三人小队\n弓手 + 治疗", "◆ THREE-PERSON LINE\nArcher + Healer"), 11, COLOR_GOLD_LIGHT)
company.name = "ActiveCompany"
company.position = Vector2(size.x * 0.02, 116)
company.size = Vector2(size.x * 0.23, 48)
company.add_theme_stylebox_override("normal", lacquer_style(Color(0.03, 0.055, 0.10, 0.92), COLOR_TEAL, 2))
display_root.add_child(company)
if battle_phase == 1 and (not second_cycle or adaptation_choice == "EQ-002"):
if third_cycle and battle_phase == 1 and retained_item != "EQ-061":
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif battle_phase == 1 and not third_cycle and (not second_cycle or adaptation_choice == "EQ-002"):
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif second_cycle and adaptation_choice == "EQ-061" and battle_phase == 2:
add_signal_label("!", Vector2(size.x * 0.61, size.y * 0.31), COLOR_CRIMSON)
elif battle_phase == 3:
if rear_choice == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
elif battle_phase == 3 and rear_choice == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.48, size.y * 0.50), COLOR_GOLD_LIGHT)
elif third_cycle and battle_phase == 4 and added_rear == "Farstring":
projectile = add_signal_label("➤", Vector2(size.x * 0.45, size.y * 0.52), COLOR_GOLD_LIGHT)
render_battle_feedback(actor_width, actor_height)
update_battle_feedback_visual()
@@ -719,11 +783,106 @@ func render_second_result() -> void:
var facts := centered_label(tr_text("前排生命 %d / 100\n战斗用时 %.2f 秒\n金币保留 %d" % [front_hp, second_result_time, coin], "Front HP %d / 100\nBattle time %.2f s\nCoin kept %d" % [front_hp, second_result_time, coin]), 13, Color("#6d5523"))
facts.name = "SecondResultFacts"
box.add_child(facts)
var replay := game_button("ReplayExperienceButton", tr_text("换一种选择重玩", "Replay with another choice"), request_restart, true)
box.add_child(replay)
var return_button := game_button("OpenGuildReturnButton", tr_text("带着经验返回公会", "Return to the Guild"), open_guild_return, true)
box.add_child(return_button)
box.add_child(game_button("SecondResultHomeButton", tr_text("回到主页", "Home"), go_home, false))
func render_guild_return() -> void:
add_full_wash(Color(0.012, 0.028, 0.06, 0.16))
render_hud(tr_text("公会庭院 · 第三次远征准备", "Guild Yard · Prepare the third expedition"))
var actor_height: float = clampf(size.y * 0.29, 98.0, 178.0)
actor_at(animation_texture(front_choice, "idle", victory_clock), Vector2(size.x * 0.055, size.y - actor_height - 34), Vector2(actor_height, actor_height), "GuildFront")
actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.19, size.y - actor_height * 0.9 - 34), Vector2(actor_height * 0.9, actor_height * 0.9), "GuildRear")
if guild_choice == "recruit_rear":
var preview_rear := missing_rear_profession()
actor_at(animation_texture(preview_rear, "idle", victory_clock), Vector2(size.x * 0.315, size.y - actor_height * 0.82 - 36), Vector2(actor_height * 0.82, actor_height * 0.82), "GuildRecruitPreview")
var resources := PanelContainer.new()
resources.name = "GuildResources"
resources.position = Vector2(18, 74)
resources.size = Vector2(size.x * 0.35, 76)
resources.add_theme_stylebox_override("panel", lacquer_style(Color(0.035, 0.06, 0.12, 0.94), COLOR_GOLD, 2))
display_root.add_child(resources)
var resource_copy := VBoxContainer.new()
resources.add_child(resource_copy)
resource_copy.add_child(game_label(tr_text("带回公会的远征资源", "EXPEDITION RESOURCES RETURNED"), 10, COLOR_TEAL))
resource_copy.add_child(game_label(tr_text("金币 %d | 小队 2 人\n借用装备:%s" % [coin, adaptation_name(adaptation_choice)], "Coin %d | Company 2\nLoaned item: %s" % [coin, adaptation_name(adaptation_choice)]), 12, COLOR_WHITE))
var bench := PanelContainer.new()
bench.name = "PreparationBench"
bench.position = Vector2(size.x * 0.40, 72)
bench.size = Vector2(size.x * 0.58, size.y - 88)
bench.add_theme_stylebox_override("panel", lacquer_style(Color(0.025, 0.05, 0.10, 0.95), COLOR_GOLD, 2))
display_root.add_child(bench)
var shell := VBoxContainer.new()
shell.add_theme_constant_override("separation", 5)
bench.add_child(shell)
shell.add_child(game_label(tr_text("队长更强:把金币花在哪?", "THE CAPTAIN IS STRONGER: where should Coin go?"), 16, COLOR_WHITE))
shell.add_child(game_label(guild_choice_guidance(), 10, COLOR_TEAL))
var choices := HBoxContainer.new()
choices.add_theme_constant_override("separation", 7)
shell.add_child(choices)
choices.add_child(guild_choice_button("retain_item"))
choices.add_child(guild_choice_button("recruit_rear"))
var depart_button := game_button("BeginThirdBattle", tr_text("确认花费并出发", "Confirm spend and depart"), begin_third_battle, true)
depart_button.disabled = guild_choice.is_empty()
depart_button.custom_minimum_size.y = 48
shell.add_child(depart_button)
func guild_choice_button(id: String) -> Button:
var selected := guild_choice == id
var button := Button.new()
button.name = "GuildChoice_%s" % id
if id == "retain_item":
button.text = tr_text("▣ 保留%s · 12 金币\n小队保持 2 人|剩余 8 金币\n继续发挥已验证的装备效果" % adaptation_name(adaptation_choice), "▣ RETAIN %s · 12 Coin\nCompany stays 2 | 8 Coin left\nKeep the proven item effect" % adaptation_name(adaptation_choice))
else:
button.text = tr_text("+ 招募%s · 14 金币\n小队扩至 3 人|剩余 6 金币\n归还借用装备,补齐弓手与治疗" % candidate_name(missing_rear_profession()), "+ RECRUIT %s · 14 Coin\nCompany grows to 3 | 6 Coin left\nReturn item; gain Archer and Healer" % candidate_name(missing_rear_profession()))
button.focus_mode = Control.FOCUS_ALL
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(215, 112)
button.add_theme_font_size_override("font_size", scaled(10))
button.add_theme_color_override("font_color", COLOR_GOLD_LIGHT if selected else COLOR_WHITE)
button.add_theme_stylebox_override("normal", candidate_style(selected, false, COLOR_TEAL))
button.add_theme_stylebox_override("hover", candidate_style(selected, true, COLOR_TEAL))
button.add_theme_stylebox_override("pressed", candidate_style(true, true, COLOR_TEAL))
button.pressed.connect(func(): choose_guild_option(id), CONNECT_DEFERRED)
return button
func render_session_result() -> void:
add_full_wash(Color(0.02, 0.035, 0.075, 0.18))
render_hud(tr_text("首轮远征完成", "First Expedition Cycle Complete"))
var actor_height: float = clampf(size.y * 0.29, 98.0, 180.0)
actor_at(animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock), Vector2(size.x * 0.05, size.y - actor_height - 38), Vector2(actor_height, actor_height), "SessionFront")
actor_at(animation_texture(rear_choice, "idle", victory_clock), Vector2(size.x * 0.18, size.y - actor_height * 0.9 - 38), Vector2(actor_height * 0.9, actor_height * 0.9), "SessionRear")
if not added_rear.is_empty():
actor_at(animation_texture(added_rear, "idle", victory_clock), Vector2(size.x * 0.30, size.y - actor_height * 0.84 - 38), Vector2(actor_height * 0.84, actor_height * 0.84), "SessionAddedRear")
actor_at(animation_texture("Riftblade", "death", 99.0), Vector2(size.x * 0.45, size.y - actor_height * 0.52 - 36), Vector2(actor_height * 0.52, actor_height * 0.52), "SessionEnemy")
var result := PanelContainer.new()
result.name = "SessionResultPanel"
result.position = Vector2(size.x * 0.57, 78)
result.size = Vector2(size.x * 0.40, size.y - 100)
result.add_theme_stylebox_override("panel", parchment_style())
display_root.add_child(result)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 5)
result.add_child(box)
var headline := tr_text("装备让两人小队守住优势", "THE ITEM KEPT A TWO-PERSON EDGE") if guild_choice == "retain_item" else tr_text("第三名成员补齐了远程与治疗", "A THIRD MEMBER COMPLETED THE REAR LINE")
box.add_child(centered_label("◆ " + headline + " ◆", 17, COLOR_INK))
var explanation := centered_label(session_result_explanation(), 11, Color("#4c402d"))
explanation.name = "SessionResultExplanation"
box.add_child(explanation)
var company_size := 3 if not added_rear.is_empty() else 2
var facts := centered_label(tr_text("金币 %d | 小队 %d 人\n前排生命 %d / 100 | 用时 %.2f 秒" % [coin, company_size, front_hp, third_result_time], "Coin %d | Company %d\nFront HP %d / 100 | Time %.2f s" % [coin, company_size, front_hp, third_result_time]), 12, Color("#6d5523"))
facts.name = "SessionResultFacts"
box.add_child(facts)
box.add_child(game_button("ReplayExperienceButton", tr_text("用另一条路线重新开始", "Restart with another route"), request_restart, true))
box.add_child(game_button("SessionResultHomeButton", tr_text("回到主页", "Home"), go_home, false))
func render_restart_confirmation() -> void:
var wash := ColorRect.new()
wash.name = "RestartWash"
@@ -825,11 +984,19 @@ func depart() -> void:
func advance_battle_phase() -> void:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE] or not battle_playing:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing:
return
commit_phase_impact(battle_phase, false)
battle_phase += 1
battle_clock = 0.0
if page == AppPage.THIRD_BATTLE:
if battle_phase == 7:
battle_playing = false
victory_events += 1
third_result_time = third_battle_elapsed
page = AppPage.SESSION_RESULT
render()
return
match battle_phase:
6:
battle_playing = false
@@ -844,6 +1011,18 @@ func advance_battle_phase() -> void:
func phase_impact_time(phase: int) -> float:
if page == AppPage.THIRD_BATTLE:
match phase:
1:
return 3.0 / 12.0 if retained_item == "EQ-061" else -1.0
2:
return 4.0 / 12.0
3, 4:
return 2.0 / 7.0
5:
return 3.0 / 10.0
6:
return 5.0 / 10.0
if page == AppPage.SECOND_BATTLE and adaptation_choice == "EQ-061":
match phase:
1:
@@ -869,6 +1048,11 @@ func phase_impact_time(phase: int) -> float:
func phase_duration(phase: int) -> float:
if page == AppPage.THIRD_BATTLE:
var route := "recruit" if not added_rear.is_empty() else retained_item
if THIRD_BATTLE_PHASE_DURATIONS.has(route):
var third_durations: Array = THIRD_BATTLE_PHASE_DURATIONS[route]
return float(third_durations[phase]) if phase >= 0 and phase < third_durations.size() else 0.0
if page == AppPage.SECOND_BATTLE and SECOND_BATTLE_PHASE_DURATIONS.has(adaptation_choice):
var durations: Array = SECOND_BATTLE_PHASE_DURATIONS[adaptation_choice]
return float(durations[phase]) if phase >= 0 and phase < durations.size() else 0.0
@@ -885,6 +1069,9 @@ func commit_phase_impact(phase: int, refresh_runtime := true) -> void:
var qualifier_zh := ""
var qualifier_en := ""
var tier := "medium"
if page == AppPage.THIRD_BATTLE:
commit_third_phase_impact(phase, refresh_runtime)
return
if page == AppPage.SECOND_BATTLE:
commit_second_phase_impact(phase, refresh_runtime)
return
@@ -1010,6 +1197,102 @@ func commit_second_phase_impact(phase: int, refresh_runtime := true) -> void:
render()
func commit_third_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:
1:
if retained_item != "EQ-061":
return
var previous_enemy_hp := enemy_hp
enemy_hp = 102
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "晨钟先手"
qualifier_en = "Early Bell"
play_cue("hit")
2:
var previous_front_hp := front_hp
front_hp = 90 if retained_item == "EQ-002" else 70
target = "front"
delta = front_hp - previous_front_hp
qualifier_zh = "装备抗推" if retained_item == "EQ-002" else "队长重击"
qualifier_en = "item resisted" if retained_item == "EQ-002" else "captain strike"
guard_events += 1
play_cue("hit")
3:
if rear_choice == "Farstring":
var previous_enemy_hp := enemy_hp
enemy_hp = 66 if added_rear.is_empty() else 94
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "弓手命中"
qualifier_en = "Archer 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:
if not added_rear.is_empty():
if added_rear == "Farstring":
var previous_enemy_hp := enemy_hp
enemy_hp = 94
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "新弓手命中"
qualifier_en = "new Archer 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 = "new Healer support"
rear_events += 1
play_cue("role")
else:
var previous_enemy_hp := enemy_hp
enemy_hp = 44
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "装备连携"
qualifier_en = "item follow-up"
play_cue("hit")
5:
var previous_enemy_hp := enemy_hp
enemy_hp = 32 if not added_rear.is_empty() else 26
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "三人连携" if not added_rear.is_empty() else "压制"
qualifier_en = "three-person combo" if not added_rear.is_empty() else "pressure"
play_cue("hit")
6:
var previous_enemy_hp := enemy_hp
enemy_hp = 0
target = "enemy"
delta = enemy_hp - previous_enemy_hp
qualifier_zh = "队长击破"
qualifier_en = "captain defeated"
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.THIRD_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,
@@ -1043,7 +1326,7 @@ func expire_battle_feedback() -> void:
func _process(delta: float) -> void:
if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT]:
if page in [AppPage.VICTORY, AppPage.ADAPTATION, AppPage.SECOND_RESULT, AppPage.GUILD_RETURN, AppPage.SESSION_RESULT]:
if front_choice.is_empty() or rear_choice.is_empty():
return
victory_clock += delta
@@ -1051,12 +1334,14 @@ func _process(delta: float) -> void:
victory_front_sprite.texture = animation_texture(front_choice, "victory" if front_choice == "Hearthguard" else "idle", victory_clock)
victory_rear_sprite.texture = animation_texture(rear_choice, "idle", victory_clock)
return
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE] or not battle_playing or restart_confirmation:
if page not in [AppPage.BATTLE, AppPage.SECOND_BATTLE, AppPage.THIRD_BATTLE] or not battle_playing or restart_confirmation:
return
var feedback_was_active := not active_battle_feedback.is_empty()
battle_clock += delta
if page == AppPage.SECOND_BATTLE:
second_battle_elapsed += delta
elif page == AppPage.THIRD_BATTLE:
third_battle_elapsed += delta
var impact_time := phase_impact_time(battle_phase)
if impact_time >= 0.0 and battle_clock >= impact_time:
commit_phase_impact(battle_phase)
@@ -1070,13 +1355,18 @@ func _process(delta: float) -> void:
if battle_phase == 0 and not reduced_motion:
front_sprite.position.x = lerp(size.x * 0.13, size.x * 0.38, progress)
rear_sprite.position.x = lerp(size.x * 0.03, size.x * 0.20, progress)
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
added_rear_sprite.position.x = lerp(-added_rear_sprite.size.x, size.x * 0.10, progress)
enemy_sprite.position.x = lerp(size.x * 0.82, size.x * 0.57, progress)
front_sprite.texture = animation_texture(front_choice, front_animation_state(), battle_clock)
rear_sprite.texture = animation_texture(rear_choice, rear_animation_state(), battle_clock)
if added_rear_sprite != null and is_instance_valid(added_rear_sprite):
added_rear_sprite.texture = animation_texture(added_rear, added_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:
var projectile_actor_is_active := (battle_phase == 3 and rear_choice == "Farstring") or (page == AppPage.THIRD_BATTLE and battle_phase == 4 and added_rear == "Farstring")
if projectile != null and is_instance_valid(projectile) and projectile_actor_is_active and not reduced_motion:
var projectile_start := 0.08
var projectile_end := phase_impact_time(3) + 0.10
var projectile_end := phase_impact_time(battle_phase) + 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)
@@ -1128,6 +1418,59 @@ func begin_second_battle() -> void:
render()
func open_guild_return() -> void:
if page != AppPage.SECOND_RESULT or not adaptation_committed:
return
guild_choice = ""
guild_committed = false
retained_item = ""
added_rear = ""
page = AppPage.GUILD_RETURN
play_cue("confirm")
render()
func choose_guild_option(id: String) -> void:
if page != AppPage.GUILD_RETURN or guild_committed or id not in ["retain_item", "recruit_rear"]:
return
var cost := RETAIN_ITEM_COST if id == "retain_item" else RECRUIT_REAR_COST
if coin < cost:
return
guild_choice = id
play_cue("select")
render()
func begin_third_battle() -> void:
if page != AppPage.GUILD_RETURN or guild_committed or guild_choice.is_empty():
return
var cost := RETAIN_ITEM_COST if guild_choice == "retain_item" else RECRUIT_REAR_COST
if coin < cost:
return
guild_committed = true
coin -= cost
if guild_choice == "retain_item":
retained_item = adaptation_choice
added_rear = ""
else:
retained_item = ""
added_rear = missing_rear_profession()
battle_phase = 0
battle_clock = 0.0
battle_playing = true
front_hp = 100
enemy_hp = 130
guard_events = 0
rear_events = 0
phase_impact_committed.clear()
reset_battle_feedback()
third_battle_elapsed = 0.0
third_result_time = 0.0
page = AppPage.THIRD_BATTLE
play_cue("depart")
render()
func request_restart() -> void:
restart_confirmation = true
render()
@@ -1172,6 +1515,12 @@ func clear_experience() -> void:
adaptation_committed = false
second_battle_elapsed = 0.0
second_result_time = 0.0
guild_choice = ""
guild_committed = false
retained_item = ""
added_rear = ""
third_battle_elapsed = 0.0
third_result_time = 0.0
phase_impact_committed.clear()
reset_battle_feedback()
victory_clock = 0.0
@@ -1201,6 +1550,8 @@ func toggle_mute() -> void:
func battle_event_text() -> String:
if page == AppPage.THIRD_BATTLE:
return third_battle_event_text()
if page == AppPage.SECOND_BATTLE:
return second_battle_event_text()
match battle_phase:
@@ -1242,6 +1593,27 @@ func second_battle_event_text() -> String:
return ""
func third_battle_event_text() -> String:
match battle_phase:
0:
return tr_text("小队向裂隙队长推进…", "The squad advances on the Rift Captain…")
1:
return tr_text("晨钟先手:前排抢在队长之前行动", "Early Bell: the front acts before the captain") if retained_item == "EQ-061" else tr_text("队长蓄力,准备撕开前排", "The captain gathers force against the front")
2:
return tr_text("钉阵胫甲守住阵线,重击只造成 -10", "Frontpin holds the line; the strike deals only -10") if retained_item == "EQ-002" else tr_text("队长重击前排;现在需要后排支援", "The captain hits the front; rear support is needed")
3:
return tr_text("原后排弓手命中队长", "The original Archer hits the captain") if rear_choice == "Farstring" else tr_text("原后排治疗为前排恢复 +12", "The original Healer restores +12 to the front")
4:
if not added_rear.is_empty():
return tr_text("新弓手补上远程伤害", "The new Archer adds ranged damage") if added_rear == "Farstring" else tr_text("新治疗补上 +12 恢复", "The new Healer adds +12 recovery")
return tr_text("保留装备继续放大两人连携", "The retained item amplifies the two-person follow-up")
5:
return tr_text("三人阵线合力压制队长", "The three-person line overwhelms the captain") if not added_rear.is_empty() else tr_text("两人阵线守住装备优势", "The two-person line keeps its item advantage")
6:
return tr_text("裂隙队长被击败!", "Rift Captain defeated!")
return ""
func adaptation_name(id: String) -> String:
return tr_text("钉阵胫甲", "Frontpin Greaves") if id == "EQ-002" else tr_text("晨钟", "Early Bell")
@@ -1266,6 +1638,26 @@ func second_result_explanation() -> String:
return tr_text("晨钟让前排在敌人之前造成 -28,\n但前排完整承受了 -30 重击。", "Early Bell dealt -28 before the enemy acted,\nbut the front took the full -30 strike.")
func missing_rear_profession() -> String:
return "Kindhand" if rear_choice == "Farstring" else "Farstring"
func guild_choice_guidance() -> String:
if guild_choice.is_empty():
return tr_text("没有预选。保留已验证的装备,或用更多金币补齐后排。", "Nothing is preselected. Keep the proven item, or spend more to complete the rear line.")
if guild_choice == "retain_item":
return tr_text("已选择保留装备;剩余 8 金币、小队 2 人。出发前仍可切换。", "Retain selected: 8 Coin, Company 2. You can still switch before departure.")
return tr_text("已选择招募%s;剩余 6 金币、小队 3 人。出发前仍可切换。" % candidate_name(missing_rear_profession()), "Recruit %s selected: 6 Coin, Company 3. You can still switch." % candidate_name(missing_rear_profession()))
func session_result_explanation() -> String:
if guild_choice == "retain_item":
if retained_item == "EQ-002":
return tr_text("你把金币用于保留钉阵胫甲。\n队伍仍是两人,但队长重击从 -30 降为 -10。", "You spent Coin to retain Frontpin.\nThe line stayed at two, but the captain strike fell from -30 to -10.")
return tr_text("你把金币用于保留晨钟。\n队伍仍是两人,但先手让战斗更快结束。", "You spent Coin to retain Early Bell.\nThe line stayed at two, but initiative ended the battle faster.")
return tr_text("你归还借用装备并招募了%s。\n弓手与治疗都行动:阵线更完整,但花费更多。" % candidate_name(added_rear), "You returned the loan and recruited %s.\nArcher and Healer both acted: a fuller line at greater cost." % candidate_name(added_rear))
func candidate_name(profession: String) -> String:
if profession.is_empty() or not CANDIDATES.has(profession):
return ""
@@ -0,0 +1,149 @@
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)
game.locale = "zh_CN"
game.muted = true
prepare_second_result(game, "Hearthguard", "Farstring", "EQ-002")
game.open_guild_return()
expect(game.page == game.AppPage.GUILD_RETURN, "second result returns to Guild")
expect(game.coin == 20 and game.guild_choice.is_empty(), "Guild opens with 20 Coin and no preselection")
expect(find_control(game, "GuildChoice_retain_item") != null and find_control(game, "GuildChoice_recruit_rear") != null, "both Guild responses render")
var depart := find_control(game, "BeginThirdBattle") as BaseButton
expect(depart != null and depart.disabled, "third departure waits for a choice")
game.choose_guild_option("invalid")
expect(game.guild_choice.is_empty(), "invalid Guild choice is rejected")
game.coin = 11
game.choose_guild_option("retain_item")
expect(game.guild_choice.is_empty() and game.coin == 11, "insufficient Coin cannot select an unaffordable response")
game.coin = 20
game.choose_guild_option("retain_item")
expect(game.coin == 20, "selection does not spend Coin")
game.choose_guild_option("recruit_rear")
expect(game.guild_choice == "recruit_rear" and game.coin == 20, "Guild choice can be revised before commit")
game.choose_guild_option("retain_item")
game.coin = 10
game.begin_third_battle()
expect(game.page == game.AppPage.GUILD_RETURN and not game.guild_committed and game.coin == 10, "commit rechecks Coin without partial mutation")
game.coin = 20
game.begin_third_battle()
expect(game.guild_committed and game.coin == 8, "retain commit spends 12 Coin exactly once")
expect(game.retained_item == "EQ-002" and game.added_rear.is_empty(), "retain route keeps item and has no added rear")
game.begin_third_battle()
expect(game.coin == 8, "duplicate departure cannot spend twice")
var safe_time := resolve_third_battle(game)
expect(game.page == game.AppPage.SESSION_RESULT, "retain route reaches session result")
expect(game.front_hp == 90 and game.enemy_hp == 0, "Frontpin Farstring route ends at 90 HP and wins")
expect(is_equal_approx(safe_time, 7.60), "Frontpin route declares 7.60 seconds")
expect(find_control(game, "SessionResultFacts") != null, "session result renders exact facts")
game.clear_experience()
prepare_second_result(game, "Wayrunner", "Kindhand", "EQ-002")
game.open_guild_return()
game.choose_guild_option("retain_item")
game.begin_third_battle()
resolve_third_battle(game)
expect(game.front_hp == 100 and game.coin == 8, "Frontpin Kindhand route heals to 100 and keeps 8 Coin")
game.clear_experience()
prepare_second_result(game, "Hearthguard", "Farstring", "EQ-061")
game.open_guild_return()
game.choose_guild_option("retain_item")
game.begin_third_battle()
var fast_time := resolve_third_battle(game)
expect(game.front_hp == 70 and game.coin == 8, "Early Bell Farstring route accepts 70 HP and keeps 8 Coin")
expect(is_equal_approx(fast_time, 6.60) and fast_time < safe_time, "Early Bell route declares 6.60 seconds and is faster")
game.clear_experience()
prepare_second_result(game, "Wayrunner", "Kindhand", "EQ-061")
game.open_guild_return()
game.choose_guild_option("retain_item")
game.begin_third_battle()
resolve_third_battle(game)
expect(game.front_hp == 82, "Early Bell Kindhand route heals 70 to 82")
game.go_home()
expect(game.page == game.AppPage.TITLE and game.coin == 0 and game.added_rear.is_empty(), "Home clears the full three-battle session")
for original_rear in ["Farstring", "Kindhand"]:
game.clear_experience()
prepare_second_result(game, "Hearthguard", original_rear, "EQ-002")
game.open_guild_return()
game.choose_guild_option("recruit_rear")
game.begin_third_battle()
expect(game.coin == 6 and game.retained_item.is_empty(), "recruit route spends 14 Coin and returns the loan")
expect(game.added_rear == ("Kindhand" if original_rear == "Farstring" else "Farstring"), "recruit route adds the missing rear role")
var recruit_time := resolve_third_battle(game)
expect(game.front_hp == 82 and game.enemy_hp == 0, "both recruit formations end at 82 HP and win")
expect(is_equal_approx(recruit_time, 7.00), "recruit route declares 7.00 seconds")
expect(game.battle_feedback_history.size() == 5, "recruit route emits exactly five HP mutations")
game.request_restart()
game.confirm_restart()
expect(game.page == game.AppPage.TITLE, "confirmed Restart returns to Title")
expect(game.coin == 0 and game.guild_choice.is_empty() and game.retained_item.is_empty() and game.added_rear.is_empty(), "Restart clears the full three-battle session")
game.queue_free()
await settle(90)
if failures.is_empty():
print("ABG_P9_0R5_FIRST_SESSION_OK unselected=true revision=true spend_once=true coin=8,6 company=2,3 hp=90,100,70,82 times=7.60,6.60,7.00 routes=6 cleanup=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func prepare_second_result(game: Control, front: String, rear: String, item: String) -> void:
game.front_choice = front
game.rear_choice = rear
game.coin = 20
game.first_reward_committed = true
game.adaptation_choice = item
game.adaptation_committed = true
game.front_hp = 90 if item == "EQ-002" else 70
game.enemy_hp = 0
game.second_result_time = 7.05 if item == "EQ-002" else 6.00
game.page = game.AppPage.SECOND_RESULT
game.render()
func resolve_third_battle(game: Control) -> float:
var declared_time := 0.0
for phase in range(7):
var duration: float = game.phase_duration(phase)
declared_time += duration
game.third_battle_elapsed += duration
game.advance_battle_phase()
return declared_time
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.0R5 first session: %s" % message)
@@ -0,0 +1 @@
uid://np4iuwyg5noo
@@ -0,0 +1,106 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var game: Control
var touch_count := 0
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
game = MainScene.instantiate()
game.auto_boot = false
root.add_child(game)
await settle(10)
game.locale = "zh_CN"
game.muted = true
game.page = game.AppPage.TITLE
game.render()
await settle(6)
await tap("StartAdventure")
await tap("Candidate_Hearthguard")
await tap("Candidate_Farstring")
await tap("DepartButton")
expect(game.page == game.AppPage.BATTLE, "Title route reaches first battle")
await wait_for_page(game.AppPage.VICTORY, 720)
await tap("ContinueAdaptationButton")
await tap("Adaptation_EQ_002")
await tap("BeginSecondBattle")
await wait_for_page(game.AppPage.SECOND_RESULT, 720)
await tap("OpenGuildReturnButton")
await tap("GuildChoice_retain_item")
await tap("GuildChoice_recruit_rear")
expect(game.guild_choice == "recruit_rear" and game.coin == 20, "touch revises Guild choice before spend")
await tap("BeginThirdBattle")
await wait_for_page(game.AppPage.SESSION_RESULT, 720)
expect(game.coin == 6 and game.added_rear == "Kindhand", "complete touch route commits recruit consequence")
expect(find_control(game, "SessionResultFacts") != null, "complete touch route renders session result")
game.queue_free()
game = null
await settle(90)
if failures.is_empty():
print("ABG_P9_0R5_FULL_ROUTE_TOUCH_OK touch=%d fallback=0 direct=0 title=1 battles=3 revise=1 result=1" % touch_count)
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func wait_for_page(target_page: int, max_frames: int) -> void:
var previous_scale := Engine.time_scale
Engine.time_scale = 12.0
for frame in range(max_frames):
if game.page == target_page:
break
await process_frame
Engine.time_scale = previous_scale
expect(game.page == target_page, "automatic battle reaches page %d" % target_page)
await settle(6)
func tap(node_name: String) -> void:
var control := find_control(game, node_name)
if control == null or not control.is_visible_in_tree() or (control is BaseButton and control.disabled):
failures.append("unavailable touch target %s" % node_name)
return
var position := root.get_final_transform() * control.get_global_rect().get_center()
var down := InputEventScreenTouch.new()
down.index = 0
down.position = position
down.pressed = true
Input.parse_input_event(down)
await process_frame
var up := InputEventScreenTouch.new()
up.index = 0
up.position = position
up.pressed = false
Input.parse_input_event(up)
touch_count += 1
await settle(7)
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.0R5 full route touch: %s" % message)
@@ -0,0 +1 @@
uid://dbk0oeqjwclhu
@@ -0,0 +1,129 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
const STATES := ["guild_unselected", "guild_recruit_selected", "third_retain", "third_recruit_action", "result_retain", "result_recruit"]
const VIEWPORT_SIZE := Vector2i(844, 390)
var output_root := ""
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
output_root = OS.get_environment("ABG_P9_0R5_CAPTURE_ROOT")
if output_root.is_empty():
output_root = "/tmp/abg-p9-0r5-preview"
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 = "zh_CN"
game.text_scale = 1.0
game.reduced_motion = false
game.muted = true
for index in range(STATES.size()):
apply_state(game, STATES[index])
game.render()
await settle(7)
verify_interactive_bounds(game, Rect2(Vector2.ZERO, Vector2(VIEWPORT_SIZE)), STATES[index])
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var path := output_root.path_join("%02d-%s.png" % [index + 1, STATES[index]])
var error := image.save_png(path)
if error != OK:
failures.append("capture %s failed with %s" % [STATES[index], error])
game.queue_free()
viewport.queue_free()
await settle(120)
if failures.is_empty():
print("ABG_P9_0R5_PREVIEW_CAPTURE_OK frames=6 viewport=844x390 locale=zh_CN min_touch=44 root=%s" % output_root)
call_deferred("finish", 0)
else:
for failure in failures:
push_error(failure)
call_deferred("finish", 1)
func apply_state(game: Control, state_name: String) -> void:
game.clear_experience()
game.front_choice = "Hearthguard"
game.rear_choice = "Farstring"
game.coin = 20
game.first_reward_committed = true
game.adaptation_choice = "EQ-002"
game.adaptation_committed = true
match state_name:
"guild_unselected":
game.page = game.AppPage.GUILD_RETURN
"guild_recruit_selected":
game.page = game.AppPage.GUILD_RETURN
game.guild_choice = "recruit_rear"
"third_retain":
prepare_third(game, "retain_item", "EQ-002", "", 2, 90, 130)
game.emit_battle_feedback(2, "front", -10, "装备抗推", "item resisted", "medium")
game.battle_feedback_clock = 0.16
"third_recruit_action":
prepare_third(game, "recruit_rear", "", "Kindhand", 4, 82, 94)
game.emit_battle_feedback(4, "front", 12, "新治疗支援", "new Healer support", "medium")
game.battle_feedback_clock = 0.16
"result_retain":
game.page = game.AppPage.SESSION_RESULT
game.guild_choice = "retain_item"
game.guild_committed = true
game.retained_item = "EQ-002"
game.coin = 8
game.front_hp = 90
game.enemy_hp = 0
game.third_result_time = 7.60
"result_recruit":
game.page = game.AppPage.SESSION_RESULT
game.guild_choice = "recruit_rear"
game.guild_committed = true
game.added_rear = "Kindhand"
game.coin = 6
game.front_hp = 82
game.enemy_hp = 0
game.third_result_time = 7.00
func prepare_third(game: Control, choice: String, item: String, recruit: String, phase: int, front_health: int, enemy_health: int) -> void:
game.page = game.AppPage.THIRD_BATTLE
game.guild_choice = choice
game.guild_committed = true
game.retained_item = item
game.added_rear = recruit
game.coin = 8 if choice == "retain_item" else 6
game.battle_playing = false
game.battle_phase = phase
game.battle_clock = 0.22
game.front_hp = front_health
game.enemy_hp = enemy_health
func verify_interactive_bounds(node: Node, bounds: Rect2, label: String) -> void:
if node is BaseButton and node.is_visible_in_tree():
var rect: Rect2 = node.get_global_rect()
if rect.position.x < -0.5 or rect.position.y < -0.5 or rect.end.x > bounds.end.x + 0.5 or rect.end.y > bounds.end.y + 0.5:
failures.append("%s button %s outside viewport: %s" % [label, node.name, rect])
if rect.size.x < 44.0 or rect.size.y < 44.0:
failures.append("%s button %s below 44px: %s" % [label, node.name, rect.size])
for child in node.get_children():
verify_interactive_bounds(child, bounds, label)
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 @@
uid://bckp8wvil4snr