feat(runtime): bind P9.2 battle to horizontal scene
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user