Files
aetherbound-guild/runtime/presentation/p9_2_battle_presenter.gd
T

384 lines
22 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", "")))
root.set_meta("speed", host.authoritative_speed_multiplier)
root.set_meta("inspect_open", host.authoritative_inspect_open)
_add_background()
_add_wash()
_add_heading(snapshot)
_add_status_panels(snapshot)
_add_world_line(snapshot, events)
_add_event_ribbon(snapshot, events)
if bool(snapshot.terminal):
_add_result_panel(snapshot)
else:
_add_control_rail(snapshot)
if host.authoritative_inspect_open:
_add_inspect_panel(snapshot)
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 _portrait() -> bool:
return host.size.y > host.size.x
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.34)
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_size := 14 if _portrait() else 18
var title: Label = host.facade.game_label(host.facade.tr_text("裂隙守望 · 自动战斗", "Breach Watch · Auto Battle"), title_size, host.facade.COLOR_WHITE)
title.name = "P92BattleTitle"
title.position = Vector2(14, 8)
title.size = Vector2(host.size.x - 28, 34) if _portrait() else Vector2(host.size.x * 0.60, 34)
root.add_child(title)
var status_text: String
if bool(snapshot.terminal):
status_text = host.facade.tr_text("结果:%s" % _outcome_name(String(snapshot.outcome)), "Result: %s" % _outcome_name(String(snapshot.outcome)))
else:
status_text = host.facade.tr_text("%d 步 · %.1f 秒 · %dx" % [int(snapshot.tick), float(snapshot.tick) * Rules.TICK_SECONDS, host.authoritative_speed_multiplier], "Tick %d · %.1fs · %dx" % [int(snapshot.tick), float(snapshot.tick) * Rules.TICK_SECONDS, host.authoritative_speed_multiplier])
if bool(snapshot.paused):
status_text += host.facade.tr_text(" · 已暂停", " · Paused")
var status: Label = host.facade.centered_label(status_text, 10, host.facade.COLOR_GOLD_LIGHT)
status.name = "P92TickStatus"
status.position = Vector2(14, 43) if _portrait() else Vector2(host.size.x * 0.65, 8)
status.size = Vector2(host.size.x - 28, 38) if _portrait() else Vector2(host.size.x * 0.33, 36)
status.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.03, 0.05, 0.10, 0.92), 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
if _portrait():
var panel_width := host.size.x - 28.0
_add_health_column("P92PartyStatus", Vector2(14, 88), Vector2(panel_width, 92), members.ids, members.hp, true)
_add_health_column("P92EnemyStatus", Vector2(14, 186), Vector2(panel_width, 72), enemies.ids, enemies.hp, false)
else:
_add_health_column("P92PartyStatus", Vector2(14, 50), Vector2(226, 88), members.ids, members.hp, true)
_add_health_column("P92EnemyStatus", Vector2(host.size.x - 240, 50), Vector2(226, 68), 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.92), 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 := 294.0 if _portrait() else maxf(136.0, host.size.y * 0.35)
var control_height := 126.0 if _portrait() else 72.0
var world_bottom := host.size.y - control_height
var ground := ColorRect.new()
ground.name = "P92ContactGround"
ground.color = Color(0.05, 0.09, 0.13, 0.88)
ground.position = Vector2(12, world_bottom - 14)
ground.size = Vector2(host.size.x - 24, 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 available_height := maxf(80.0, world_bottom - world_top)
var actor_height := clampf(available_height * (0.46 if _portrait() else 0.68), 62.0, 132.0)
var actor_width := actor_height
var edge := actor_width * 0.48 + 12.0
var center_x := lerpf(edge, host.size.x - edge, 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) * Rules.TICK_SECONDS)
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 - 8)
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, 7, host.facade.COLOR_WHITE)
plaque.name = actor.name + "_Plaque"
plaque.position = Vector2(clampf(actor.position.x - 4, 2, host.size.x - actor_width - 6), world_top)
plaque.size = Vector2(actor_width + 8, 22)
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 ribbon: Label = host.facade.centered_label(_event_text(event), 9, host.facade.COLOR_WHITE)
ribbon.name = "P92EventRibbon"
if _portrait():
ribbon.position = Vector2(14, 264)
ribbon.size = Vector2(host.size.x - 28, 42)
else:
ribbon.position = Vector2(host.size.x * 0.29, 50)
ribbon.size = Vector2(host.size.x * 0.42, 44)
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 _add_control_rail(snapshot: Dictionary) -> void:
var panel := PanelContainer.new()
panel.name = "P92ControlRail"
panel.position = Vector2(10, host.size.y - (118 if _portrait() else 68))
panel.size = Vector2(host.size.x - 20, 110 if _portrait() else 60)
panel.add_theme_stylebox_override("panel", host.facade.lacquer_style(Color(0.02, 0.035, 0.075, 0.97), host.facade.COLOR_GOLD, 2))
root.add_child(panel)
var controls := GridContainer.new()
controls.columns = 3 if _portrait() else 5
controls.add_theme_constant_override("h_separation", 5)
controls.add_theme_constant_override("v_separation", 4)
panel.add_child(controls)
var pause_text: String = host.facade.tr_text("继续", "Resume") if bool(snapshot.paused) else host.facade.tr_text("暂停", "Pause")
controls.add_child(_control_button("P92PauseButton", pause_text, host.toggle_authoritative_pause, false, 72))
controls.add_child(_control_button("P92Speed1Button", ("● " if host.authoritative_speed_multiplier == 1 else "") + "1×", func(): host.set_authoritative_speed(1), host.authoritative_speed_multiplier == 1, 60))
controls.add_child(_control_button("P92Speed2Button", ("● " if host.authoritative_speed_multiplier == 2 else "") + "2×", func(): host.set_authoritative_speed(2), host.authoritative_speed_multiplier == 2, 60))
var inspect_text: String = host.facade.tr_text("关闭战报", "Close log") if host.authoritative_inspect_open else host.facade.tr_text("查看战报", "Inspect")
controls.add_child(_control_button("P92InspectButton", inspect_text, host.toggle_authoritative_inspect, host.authoritative_inspect_open, 92))
var retreat_available := int(snapshot.tick) >= Rules.RETREAT_MIN_TICK
var retreat_pending := int(snapshot.retreat_requested_tick) >= 0
var retreat_text: String = host.facade.tr_text("撤退 %d步" % maxi(0, int(snapshot.retreat_resolution_tick) - int(snapshot.tick)), "Retreat %dt" % maxi(0, int(snapshot.retreat_resolution_tick) - int(snapshot.tick))) if retreat_pending else (host.facade.tr_text("撤退", "Retreat") if retreat_available else host.facade.tr_text("撤退 · %d步解锁" % Rules.RETREAT_MIN_TICK, "Retreat · unlock %d" % Rules.RETREAT_MIN_TICK))
var retreat := _control_button("P92RetreatButton", retreat_text, host.request_authoritative_retreat, retreat_pending, 138)
retreat.disabled = not retreat_available
retreat.tooltip_text = host.facade.tr_text("第 50 步后可撤退", "Retreat unlocks after tick 50") if not retreat_available else host.facade.tr_text("撤退需继续结算 20 步;重复点击不会重复发起", "Withdrawal resolves over 20 ticks; repeated taps keep the first receipt")
retreat.set_meta("unlock_tick", Rules.RETREAT_MIN_TICK)
retreat.set_meta("pending", retreat_pending)
controls.add_child(retreat)
func _control_button(node_name: String, text: String, callback: Callable, selected: bool, minimum_width: float) -> Button:
var button: Button = host.facade.game_button(node_name, text, callback, false)
button.custom_minimum_size = Vector2(minimum_width if not _portrait() else 102, 44)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.add_theme_font_size_override("font_size", host.facade.scaled(10))
if selected:
button.add_theme_color_override("font_color", host.facade.COLOR_GOLD_LIGHT)
button.add_theme_stylebox_override("normal", host.facade.lacquer_style(Color(0.06, 0.13, 0.18, 0.98), host.facade.COLOR_TEAL, 3))
return button
func _add_inspect_panel(snapshot: Dictionary) -> void:
var panel := PanelContainer.new()
panel.name = "P92InspectPanel"
panel.position = Vector2(14, 314) if _portrait() else Vector2(host.size.x * 0.29, 100)
panel.size = Vector2(host.size.x - 28, minf(360, host.size.y - 440)) if _portrait() else Vector2(host.size.x * 0.42, host.size.y - 176)
panel.add_theme_stylebox_override("panel", host.facade.lacquer_style(Color(0.018, 0.03, 0.065, 0.98), host.facade.COLOR_TEAL, 3))
panel.set_meta("snapshot_hash", JSON.stringify(snapshot).hash())
root.add_child(panel)
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 2)
panel.add_child(box)
box.add_child(host.facade.game_label(host.facade.tr_text("最近权威战报 · 只读", "Recent authoritative events · read only"), 12, host.facade.COLOR_GOLD_LIGHT))
box.add_child(host.facade.game_label(_inspect_actor_line(snapshot), 8, host.facade.COLOR_PARCHMENT))
var events: Array = host.authoritative_inspect_events
if events.is_empty():
box.add_child(host.facade.game_label(host.facade.tr_text("尚无事件", "No events yet"), 9, host.facade.COLOR_WHITE))
else:
for event in events.slice(maxi(0, events.size() - (4 if _portrait() else 5))):
var line := "#%d · %d · %s" % [int(event.get("index", -1)), int(event.get("tick", 0)), _event_text(event)]
box.add_child(host.facade.game_label(line, 8, host.facade.COLOR_WHITE))
func _inspect_actor_line(snapshot: Dictionary) -> String:
var living_members: Array[String] = []
for member_id in snapshot.members.ids:
living_members.append("%s:%d" % [String(member_id), int(snapshot.members.hp.get(member_id, 0))])
var living_enemies: Array[String] = []
for enemy_id in snapshot.enemies.ids:
living_enemies.append("%s:%d" % [String(enemy_id), int(snapshot.enemies.hp.get(enemy_id, 0))])
return host.facade.tr_text("队伍 %s 敌人 %s" % [" / ".join(living_members), " / ".join(living_enemies)], "Party %s · Enemies %s" % [" / ".join(living_members), " / ".join(living_enemies)])
func _add_result_panel(snapshot: Dictionary) -> void:
var panel := Panel.new()
panel.name = "P92ResultPanel"
panel.position = Vector2(12, 86) if _portrait() else Vector2(host.size.x * 0.14, 52)
panel.size = Vector2(host.size.x - 24, host.size.y - 110) if _portrait() else Vector2(host.size.x * 0.72, host.size.y - 68)
panel.add_theme_stylebox_override("panel", host.facade.lacquer_style(Color(0.018, 0.03, 0.065, 0.98), host.facade.COLOR_GOLD, 3))
root.add_child(panel)
var lines := _result_lines(snapshot)
var line_height := clampf((panel.size.y - 78.0) / 5.0, 36.0, 64.0)
for index in lines.size():
var line: Label = host.facade.game_label(lines[index], 10 if index == 0 else 9, host.facade.COLOR_GOLD_LIGHT if index == 0 else host.facade.COLOR_WHITE)
line.name = "P92ResultLine%d" % (index + 1)
line.position = Vector2(14, 10 + float(index) * line_height)
line.size = Vector2(panel.size.x - 28, line_height - 2)
line.set_meta("grammar_index", index + 1)
panel.add_child(line)
var actions := Control.new()
actions.name = "P92ResultActions"
actions.position = Vector2(14, panel.size.y - 60)
actions.size = Vector2(panel.size.x - 28, 50)
panel.add_child(actions)
var home: Button = host.facade.game_button("P92HomeButton", host.facade.tr_text("返回首页", "Home"), host.exit_authoritative_battle_to_home, false)
var retry: Button = host.facade.game_button("P92RetryButton", host.facade.tr_text("重新挑战", "Retry"), host.retry_authoritative_battle, true)
home.custom_minimum_size = Vector2(120, 44)
retry.custom_minimum_size = Vector2(120, 44)
var button_width := (actions.size.x - 8) * 0.5
home.position = Vector2.ZERO
home.size = Vector2(button_width, 48)
retry.position = Vector2(button_width + 8, 0)
retry.size = Vector2(button_width, 48)
actions.add_child(home)
actions.add_child(retry)
func _result_lines(snapshot: Dictionary) -> Array[String]:
var outcome := String(snapshot.outcome)
return [
host.facade.tr_text("1. 结果:%s" % _outcome_name(outcome), "1. Outcome: %s" % _outcome_name(outcome)),
host.facade.tr_text("2. 原因:%s" % _cause_name(String(snapshot.outcome_cause)), "2. Cause: %s" % _cause_name(String(snapshot.outcome_cause))),
host.facade.tr_text("3. 生还者:%s" % _survivor_text(snapshot), "3. Survivors: %s" % _survivor_text(snapshot)),
host.facade.tr_text("4. 收益/代价:裂隙守望凭证 BW-01(本阶段不写入存档)" if outcome == "victory" else "4. 收益/代价:0 金币", "4. Reward/cost: Breach Watch receipt BW-01 (not persisted yet)" if outcome == "victory" else "4. Reward/cost: 0 Coin"),
host.facade.tr_text("5. 下一步:返回首页或重试;P9.2 不执行永久伤亡/恢复", "5. Next: Home or Retry; P9.2 applies no permanent casualty/recovery"),
]
func _survivor_text(snapshot: Dictionary) -> String:
var survivors: Array[String] = []
for member_id in snapshot.members.ids:
var hp := int(snapshot.members.hp.get(member_id, 0))
if hp > 0:
survivors.append("%s %d HP" % [String(member_id), hp])
return host.facade.tr_text("无", "none") if survivors.is_empty() else ", ".join(survivors)
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 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))
"paused_changed":
return host.facade.tr_text("战斗已暂停" if bool(event.get("paused", false)) else "战斗继续", "Battle paused" if bool(event.get("paused", false)) else "Battle resumed")
"retreat_started":
return host.facade.tr_text("撤退已发起:第 %d 步结算" % int(event.get("resolves_at", 0)), "Withdrawal started: resolves at tick %d" % int(event.get("resolves_at", 0)))
"outcome_locked":
return host.facade.tr_text("结果锁定:%s" % _outcome_name(String(event.get("outcome", ""))), "Outcome locked: %s" % _outcome_name(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"))
func _cause_name(cause: String) -> String:
return {
"all_enemies_defeated": host.facade.tr_text("全部敌人被击败", "all enemies defeated"),
"all_members_fallen": host.facade.tr_text("本次遭遇中全队倒下", "all Guild members fell in this encounter"),
"encounter_timeout": host.facade.tr_text("90 秒内未完成目标", "the 90-second objective expired"),
"withdrawal_complete": host.facade.tr_text("撤退指令完成 20 步结算", "withdrawal completed after 20 ticks"),
}.get(cause, cause)