feat(runtime): bind P9.2 battle to horizontal scene

This commit is contained in:
2026-08-14 08:27:50 +08:00
parent d22040ac46
commit 2e2ed932f0
6 changed files with 467 additions and 8 deletions
+19 -2
View File
@@ -1,5 +1,7 @@
extends Control
signal authoritative_battle_requested
const FirstSessionStateScript = preload("res://domain/p9_1/session_state.gd")
const FirstSessionServiceScript = preload("res://domain/p9_1/session_service.gd")
const P91ContractsScript = preload("res://domain/p9_1/contracts.gd")
@@ -1823,8 +1825,23 @@ func render_third_cycle_result() -> void:
var levels := centered_label(member_levels_summary(), 8, Color("#6d5523"))
levels.name = "MemberLevels"
box.add_child(levels)
box.add_child(game_button("ReplayGrowthButton", tr_text("重新开始完整体验", "Restart the full experience"), request_restart, true))
box.add_child(game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false))
box.add_child(game_button("ContinueAuthoritativeBattleButton", tr_text("继续:裂隙守望战", "Continue: Breach Watch"), request_authoritative_battle, true))
var result_actions := HBoxContainer.new()
result_actions.name = "ThirdCycleResultActions"
result_actions.add_theme_constant_override("separation", 6)
box.add_child(result_actions)
var replay := game_button("ReplayGrowthButton", tr_text("重新开始", "Restart"), request_restart, false)
replay.size_flags_horizontal = Control.SIZE_EXPAND_FILL
result_actions.add_child(replay)
var home := game_button("ThirdCycleHomeButton", tr_text("回到主页", "Home"), go_home, false)
home.size_flags_horizontal = Control.SIZE_EXPAND_FILL
result_actions.add_child(home)
func request_authoritative_battle() -> void:
if page != AppPage.THIRD_CYCLE_RESULT:
return
authoritative_battle_requested.emit()
func add_causal_result_lines(container: VBoxContainer, result_index: int, prefix: String, legacy_names: Dictionary = {}) -> void:
+8 -5
View File
@@ -9,7 +9,7 @@ var state
func _init(initial_state = null) -> void:
state = initial_state if initial_state != null else State.new()
func start_fixture(selected_members: Array[String], fixture_seed := 1) -> Dictionary:
func start_fixture(selected_members: Array[String], fixture_seed := 1, starting_hp: Dictionary = {}) -> Dictionary:
if selected_members.is_empty() or selected_members.size() > Rules.MEMBER_IDS.size():
return _reject("selection_required")
var seen := {}
@@ -22,7 +22,8 @@ func start_fixture(selected_members: Array[String], fixture_seed := 1) -> Dictio
state.member_ids = selected_members.duplicate()
for index in state.member_ids.size():
var member_id: String = state.member_ids[index]
state.member_hp[member_id] = int(Rules.member_definition(member_id).get("max_hp", 0))
var maximum: int = int(Rules.member_definition(member_id).get("max_hp", 0))
state.member_hp[member_id] = Rules.clamp_member_hp(member_id, int(starting_hp.get(member_id, maximum)))
state.member_positions[member_id] = -index * 12
state.member_next_action_tick[member_id] = 0
state.enemy_ids = Rules.ENEMY_IDS.duplicate()
@@ -108,11 +109,13 @@ func _move_lines(events: Array[Dictionary]) -> void:
state.member_positions[member_id] = mini(current + 2, desired) if current < desired else maxi(current - 2, desired)
func _resolve_member_actions(events: Array[Dictionary]) -> void:
var living_enemies: Array[String] = state.living_enemy_ids()
if living_enemies.is_empty():
if state.living_enemy_ids().is_empty():
return
var target_enemy: String = living_enemies[0]
for member_id in state.living_member_ids():
var living_enemies: Array[String] = state.living_enemy_ids()
if living_enemies.is_empty():
break
var target_enemy: String = living_enemies[0]
var ready_tick: int = int(state.member_next_action_tick.get(member_id, 0))
if state.tick < ready_tick:
continue
+56 -1
View File
@@ -1,12 +1,20 @@
extends Control
const FacadeScript = preload("res://compat/p9_0_root_facade.gd")
const P92BattleServiceScript = preload("res://domain/p9_2/battle_service.gd")
const P92BattlePresenterScript = preload("res://presentation/p9_2_battle_presenter.gd")
const AppPage = FacadeScript.AppPage
const ANIMATION_SPECS = FacadeScript.ANIMATION_SPECS
const BATTLE_PHASE_DURATIONS = FacadeScript.BATTLE_PHASE_DURATIONS
const BATTLE_FEEDBACK_DURATION = FacadeScript.BATTLE_FEEDBACK_DURATION
var facade := FacadeScript.new()
var authoritative_battle_service := P92BattleServiceScript.new()
var authoritative_battle_presenter
var authoritative_battle_active := false
var authoritative_auto_advance := true
var authoritative_tick_accumulator := 0.0
var authoritative_last_events: Array[Dictionary] = []
var active_battle_feedback:
get: return facade.active_battle_feedback
@@ -184,6 +192,8 @@ func _ready() -> void:
facade.name = "RuntimeFacade"
facade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(facade)
facade.authoritative_battle_requested.connect(start_authoritative_battle)
authoritative_battle_presenter = P92BattlePresenterScript.new(self)
func _get(property: StringName) -> Variant:
return facade.get(property)
@@ -207,7 +217,52 @@ func _get_property_list() -> Array[Dictionary]:
})
return result
func _process(delta: float) -> void: facade._process(delta)
func _process(delta: float) -> void:
if authoritative_battle_active:
_process_authoritative_battle(delta)
return
facade._process(delta)
func start_authoritative_battle() -> bool:
var members: Array[String] = facade.session_state.current_member_ids()
if members.is_empty():
return false
var starting_hp: Dictionary = {}
for member_id in members:
starting_hp[member_id] = facade.session_state.member_health(member_id)
var result: Dictionary = authoritative_battle_service.start_fixture(members, 9201, starting_hp)
if not result.ok:
return false
authoritative_battle_active = true
authoritative_tick_accumulator = 0.0
authoritative_last_events.clear()
for event in result.events:
authoritative_last_events.append(Dictionary(event).duplicate(true))
facade.visible = false
authoritative_battle_presenter.render(authoritative_battle_service.state.current_snapshot(), authoritative_last_events)
return true
func advance_authoritative_ticks(ticks: int) -> Dictionary:
if not authoritative_battle_active:
return {"ok": false, "code": "inactive", "events": []}
var result: Dictionary = authoritative_battle_service.advance_ticks(ticks)
authoritative_last_events.clear()
for event in result.events:
authoritative_last_events.append(Dictionary(event).duplicate(true))
authoritative_battle_presenter.render(result.snapshot, authoritative_last_events)
return result
func _process_authoritative_battle(delta: float) -> void:
if authoritative_battle_service.state.terminal:
return
if not authoritative_auto_advance:
return
authoritative_tick_accumulator += maxf(0.0, delta)
var ticks := int(floor(authoritative_tick_accumulator / 0.1))
if ticks <= 0:
return
authoritative_tick_accumulator -= float(ticks) * 0.1
advance_authoritative_ticks(ticks)
func active_boot_paths() -> Array[String]: return facade.active_boot_paths()
func adaptation_effect(id: String) -> String: return facade.adaptation_effect(id)
func adaptation_name(id: String) -> String: return facade.adaptation_name(id)
@@ -0,0 +1,216 @@
class_name P92BattlePresenter
extends RefCounted
const FIELD_BACKGROUND := preload("res://assets/backgrounds/first_expedition_field.png")
const Rules := preload("res://domain/p9_2/battle_rules.gd")
var host: Control
var root: Control
func _init(runtime_host: Control) -> void:
host = runtime_host
func render(snapshot: Dictionary, events: Array[Dictionary]) -> void:
_ensure_root()
_clear_root()
root.set_meta("domain_tick", int(snapshot.get("tick", 0)))
root.set_meta("state_hash", host.authoritative_battle_service.state.state_hash())
root.set_meta("event_hash", host.authoritative_battle_service.state.event_hash())
root.set_meta("outcome", String(snapshot.get("outcome", "")))
_add_background()
_add_wash()
_add_heading(snapshot)
_add_status_panels(snapshot)
_add_world_line(snapshot, events)
_add_event_ribbon(snapshot, events)
func cleanup() -> void:
if root != null and is_instance_valid(root):
root.queue_free()
root = null
func _ensure_root() -> void:
if root != null and is_instance_valid(root):
return
root = Control.new()
root.name = "P92BattleRoot"
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
host.add_child(root)
func _clear_root() -> void:
for child in root.get_children():
root.remove_child(child)
child.queue_free()
func _add_background() -> void:
var background := TextureRect.new()
background.name = "P92WorldBackground"
background.texture = FIELD_BACKGROUND
background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
background.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(background)
func _add_wash() -> void:
var wash := ColorRect.new()
wash.name = "P92BattleWash"
wash.color = Color(0.01, 0.025, 0.055, 0.26)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(wash)
func _add_heading(snapshot: Dictionary) -> void:
var title: Label = host.facade.game_label(host.facade.tr_text("裂隙守望 · 权威自动战斗", "Breach Watch · Authoritative Auto Battle"), 18, host.facade.COLOR_WHITE)
title.name = "P92BattleTitle"
title.position = Vector2(18, 12)
title.size = Vector2(host.size.x * 0.58, 34)
root.add_child(title)
var status_text: String = host.facade.tr_text("结果已锁定:%s" % _outcome_name(String(snapshot.outcome)), "Outcome locked: %s" % String(snapshot.outcome)) if bool(snapshot.terminal) else host.facade.tr_text("固定步 %d · %.1f 秒" % [int(snapshot.tick), float(snapshot.tick) * 0.1], "Tick %d · %.1f seconds" % [int(snapshot.tick), float(snapshot.tick) * 0.1])
var status: Label = host.facade.centered_label(status_text, 11, host.facade.COLOR_GOLD_LIGHT)
status.name = "P92TickStatus"
status.position = Vector2(host.size.x * 0.66, 10)
status.size = Vector2(host.size.x * 0.32, 38)
status.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.03, 0.05, 0.10, 0.9), host.facade.COLOR_GOLD, 2))
root.add_child(status)
func _add_status_panels(snapshot: Dictionary) -> void:
var members: Dictionary = snapshot.members
var enemies: Dictionary = snapshot.enemies
_add_health_column("P92PartyStatus", Vector2(18, 54), Vector2(230, 92), members.ids, members.hp, true)
_add_health_column("P92EnemyStatus", Vector2(host.size.x - 248, 54), Vector2(230, 72), enemies.ids, enemies.hp, false)
func _add_health_column(node_name: String, panel_position: Vector2, panel_size: Vector2, ids: Array, hp: Dictionary, members: bool) -> void:
var panel := PanelContainer.new()
panel.name = node_name
panel.position = panel_position
panel.size = panel_size
panel.add_theme_stylebox_override("panel", host.facade.lacquer_style(Color(0.025, 0.045, 0.09, 0.9), host.facade.COLOR_TEAL if members else host.facade.COLOR_CRIMSON, 2))
root.add_child(panel)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 1)
panel.add_child(box)
for actor_id in ids:
var maximum: int = int(Rules.member_definition(actor_id).get("max_hp", 0)) if members else int(Rules.enemy_definition(actor_id).get("max_hp", 0))
var row := HBoxContainer.new()
row.name = ("MemberHP_" if members else "EnemyHP_") + String(actor_id)
row.set_meta("actor_id", actor_id)
row.set_meta("hp", int(hp.get(actor_id, 0)))
box.add_child(row)
var label: Label = host.facade.game_label((_member_name(actor_id) if members else _enemy_name(actor_id)), 8, host.facade.COLOR_WHITE)
label.custom_minimum_size.x = 80
row.add_child(label)
var bar := ProgressBar.new()
bar.name = "Bar"
bar.max_value = maximum
bar.value = int(hp.get(actor_id, 0))
bar.show_percentage = false
bar.custom_minimum_size = Vector2(112, 10)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.add_theme_stylebox_override("background", host.facade.lacquer_style(Color("#14192a"), Color("#667085"), 1))
bar.add_theme_stylebox_override("fill", host.facade.lacquer_style(host.facade.COLOR_TEAL if members else host.facade.COLOR_CRIMSON, host.facade.COLOR_WHITE, 1))
row.add_child(bar)
func _add_world_line(snapshot: Dictionary, events: Array[Dictionary]) -> void:
var world_top := maxf(150.0, host.size.y * 0.38)
var world_bottom := host.size.y - 20.0
var ground := ColorRect.new()
ground.name = "P92ContactGround"
ground.color = Color(0.05, 0.09, 0.13, 0.88)
ground.position = Vector2(16, world_bottom - 16)
ground.size = Vector2(host.size.x - 32, 4)
root.add_child(ground)
var members: Dictionary = snapshot.members
var enemies: Dictionary = snapshot.enemies
for member_id in members.ids:
_add_actor(String(member_id), int(members.positions.get(member_id, 0)), int(members.hp.get(member_id, 0)), true, world_top, world_bottom, events)
for enemy_id in enemies.ids:
_add_actor(String(enemy_id), int(enemies.positions.get(enemy_id, 100)), int(enemies.hp.get(enemy_id, 0)), false, world_top, world_bottom, events)
func _add_actor(actor_id: String, logical_position: int, hp: int, member: bool, world_top: float, world_bottom: float, events: Array[Dictionary]) -> void:
var actor_height := clampf(host.size.y * 0.25, 78.0, 132.0)
var actor_width := actor_height
var center_x := lerpf(76.0, host.size.x - 76.0, inverse_lerp(-36.0, 112.0, float(logical_position)))
var event := _latest_actor_event(actor_id, member, events)
var animation_state := _member_animation(actor_id, event, logical_position) if member else _enemy_animation(event, hp)
var texture: Texture2D = host.facade.animation_texture(actor_id if member else "Riftblade", animation_state, float(host.authoritative_battle_service.state.tick) * 0.1)
var actor := TextureRect.new()
actor.name = ("P92Member_" if member else "P92Enemy_") + actor_id
actor.texture = texture
actor.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
actor.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
actor.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
actor.position = Vector2(center_x - actor_width * 0.5, world_bottom - actor_height)
actor.size = Vector2(actor_width, actor_height)
actor.mouse_filter = Control.MOUSE_FILTER_IGNORE
actor.modulate = Color(0.75, 0.65, 1.0) if actor_id == "RiftSpitter" else Color.WHITE
actor.set_meta("actor_id", actor_id)
actor.set_meta("logical_position", logical_position)
actor.set_meta("hp", hp)
actor.set_meta("event_index", int(event.get("index", -1)))
actor.set_meta("animation_state", animation_state)
root.add_child(actor)
var plaque: Label = host.facade.centered_label((_member_name(actor_id) if member else _enemy_name(actor_id)) + " %d" % hp, 8, host.facade.COLOR_WHITE)
plaque.name = actor.name + "_Plaque"
plaque.position = Vector2(actor.position.x - 6, world_top - 4)
plaque.size = Vector2(actor_width + 12, 24)
root.add_child(plaque)
func _add_event_ribbon(snapshot: Dictionary, events: Array[Dictionary]) -> void:
var event: Dictionary = events[-1] if not events.is_empty() else (snapshot.events[-1] if not snapshot.events.is_empty() else {})
var text: String = _event_text(event)
var ribbon: Label = host.facade.centered_label(text, 11, host.facade.COLOR_WHITE)
ribbon.name = "P92EventRibbon"
ribbon.position = Vector2(host.size.x * 0.27, 60)
ribbon.size = Vector2(host.size.x * 0.46, 58)
ribbon.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.04, 0.07, 0.13, 0.95), host.facade.COLOR_GOLD, 2))
ribbon.set_meta("event_index", int(event.get("index", -1)))
ribbon.set_meta("event_kind", String(event.get("kind", "")))
ribbon.set_meta("source_member_id", String(event.get("source_member_id", "")))
ribbon.set_meta("target_member_id", String(event.get("target_member_id", "")))
root.add_child(ribbon)
func _latest_actor_event(actor_id: String, member: bool, events: Array[Dictionary]) -> Dictionary:
for index in range(events.size() - 1, -1, -1):
var event: Dictionary = events[index]
if member and String(event.get("source_member_id", "")) == actor_id:
return event
if not member and String(event.get("source_enemy_id", "")) == actor_id:
return event
return {}
func _member_animation(actor_id: String, event: Dictionary, logical_position: int) -> String:
if not event.is_empty():
if String(event.get("cue", "")) == "heal": return "lantern_heal"
return host.facade.profession_action_state(actor_id)
if logical_position < 40:
return "advance"
return "idle"
func _enemy_animation(event: Dictionary, hp: int) -> String:
if hp <= 0: return "death"
if not event.is_empty(): return "attack"
return "advance" if host.authoritative_battle_service.state.tick < 24 else "idle"
func _event_text(event: Dictionary) -> String:
if event.is_empty():
return host.facade.tr_text("双方正在接近…", "Both lines are advancing…")
match String(event.get("kind", "")):
"battle_started": return host.facade.tr_text("队伍进入裂隙守望,战斗自动执行", "The Company enters Breach Watch; combat resolves automatically")
"contact": return host.facade.tr_text("前线接触:近战停止推进并开始交战", "Contact: melee stops advancing and engages")
"member_action":
var source := _member_name(String(event.get("source_member_id", "")))
return host.facade.tr_text("%s 执行职业行动 %+d" % [source, int(event.get("delta", 0))], "%s used a profession action %+d" % [source, int(event.get("delta", 0))])
"enemy_action": return host.facade.tr_text("敌方施压 %+d", "Enemy pressure %+d") % int(event.get("delta", 0))
"outcome_locked": return host.facade.tr_text("结果锁定:%s" % _outcome_name(String(event.get("outcome", ""))), "Outcome locked: %s" % String(event.get("outcome", "")))
return host.facade.tr_text("自动战斗正在解析", "Automatic battle is resolving")
func _member_name(member_id: String) -> String:
return host.facade.candidate_name(member_id)
func _enemy_name(enemy_id: String) -> String:
return {"RiftBulwark": host.facade.tr_text("裂隙壁垒", "Rift Bulwark"), "RiftSpitter": host.facade.tr_text("裂隙喷吐者", "Rift Spitter")}.get(enemy_id, enemy_id)
func _outcome_name(outcome: String) -> String:
return {"victory": host.facade.tr_text("胜利", "Victory"), "defeat": host.facade.tr_text("失败", "Defeat"), "timeout": host.facade.tr_text("超时", "Timeout"), "retreat": host.facade.tr_text("撤退", "Retreat")}.get(outcome, host.facade.tr_text("进行中", "In progress"))
@@ -43,6 +43,15 @@ func _test_start_and_rejections() -> void:
_expect(service.start_fixture(party(["Hearthguard", "Farstring", "Kindhand"]), 17).ok, "three member fixture starts")
_expect(service.state.member_hp["Hearthguard"] == 130, "Hearthguard max hp")
_expect(service.state.enemy_hp["RiftBulwark"] == 180, "Bulwark max hp")
var imported := Service.new()
_expect(imported.start_fixture(party(["Hearthguard", "Farstring"]), 18, {"Hearthguard": 72, "Farstring": 46}).ok, "P9.1 HP fixture starts")
_expect(imported.state.member_hp == {"Hearthguard": 72, "Farstring": 46}, "P9.1 end HP imports exactly")
imported.state.enemy_hp["RiftBulwark"] = 5
imported.state.enemy_hp["RiftSpitter"] = 9
imported.state.member_positions["Hearthguard"] = 40
imported.state.enemy_positions["RiftBulwark"] = 50
imported.advance_ticks(1)
_expect(imported.state.enemy_hp["RiftBulwark"] == 0 and imported.state.enemy_hp["RiftSpitter"] < 9, "later same-tick member retargets after defeat")
func _test_movement_contact_and_roles() -> void:
var service: Variant = _service(party(["Hearthguard", "Farstring", "Kindhand"]), 4)
@@ -0,0 +1,159 @@
extends SceneTree
const MainScene = preload("res://main.tscn")
var failures: Array[String] = []
var game: Control
var viewport: SubViewport
var capture_root := ""
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
capture_root = OS.get_environment("ABG_P9_2_RUNTIME_CAPTURE_ROOT")
viewport = SubViewport.new()
viewport.size = Vector2i(844, 390)
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
game = MainScene.instantiate()
game.auto_boot = false
game.authoritative_auto_advance = false
game.audio_enabled = false
viewport.add_child(game)
await settle(8)
_prepare_p9_1_result()
game.render()
await settle(5)
var continue_button: Control = find_control(game, "ContinueAuthoritativeBattleButton")
_expect(continue_button != null and continue_button.is_visible_in_tree(), "P9.1 result exposes the P9.2 continuation")
if continue_button == null:
_finish()
return
continue_button.pressed.emit()
await settle(4)
_expect(game.authoritative_battle_active, "continuation enters authoritative battle")
if not game.authoritative_battle_active or game.authoritative_battle_presenter.root == null:
_finish()
return
_expect(not game.facade.visible, "legacy facade is hidden during P9.2")
var initial: Dictionary = game.authoritative_battle_service.state.current_snapshot()
_expect(initial.members.ids == ["Hearthguard", "Farstring", "Kindhand"], "P9.1 field order imports")
_expect(initial.members.hp == {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85}, "P9.1 end HP imports")
_assert_runtime_binding("initial")
await capture("01-entry")
var initial_lead_x: int = logical_position("P92Member_Hearthguard")
game.advance_authoritative_ticks(10)
await settle(3)
_expect(logical_position("P92Member_Hearthguard") > initial_lead_x, "Guild line visibly advances from domain position")
_assert_runtime_binding("advance")
await capture("02-advance")
game.advance_authoritative_ticks(14)
await settle(3)
_expect(has_event("contact"), "domain reaches contact")
_assert_runtime_binding("contact")
await capture("03-contact")
game.advance_authoritative_ticks(18)
await settle(3)
_expect(has_event("member_action"), "member profession event reaches runtime")
_expect(has_event("enemy_action"), "enemy event reaches runtime")
_expect(event_ribbon_bound(), "event ribbon retains source-bound event identity")
_assert_runtime_binding("actions")
await capture("04-actions")
for _index in 220:
if game.authoritative_battle_service.state.terminal:
break
game.advance_authoritative_ticks(1)
_expect(game.authoritative_battle_service.state.outcome == "victory", "imported three-member line reaches deterministic victory")
_assert_runtime_binding("victory")
await capture("05-victory")
_verify_bounds(game.authoritative_battle_presenter.root)
_finish()
func _finish() -> void:
if failures.is_empty():
print("ABG_P9_2_HORIZONTAL_BATTLE_RUNTIME_OK viewport=844x390 source=p9_1_result members=3 hp_import=true movement=true contact=true member_events=true enemy_events=true outcome=victory domain_bound=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func _prepare_p9_1_result() -> void:
game.locale = "zh_CN"
game.muted = true
game.facade.session_state.field_order.assign(["Hearthguard", "Farstring", "Kindhand"])
game.facade.session_state.starting_pair.assign(["Hearthguard", "Farstring"])
game.facade.session_state.front_choice = "Hearthguard"
game.facade.session_state.rear_choice = "Farstring"
game.facade.session_state.added_rear = "Kindhand"
game.facade.session_state.member_hp = {"Hearthguard": 72, "Farstring": 46, "Kindhand": 85}
game.facade.session_state.member_levels = {"Hearthguard": 2, "Farstring": 1, "Kindhand": 1}
game.page = game.AppPage.THIRD_CYCLE_RESULT
func _assert_runtime_binding(label: String) -> void:
var snapshot: Dictionary = game.authoritative_battle_service.state.current_snapshot()
var battle_root: Control = game.authoritative_battle_presenter.root
_expect(battle_root != null and battle_root.name == "P92BattleRoot", "%s renders P9.2 root" % label)
_expect(int(battle_root.get_meta("domain_tick", -1)) == int(snapshot.tick), "%s root tick matches domain" % label)
_expect(int(battle_root.get_meta("state_hash", 0)) == game.authoritative_battle_service.state.state_hash(), "%s root hash matches domain" % label)
for member_id in snapshot.members.ids:
var actor: Control = find_control(battle_root, "P92Member_" + String(member_id))
_expect(actor != null, "%s renders member %s" % [label, member_id])
if actor != null:
_expect(int(actor.get_meta("logical_position", -999)) == int(snapshot.members.positions[member_id]), "%s %s position bound" % [label, member_id])
_expect(int(actor.get_meta("hp", -1)) == int(snapshot.members.hp[member_id]), "%s %s HP bound" % [label, member_id])
for enemy_id in snapshot.enemies.ids:
var actor: Control = find_control(battle_root, "P92Enemy_" + String(enemy_id))
_expect(actor != null, "%s renders enemy %s" % [label, enemy_id])
if actor != null:
_expect(int(actor.get_meta("logical_position", -999)) == int(snapshot.enemies.positions[enemy_id]), "%s %s position bound" % [label, enemy_id])
_expect(int(actor.get_meta("hp", -1)) == int(snapshot.enemies.hp[enemy_id]), "%s %s HP bound" % [label, enemy_id])
func logical_position(node_name: String) -> int:
var actor: Control = find_control(game.authoritative_battle_presenter.root, node_name)
return int(actor.get_meta("logical_position", -999)) if actor != null else -999
func has_event(kind: String) -> bool:
for event in game.authoritative_battle_service.state.event_log:
if String(event.get("kind", "")) == kind:
return true
return false
func event_ribbon_bound() -> bool:
var ribbon: Control = find_control(game.authoritative_battle_presenter.root, "P92EventRibbon")
return ribbon != null and int(ribbon.get_meta("event_index", -1)) >= 0 and not String(ribbon.get_meta("event_kind", "")).is_empty()
func _verify_bounds(node: Node) -> void:
if node is Control 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 > 844.5 or rect.end.y > 390.5:
failures.append("control %s outside 844x390: %s" % [node.name, rect])
for child in node.get_children():
_verify_bounds(child)
func capture(name: String) -> void:
if capture_root.is_empty():
return
DirAccess.make_dir_recursive_absolute(capture_root)
await settle(2)
await RenderingServer.frame_post_draw
var image := viewport.get_texture().get_image()
var error := image.save_png(capture_root.path_join(name + ".png"))
_expect(error == OK, "capture %s succeeds" % name)
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: Control = find_control(child, node_name)
if found != null: return found
return null
func settle(frames: int) -> void:
for _frame in frames:
await process_frame
func _expect(condition: bool, message: String) -> void:
if not condition and failures.size() < 100:
failures.append("P9.2 horizontal runtime: %s" % message)