extends Node2D

const BattleStateScript := preload("res://scripts/core/battle_state.gd")
const CampaignStateScript := preload("res://scripts/core/campaign_state.gd")

const CAMPAIGN_PATH := "res://data/campaign/campaign.json"
const TILE_SIZE := 64
const BOARD_OFFSET := Vector2(48, 104)
const DEFAULT_BATTLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png"
const PLAYER_COLOR := Color(0.20, 0.42, 0.82)
const ENEMY_COLOR := Color(0.72, 0.18, 0.16)
const GRID_COLOR := Color(0.05, 0.06, 0.07, 0.45)
const SKILL_OVERLAY_COLOR := Color(0.56, 0.28, 0.95, 0.28)
const SKILL_AREA_OVERLAY_COLOR := Color(0.72, 0.46, 1.0, 0.30)
const ITEM_OVERLAY_COLOR := Color(0.24, 0.74, 0.34, 0.30)
const FORMATION_OVERLAY_COLOR := Color(0.95, 0.76, 0.25, 0.34)
const OBJECTIVE_OVERLAY_COLOR := Color(1.0, 0.78, 0.18, 0.30)
const THREAT_OVERLAY_COLOR := Color(1.0, 0.12, 0.10, 0.18)
const THREAT_BORDER_COLOR := Color(1.0, 0.16, 0.12, 0.44)
const BGM_MENU_PATH := "res://audio/bgm/menu_theme_placeholder.wav"
const BGM_BATTLE_PATH := "res://audio/bgm/battle_loop_placeholder.wav"
const SFX_BOW_RELEASE_PATH := "res://audio/sfx/bow_release_01.wav"
const SFX_DEFEAT_STING_PATH := "res://audio/sfx/defeat_sting_01.wav"
const SFX_FIRE_SKILL_PATH := "res://audio/sfx/fire_skill_01.wav"
const SFX_FOOTSTEP_PATH := "res://audio/sfx/footstep_armor_01.wav"
const SFX_GUARD_BLOCK_PATH := "res://audio/sfx/guard_block_01.wav"
const SFX_HIT_HEAVY_PATH := "res://audio/sfx/hit_heavy_01.wav"
const SFX_ITEM_PICKUP_PATH := "res://audio/sfx/item_pickup_01.wav"
const SFX_MENU_CANCEL_PATH := "res://audio/sfx/menu_cancel_01.wav"
const SFX_MENU_CONFIRM_PATH := "res://audio/sfx/menu_confirm_01.wav"
const SFX_SKILL_CAST_PATH := "res://audio/sfx/skill_cast_01.wav"
const SFX_SLASH_PATH := "res://audio/sfx/slash_01.wav"
const SFX_UI_CLICK_PATH := "res://audio/sfx/ui_click_01.wav"
const SFX_VICTORY_STING_PATH := "res://audio/sfx/victory_sting_01.wav"
const BGM_VOLUME_DB := -14.0
const SFX_VOLUME_DB := -4.0
const UI_SFX_VOLUME_DB := -8.0
const FLOATING_TEXT_LIFETIME := 1.0
const FLOATING_TEXT_RISE := 42.0
const OBJECTIVE_NOTICE_DURATION := 2.6
const UNIT_MOVE_ANIMATION_DURATION := 0.18
const TARGET_PREVIEW_BADGE_SIZE := Vector2(92, 22)
const LOW_HP_WARNING_RATIO := 0.35
const UNIT_STATUS_MARKER_RADIUS := 4.0
const UNIT_STATUS_MARKER_GAP := 9.0
const UNIT_STATUS_MARKER_OFFSET_Y := 14.0
const MAX_UNIT_STATUS_MARKERS := 4
const UNIT_ATTACK_ANIMATION_DURATION := 0.30
const UNIT_ATTACK_LUNGE_DISTANCE := 15.0
const DIALOGUE_PANEL_POSITION := Vector2(96, 448)
const DIALOGUE_PANEL_SIZE := Vector2(1040, 210)
const DIALOGUE_PORTRAIT_SIZE := Vector2(164, 186)
const DIALOGUE_COLUMN_SIZE := Vector2(820, 186)
const DIALOGUE_TEXT_SIZE := Vector2(800, 104)
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)

var state: BattleState = BattleStateScript.new()
var campaign_state: CampaignState = CampaignStateScript.new()
var hover_cell := Vector2i(-1, -1)
var move_cells: Array[Vector2i] = []
var attack_cells: Array[Vector2i] = []
var skill_cells: Array[Vector2i] = []
var item_cells: Array[Vector2i] = []
var threat_cells: Array[Vector2i] = []
var selected_skill_id := ""
var selected_item_id := ""
var show_threat_overlay := false
var active_scenario_id := ""
var battle_started := false
var battle_result_applied := false
var battle_result_summary := {}
var post_battle_dialogue_started := false
var campaign_complete_screen := false

var status_label: Label
var objective_label: Label
var campaign_status_label: Label
var mission_title_label: Label
var mission_detail_label: Label
var objective_notice_panel: PanelContainer
var objective_notice_label: Label
var hud_unit_portrait_panel: PanelContainer
var hud_unit_portrait_texture: TextureRect
var hud_unit_portrait_label: Label
var selected_label: Label
var cell_info_label: Label
var forecast_label: Label
var inventory_label: Label
var briefing_panel: PanelContainer
var briefing_title_label: Label
var briefing_location_label: Label
var briefing_objective_label: Label
var briefing_label: Label
var chapter_button: Button
var chapter_menu: VBoxContainer
var chapter_list: VBoxContainer
var chapter_status_label: Label
var shop_button: Button
var talk_button: Button
var talk_menu: VBoxContainer
var talk_list: VBoxContainer
var talk_status_label: Label
var shop_menu: VBoxContainer
var shop_buy_button: Button
var shop_sell_button: Button
var shop_list: VBoxContainer
var shop_status_label: Label
var shop_sell_mode := false
var armory_button: Button
var armory_menu: VBoxContainer
var armory_list: VBoxContainer
var armory_status_label: Label
var armory_unit_id := ""
var roster_button: Button
var roster_menu: VBoxContainer
var roster_list: VBoxContainer
var roster_status_label: Label
var formation_button: Button
var formation_menu: VBoxContainer
var formation_list: VBoxContainer
var formation_status_label: Label
var formation_unit_id := ""
var save_button: Button
var save_menu: VBoxContainer
var save_status_label: Label
var manual_save_button: Button
var manual_load_button: Button
var dialogue_row: HBoxContainer
var dialogue_panel: PanelContainer
var dialogue_portrait_panel: PanelContainer
var dialogue_portrait_texture: TextureRect
var dialogue_portrait_label: Label
var dialogue_column: VBoxContainer
var dialogue_speaker_label: Label
var dialogue_text_label: Label
var dialogue_progress_label: Label
var dialogue_previous_button: Button
var dialogue_continue_button: Button
var result_panel: PanelContainer
var result_label: Label
var result_choice_list: VBoxContainer
var result_restart_button: Button
var next_battle_button: Button
var end_turn_button: Button
var wait_button: Button
var tactic_button: Button
var tactic_menu: VBoxContainer
var tactic_list: VBoxContainer
var item_button: Button
var item_menu: VBoxContainer
var item_list: VBoxContainer
var equip_button: Button
var equip_menu: VBoxContainer
var equip_list: VBoxContainer
var threat_button: Button
var restart_button: Button
var new_campaign_button: Button
var log_box: RichTextLabel
var dialogue_queue: Array = []
var active_dialogue_lines: Array = []
var active_dialogue_index := 0
var portrait_texture_cache: Dictionary = {}
var missing_portrait_paths := {}
var bgm_player: AudioStreamPlayer
var current_bgm_key := ""
var audio_stream_cache := {}
var last_announced_battle_status := ""
var floating_texts: Array[Dictionary] = []
var objective_notice_timer := 0.0
var unit_motion_by_unit: Dictionary = {}
var unit_action_motion_by_unit: Dictionary = {}
var battle_background_path := ""
var battle_background_texture: Texture2D


func _ready() -> void:
	_create_hud()
	_create_audio()
	state.changed.connect(_on_state_changed)
	state.log_added.connect(_on_log_added)
	state.dialogue_requested.connect(_on_dialogue_requested)
	state.combat_feedback_requested.connect(_on_combat_feedback_requested)
	state.unit_motion_requested.connect(_on_unit_motion_requested)
	state.unit_action_motion_requested.connect(_on_unit_action_motion_requested)
	state.objective_updated.connect(_on_objective_updated)
	campaign_state.load_campaign(CAMPAIGN_PATH)
	campaign_state.load_or_start(campaign_state.get_start_scenario_id())
	if campaign_state.has_pending_post_battle_choice():
		_load_pending_post_battle_choice()
	elif campaign_state.is_campaign_complete():
		_show_campaign_complete()
	else:
		_load_current_battle()
		_show_briefing()
	_update_hud()


func _unhandled_input(event: InputEvent) -> void:
	if _is_dialogue_visible():
		if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
			_advance_dialogue()
		elif event is InputEventKey and event.pressed and not event.echo:
			if event.keycode == KEY_LEFT or event.keycode == KEY_BACKSPACE:
				_retreat_dialogue()
			elif event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE or event.keycode == KEY_RIGHT:
				_advance_dialogue()
		return

	if event is InputEventMouseMotion:
		var next_hover := _cell_from_screen(event.position)
		if next_hover != hover_cell:
			hover_cell = next_hover
			_update_hud()
			queue_redraw()
	elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
		_handle_board_click(event.position)
	elif event is InputEventKey and event.pressed and not event.echo:
		_handle_key(event)


func _draw() -> void:
	_draw_map()
	_draw_overlays()
	_draw_units()
	_draw_attack_effects()
	_draw_target_preview_badge()
	_draw_floating_texts()


func _create_hud() -> void:
	var layer := CanvasLayer.new()
	add_child(layer)

	var root := Control.new()
	root.set_anchors_preset(Control.PRESET_FULL_RECT)
	root.mouse_filter = Control.MOUSE_FILTER_IGNORE
	layer.add_child(root)

	var top_bar := PanelContainer.new()
	top_bar.position = Vector2(24, 16)
	top_bar.size = Vector2(1120, 64)
	root.add_child(top_bar)

	var top_row := HBoxContainer.new()
	top_row.add_theme_constant_override("separation", 12)
	top_bar.add_child(top_row)

	status_label = Label.new()
	status_label.custom_minimum_size = Vector2(180, 40)
	top_row.add_child(status_label)

	objective_label = Label.new()
	objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	objective_label.custom_minimum_size = Vector2(360, 48)
	top_row.add_child(objective_label)

	campaign_status_label = Label.new()
	campaign_status_label.custom_minimum_size = Vector2(300, 40)
	top_row.add_child(campaign_status_label)

	restart_button = Button.new()
	restart_button.text = "Restart"
	restart_button.pressed.connect(_on_restart_pressed)
	top_row.add_child(restart_button)

	new_campaign_button = Button.new()
	new_campaign_button.text = "New Campaign"
	new_campaign_button.pressed.connect(_on_new_campaign_pressed)
	top_row.add_child(new_campaign_button)

	objective_notice_panel = PanelContainer.new()
	objective_notice_panel.visible = false
	objective_notice_panel.position = Vector2(360, 84)
	objective_notice_panel.size = Vector2(560, 64)
	objective_notice_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
	root.add_child(objective_notice_panel)

	objective_notice_label = Label.new()
	objective_notice_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	objective_notice_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	objective_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	objective_notice_label.custom_minimum_size = Vector2(520, 48)
	objective_notice_panel.add_child(objective_notice_label)

	var side_panel := PanelContainer.new()
	side_panel.position = Vector2(760, 104)
	side_panel.size = Vector2(480, 610)
	root.add_child(side_panel)

	var side_column := VBoxContainer.new()
	side_column.add_theme_constant_override("separation", 6)
	side_panel.add_child(side_column)

	var selected_row := HBoxContainer.new()
	selected_row.custom_minimum_size = Vector2(420, 126)
	selected_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	selected_row.add_theme_constant_override("separation", 8)
	side_column.add_child(selected_row)

	hud_unit_portrait_panel = PanelContainer.new()
	hud_unit_portrait_panel.custom_minimum_size = HUD_UNIT_PORTRAIT_SIZE
	selected_row.add_child(hud_unit_portrait_panel)

	var hud_unit_portrait_stack := Control.new()
	hud_unit_portrait_stack.custom_minimum_size = HUD_UNIT_PORTRAIT_STACK_SIZE
	hud_unit_portrait_panel.add_child(hud_unit_portrait_stack)

	hud_unit_portrait_texture = TextureRect.new()
	hud_unit_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
	hud_unit_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	hud_unit_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	hud_unit_portrait_stack.add_child(hud_unit_portrait_texture)

	hud_unit_portrait_label = Label.new()
	hud_unit_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
	hud_unit_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	hud_unit_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	hud_unit_portrait_label.add_theme_font_size_override("font_size", 24)
	hud_unit_portrait_stack.add_child(hud_unit_portrait_label)

	selected_label = Label.new()
	selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	selected_label.custom_minimum_size = Vector2(320, 126)
	selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	selected_row.add_child(selected_label)

	mission_title_label = Label.new()
	mission_title_label.text = "Objective"
	mission_title_label.add_theme_font_size_override("font_size", 14)
	side_column.add_child(mission_title_label)

	mission_detail_label = Label.new()
	mission_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	mission_detail_label.custom_minimum_size = Vector2(420, 126)
	side_column.add_child(mission_detail_label)

	cell_info_label = Label.new()
	cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	cell_info_label.custom_minimum_size = Vector2(420, 78)
	side_column.add_child(cell_info_label)

	forecast_label = Label.new()
	forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	forecast_label.custom_minimum_size = Vector2(420, 60)
	side_column.add_child(forecast_label)

	inventory_label = Label.new()
	inventory_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	inventory_label.custom_minimum_size = Vector2(420, 34)
	side_column.add_child(inventory_label)

	var button_row := HBoxContainer.new()
	button_row.add_theme_constant_override("separation", 8)
	side_column.add_child(button_row)

	wait_button = Button.new()
	wait_button.text = "Wait"
	wait_button.pressed.connect(_on_wait_pressed)
	button_row.add_child(wait_button)

	tactic_button = Button.new()
	tactic_button.text = "Tactic"
	tactic_button.pressed.connect(_on_tactic_pressed)
	button_row.add_child(tactic_button)

	item_button = Button.new()
	item_button.text = "Item"
	item_button.pressed.connect(_on_item_pressed)
	button_row.add_child(item_button)

	equip_button = Button.new()
	equip_button.text = "Equip"
	equip_button.pressed.connect(_on_equip_pressed)
	button_row.add_child(equip_button)

	threat_button = Button.new()
	threat_button.text = "Threat"
	threat_button.toggle_mode = true
	threat_button.pressed.connect(_on_threat_pressed)
	button_row.add_child(threat_button)

	end_turn_button = Button.new()
	end_turn_button.text = "End Turn"
	end_turn_button.pressed.connect(_on_end_turn_pressed)
	button_row.add_child(end_turn_button)

	tactic_menu = VBoxContainer.new()
	tactic_menu.visible = false
	tactic_menu.add_theme_constant_override("separation", 6)
	side_column.add_child(tactic_menu)

	var tactic_title := Label.new()
	tactic_title.text = "Tactics"
	tactic_menu.add_child(tactic_title)

	tactic_list = VBoxContainer.new()
	tactic_list.add_theme_constant_override("separation", 4)
	tactic_menu.add_child(tactic_list)

	item_menu = VBoxContainer.new()
	item_menu.visible = false
	item_menu.add_theme_constant_override("separation", 6)
	side_column.add_child(item_menu)

	var item_title := Label.new()
	item_title.text = "Items"
	item_menu.add_child(item_title)

	item_list = VBoxContainer.new()
	item_list.add_theme_constant_override("separation", 4)
	item_menu.add_child(item_list)

	equip_menu = VBoxContainer.new()
	equip_menu.visible = false
	equip_menu.add_theme_constant_override("separation", 6)
	side_column.add_child(equip_menu)

	var equip_title := Label.new()
	equip_title.text = "Equipment"
	equip_menu.add_child(equip_title)

	equip_list = VBoxContainer.new()
	equip_list.add_theme_constant_override("separation", 4)
	equip_menu.add_child(equip_list)

	log_box = RichTextLabel.new()
	log_box.custom_minimum_size = Vector2(420, 70)
	log_box.fit_content = false
	side_column.add_child(log_box)

	briefing_panel = PanelContainer.new()
	briefing_panel.visible = false
	briefing_panel.position = Vector2(300, 88)
	briefing_panel.size = Vector2(680, 600)
	root.add_child(briefing_panel)

	var briefing_column := VBoxContainer.new()
	briefing_column.add_theme_constant_override("separation", 8)
	briefing_panel.add_child(briefing_column)

	briefing_title_label = Label.new()
	briefing_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	briefing_title_label.custom_minimum_size = Vector2(640, 28)
	briefing_title_label.add_theme_font_size_override("font_size", 20)
	briefing_column.add_child(briefing_title_label)

	briefing_location_label = Label.new()
	briefing_location_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	briefing_location_label.custom_minimum_size = Vector2(640, 22)
	briefing_column.add_child(briefing_location_label)

	briefing_objective_label = Label.new()
	briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	briefing_objective_label.custom_minimum_size = Vector2(640, 48)
	briefing_column.add_child(briefing_objective_label)

	var briefing_scroll := ScrollContainer.new()
	briefing_scroll.custom_minimum_size = Vector2(640, 96)
	briefing_column.add_child(briefing_scroll)

	briefing_label = Label.new()
	briefing_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	briefing_label.custom_minimum_size = Vector2(620, 0)
	briefing_scroll.add_child(briefing_label)

	var prep_button_row := HBoxContainer.new()
	prep_button_row.add_theme_constant_override("separation", 8)
	briefing_column.add_child(prep_button_row)

	chapter_button = Button.new()
	chapter_button.text = "Chapters"
	chapter_button.pressed.connect(_on_chapter_pressed)
	prep_button_row.add_child(chapter_button)

	shop_button = Button.new()
	shop_button.text = "Shop"
	shop_button.pressed.connect(_on_shop_pressed)
	prep_button_row.add_child(shop_button)

	talk_button = Button.new()
	talk_button.text = "Talk"
	talk_button.pressed.connect(_on_talk_pressed)
	prep_button_row.add_child(talk_button)

	armory_button = Button.new()
	armory_button.text = "Armory"
	armory_button.pressed.connect(_on_armory_pressed)
	prep_button_row.add_child(armory_button)

	roster_button = Button.new()
	roster_button.text = "Roster"
	roster_button.pressed.connect(_on_roster_pressed)
	prep_button_row.add_child(roster_button)

	formation_button = Button.new()
	formation_button.text = "Formation"
	formation_button.pressed.connect(_on_formation_pressed)
	prep_button_row.add_child(formation_button)

	save_button = Button.new()
	save_button.text = "Save"
	save_button.pressed.connect(_on_save_pressed)
	prep_button_row.add_child(save_button)

	chapter_menu = VBoxContainer.new()
	chapter_menu.visible = false
	chapter_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(chapter_menu)

	chapter_status_label = Label.new()
	chapter_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	chapter_status_label.custom_minimum_size = Vector2(600, 22)
	chapter_menu.add_child(chapter_status_label)

	var chapter_scroll := ScrollContainer.new()
	chapter_scroll.custom_minimum_size = Vector2(600, 190)
	chapter_menu.add_child(chapter_scroll)

	chapter_list = VBoxContainer.new()
	chapter_list.custom_minimum_size = Vector2(580, 0)
	chapter_list.add_theme_constant_override("separation", 4)
	chapter_scroll.add_child(chapter_list)

	shop_menu = VBoxContainer.new()
	shop_menu.visible = false
	shop_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(shop_menu)

	shop_status_label = Label.new()
	shop_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	shop_status_label.custom_minimum_size = Vector2(600, 22)
	shop_menu.add_child(shop_status_label)

	var shop_mode_row := HBoxContainer.new()
	shop_mode_row.add_theme_constant_override("separation", 8)
	shop_menu.add_child(shop_mode_row)

	shop_buy_button = Button.new()
	shop_buy_button.text = "Buy"
	shop_buy_button.pressed.connect(_on_shop_buy_mode_pressed)
	shop_mode_row.add_child(shop_buy_button)

	shop_sell_button = Button.new()
	shop_sell_button.text = "Sell"
	shop_sell_button.pressed.connect(_on_shop_sell_mode_pressed)
	shop_mode_row.add_child(shop_sell_button)

	var shop_scroll := ScrollContainer.new()
	shop_scroll.custom_minimum_size = Vector2(600, 190)
	shop_menu.add_child(shop_scroll)

	shop_list = VBoxContainer.new()
	shop_list.custom_minimum_size = Vector2(580, 0)
	shop_list.add_theme_constant_override("separation", 4)
	shop_scroll.add_child(shop_list)

	talk_menu = VBoxContainer.new()
	talk_menu.visible = false
	talk_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(talk_menu)

	talk_status_label = Label.new()
	talk_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	talk_status_label.custom_minimum_size = Vector2(600, 22)
	talk_menu.add_child(talk_status_label)

	var talk_scroll := ScrollContainer.new()
	talk_scroll.custom_minimum_size = Vector2(600, 150)
	talk_menu.add_child(talk_scroll)

	talk_list = VBoxContainer.new()
	talk_list.custom_minimum_size = Vector2(580, 0)
	talk_list.add_theme_constant_override("separation", 4)
	talk_scroll.add_child(talk_list)

	armory_menu = VBoxContainer.new()
	armory_menu.visible = false
	armory_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(armory_menu)

	armory_status_label = Label.new()
	armory_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	armory_status_label.custom_minimum_size = Vector2(600, 22)
	armory_menu.add_child(armory_status_label)

	var armory_scroll := ScrollContainer.new()
	armory_scroll.custom_minimum_size = Vector2(600, 190)
	armory_menu.add_child(armory_scroll)

	armory_list = VBoxContainer.new()
	armory_list.custom_minimum_size = Vector2(580, 0)
	armory_list.add_theme_constant_override("separation", 4)
	armory_scroll.add_child(armory_list)

	roster_menu = VBoxContainer.new()
	roster_menu.visible = false
	roster_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(roster_menu)

	roster_status_label = Label.new()
	roster_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	roster_status_label.custom_minimum_size = Vector2(600, 22)
	roster_menu.add_child(roster_status_label)

	var roster_scroll := ScrollContainer.new()
	roster_scroll.custom_minimum_size = Vector2(600, 190)
	roster_menu.add_child(roster_scroll)

	roster_list = VBoxContainer.new()
	roster_list.custom_minimum_size = Vector2(580, 0)
	roster_list.add_theme_constant_override("separation", 4)
	roster_scroll.add_child(roster_list)

	formation_menu = VBoxContainer.new()
	formation_menu.visible = false
	formation_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(formation_menu)

	formation_status_label = Label.new()
	formation_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	formation_status_label.custom_minimum_size = Vector2(600, 22)
	formation_menu.add_child(formation_status_label)

	var formation_scroll := ScrollContainer.new()
	formation_scroll.custom_minimum_size = Vector2(600, 190)
	formation_menu.add_child(formation_scroll)

	formation_list = VBoxContainer.new()
	formation_list.custom_minimum_size = Vector2(580, 0)
	formation_list.add_theme_constant_override("separation", 4)
	formation_scroll.add_child(formation_list)

	save_menu = VBoxContainer.new()
	save_menu.visible = false
	save_menu.add_theme_constant_override("separation", 6)
	briefing_column.add_child(save_menu)

	save_status_label = Label.new()
	save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	save_status_label.custom_minimum_size = Vector2(600, 64)
	save_menu.add_child(save_status_label)

	var save_action_row := HBoxContainer.new()
	save_action_row.add_theme_constant_override("separation", 8)
	save_menu.add_child(save_action_row)

	manual_save_button = Button.new()
	manual_save_button.text = "Save Checkpoint"
	manual_save_button.pressed.connect(_on_manual_save_pressed)
	save_action_row.add_child(manual_save_button)

	manual_load_button = Button.new()
	manual_load_button.text = "Load Checkpoint"
	manual_load_button.pressed.connect(_on_manual_load_pressed)
	save_action_row.add_child(manual_load_button)

	var begin_button := Button.new()
	begin_button.text = "Begin Battle"
	begin_button.pressed.connect(_on_begin_battle_pressed)
	briefing_column.add_child(begin_button)

	dialogue_panel = PanelContainer.new()
	dialogue_panel.visible = false
	dialogue_panel.position = DIALOGUE_PANEL_POSITION
	dialogue_panel.size = DIALOGUE_PANEL_SIZE
	dialogue_panel.mouse_filter = Control.MOUSE_FILTER_STOP
	root.add_child(dialogue_panel)

	dialogue_row = HBoxContainer.new()
	dialogue_row.add_theme_constant_override("separation", 18)
	dialogue_panel.add_child(dialogue_row)

	dialogue_portrait_panel = PanelContainer.new()
	dialogue_portrait_panel.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
	dialogue_row.add_child(dialogue_portrait_panel)

	var dialogue_portrait_stack := Control.new()
	dialogue_portrait_stack.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
	dialogue_portrait_panel.add_child(dialogue_portrait_stack)

	dialogue_portrait_texture = TextureRect.new()
	dialogue_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
	dialogue_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	dialogue_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	dialogue_portrait_stack.add_child(dialogue_portrait_texture)

	dialogue_portrait_label = Label.new()
	dialogue_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
	dialogue_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	dialogue_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	dialogue_portrait_label.add_theme_font_size_override("font_size", 30)
	dialogue_portrait_stack.add_child(dialogue_portrait_label)

	dialogue_column = VBoxContainer.new()
	dialogue_column.custom_minimum_size = DIALOGUE_COLUMN_SIZE
	dialogue_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	dialogue_column.add_theme_constant_override("separation", 8)
	dialogue_row.add_child(dialogue_column)

	dialogue_speaker_label = Label.new()
	dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, 26)
	dialogue_speaker_label.add_theme_font_size_override("font_size", 18)
	dialogue_column.add_child(dialogue_speaker_label)

	dialogue_text_label = Label.new()
	dialogue_text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	dialogue_text_label.custom_minimum_size = DIALOGUE_TEXT_SIZE
	dialogue_text_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
	dialogue_text_label.add_theme_font_size_override("font_size", 17)
	dialogue_column.add_child(dialogue_text_label)

	var dialogue_control_row := HBoxContainer.new()
	dialogue_control_row.add_theme_constant_override("separation", 8)
	dialogue_column.add_child(dialogue_control_row)

	dialogue_progress_label = Label.new()
	dialogue_progress_label.custom_minimum_size = Vector2(520, 32)
	dialogue_progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	dialogue_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
	dialogue_control_row.add_child(dialogue_progress_label)

	dialogue_previous_button = Button.new()
	dialogue_previous_button.text = "Prev"
	dialogue_previous_button.disabled = true
	dialogue_previous_button.custom_minimum_size = Vector2(88, 32)
	dialogue_previous_button.pressed.connect(_retreat_dialogue)
	dialogue_control_row.add_child(dialogue_previous_button)

	dialogue_continue_button = Button.new()
	dialogue_continue_button.text = "Next"
	dialogue_continue_button.custom_minimum_size = Vector2(120, 32)
	dialogue_continue_button.pressed.connect(_advance_dialogue)
	dialogue_control_row.add_child(dialogue_continue_button)

	result_panel = PanelContainer.new()
	result_panel.visible = false
	result_panel.position = Vector2(280, 156)
	result_panel.size = Vector2(560, 420)
	root.add_child(result_panel)

	var result_column := VBoxContainer.new()
	result_column.alignment = BoxContainer.ALIGNMENT_CENTER
	result_panel.add_child(result_column)

	result_label = Label.new()
	result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
	result_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
	result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	result_label.custom_minimum_size = Vector2(520, 236)
	result_column.add_child(result_label)

	result_choice_list = VBoxContainer.new()
	result_choice_list.add_theme_constant_override("separation", 6)
	result_column.add_child(result_choice_list)

	result_restart_button = Button.new()
	result_restart_button.text = "Restart Battle"
	result_restart_button.pressed.connect(_on_restart_pressed)
	result_column.add_child(result_restart_button)

	next_battle_button = Button.new()
	next_battle_button.text = "Next Battle"
	next_battle_button.pressed.connect(_on_next_battle_pressed)
	result_column.add_child(next_battle_button)


func _create_audio() -> void:
	bgm_player = AudioStreamPlayer.new()
	bgm_player.name = "BgmPlayer"
	bgm_player.volume_db = BGM_VOLUME_DB
	bgm_player.finished.connect(_on_bgm_finished)
	add_child(bgm_player)


func _on_bgm_finished() -> void:
	if current_bgm_key.is_empty() or bgm_player == null or bgm_player.stream == null:
		return
	bgm_player.play()


func _play_bgm(key: String) -> void:
	if bgm_player == null:
		return
	if current_bgm_key == key and bgm_player.playing:
		return
	var stream := _bgm_stream_for(key)
	if stream == null:
		return
	current_bgm_key = key
	bgm_player.stop()
	bgm_player.stream = stream
	bgm_player.play()


func _bgm_stream_for(key: String) -> AudioStream:
	if key == "battle":
		return _audio_stream_from_path(BGM_BATTLE_PATH)
	if key == "menu":
		return _audio_stream_from_path(BGM_MENU_PATH)
	return null


func _play_sfx(key: String, volume_db := SFX_VOLUME_DB) -> void:
	var stream := _sfx_stream_for(key)
	if stream == null:
		return
	var player := AudioStreamPlayer.new()
	player.name = "Sfx_%s" % key
	player.stream = stream
	player.volume_db = volume_db
	player.finished.connect(player.queue_free)
	add_child(player)
	player.play()


func _sfx_stream_for(key: String) -> AudioStream:
	if key == "bow_release":
		return _audio_stream_from_path(SFX_BOW_RELEASE_PATH)
	if key == "defeat_sting":
		return _audio_stream_from_path(SFX_DEFEAT_STING_PATH)
	if key == "fire_skill":
		return _audio_stream_from_path(SFX_FIRE_SKILL_PATH)
	if key == "footstep":
		return _audio_stream_from_path(SFX_FOOTSTEP_PATH)
	if key == "guard_block":
		return _audio_stream_from_path(SFX_GUARD_BLOCK_PATH)
	if key == "hit_heavy":
		return _audio_stream_from_path(SFX_HIT_HEAVY_PATH)
	if key == "item_pickup":
		return _audio_stream_from_path(SFX_ITEM_PICKUP_PATH)
	if key == "menu_cancel":
		return _audio_stream_from_path(SFX_MENU_CANCEL_PATH)
	if key == "menu_confirm":
		return _audio_stream_from_path(SFX_MENU_CONFIRM_PATH)
	if key == "skill_cast":
		return _audio_stream_from_path(SFX_SKILL_CAST_PATH)
	if key == "slash":
		return _audio_stream_from_path(SFX_SLASH_PATH)
	if key == "ui_click":
		return _audio_stream_from_path(SFX_UI_CLICK_PATH)
	if key == "victory_sting":
		return _audio_stream_from_path(SFX_VICTORY_STING_PATH)
	return null


func _audio_stream_from_path(path: String) -> AudioStream:
	if path.is_empty():
		return null
	if not ResourceLoader.exists(path, "AudioStream"):
		return null
	if not audio_stream_cache.has(path):
		audio_stream_cache[path] = load(path)
	return audio_stream_cache[path] as AudioStream


func _play_ui_click() -> void:
	_play_sfx("ui_click", UI_SFX_VOLUME_DB)


func _play_ui_confirm() -> void:
	_play_sfx("menu_confirm", UI_SFX_VOLUME_DB)


func _play_ui_cancel() -> void:
	_play_sfx("menu_cancel", UI_SFX_VOLUME_DB)


func _play_log_sfx(message: String) -> void:
	if message.contains(" but misses."):
		_play_sfx("guard_block")
	elif message.begins_with("Objective updated:") or message.begins_with("Defeat condition updated:"):
		_play_ui_confirm()
	elif message.ends_with(" withdraws from the battlefield."):
		_play_ui_confirm()
	elif message.contains(" attacks ") or message.contains(" counterattacks "):
		if message.contains(" for "):
			_play_sfx("hit_heavy")
	elif message.contains(" casts "):
		if message.contains("restoring") or message.contains(" until "):
			_play_sfx("skill_cast")
		else:
			_play_sfx("fire_skill")
	elif message.contains(" moved to ") or message.contains(" advances to ") or message.contains(" takes formation at ") or message.contains(" arrives at "):
		_play_sfx("footstep")
	elif message.contains(" uses ") or message.contains(" equips ") or message.begins_with("Bought ") or message.begins_with("Sold ") or message.begins_with("Received "):
		_play_sfx("item_pickup")
	elif message.contains(" reaches Lv.") or message.contains(" promotes to "):
		_play_sfx("menu_confirm")


func _announce_battle_status_audio() -> void:
	if state.battle_status == BattleState.STATUS_ACTIVE:
		last_announced_battle_status = ""
		return
	if last_announced_battle_status == state.battle_status:
		return
	last_announced_battle_status = state.battle_status
	_play_bgm("menu")
	if state.battle_status == BattleState.STATUS_VICTORY:
		_play_sfx("victory_sting")
	elif state.battle_status == BattleState.STATUS_DEFEAT:
		_play_sfx("defeat_sting")


func _handle_board_click(screen_position: Vector2) -> void:
	if formation_menu != null and formation_menu.visible:
		_handle_formation_board_click(screen_position)
		return
	if _is_input_locked():
		return

	var cell := _cell_from_screen(screen_position)
	if tactic_menu != null and tactic_menu.visible:
		_hide_tactic_menu()
		_update_hud()
		queue_redraw()
	if item_menu != null and item_menu.visible:
		_hide_item_menu()
		_update_hud()
		queue_redraw()
	if equip_menu != null and equip_menu.visible:
		_hide_equip_menu()
		_update_hud()
		queue_redraw()
	if not state.is_inside(cell) or not state.can_player_act():
		return

	var clicked_unit := state.get_unit_at(cell)
	var selected_unit := state.get_selected_unit()

	if selected_unit.is_empty():
		if _can_select(clicked_unit):
			selected_skill_id = ""
			selected_item_id = ""
			state.select_unit(clicked_unit["id"])
		return

	if not selected_item_id.is_empty():
		if state.try_use_selected_item_on_cell(selected_item_id, cell):
			selected_item_id = ""
			return
		if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit):
			selected_item_id = ""
			state.select_unit(clicked_unit["id"])
		return

	if not selected_skill_id.is_empty():
		if state.try_cast_selected_skill(selected_skill_id, cell):
			selected_skill_id = ""
			return
		if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit):
			selected_skill_id = ""
			selected_item_id = ""
			state.select_unit(clicked_unit["id"])
		return

	if not clicked_unit.is_empty() and clicked_unit.get("team", "") != selected_unit.get("team", ""):
		var attack_sfx := "bow_release" if int(selected_unit.get("range", 1)) > 1 else "slash"
		if attack_cells.has(clicked_unit.get("pos", Vector2i(-1, -1))):
			_play_sfx(attack_sfx)
		if state.try_attack_selected(clicked_unit["id"]):
			selected_skill_id = ""
			selected_item_id = ""
			return

	if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""):
		if _can_select(clicked_unit):
			selected_skill_id = ""
			selected_item_id = ""
			state.select_unit(clicked_unit["id"])
		return

	if state.try_move_selected(cell):
		return

	if _can_select(clicked_unit):
		selected_skill_id = ""
		selected_item_id = ""
		state.select_unit(clicked_unit["id"])


func _handle_key(event: InputEventKey) -> void:
	if event.keycode == KEY_ENTER and briefing_panel != null and briefing_panel.visible:
		if _is_prep_menu_visible():
			return
		_on_begin_battle_pressed()
	elif event.keycode == KEY_ESCAPE:
		if chapter_menu != null and chapter_menu.visible:
			_hide_chapter_menu()
		elif shop_menu != null and shop_menu.visible:
			_hide_shop_menu()
		elif armory_menu != null and armory_menu.visible:
			_hide_armory_menu()
		elif roster_menu != null and roster_menu.visible:
			_hide_roster_menu()
		elif formation_menu != null and formation_menu.visible:
			_hide_formation_menu()
		elif save_menu != null and save_menu.visible:
			_hide_save_menu()
		elif tactic_menu != null and tactic_menu.visible:
			_hide_tactic_menu()
		elif item_menu != null and item_menu.visible:
			_hide_item_menu()
		elif equip_menu != null and equip_menu.visible:
			_hide_equip_menu()
		elif selected_skill_id.is_empty() and selected_item_id.is_empty():
			state.clear_selection()
		else:
			selected_skill_id = ""
			selected_item_id = ""
			_refresh_ranges()
			_update_hud()
			queue_redraw()
	elif event.keycode == KEY_SPACE and not _is_input_locked():
		selected_skill_id = ""
		selected_item_id = ""
		_hide_tactic_menu()
		_hide_item_menu()
		_hide_equip_menu()
		state.end_player_turn()
	elif event.keycode == KEY_R and not _is_prep_menu_visible():
		_on_restart_pressed()
	elif event.keycode == KEY_T and not _is_input_locked():
		_on_tactic_pressed()
	elif event.keycode == KEY_I and not _is_input_locked():
		_on_item_pressed()
	elif event.keycode == KEY_E and not _is_input_locked():
		_on_equip_pressed()
	elif event.keycode == KEY_Y and not _is_prep_menu_visible():
		_on_threat_pressed()


func _process(delta: float) -> void:
	var needs_redraw := false
	if objective_notice_timer > 0.0:
		objective_notice_timer = maxf(0.0, objective_notice_timer - delta)
		if objective_notice_timer <= 0.0 and objective_notice_panel != null:
			objective_notice_panel.visible = false

	if not floating_texts.is_empty():
		var active_texts: Array[Dictionary] = []
		for popup in floating_texts:
			var next_popup := popup.duplicate(true)
			next_popup["age"] = float(next_popup.get("age", 0.0)) + delta
			if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)):
				active_texts.append(next_popup)
		floating_texts = active_texts
		needs_redraw = true

	if not unit_motion_by_unit.is_empty():
		var active_motion := {}
		for unit_id in unit_motion_by_unit.keys():
			var motion: Dictionary = unit_motion_by_unit[unit_id]
			var next_motion := motion.duplicate(true)
			next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
			if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)):
				active_motion[unit_id] = next_motion
		unit_motion_by_unit = active_motion
		needs_redraw = true

	if not unit_action_motion_by_unit.is_empty():
		var active_actions := {}
		for unit_id in unit_action_motion_by_unit.keys():
			var motion: Dictionary = unit_action_motion_by_unit[unit_id]
			var next_motion := motion.duplicate(true)
			next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
			if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)):
				active_actions[unit_id] = next_motion
		unit_action_motion_by_unit = active_actions
		needs_redraw = true

	if needs_redraw:
		queue_redraw()


func _can_select(unit: Dictionary) -> bool:
	if unit.is_empty():
		return false
	return (
		unit.get("team", "") == BattleState.TEAM_PLAYER
		and bool(unit.get("controllable", true))
		and not unit.get("acted", false)
	)


func _draw_map() -> void:
	var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE)
	var background := _current_battle_background_texture()
	if background != null:
		_draw_texture_cover(background, board_rect, Color(1.0, 1.0, 1.0, 0.96))
		draw_rect(board_rect, Color(0.02, 0.025, 0.03, 0.12))
	else:
		draw_rect(board_rect, Color(0.18, 0.26, 0.18))

	for y in range(state.map_size.y):
		for x in range(state.map_size.x):
			var cell := Vector2i(x, y)
			var rect := _rect_for_cell(cell)
			var terrain_key := state.get_terrain_key(cell)
			var terrain_color := state.get_terrain_color(cell)
			terrain_color.a = 0.26
			draw_rect(rect, terrain_color)
			_draw_terrain_detail(cell, rect, terrain_key)
			draw_rect(rect, GRID_COLOR, false, 1.0)

	draw_rect(board_rect, Color(0.02, 0.025, 0.03), false, 3.0)


func _current_battle_background_texture() -> Texture2D:
	var next_path := state.get_map_background_path()
	if next_path.is_empty():
		next_path = DEFAULT_BATTLE_BACKGROUND_PATH
	if next_path == battle_background_path:
		return battle_background_texture
	battle_background_path = next_path
	battle_background_texture = _load_art_texture(next_path)
	return battle_background_texture


func _draw_texture_cover(texture: Texture2D, rect: Rect2, modulate: Color) -> void:
	var texture_size := texture.get_size()
	if texture_size.x <= 0.0 or texture_size.y <= 0.0 or rect.size.x <= 0.0 or rect.size.y <= 0.0:
		return
	var target_ratio := rect.size.x / rect.size.y
	var source_ratio := texture_size.x / texture_size.y
	var source_rect := Rect2(Vector2.ZERO, texture_size)
	if source_ratio > target_ratio:
		var source_width := texture_size.y * target_ratio
		source_rect.position.x = (texture_size.x - source_width) * 0.5
		source_rect.size.x = source_width
	else:
		var source_height := texture_size.x / target_ratio
		source_rect.position.y = (texture_size.y - source_height) * 0.5
		source_rect.size.y = source_height
	draw_texture_rect_region(texture, rect, source_rect, modulate)


func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> void:
	if terrain_key == "F":
		for index in range(4):
			var point := _terrain_detail_point(cell, rect, index)
			draw_circle(point, 4.5 + _cell_noise(cell, index + 11) * 2.0, Color(0.05, 0.20, 0.09, 0.42))
			draw_line(point + Vector2(0, 4), point + Vector2(0, 9), Color(0.08, 0.11, 0.06, 0.34), 1.3)
	elif terrain_key == "H":
		for index in range(3):
			var point := _terrain_detail_point(cell, rect, index + 8)
			var peak := point + Vector2(0, -9)
			draw_line(point + Vector2(-10, 8), peak, Color(0.88, 0.84, 0.72, 0.38), 2.0)
			draw_line(peak, point + Vector2(10, 8), Color(0.22, 0.20, 0.16, 0.34), 2.0)
	elif terrain_key == "R":
		var road_color := Color(0.82, 0.69, 0.48, 0.34)
		draw_rect(Rect2(rect.position + Vector2(rect.size.x * 0.36, 0), Vector2(rect.size.x * 0.28, rect.size.y)), road_color)
		draw_line(rect.position + Vector2(rect.size.x * 0.48, 6), rect.position + Vector2(rect.size.x * 0.52, rect.size.y - 6), Color(0.36, 0.28, 0.18, 0.24), 1.5)
	elif terrain_key == "W":
		for index in range(3):
			var y := rect.position.y + 15.0 + float(index) * 14.0
			draw_line(rect.position + Vector2(10, y - rect.position.y), rect.position + Vector2(rect.size.x - 10, y - rect.position.y + 4), Color(0.70, 0.88, 1.0, 0.32), 1.6)
	elif terrain_key == "C":
		draw_rect(rect.grow(-7.0), Color(0.16, 0.15, 0.14, 0.28), false, 2.0)
		draw_line(rect.position + Vector2(10, 21), rect.position + Vector2(rect.size.x - 10, 21), Color(0.84, 0.80, 0.72, 0.30), 1.4)
		draw_line(rect.position + Vector2(10, 39), rect.position + Vector2(rect.size.x - 10, 39), Color(0.84, 0.80, 0.72, 0.22), 1.2)
		draw_line(rect.position + Vector2(23, 11), rect.position + Vector2(23, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2)
		draw_line(rect.position + Vector2(43, 11), rect.position + Vector2(43, rect.size.y - 11), Color(0.05, 0.05, 0.05, 0.20), 1.2)
	else:
		for index in range(2):
			var point := _terrain_detail_point(cell, rect, index + 15)
			draw_line(point, point + Vector2(3, -7), Color(0.78, 0.88, 0.50, 0.26), 1.2)


func _terrain_detail_point(cell: Vector2i, rect: Rect2, salt: int) -> Vector2:
	return rect.position + Vector2(
		10.0 + _cell_noise(cell, salt) * (rect.size.x - 20.0),
		10.0 + _cell_noise(cell, salt + 37) * (rect.size.y - 20.0)
	)


func _cell_noise(cell: Vector2i, salt: int) -> float:
	return fposmod(sin(float(cell.x * 91 + cell.y * 193 + salt * 53)) * 43758.5453, 1.0)


func _draw_overlays() -> void:
	for cell in state.get_objective_cells():
		var objective_rect := _rect_for_cell(cell)
		draw_rect(objective_rect, OBJECTIVE_OVERLAY_COLOR)
		draw_rect(objective_rect.grow(-5.0), Color(1.0, 0.88, 0.30, 0.82), false, 2.0)

	for cell in threat_cells:
		var threat_rect := _rect_for_cell(cell)
		draw_rect(threat_rect, THREAT_OVERLAY_COLOR)
		draw_rect(threat_rect.grow(-6.0), THREAT_BORDER_COLOR, false, 1.5)

	for cell in move_cells:
		draw_rect(_rect_for_cell(cell), Color(0.16, 0.55, 0.95, 0.28))

	for cell in attack_cells:
		draw_rect(_rect_for_cell(cell), Color(0.95, 0.22, 0.18, 0.24))

	for cell in skill_cells:
		draw_rect(_rect_for_cell(cell), SKILL_OVERLAY_COLOR)

	if not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
		var area_selected := state.get_selected_unit()
		if not area_selected.is_empty():
			for cell in state.get_skill_area_cells(area_selected["id"], selected_skill_id, hover_cell):
				var area_rect := _rect_for_cell(cell)
				draw_rect(area_rect, SKILL_AREA_OVERLAY_COLOR)
				draw_rect(area_rect.grow(-7.0), Color(0.84, 0.62, 1.0, 0.62), false, 1.5)

	for cell in item_cells:
		draw_rect(_rect_for_cell(cell), ITEM_OVERLAY_COLOR)

	if formation_menu != null and formation_menu.visible:
		for cell in state.get_formation_cells():
			draw_rect(_rect_for_cell(cell), FORMATION_OVERLAY_COLOR)

	if state.is_inside(hover_cell):
		var hover_color := Color(1.0, 1.0, 1.0, 0.22)
		var hover_unit := state.get_unit_at(hover_cell)
		var selected := state.get_selected_unit()
		if formation_menu != null and formation_menu.visible and state.is_formation_cell(hover_cell):
			hover_color = Color(1.0, 0.86, 0.35, 0.62)
		elif not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
			hover_color = Color(0.72, 0.46, 1.0, 0.54)
		elif not selected_item_id.is_empty() and item_cells.has(hover_cell):
			hover_color = Color(0.36, 0.95, 0.42, 0.52)
		elif not hover_unit.is_empty() and not selected.is_empty() and hover_unit.get("team", "") != selected.get("team", ""):
			hover_color = Color(1.0, 0.34, 0.24, 0.45)
		draw_rect(_rect_for_cell(hover_cell), hover_color, false, 3.0)


func _draw_units() -> void:
	var font := ThemeDB.fallback_font
	for unit in state.units:
		if not unit.get("alive", false) or not unit.get("deployed", true):
			continue

		var rect := _visual_rect_for_unit(unit)
		var center := rect.position + rect.size * 0.5
		var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR
		var token_back := Color(0.02, 0.022, 0.026, 0.78)
		var acted := bool(unit.get("acted", false))
		var sprite_modulate := Color(0.60, 0.62, 0.66, 0.82) if acted else Color.WHITE

		draw_circle(center + Vector2(0, 3), 29, Color(0.0, 0.0, 0.0, 0.38))
		draw_circle(center, 28, token_back)
		var sprite_texture := _load_art_texture(str(unit.get("sprite", "")))
		if sprite_texture != null:
			var sprite_target := Rect2(rect.position + Vector2(5, 1), Vector2(TILE_SIZE - 10, TILE_SIZE - 16))
			var sprite_rect := _fit_texture_rect(sprite_texture, sprite_target)
			draw_texture_rect(sprite_texture, sprite_rect, false, sprite_modulate)
		else:
			var body_color := team_color.darkened(0.35) if acted else team_color
			draw_circle(center, 22, body_color)
		draw_arc(center, 28, 0.0, TAU, 64, Color(0.03, 0.035, 0.04), 3.0)
		draw_arc(center, 28, -PI * 0.65, PI * 0.35, 36, team_color.lightened(0.18), 3.0)
		_draw_unit_class_badge(rect, unit, team_color)

		var label := _short_name(String(unit["name"]))
		draw_string(font, rect.position + Vector2(2, TILE_SIZE - 18), label, HORIZONTAL_ALIGNMENT_CENTER, TILE_SIZE - 4, 11, Color.WHITE)

		var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"])))
		var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5))
		var hp_front := Rect2(hp_back.position, Vector2(hp_back.size.x * hp_ratio, hp_back.size.y))
		draw_rect(hp_back, Color(0.08, 0.08, 0.09))
		draw_rect(hp_front, _unit_hp_color(hp_ratio))
		_draw_unit_status_markers(center, hp_ratio, unit)

		if unit.get("id", "") == state.selected_unit_id:
			draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0)


func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2:
	var texture_size := texture.get_size()
	if texture_size.x <= 0.0 or texture_size.y <= 0.0:
		return target
	var scale := minf(target.size.x / texture_size.x, target.size.y / texture_size.y)
	var fit_size := texture_size * scale
	return Rect2(target.position + (target.size - fit_size) * 0.5, fit_size)


func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> void:
	var badge_rect := Rect2(rect.position + Vector2(5, 5), Vector2(28, 13))
	draw_rect(badge_rect, Color(0.02, 0.022, 0.026, 0.82))
	draw_rect(badge_rect, team_color.lightened(0.18), false, 1.2)
	var font := ThemeDB.fallback_font
	draw_string(font, badge_rect.position + Vector2(1, 11), _unit_class_abbrev(unit), HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x - 2, 8, Color.WHITE)


func _unit_class_abbrev(unit: Dictionary) -> String:
	var class_id := str(unit.get("class_id", ""))
	if class_id.contains("cavalry"):
		return "CAV"
	if class_id.contains("archer") or class_id.contains("marksman"):
		return "ARC"
	if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"):
		return "TAC"
	if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
		return "WAR"
	return "INF"


func _unit_hp_color(hp_ratio: float) -> Color:
	if hp_ratio <= LOW_HP_WARNING_RATIO:
		return Color(1.0, 0.24, 0.18)
	if hp_ratio <= 0.55:
		return Color(1.0, 0.72, 0.24)
	return Color(0.28, 0.82, 0.36)


func _draw_unit_status_markers(center: Vector2, hp_ratio: float, unit: Dictionary) -> void:
	if hp_ratio <= LOW_HP_WARNING_RATIO:
		draw_arc(center, 27, 0.0, TAU, 48, Color(1.0, 0.28, 0.14), 3.0)

	var marker_kinds := _unit_status_marker_kinds(unit)
	if marker_kinds.is_empty():
		return

	var start_x := -float(marker_kinds.size() - 1) * UNIT_STATUS_MARKER_GAP * 0.5
	for index in range(marker_kinds.size()):
		var marker_center := center + Vector2(start_x + float(index) * UNIT_STATUS_MARKER_GAP, UNIT_STATUS_MARKER_OFFSET_Y)
		draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS + 1.5, Color(0.02, 0.022, 0.028, 0.88))
		draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS, _unit_status_marker_color(marker_kinds[index]))


func _unit_status_marker_kinds(unit: Dictionary) -> Array[String]:
	var result: Array[String] = []
	var status_effects = unit.get("status_effects", [])
	if typeof(status_effects) != TYPE_ARRAY:
		return result

	for effect in status_effects:
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if int(effect.get("remaining_phases", 0)) <= 0:
			continue

		var marker_kind := _unit_status_marker_kind(effect)
		if marker_kind.is_empty() or result.has(marker_kind):
			continue

		result.append(marker_kind)
		if result.size() >= MAX_UNIT_STATUS_MARKERS:
			break

	return result


func _unit_status_marker_kind(effect: Dictionary) -> String:
	var effect_type := str(effect.get("type", "stat_bonus"))
	if effect_type == "action_lock":
		return str(effect.get("status", "status"))
	if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
		return str(effect.get("status", "poison"))
	if effect_type == "stat_bonus":
		var amount := int(effect.get("amount", 0))
		if amount > 0:
			return "support"
		if amount < 0:
			return "debuff"
	return ""


func _unit_status_marker_color(marker_kind: String) -> Color:
	if marker_kind == "support":
		return Color(1.0, 0.88, 0.30)
	if marker_kind == "debuff":
		return Color(0.78, 0.55, 1.0)
	if marker_kind == "poison":
		return Color(0.54, 0.95, 0.36)
	if marker_kind == "seal":
		return Color(0.44, 0.86, 1.0)
	if marker_kind == "snare":
		return Color(1.0, 0.64, 0.22)
	if marker_kind == "disarm":
		return Color(1.0, 0.48, 0.42)
	return Color(0.72, 0.86, 1.0)


func _visual_rect_for_unit(unit: Dictionary) -> Rect2:
	var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
	var rect := _rect_for_cell(cell)
	var unit_id := str(unit.get("id", ""))
	if unit_id.is_empty():
		return rect

	if unit_motion_by_unit.has(unit_id):
		var move_motion: Dictionary = unit_motion_by_unit[unit_id]
		var move_from_cell: Vector2i = move_motion.get("from", cell)
		var move_to_cell: Vector2i = move_motion.get("to", cell)
		var move_duration: float = maxf(0.01, float(move_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
		var move_age := float(move_motion.get("age", 0.0))
		var move_progress := clampf(move_age / move_duration, 0.0, 1.0)
		var smooth_progress := move_progress * move_progress * (3.0 - 2.0 * move_progress)
		var from_rect := _rect_for_cell(move_from_cell)
		var to_rect := _rect_for_cell(move_to_cell)
		rect.position = from_rect.position.lerp(to_rect.position, smooth_progress)

	if unit_action_motion_by_unit.has(unit_id):
		var action_motion: Dictionary = unit_action_motion_by_unit[unit_id]
		var action_from_cell: Vector2i = action_motion.get("from", cell)
		var action_to_cell: Vector2i = action_motion.get("to", cell)
		var direction := _cell_center(action_to_cell) - _cell_center(action_from_cell)
		if direction.length() > 0.01:
			direction = direction.normalized()
			var action_duration: float = maxf(0.01, float(action_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
			var action_age := float(action_motion.get("age", 0.0))
			var action_progress := clampf(action_age / action_duration, 0.0, 1.0)
			var lunge_scale := 0.35 if str(action_motion.get("style", "melee")) == "ranged" else 1.0
			rect.position += direction * sin(action_progress * PI) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale
	return rect


func _cell_center(cell: Vector2i) -> Vector2:
	var rect := _rect_for_cell(cell)
	return rect.position + rect.size * 0.5


func _draw_attack_effects() -> void:
	if unit_action_motion_by_unit.is_empty():
		return
	for unit_id in unit_action_motion_by_unit.keys():
		var motion: Dictionary = unit_action_motion_by_unit[unit_id]
		var duration: float = maxf(0.01, float(motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
		var age := float(motion.get("age", 0.0))
		var progress := clampf(age / duration, 0.0, 1.0)
		var peak := sin(progress * PI)
		if peak <= 0.0:
			continue
		var from_cell: Vector2i = motion.get("from", Vector2i(-1, -1))
		var to_cell: Vector2i = motion.get("to", Vector2i(-1, -1))
		if not state.is_inside(from_cell) or not state.is_inside(to_cell):
			continue
		var start := _cell_center(from_cell)
		var end := _cell_center(to_cell)
		var team_color := PLAYER_COLOR
		var unit := state.get_unit(str(unit_id))
		if not unit.is_empty() and unit.get("team", "") == BattleState.TEAM_ENEMY:
			team_color = ENEMY_COLOR
		var flash := team_color.lightened(0.45)
		flash.a = 0.55 * peak
		if str(motion.get("style", "melee")) == "ranged":
			var head := start.lerp(end, clampf(progress + 0.18, 0.0, 1.0))
			var tail := start.lerp(end, clampf(progress - 0.18, 0.0, 1.0))
			draw_line(tail, head, flash, 3.0)
			draw_circle(head, 4.0 + 4.0 * peak, Color(1.0, 0.92, 0.52, 0.42 * peak))
		else:
			draw_line(end + Vector2(-18, -13), end + Vector2(18, 13), flash, 3.0)
			draw_line(end + Vector2(16, -14), end + Vector2(-14, 15), Color(1.0, 0.88, 0.42, 0.38 * peak), 2.0)
			draw_circle(end, 8.0 + 8.0 * peak, Color(1.0, 0.82, 0.34, 0.20 * peak))


func _draw_floating_texts() -> void:
	if floating_texts.is_empty():
		return
	var font := ThemeDB.fallback_font
	for popup in floating_texts:
		var duration: float = maxf(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME)))
		var age := float(popup.get("age", 0.0))
		var progress := clampf(age / duration, 0.0, 1.0)
		var alpha := 1.0 - progress
		var color := _floating_text_color(str(popup.get("kind", "")))
		color.a = alpha
		var shadow := Color(0.02, 0.02, 0.025, 0.82 * alpha)
		var center: Vector2 = popup.get("pos", Vector2.ZERO)
		var text := str(popup.get("text", ""))
		var width: float = 118.0
		var draw_position: Vector2 = center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE)
		draw_string(font, draw_position + Vector2(1, 1), text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, shadow)
		draw_string(font, draw_position, text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, color)


func _floating_text_color(kind: String) -> Color:
	if kind == "damage":
		return Color(1.0, 0.30, 0.20)
	if kind == "heal":
		return Color(0.36, 1.0, 0.46)
	if kind == "mp":
		return Color(0.48, 0.74, 1.0)
	if kind == "item":
		return Color(0.98, 0.76, 0.28)
	if kind == "priority":
		return Color(1.0, 0.54, 0.22)
	if kind == "support":
		return Color(1.0, 0.88, 0.28)
	if kind == "debuff":
		return Color(0.80, 0.52, 1.0)
	if kind == "poison":
		return Color(0.54, 0.95, 0.36)
	if kind == "seal":
		return Color(0.44, 0.86, 1.0)
	if kind == "snare":
		return Color(1.0, 0.64, 0.22)
	if kind == "disarm":
		return Color(1.0, 0.48, 0.42)
	if kind == "miss" or kind == "fade":
		return Color(0.82, 0.84, 0.90)
	if kind == "defeat":
		return Color(1.0, 0.18, 0.16)
	if kind == "progress":
		return Color(1.0, 0.78, 0.28)
	return Color.WHITE


func _draw_target_preview_badge() -> void:
	var badge := _target_preview_badge()
	if badge.is_empty():
		return

	var cell: Vector2i = badge.get("cell", hover_cell)
	if not state.is_inside(cell):
		return

	var rect := _rect_for_cell(cell)
	var size := TARGET_PREVIEW_BADGE_SIZE
	var badge_rect := Rect2(
		rect.position + Vector2((TILE_SIZE - size.x) * 0.5, 5.0),
		size
	)
	var kind := str(badge.get("kind", ""))
	var color := _target_preview_badge_color(kind)
	var background := Color(0.025, 0.028, 0.034, 0.88)
	var font := ThemeDB.fallback_font
	var text := str(badge.get("text", ""))

	draw_rect(badge_rect, background)
	draw_rect(badge_rect, color, false, 2.0)
	draw_string(font, badge_rect.position + Vector2(0, 16), text, HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 14, color)


func _target_preview_badge() -> Dictionary:
	if _is_input_locked() or not state.is_inside(hover_cell):
		return {}

	var selected := state.get_selected_unit()
	if selected.is_empty():
		return {}

	var target := state.get_unit_at(hover_cell)
	if not selected_skill_id.is_empty():
		return _skill_target_preview_badge(selected, target)
	if not selected_item_id.is_empty():
		return _item_target_preview_badge(selected, target)
	return _attack_target_preview_badge(selected, target)


func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
	if target.is_empty() or target.get("team", "") == selected.get("team", ""):
		return {}

	var preview := state.get_damage_preview(selected["id"], target["id"])
	if preview.is_empty():
		return {}

	if bool(preview.get("attack_locked", false)):
		return _make_target_preview_badge("DISARMED", "disarm")
	if not bool(preview.get("in_range", false)):
		return _make_target_preview_badge("OUT", "invalid")

	var hit_chance := int(preview.get("hit_chance", 100))
	var result_text := "KO" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
	return _make_target_preview_badge("%s %d%%" % [result_text, hit_chance], "damage")


func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
	var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell)
	if preview.is_empty():
		return {}

	if bool(preview.get("skill_locked", false)):
		return _make_target_preview_badge("SEALED", "seal")
	if not bool(preview.get("in_range", false)):
		return _make_target_preview_badge("OUT", "invalid")
	if not bool(preview.get("has_mp", false)):
		return _make_target_preview_badge("NO MP", "invalid")
	if not bool(preview.get("valid_target", false)):
		return _make_target_preview_badge("NO TGT" if bool(preview.get("has_area", false)) else "INVALID", "invalid")
	if target.is_empty() and not bool(preview.get("has_area", false)):
		return {}

	var kind := str(preview.get("kind", "damage"))
	var target_count := int(preview.get("target_count", 1))
	if kind == "heal":
		var heal := int(preview.get("heal", 0))
		var total_heal := int(preview.get("total_heal", heal))
		if total_heal <= 0:
			return _make_target_preview_badge("FULL", "invalid")
		if bool(preview.get("has_area", false)):
			return _make_target_preview_badge("+%d x%d" % [total_heal, target_count], "heal")
		return _make_target_preview_badge("+%d HP" % heal, "heal")
	if kind == "support":
		var effect_text := str(preview.get("effect_text", "Support"))
		var badge_kind := "disarm" if effect_text.contains("Disarm") else ("snare" if effect_text.contains("Snare") else ("seal" if effect_text.contains("Seal") else ("poison" if effect_text.contains("Poison") else ("debuff" if effect_text.contains("-") else "support"))))
		if bool(preview.get("has_area", false)):
			return _make_target_preview_badge("%s x%d" % [_compact_support_preview_text(effect_text), target_count], badge_kind)
		return _make_target_preview_badge(_compact_support_preview_text(effect_text), badge_kind)

	if bool(preview.get("has_area", false)):
		var defeat_count := int(preview.get("defeat_count", 0))
		if defeat_count > 0:
			return _make_target_preview_badge("KO x%d" % defeat_count, "damage")
		return _make_target_preview_badge("-%d x%d" % [int(preview.get("total_damage", 0)), target_count], "damage")
	var result_text := "KO" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
	return _make_target_preview_badge(result_text, "damage")


func _item_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
	var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell)
	if preview.is_empty() or target.is_empty():
		return {}

	if not bool(preview.get("valid_target", false)):
		return _make_target_preview_badge("INVALID", "invalid")
	if not bool(preview.get("in_range", false)):
		return _make_target_preview_badge("OUT", "invalid")
	if int(preview.get("count", 0)) <= 0:
		return _make_target_preview_badge("NONE", "invalid")

	var hp_heal := int(preview.get("heal", 0))
	var mp_heal := int(preview.get("mp_heal", 0))
	var cure_statuses: Array = preview.get("cure_statuses", [])
	if hp_heal <= 0 and mp_heal <= 0 and cure_statuses.is_empty():
		return _make_target_preview_badge("NO FX", "invalid")
	if not cure_statuses.is_empty():
		return _make_target_preview_badge("CURE", "support")
	if hp_heal > 0 and mp_heal > 0:
		return _make_target_preview_badge("+%d/%d" % [hp_heal, mp_heal], "heal")
	if hp_heal > 0:
		return _make_target_preview_badge("+%d HP" % hp_heal, "heal")
	return _make_target_preview_badge("+%d MP" % mp_heal, "mp")


func _make_target_preview_badge(text: String, kind: String) -> Dictionary:
	if text.is_empty():
		return {}
	return {
		"text": text,
		"kind": kind,
		"cell": hover_cell
	}


func _compact_support_preview_text(effect_text: String) -> String:
	var parts := []
	for raw_part in effect_text.split(","):
		var part := raw_part.strip_edges()
		var until_index := part.find(" until ")
		if until_index >= 0:
			part = part.substr(0, until_index)
		var for_index := part.find(" for ")
		if for_index >= 0:
			part = part.substr(0, for_index)
		if not part.is_empty() and part != "No support effect.":
			parts.append(part)
	if parts.is_empty():
		return "SUPPORT"
	if parts.size() > 1:
		return "%s+" % parts[0]
	return str(parts[0])


func _target_preview_badge_color(kind: String) -> Color:
	if kind == "damage":
		return Color(1.0, 0.38, 0.26)
	if kind == "heal":
		return Color(0.44, 1.0, 0.52)
	if kind == "mp":
		return Color(0.54, 0.78, 1.0)
	if kind == "support":
		return Color(1.0, 0.88, 0.30)
	if kind == "debuff":
		return Color(0.78, 0.55, 1.0)
	if kind == "poison":
		return Color(0.54, 0.95, 0.36)
	if kind == "seal":
		return Color(0.44, 0.86, 1.0)
	if kind == "snare":
		return Color(1.0, 0.64, 0.22)
	if kind == "disarm":
		return Color(1.0, 0.48, 0.42)
	return Color(0.78, 0.80, 0.86)


func _cell_from_screen(screen_position: Vector2) -> Vector2i:
	var local := screen_position - BOARD_OFFSET
	if local.x < 0 or local.y < 0:
		return Vector2i(-1, -1)
	return Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE)))


func _rect_for_cell(cell: Vector2i) -> Rect2:
	return Rect2(BOARD_OFFSET + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE))


func _short_name(unit_name: String) -> String:
	if unit_name.length() <= 9:
		return unit_name
	return unit_name.substr(0, 8) + "."


func _refresh_ranges() -> void:
	move_cells.clear()
	attack_cells.clear()
	skill_cells.clear()
	item_cells.clear()
	threat_cells.clear()
	if show_threat_overlay and battle_started and state.battle_status == BattleState.STATUS_ACTIVE:
		threat_cells = state.get_threat_cells(BattleState.TEAM_ENEMY)
	var selected := state.get_selected_unit()
	if selected.is_empty():
		selected_skill_id = ""
		selected_item_id = ""
		return
	move_cells = state.get_movement_range(selected["id"])
	if not selected_skill_id.is_empty():
		skill_cells = state.get_skill_cells(selected["id"], selected_skill_id)
		if skill_cells.is_empty():
			selected_skill_id = ""
			attack_cells = state.get_attack_cells(selected["id"])
	elif not selected_item_id.is_empty():
		item_cells = state.get_item_target_cells(selected["id"], selected_item_id)
		if item_cells.is_empty():
			selected_item_id = ""
			attack_cells = state.get_attack_cells(selected["id"])
	else:
		attack_cells = state.get_attack_cells(selected["id"])


func _update_hud() -> void:
	status_label.text = state.get_status_text()
	objective_label.text = _format_objective_hud_text()
	campaign_status_label.text = campaign_state.get_progress_text()
	_update_mission_panel()
	_update_cell_info()
	_update_forecast()
	_update_result_panel()
	if inventory_label != null:
		inventory_label.text = _format_inventory_status_text()

	var selected := state.get_selected_unit()
	if selected.is_empty():
		var hovered_unit := _hovered_unit_for_hud_portrait()
		_update_hud_unit_portrait(hovered_unit)
		if hovered_unit.is_empty():
			selected_label.text = "No unit selected."
		else:
			selected_label.text = _format_unit_focus_text(hovered_unit, "Hover")
		wait_button.disabled = true
		_update_tactic_button({})
		_hide_tactic_menu()
		_update_item_button({})
		_hide_item_menu()
		_update_equip_button({})
		_hide_equip_menu()
	else:
		_update_hud_unit_portrait(selected)
		selected_label.text = _format_unit_focus_text(selected, "Selected")
		wait_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked()
		_update_tactic_button(selected)
		_update_item_button(selected)
		_update_equip_button(selected)
		if tactic_menu != null and tactic_menu.visible:
			_rebuild_tactic_menu(selected)
		if item_menu != null and item_menu.visible:
			_rebuild_item_menu(selected)
		if equip_menu != null and equip_menu.visible:
			_rebuild_equip_menu(selected)

	end_turn_button.disabled = not state.can_player_act() or _is_input_locked()
	restart_button.disabled = campaign_complete_screen
	if threat_button != null:
		threat_button.button_pressed = show_threat_overlay
		threat_button.disabled = not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen


func _format_objective_hud_text() -> String:
	var text := String(state.objectives.get("victory", "Defeat all enemies."))
	if text.is_empty():
		return "Objective"
	return "Objective: %s" % text


func _format_unit_focus_text(unit: Dictionary, prefix: String) -> String:
	if unit.is_empty():
		return "No unit selected."
	var roster_mark := " *" if unit.get("loaded_from_roster", false) else ""
	var control_label := " Protected" if not bool(unit.get("controllable", true)) else ""
	var lines := []
	lines.append("%s: %s%s%s" % [
		prefix,
		str(unit.get("name", "Unit")),
		roster_mark,
		control_label
	])
	lines.append("%s Lv.%d  %s  %s" % [
		str(unit.get("team", "")).capitalize(),
		int(unit.get("level", 1)),
		str(unit.get("class", "Unit")),
		_unit_role_text(unit)
	])
	lines.append("HP %d/%d  MP %d/%d  EXP %d" % [
		int(unit.get("hp", 0)),
		int(unit.get("max_hp", 1)),
		int(unit.get("mp", 0)),
		int(unit.get("max_mp", 0)),
		int(unit.get("exp", 0))
	])
	lines.append("ATK %d  DEF %d  INT %d  AGI %d" % [
		int(unit.get("atk", 0)),
		int(unit.get("def", 0)),
		int(unit.get("int", 0)),
		int(unit.get("agi", 0))
	])
	lines.append("MOV %d %s  RNG %d-%d" % [
		int(unit.get("move", 0)),
		_format_move_type(str(unit.get("move_type", "foot"))),
		int(unit.get("min_range", 1)),
		int(unit.get("range", 1))
	])
	var terrain_text := _unit_current_terrain_text(unit)
	if not terrain_text.is_empty():
		lines.append(terrain_text)
	var status_effects := state.get_status_effect_summary(str(unit.get("id", "")))
	if not status_effects.is_empty():
		lines.append("Effects: %s" % status_effects)
	return _join_strings(lines, "\n")


func _unit_role_text(unit: Dictionary) -> String:
	var class_id := str(unit.get("class_id", ""))
	if class_id.contains("cavalry"):
		return "Shock cavalry"
	if class_id.contains("archer") or class_id.contains("marksman"):
		return "Ranged pressure"
	if class_id.contains("strategist") or class_id.contains("advisor"):
		return "Tactics support"
	if class_id.contains("hero") or class_id.contains("commander"):
		return "Command tactics"
	if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
		return "Assault fighter"
	return "Line infantry"


func _unit_current_terrain_text(unit: Dictionary) -> String:
	var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
	if not state.is_inside(cell):
		return ""
	return "Tile %d,%d %s  Cost %d  DEF +%d  AVO +%d%%" % [
		cell.x + 1,
		cell.y + 1,
		state.get_terrain_name(cell),
		state.get_move_cost(cell, str(unit.get("move_type", "foot"))),
		state.get_terrain_defense(cell),
		state.get_terrain_avoid(cell)
	]


func _update_mission_panel() -> void:
	if mission_detail_label == null:
		return
	mission_detail_label.text = _format_mission_panel_text()


func _format_mission_panel_text() -> String:
	var lines := []
	var victory := str(state.objectives.get("victory", ""))
	if not victory.is_empty():
		lines.append("Win: %s" % victory)
	var progress_lines := state.get_objective_progress_lines(false, false)
	if not progress_lines.is_empty():
		lines.append("Progress:")
		for progress_line in progress_lines:
			lines.append("  %s" % progress_line)
	var risk_lines := state.get_defeat_progress_lines()
	if not risk_lines.is_empty():
		lines.append("Risk:")
		for risk_line in risk_lines:
			lines.append("  %s" % risk_line)
	if lines.is_empty():
		return "No objective data."
	return _join_strings(lines, "\n")


func _hovered_unit_for_hud_portrait() -> Dictionary:
	if not state.is_inside(hover_cell):
		return {}
	return state.get_unit_at(hover_cell)


func _update_hud_unit_portrait(unit: Dictionary) -> void:
	if hud_unit_portrait_panel == null:
		return
	if unit.is_empty():
		hud_unit_portrait_panel.visible = false
		hud_unit_portrait_texture.texture = null
		hud_unit_portrait_texture.visible = false
		hud_unit_portrait_label.text = ""
		hud_unit_portrait_label.visible = false
		return
	hud_unit_portrait_panel.visible = true
	var texture := _load_portrait_texture(str(unit.get("portrait", "")))
	if texture == null:
		texture = _load_art_texture(str(unit.get("sprite", "")))
	var has_portrait := texture != null
	hud_unit_portrait_texture.texture = texture
	hud_unit_portrait_texture.visible = has_portrait
	hud_unit_portrait_label.text = _dialogue_portrait_initials(str(unit.get("name", "")))
	hud_unit_portrait_label.visible = not has_portrait


func _update_cell_info() -> void:
	if not state.is_inside(hover_cell):
		cell_info_label.text = "Tile: -"
		return

	var summary := state.get_cell_summary(hover_cell)
	var cell: Vector2i = summary["cell"]
	var text := "Tile %d,%d  %s\nMove cost %d  DEF +%d  AVO +%d%%" % [
		cell.x + 1,
		cell.y + 1,
		summary["terrain"],
		summary["move_cost"],
		summary["defense"],
		summary.get("avoid", 0)
	]
	if state.get_objective_cells().has(cell):
		text += "\nObjective marker"
	if show_threat_overlay:
		var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
		if not threat_names.is_empty():
			text += "\nThreatened by %s" % _compact_name_list(threat_names, 3)

	var unit: Dictionary = summary["unit"]
	if not unit.is_empty():
		var control_label := " Protected" if not bool(unit.get("controllable", true)) else ""
		text += "\nUnit: %s%s  %s Lv.%d\n%s  %s  HP %d/%d  MP %d/%d\nGear: %s" % [
			unit["name"],
			control_label,
			str(unit["team"]).capitalize(),
			unit["level"],
			str(unit.get("class", "Unit")),
			_unit_role_text(unit),
			unit["hp"],
			unit["max_hp"],
			unit.get("mp", 0),
			unit.get("max_mp", 0),
			_format_hover_unit_equipment(unit)
		]
		var status_effects := state.get_status_effect_summary(unit["id"])
		if not status_effects.is_empty():
			text += "\nEffects: %s" % status_effects
		if show_threat_overlay:
			var physical_threats := state.get_physical_threat_previews(cell, BattleState.TEAM_ENEMY)
			if not physical_threats.is_empty():
				text += "\nPhysical threat: %s" % _format_physical_threat_preview_text(physical_threats, 3)
			var skill_threats := state.get_skill_threat_previews(cell, BattleState.TEAM_ENEMY)
			if not skill_threats.is_empty():
				text += "\nSkill threat: %s" % _format_skill_threat_preview_text(skill_threats, 3)

	cell_info_label.text = text


func _format_physical_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
	var parts := []
	var safe_limit: int = maxi(1, limit)
	for index in range(mini(previews.size(), safe_limit)):
		var preview: Dictionary = previews[index]
		var defeat_text := " KO" if bool(preview.get("would_defeat", false)) else ""
		var effective_text := ""
		var effective_bonus := int(preview.get("effective_bonus", 0))
		if effective_bonus > 0:
			var effective_type := _format_move_type(str(preview.get("effective_target_type", "")))
			var effective_target_text := "" if effective_type.is_empty() else " vs %s" % effective_type
			effective_text = " Eff +%d%s" % [effective_bonus, effective_target_text]
		parts.append("%s %d/%d%%%s%s" % [
			str(preview.get("source_name", "Unit")),
			int(preview.get("damage", 0)),
			int(preview.get("hit_chance", 0)),
			effective_text,
			defeat_text
		])
	if previews.size() > safe_limit:
		parts.append("+%d" % (previews.size() - safe_limit))
	return _join_strings(parts, "; ")


func _format_skill_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
	var parts := []
	var safe_limit: int = maxi(1, limit)
	for index in range(mini(previews.size(), safe_limit)):
		var preview: Dictionary = previews[index]
		var source_text := str(preview.get("source_name", "Unit"))
		var skill_text := str(preview.get("skill_name", "Skill"))
		var area_text := _format_skill_threat_area_text(preview)
		if str(preview.get("kind", "")) == "damage":
			var defeat_text := " KO" if bool(preview.get("would_defeat", false)) else ""
			parts.append("%s %s %d dmg%s%s" % [
				source_text,
				skill_text,
				int(preview.get("damage", 0)),
				area_text,
				defeat_text
			])
		else:
			var active_text := " active" if bool(preview.get("already_active", false)) else ""
			parts.append("%s %s %s%s%s" % [
				source_text,
				skill_text,
				str(preview.get("effect_text", "Effect")),
				area_text,
				active_text
			])
	if previews.size() > safe_limit:
		parts.append("+%d" % (previews.size() - safe_limit))
	return _join_strings(parts, "; ")


func _format_skill_threat_area_text(preview: Dictionary) -> String:
	var target_count := int(preview.get("target_count", 0))
	if target_count <= 1:
		return ""
	return " x%d" % target_count


func _compact_name_list(names: Array[String], limit: int) -> String:
	var visible_names: Array[String] = []
	var safe_limit: int = maxi(1, limit)
	for index in range(mini(names.size(), safe_limit)):
		visible_names.append(names[index])
	if names.size() > safe_limit:
		visible_names.append("+%d" % (names.size() - safe_limit))
	return _join_strings(visible_names, ", ")


func _update_forecast() -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty() or not state.is_inside(hover_cell):
		forecast_label.text = "Forecast: -"
		return

	if not selected_skill_id.is_empty():
		_update_skill_forecast(selected)
		return
	if not selected_item_id.is_empty():
		_update_item_forecast(selected)
		return

	var target := state.get_unit_at(hover_cell)
	if target.is_empty() or target.get("team", "") == selected.get("team", ""):
		forecast_label.text = "Forecast: -"
		return

	var preview := state.get_damage_preview(selected["id"], target["id"])
	if preview.is_empty():
		forecast_label.text = "Forecast: -"
		return

	if bool(preview.get("attack_locked", false)):
		forecast_label.text = "Forecast: disarmed.\nCannot use physical attacks."
		return

	var range_text := "ready" if preview["in_range"] else "out of range"
	var defeat_text := " Defeat" if preview["would_defeat"] else ""
	var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type")
	var counter_text := ""
	if preview["counter_in_range"]:
		var counter_defeat := " Defeat" if preview["counter_would_defeat"] else ""
		var counter_effective_text := _format_effective_forecast_text(preview, "counter_effective_bonus", "counter_effective_target_type")
		counter_text = "\nCounter %d%s, Hit %d%%, your HP %d/%d.%s" % [
			preview["counter_damage"],
			counter_effective_text,
			preview.get("counter_hit_chance", 0),
			preview["attacker_hp_after"],
			selected["max_hp"],
			counter_defeat
		]
	forecast_label.text = "Forecast: %s\n%d damage%s, Hit %d%%, target HP %d/%d.%s%s" % [
		range_text,
		preview["damage"],
		effective_text,
		preview.get("hit_chance", 100),
		preview["target_hp_after"],
		target["max_hp"],
		defeat_text,
		counter_text
	]


func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, type_key: String) -> String:
	var bonus := int(preview.get(bonus_key, 0))
	if bonus <= 0:
		return ""
	var move_type := _format_move_type(str(preview.get(type_key, "")))
	if move_type.is_empty():
		move_type = "Target"
	return " (Effective +%d vs %s)" % [bonus, move_type]


func _update_skill_forecast(selected: Dictionary) -> void:
	var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell)
	if preview.is_empty():
		forecast_label.text = "Forecast: -"
		return

	var range_text := "ready" if preview["in_range"] else "out of range"
	var mp_text := "" if preview["has_mp"] else " Not enough MP."
	if bool(preview.get("skill_locked", false)):
		forecast_label.text = "%s: sealed, %d MP.\nCannot use tactics." % [
			preview["skill_name"],
			preview["mp_cost"]
		]
		return
	if not preview["valid_target"]:
		forecast_label.text = "%s: %s, %d MP.%s\nNo valid target." % [
			preview["skill_name"],
			range_text,
			preview["mp_cost"],
			mp_text
		]
		return

	var target := state.get_unit(str(preview.get("target_id", "")))
	var target_max_hp := int(target.get("max_hp", 1)) if not target.is_empty() else 1
	var target_count := int(preview.get("target_count", 1))
	var has_area := bool(preview.get("has_area", false))
	var area_text := " Area targets: %d." % target_count if has_area else ""
	if preview["kind"] == "heal":
		var total_heal := int(preview.get("total_heal", preview.get("heal", 0)))
		if not has_area:
			forecast_label.text = "%s: %s, %d MP.%s\nRestore %d HP, target HP %d/%d." % [
				preview["skill_name"],
				range_text,
				preview["mp_cost"],
				mp_text,
				preview["heal"],
				preview["target_hp_after"],
				target_max_hp
			]
			return
		forecast_label.text = "%s: %s, %d MP.%s%s\nRestore %d HP total, focus HP %d/%d." % [
			preview["skill_name"],
			range_text,
			preview["mp_cost"],
			mp_text,
			area_text,
			total_heal,
			preview["target_hp_after"],
			target_max_hp
		]
	elif preview["kind"] == "support":
		var refresh_text := " Refresh." if bool(preview.get("already_active", false)) else ""
		forecast_label.text = "%s: %s, %d MP.%s%s\n%s.%s" % [
			preview["skill_name"],
			range_text,
			preview["mp_cost"],
			mp_text,
			area_text,
			preview.get("effect_text", "Support effect"),
			refresh_text
		]
	else:
		var defeat_count := int(preview.get("defeat_count", 0))
		var defeat_text := " Defeat x%d" % defeat_count if defeat_count > 0 else ""
		var total_damage := int(preview.get("total_damage", preview.get("damage", 0)))
		if not has_area:
			var single_defeat_text := " Defeat" if bool(preview.get("would_defeat", false)) else ""
			forecast_label.text = "%s: %s, %d MP.%s\n%d damage, target HP %d/%d.%s" % [
				preview["skill_name"],
				range_text,
				preview["mp_cost"],
				mp_text,
				preview["damage"],
				preview["target_hp_after"],
				target_max_hp,
				single_defeat_text
			]
			return
		forecast_label.text = "%s: %s, %d MP.%s%s\n%d total damage, focus HP %d/%d.%s" % [
			preview["skill_name"],
			range_text,
			preview["mp_cost"],
			mp_text,
			area_text,
			total_damage,
			preview["target_hp_after"],
			target_max_hp,
			defeat_text
		]


func _update_item_forecast(selected: Dictionary) -> void:
	var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell)
	if preview.is_empty():
		forecast_label.text = "Forecast: -"
		return

	var target := state.get_unit_at(hover_cell)
	var range_text := "ready" if preview["in_range"] else "out of range"
	var count_text := "x%d" % int(preview.get("count", 0))
	if target.is_empty() or not preview["valid_target"]:
		forecast_label.text = "%s: %s, %s\nNo valid target." % [
			preview["item_name"],
			range_text,
			count_text
		]
		return

	var hp_heal := int(preview.get("heal", 0))
	var mp_heal := int(preview.get("mp_heal", 0))
	var cure_statuses: Array = preview.get("cure_statuses", [])
	if hp_heal <= 0 and mp_heal <= 0 and cure_statuses.is_empty():
		forecast_label.text = "%s: %s, %s\nNo item effect needed." % [
			preview["item_name"],
			range_text,
			count_text
		]
		return

	var recovery_parts := []
	if hp_heal > 0:
		recovery_parts.append("Restore %d HP, target HP %d/%d" % [
			hp_heal,
			preview["target_hp_after"],
			target["max_hp"]
		])
	if mp_heal > 0:
		recovery_parts.append("Restore %d MP, target MP %d/%d" % [
			mp_heal,
			preview["target_mp_after"],
			target.get("max_mp", 0)
		])
	if not cure_statuses.is_empty():
		recovery_parts.append("Cure %s" % _join_strings(cure_statuses, ", "))

	forecast_label.text = "%s: %s, %s\n%s." % [
		preview["item_name"],
		range_text,
		count_text,
		_join_strings(recovery_parts, "; ")
	]


func _update_result_panel() -> void:
	if result_panel == null:
		return
	_announce_battle_status_audio()
	if campaign_complete_screen:
		_clear_dialogue()
		_clear_result_choices()
		result_panel.visible = true
		if result_restart_button != null:
			result_restart_button.visible = false
		if next_battle_button != null:
			next_battle_button.visible = false
		result_label.text = "%s Complete\nAll current scenarios cleared.\nGold %d\nStart a new campaign to replay." % [
			campaign_state.campaign_title,
			campaign_state.gold
		]
		return
	if state.battle_status == BattleState.STATUS_VICTORY:
		if _try_show_post_battle_dialogue():
			_clear_result_choices()
			result_panel.visible = false
			return
		if _is_dialogue_visible():
			_clear_result_choices()
			result_panel.visible = false
			return
		_apply_battle_result_once()
		result_panel.visible = true
		_rebuild_result_choices()
		if result_restart_button != null:
			result_restart_button.visible = true
		if next_battle_button != null:
			next_battle_button.visible = not str(battle_result_summary.get("next_scenario_id", "")).is_empty() and bool(battle_result_summary.get("choice_applied", true)) and bool(battle_result_summary.get("saved", false))
		result_label.text = "Victory\n%s\n%s" % [
			state.objectives.get("victory", ""),
			_format_battle_result_summary()
		]
	elif state.battle_status == BattleState.STATUS_DEFEAT:
		_clear_dialogue()
		_clear_result_choices()
		result_panel.visible = true
		if result_restart_button != null:
			result_restart_button.visible = true
		if next_battle_button != null:
			next_battle_button.visible = false
		result_label.text = "Defeat\n%s" % state.objectives.get("defeat", "")
	else:
		_clear_result_choices()
		result_panel.visible = false
		if result_restart_button != null:
			result_restart_button.visible = true
		if next_battle_button != null:
			next_battle_button.visible = false


func _on_state_changed() -> void:
	if state.get_selected_unit().is_empty():
		selected_skill_id = ""
		selected_item_id = ""
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_log_added(message: String) -> void:
	_play_log_sfx(message)
	if log_box == null:
		return
	log_box.append_text(message + "\n")
	log_box.scroll_to_line(max(0, log_box.get_line_count() - 1))


func _on_objective_updated(victory: String, defeat: String) -> void:
	var notice_lines := ["Objective Updated"]
	if not victory.is_empty():
		notice_lines.append(victory)
	if not defeat.is_empty():
		notice_lines.append(defeat)
	if objective_notice_label != null:
		objective_notice_label.text = "\n".join(notice_lines)
	if objective_notice_panel != null:
		objective_notice_panel.visible = true
	objective_notice_timer = OBJECTIVE_NOTICE_DURATION
	_update_hud()
	queue_redraw()


func _on_dialogue_requested(lines: Array) -> void:
	var normalized_lines := _normalized_dialogue_lines(lines)
	if normalized_lines.is_empty():
		return
	dialogue_queue.append(normalized_lines)
	if _can_show_dialogue_now():
		_show_next_dialogue()


func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) -> void:
	var unit := state.get_unit(unit_id)
	if unit.is_empty():
		return
	var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
	if not state.is_inside(cell):
		return
	var jitter := float((floating_texts.size() % 3) - 1) * 12.0
	floating_texts.append({
		"text": text,
		"kind": kind,
		"pos": _floating_text_origin(cell) + Vector2(jitter, 0),
		"age": 0.0,
		"duration": FLOATING_TEXT_LIFETIME
	})
	queue_redraw()


func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> void:
	if unit_id.is_empty() or from_cell == to_cell:
		return
	unit_motion_by_unit[unit_id] = {
		"from": from_cell,
		"to": to_cell,
		"age": 0.0,
		"duration": UNIT_MOVE_ANIMATION_DURATION
	}
	queue_redraw()


func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void:
	if unit_id.is_empty() or from_cell == to_cell:
		return
	var unit := state.get_unit(unit_id)
	var distance := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y)
	var style := "ranged" if int(unit.get("range", 1)) > 1 and distance > 1 else "melee"
	unit_action_motion_by_unit[unit_id] = {
		"from": from_cell,
		"to": to_cell,
		"kind": action_kind,
		"style": style,
		"age": 0.0,
		"duration": UNIT_ATTACK_ANIMATION_DURATION
	}
	queue_redraw()


func _floating_text_origin(cell: Vector2i) -> Vector2:
	var rect := _rect_for_cell(cell)
	return rect.position + Vector2(TILE_SIZE * 0.5, 18.0)


func _normalized_dialogue_lines(lines: Array) -> Array:
	var result := []
	for line in lines:
		if typeof(line) == TYPE_DICTIONARY:
			var text := str(line.get("text", ""))
			if text.is_empty():
				continue
			result.append({
				"speaker": str(line.get("speaker", "")),
				"text": text,
				"portrait": str(line.get("portrait", "")),
				"side": _normalized_dialogue_side(line.get("side", "left"))
			})
		elif typeof(line) == TYPE_STRING:
			var text := str(line)
			if text.is_empty():
				continue
			result.append({"speaker": "", "text": text, "portrait": "", "side": "left"})
	return result


func _normalized_dialogue_side(value) -> String:
	var side := str(value).strip_edges().to_lower()
	if side == "right":
		return "right"
	return "left"


func _can_show_dialogue_now() -> bool:
	if not battle_started or campaign_complete_screen:
		return false
	if state.battle_status != BattleState.STATUS_ACTIVE:
		return false
	if briefing_panel != null and briefing_panel.visible:
		return false
	if result_panel != null and result_panel.visible:
		return false
	return not _is_dialogue_visible()


func _show_next_dialogue() -> void:
	if dialogue_panel == null or dialogue_queue.is_empty():
		return
	if _is_dialogue_visible():
		return
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_refresh_ranges()
	active_dialogue_lines = dialogue_queue.pop_front()
	active_dialogue_index = 0
	_render_dialogue_line()


func _render_dialogue_line() -> void:
	if active_dialogue_index < 0 or active_dialogue_index >= active_dialogue_lines.size():
		_hide_dialogue_panel()
		return
	var line: Dictionary = active_dialogue_lines[active_dialogue_index]
	var speaker := str(line.get("speaker", ""))
	var side := _normalized_dialogue_side(line.get("side", "left"))
	_apply_dialogue_side(side)
	dialogue_speaker_label.text = speaker
	dialogue_speaker_label.visible = not speaker.is_empty()
	dialogue_text_label.text = str(line.get("text", ""))
	_update_dialogue_portrait(speaker, str(line.get("portrait", "")))
	_update_dialogue_controls()
	dialogue_panel.visible = true
	_update_hud()
	queue_redraw()


func _update_dialogue_controls() -> void:
	if dialogue_progress_label != null:
		dialogue_progress_label.text = "%d / %d" % [active_dialogue_index + 1, active_dialogue_lines.size()]
	if dialogue_previous_button != null:
		dialogue_previous_button.disabled = active_dialogue_index <= 0
	dialogue_continue_button.text = "Close" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "Next"


func _apply_dialogue_side(side: String) -> void:
	if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null:
		return
	var portrait_index := 1 if side == "right" else 0
	dialogue_row.move_child(dialogue_portrait_panel, portrait_index)
	dialogue_speaker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT
	dialogue_text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT


func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void:
	if dialogue_portrait_panel == null:
		return
	var texture := _load_portrait_texture(portrait_path)
	var has_portrait := texture != null
	var has_placeholder := not speaker.is_empty()
	dialogue_portrait_panel.visible = has_portrait or has_placeholder
	dialogue_portrait_texture.texture = texture
	dialogue_portrait_texture.visible = has_portrait
	dialogue_portrait_label.text = _dialogue_portrait_initials(speaker)
	dialogue_portrait_label.visible = not has_portrait and has_placeholder


func _load_art_texture(texture_path: String) -> Texture2D:
	return _load_portrait_texture(texture_path)


func _load_portrait_texture(portrait_path: String) -> Texture2D:
	var normalized_path := portrait_path.strip_edges()
	if normalized_path.is_empty():
		return null
	if not normalized_path.begins_with("res://") and not normalized_path.begins_with("user://"):
		normalized_path = "res://%s" % normalized_path
	if missing_portrait_paths.has(normalized_path):
		return null
	if portrait_texture_cache.has(normalized_path):
		return portrait_texture_cache[normalized_path] as Texture2D
	if ResourceLoader.exists(normalized_path, "Texture2D"):
		var imported_texture := load(normalized_path) as Texture2D
		if imported_texture != null:
			portrait_texture_cache[normalized_path] = imported_texture
			return imported_texture
	if not FileAccess.file_exists(normalized_path):
		missing_portrait_paths[normalized_path] = true
		return null
	var image := Image.new()
	if image.load(normalized_path) != OK:
		missing_portrait_paths[normalized_path] = true
		return null
	var raw_texture := ImageTexture.create_from_image(image)
	portrait_texture_cache[normalized_path] = raw_texture
	return raw_texture


func _dialogue_portrait_initials(speaker: String) -> String:
	var normalized_speaker := speaker.strip_edges()
	if normalized_speaker.is_empty():
		return ""
	var parts := normalized_speaker.split(" ", false)
	var initials := ""
	for part in parts:
		if str(part).is_empty():
			continue
		initials += str(part).substr(0, 1).to_upper()
		if initials.length() >= 2:
			break
	if initials.is_empty():
		return normalized_speaker.substr(0, 1).to_upper()
	return initials


func _advance_dialogue() -> void:
	if not _is_dialogue_visible():
		return
	_play_ui_click()
	active_dialogue_index += 1
	if active_dialogue_index < active_dialogue_lines.size():
		_render_dialogue_line()
		return
	_hide_dialogue_panel()
	if _can_show_dialogue_now():
		_show_next_dialogue()
	else:
		_update_hud()
		queue_redraw()


func _retreat_dialogue() -> void:
	if not _is_dialogue_visible() or active_dialogue_index <= 0:
		return
	_play_ui_click()
	active_dialogue_index -= 1
	_render_dialogue_line()


func _hide_dialogue_panel() -> void:
	if dialogue_panel != null:
		dialogue_panel.visible = false
	active_dialogue_lines.clear()
	active_dialogue_index = 0
	if dialogue_progress_label != null:
		dialogue_progress_label.text = ""
	if dialogue_previous_button != null:
		dialogue_previous_button.disabled = true


func _clear_dialogue() -> void:
	dialogue_queue.clear()
	_hide_dialogue_panel()


func _is_dialogue_visible() -> bool:
	return dialogue_panel != null and dialogue_panel.visible


func _on_wait_pressed() -> void:
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	if state.try_wait_selected():
		_play_ui_confirm()


func _on_end_turn_pressed() -> void:
	if state.can_player_act():
		_play_ui_confirm()
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	state.end_player_turn()


func _on_tactic_pressed() -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty():
		return
	if state.is_unit_skill_sealed(selected["id"]):
		_play_ui_cancel()
		selected_skill_id = ""
		_hide_tactic_menu()
		_refresh_ranges()
		_update_hud()
		queue_redraw()
		return
	_play_ui_click()
	if tactic_menu != null and tactic_menu.visible:
		_hide_tactic_menu()
	else:
		selected_item_id = ""
		_hide_item_menu()
		_hide_equip_menu()
		_show_tactic_menu(selected)
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_item_pressed() -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty():
		return
	_play_ui_click()
	if item_menu != null and item_menu.visible:
		_hide_item_menu()
	else:
		selected_skill_id = ""
		_hide_tactic_menu()
		_hide_equip_menu()
		_show_item_menu(selected)
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_equip_pressed() -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty():
		return
	if selected.get("moved", false) or selected.get("acted", false):
		return
	_play_ui_click()
	if equip_menu != null and equip_menu.visible:
		_hide_equip_menu()
	else:
		selected_skill_id = ""
		selected_item_id = ""
		_hide_tactic_menu()
		_hide_item_menu()
		_show_equip_menu(selected)
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_threat_pressed() -> void:
	if not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen:
		if threat_button != null:
			threat_button.button_pressed = show_threat_overlay
		return
	show_threat_overlay = not show_threat_overlay
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_restart_pressed() -> void:
	if campaign_complete_screen:
		return
	_play_ui_confirm()
	if log_box != null:
		log_box.clear()
	battle_started = false
	battle_result_applied = false
	battle_result_summary.clear()
	post_battle_dialogue_started = false
	floating_texts.clear()
	unit_motion_by_unit.clear()
	shop_sell_mode = false
	selected_skill_id = ""
	selected_item_id = ""
	armory_unit_id = ""
	formation_unit_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_load_scenario(active_scenario_id)
	_show_briefing()


func _load_current_battle() -> void:
	_load_scenario(campaign_state.current_scenario_id)


func _load_pending_post_battle_choice() -> void:
	_load_scenario(campaign_state.get_pending_post_battle_choice_scenario_id())
	battle_started = true
	battle_result_applied = true
	post_battle_dialogue_started = true
	battle_result_summary = campaign_state.get_pending_post_battle_choice_summary()
	state.battle_status = BattleState.STATUS_VICTORY
	last_announced_battle_status = BattleState.STATUS_VICTORY
	_play_bgm("menu")
	if briefing_panel != null:
		briefing_panel.visible = false
	if log_box != null:
		log_box.append_text("Campaign choice pending.\n")


func _load_scenario(scenario_id: String) -> void:
	campaign_complete_screen = false
	active_scenario_id = scenario_id
	last_announced_battle_status = ""
	var scenario_path := campaign_state.get_scenario_path(active_scenario_id)
	if scenario_path.is_empty():
		push_error("Missing scenario path for %s." % active_scenario_id)
		return
	battle_started = false
	battle_result_applied = false
	battle_result_summary.clear()
	post_battle_dialogue_started = false
	floating_texts.clear()
	unit_motion_by_unit.clear()
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_talk_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	_clear_dialogue()
	state.load_battle(
		scenario_path,
		campaign_state.get_roster_overrides(),
		campaign_state.get_inventory_snapshot(),
		campaign_state.get_flags_snapshot(),
		campaign_state.get_joined_officers_snapshot()
	)


func _format_briefing_title(briefing: Dictionary) -> String:
	var title := str(briefing.get("title", state.battle_name))
	if title.is_empty():
		title = campaign_state.get_scenario_title(active_scenario_id)
	var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
	if not chapter_title.is_empty():
		var chapter_position := campaign_state.get_chapter_position_for_scenario(active_scenario_id)
		var chapter_count := campaign_state.get_chapter_count()
		if chapter_position > 0 and chapter_count > 0:
			return "Chapter %02d/%02d - %s" % [chapter_position, chapter_count, chapter_title]
		return chapter_title
	var position := campaign_state.get_scenario_position(active_scenario_id)
	var count := campaign_state.get_scenario_count()
	if position > 0 and count > 0:
		return "Battle %02d/%02d - %s" % [position, count, title]
	return title


func _format_briefing_location(briefing: Dictionary) -> String:
	var location := str(briefing.get("location", ""))
	var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
	if not chapter_title.is_empty():
		var battle_text := _format_briefing_chapter_battle_text(briefing)
		if location.is_empty():
			return battle_text
		return "%s | %s" % [battle_text, location]
	if campaign_state.campaign_title.is_empty():
		return location
	if location.is_empty():
		return campaign_state.campaign_title
	return "%s | %s" % [campaign_state.campaign_title, location]


func _format_briefing_chapter_battle_text(briefing: Dictionary) -> String:
	var title := str(briefing.get("title", state.battle_name))
	if title.is_empty():
		title = campaign_state.get_scenario_title(active_scenario_id)
	var position := campaign_state.get_scenario_position_in_chapter(active_scenario_id)
	var count := campaign_state.get_scenario_count_in_chapter(active_scenario_id)
	if position > 0 and count > 0:
		return "Battle %02d/%02d: %s" % [position, count, title]
	return title


func _format_briefing_objectives() -> String:
	var text := ""
	var victory := str(state.objectives.get("victory", ""))
	if not victory.is_empty():
		text = "Victory: %s" % victory
	var defeat := str(state.objectives.get("defeat", ""))
	if not defeat.is_empty():
		if not text.is_empty():
			text += "\n"
		text += "Defeat: %s" % defeat
	var progress_lines := state.get_objective_progress_lines(false, false)
	if not progress_lines.is_empty():
		if not text.is_empty():
			text += "\n"
		text += "Progress: %s" % _join_strings(progress_lines, ", ")
	var risk_lines := state.get_defeat_progress_lines()
	if not risk_lines.is_empty():
		if not text.is_empty():
			text += "\n"
		text += "Risk: %s" % _join_strings(risk_lines, ", ")
	var rewards_text := _format_briefing_rewards()
	if not rewards_text.is_empty():
		if not text.is_empty():
			text += "\n"
		text += "Rewards: %s" % rewards_text
	return text


func _format_briefing_rewards() -> String:
	if campaign_state.is_scenario_completed(active_scenario_id):
		return "already claimed"
	var rewards := state.get_rewards()
	var parts := []
	var gold: int = maxi(0, int(rewards.get("gold", 0)))
	if gold > 0:
		parts.append("%dG" % gold)
	var items = rewards.get("items", [])
	if typeof(items) == TYPE_ARRAY and not items.is_empty():
		var item_text := _format_reward_items(items)
		if item_text != "none":
			parts.append(item_text)
	var joined_text := _format_officer_id_list(rewards.get("join_officers", []))
	if not joined_text.is_empty():
		parts.append("Join %s" % joined_text)
	var left_text := _format_officer_id_list(rewards.get("leave_officers", []))
	if not left_text.is_empty():
		parts.append("Leave %s" % left_text)
	return _join_strings(parts, ", ")


func _show_briefing() -> void:
	_play_bgm("menu")
	var briefing := state.get_briefing()
	if briefing.is_empty():
		_start_battle_from_briefing()
		return

	var body := ""
	for line in briefing.get("lines", []):
		if not body.is_empty():
			body += "\n"
		body += str(line)

	briefing_title_label.text = _format_briefing_title(briefing)
	briefing_location_label.text = _format_briefing_location(briefing)
	briefing_location_label.visible = not briefing_location_label.text.is_empty()
	briefing_objective_label.text = _format_briefing_objectives()
	briefing_objective_label.visible = not briefing_objective_label.text.is_empty()
	briefing_label.text = body
	briefing_panel.visible = true
	battle_started = false
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_talk_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	var prep_locked := _is_prebattle_prep_locked()
	if chapter_button != null:
		chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty()
	if shop_button != null:
		shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty())
	if talk_button != null:
		talk_button.disabled = prep_locked or _camp_conversation_entries().is_empty()
	if armory_button != null:
		armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty()
	if roster_button != null:
		roster_button.disabled = prep_locked or not state.has_deployment_roster_choices()
	if formation_button != null:
		formation_button.disabled = prep_locked or state.get_formation_cells().is_empty()
	if save_button != null:
		save_button.disabled = prep_locked


func _on_begin_battle_pressed() -> void:
	_play_ui_confirm()
	_start_battle_from_briefing()


func _start_battle_from_briefing() -> void:
	battle_started = true
	_play_bgm("battle")
	if briefing_panel != null:
		briefing_panel.visible = false
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_talk_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	state.run_battle_begin_events()
	_show_next_dialogue()
	_update_hud()


func _on_chapter_pressed() -> void:
	if battle_started or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if chapter_menu != null and chapter_menu.visible:
		_hide_chapter_menu()
	else:
		_hide_shop_menu()
		_hide_talk_menu()
		_hide_armory_menu()
		_hide_roster_menu()
		_hide_formation_menu()
		_hide_save_menu()
		_rebuild_chapter_menu()
		chapter_menu.visible = true
	_update_hud()


func _hide_chapter_menu() -> void:
	if chapter_menu == null:
		return
	chapter_menu.visible = false


func _rebuild_chapter_menu() -> void:
	if chapter_list == null:
		return
	for child in chapter_list.get_children():
		chapter_list.remove_child(child)
		child.queue_free()
	var entries := campaign_state.get_chapter_progress_entries()
	if chapter_status_label != null:
		chapter_status_label.text = "%s chapters | %d/%d battles complete" % [
			campaign_state.campaign_title,
			campaign_state.completed_scenarios.size(),
			campaign_state.get_scenario_count()
		]
	if entries.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No campaign chapters are defined."
		chapter_list.add_child(empty_label)
		return
	for entry in entries:
		var completed := int(entry.get("completed", 0))
		var total := int(entry.get("total", 0))
		var current := bool(entry.get("current", false))
		var status := "Current" if current else ("Complete" if total > 0 and completed >= total else "Upcoming")
		var current_title := str(entry.get("current_scenario_title", ""))
		var line := "Chapter %02d/%02d - %s  [%s]  %d/%d" % [
			int(entry.get("position", 0)),
			int(entry.get("chapter_count", 0)),
			entry.get("title", ""),
			status,
			completed,
			total
		]
		if current and not current_title.is_empty():
			line += "\nCurrent battle: %s" % current_title
		var chapter_label := Label.new()
		chapter_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		chapter_label.custom_minimum_size = Vector2(560, 0)
		chapter_label.text = line
		chapter_list.add_child(chapter_label)
		for scenario in entry.get("scenarios", []):
			_add_chapter_scenario_entry(scenario)


func _add_chapter_scenario_entry(scenario: Dictionary) -> void:
	var scenario_id := str(scenario.get("id", ""))
	var title := str(scenario.get("title", scenario_id))
	var position := int(scenario.get("position", 0))
	var total := int(scenario.get("total", 0))
	var completed := bool(scenario.get("completed", false))
	var current := bool(scenario.get("current", false))
	var available := bool(scenario.get("available", false))
	var status := "Current" if current else ("Replay" if completed else "Upcoming")
	var text := "  %02d/%02d %s [%s]" % [position, total, title, status]
	if available:
		var scenario_button := Button.new()
		scenario_button.custom_minimum_size = Vector2(560, 0)
		scenario_button.text = text
		scenario_button.pressed.connect(_on_chapter_scenario_pressed.bind(scenario_id))
		chapter_list.add_child(scenario_button)
	else:
		var scenario_label := Label.new()
		scenario_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
		scenario_label.custom_minimum_size = Vector2(560, 0)
		scenario_label.text = text
		chapter_list.add_child(scenario_label)


func _on_chapter_scenario_pressed(scenario_id: String) -> void:
	if battle_started or briefing_panel == null or not briefing_panel.visible:
		return
	if scenario_id.is_empty():
		return
	if not campaign_state.is_scenario_completed(scenario_id) and scenario_id != campaign_state.current_scenario_id:
		_play_ui_cancel()
		return
	_play_ui_confirm()
	if log_box != null:
		log_box.clear()
	_load_scenario(scenario_id)
	_show_briefing()
	_update_hud()


func _on_shop_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if shop_menu != null and shop_menu.visible:
		_hide_shop_menu()
	else:
		shop_sell_mode = state.get_shop_item_ids().is_empty() and not _get_sellable_item_ids().is_empty()
		_hide_chapter_menu()
		_hide_talk_menu()
		_hide_armory_menu()
		_hide_roster_menu()
		_hide_formation_menu()
		_hide_save_menu()
		_rebuild_shop_menu()
		shop_menu.visible = true
	_update_hud()


func _hide_shop_menu() -> void:
	if shop_menu == null:
		return
	shop_menu.visible = false


func _on_talk_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
		return
	if _camp_conversation_entries().is_empty():
		_play_ui_cancel()
		return
	_play_ui_click()
	if talk_menu != null and talk_menu.visible:
		_hide_talk_menu()
	else:
		_hide_chapter_menu()
		_hide_shop_menu()
		_hide_armory_menu()
		_hide_roster_menu()
		_hide_formation_menu()
		_hide_save_menu()
		_rebuild_talk_menu()
		talk_menu.visible = true
	_update_hud()


func _hide_talk_menu() -> void:
	if talk_menu == null:
		return
	talk_menu.visible = false


func _rebuild_talk_menu() -> void:
	if talk_list == null:
		return
	_clear_talk_list()
	var conversations := _camp_conversation_entries()
	if talk_status_label != null:
		talk_status_label.text = "Camp conversations | %d available" % conversations.size()
	if conversations.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No camp conversations"
		talk_list.add_child(empty_label)
		return
	for conversation in conversations:
		var conversation_id := str(conversation.get("id", ""))
		var button := Button.new()
		button.text = _format_camp_conversation_button_text(conversation)
		button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id))
		talk_list.add_child(button)


func _clear_talk_list() -> void:
	for child in talk_list.get_children():
		talk_list.remove_child(child)
		child.queue_free()


func _camp_conversation_entries() -> Array:
	var briefing := state.get_briefing()
	var conversations = briefing.get("camp_conversations", [])
	if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty():
		return conversations
	var lines = briefing.get("camp_dialogue", [])
	if typeof(lines) == TYPE_ARRAY and not lines.is_empty():
		return [{
			"id": "camp_dialogue",
			"group": "topic",
			"officer_id": "",
			"label": "War Council",
			"speaker": "",
			"summary": "Review the immediate situation.",
			"lines": lines
		}]
	return []


func _format_camp_conversation_button_text(conversation: Dictionary) -> String:
	var label := str(conversation.get("label", "Camp Talk"))
	var speaker := str(conversation.get("speaker", ""))
	var summary := str(conversation.get("summary", ""))
	var group := str(conversation.get("group", "topic")).capitalize()
	var parts := [group, label]
	if not speaker.is_empty():
		parts.append(speaker)
	if not summary.is_empty():
		parts.append(summary)
	return _join_strings(parts, "  -  ")


func _on_camp_conversation_pressed(conversation_id: String) -> void:
	var conversation := _camp_conversation_by_id(conversation_id)
	if conversation.is_empty():
		_play_ui_cancel()
		return
	_play_ui_confirm()
	_hide_talk_menu()
	_show_camp_dialogue(conversation.get("lines", []))
	_update_hud()


func _camp_conversation_by_id(conversation_id: String) -> Dictionary:
	for conversation in _camp_conversation_entries():
		if str(conversation.get("id", "")) == conversation_id:
			return conversation
	return {}


func _show_camp_dialogue(lines: Array) -> void:
	var normalized_lines := _normalized_dialogue_lines(lines)
	if normalized_lines.is_empty():
		return
	dialogue_queue.clear()
	active_dialogue_lines = normalized_lines
	active_dialogue_index = 0
	_render_dialogue_line()


func _on_armory_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if armory_menu != null and armory_menu.visible:
		_hide_armory_menu()
	else:
		_hide_chapter_menu()
		_hide_shop_menu()
		_hide_talk_menu()
		_hide_roster_menu()
		_hide_formation_menu()
		_hide_save_menu()
		_rebuild_armory_menu()
		armory_menu.visible = true
	_update_hud()


func _hide_armory_menu() -> void:
	if armory_menu == null:
		return
	armory_menu.visible = false


func _on_roster_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or not state.has_deployment_roster_choices() or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if roster_menu != null and roster_menu.visible:
		_hide_roster_menu()
	else:
		_hide_chapter_menu()
		_hide_shop_menu()
		_hide_talk_menu()
		_hide_armory_menu()
		_hide_formation_menu()
		_hide_save_menu()
		_rebuild_roster_menu()
		roster_menu.visible = true
	_update_hud()


func _hide_roster_menu() -> void:
	if roster_menu == null:
		return
	roster_menu.visible = false


func _on_formation_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if formation_menu != null and formation_menu.visible:
		_hide_formation_menu()
	else:
		_hide_chapter_menu()
		_hide_shop_menu()
		_hide_talk_menu()
		_hide_armory_menu()
		_hide_roster_menu()
		_hide_save_menu()
		_rebuild_formation_menu()
		formation_menu.visible = true
	_update_hud()
	queue_redraw()


func _hide_formation_menu() -> void:
	if formation_menu == null:
		return
	formation_menu.visible = false


func _on_save_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
		return
	_play_ui_click()
	if save_menu != null and save_menu.visible:
		_hide_save_menu()
	else:
		_hide_chapter_menu()
		_hide_shop_menu()
		_hide_talk_menu()
		_hide_armory_menu()
		_hide_roster_menu()
		_hide_formation_menu()
		_rebuild_save_menu()
		save_menu.visible = true
	_update_hud()


func _hide_save_menu() -> void:
	if save_menu == null:
		return
	save_menu.visible = false


func _rebuild_save_menu() -> void:
	if save_status_label != null:
		save_status_label.text = "%s\n%s" % [
			_format_current_checkpoint_text(),
			_format_manual_checkpoint_text()
		]
	if manual_load_button != null:
		manual_load_button.disabled = not campaign_state.has_manual_save()


func _format_current_checkpoint_text() -> String:
	var title := campaign_state.get_scenario_title(campaign_state.current_scenario_id)
	if title.is_empty():
		title = campaign_state.get_scenario_title(campaign_state.get_start_scenario_id())
	return "Current: %s | %d/%d complete | Gold %d" % [
		title,
		campaign_state.completed_scenarios.size(),
		campaign_state.get_scenario_count(),
		campaign_state.gold
	]


func _format_manual_checkpoint_text() -> String:
	var summary := campaign_state.get_manual_save_summary()
	if summary.is_empty():
		return "Manual: empty"
	var title := str(summary.get("current_scenario_title", ""))
	if title.is_empty():
		title = str(summary.get("current_scenario_id", "Unknown"))
	var pending_suffix := " | choice pending" if bool(summary.get("pending_choice", false)) else ""
	return "Manual: %s | %d/%d complete | Gold %d%s" % [
		title,
		int(summary.get("completed_count", 0)),
		int(summary.get("total_count", 0)),
		int(summary.get("gold", 0)),
		pending_suffix
	]


func _on_manual_save_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked():
		return
	_play_ui_confirm()
	if campaign_state.save_manual_game():
		_on_log_added("Manual checkpoint saved.")
	else:
		_on_log_added("Could not save manual checkpoint.")
	_rebuild_save_menu()


func _on_manual_load_pressed() -> void:
	if battle_started or _is_prebattle_prep_locked() or not campaign_state.has_manual_save():
		return
	_play_ui_confirm()
	if not campaign_state.load_manual_game():
		_on_log_added("Could not load manual checkpoint.")
		_rebuild_save_menu()
		return
	if log_box != null:
		log_box.clear()
		log_box.append_text("Manual checkpoint loaded.\n")
	_reload_from_campaign_state()


func _reload_from_campaign_state() -> void:
	campaign_complete_screen = false
	battle_started = false
	battle_result_applied = false
	battle_result_summary.clear()
	post_battle_dialogue_started = false
	floating_texts.clear()
	unit_motion_by_unit.clear()
	selected_skill_id = ""
	selected_item_id = ""
	armory_unit_id = ""
	formation_unit_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	if result_panel != null:
		result_panel.visible = false
	if campaign_state.has_pending_post_battle_choice():
		_load_pending_post_battle_choice()
		_update_result_panel()
	elif campaign_state.is_campaign_complete():
		_show_campaign_complete()
	else:
		_load_current_battle()
		_show_briefing()
	_update_hud()


func _is_prep_menu_visible() -> bool:
	return (
		(chapter_menu != null and chapter_menu.visible)
		or (shop_menu != null and shop_menu.visible)
		or (talk_menu != null and talk_menu.visible)
		or (armory_menu != null and armory_menu.visible)
		or (roster_menu != null and roster_menu.visible)
		or (formation_menu != null and formation_menu.visible)
		or (save_menu != null and save_menu.visible)
	)


func _is_prebattle_prep_locked() -> bool:
	return campaign_state.is_scenario_completed(active_scenario_id)


func _rebuild_roster_menu() -> void:
	if roster_list == null:
		return
	_clear_roster_list()
	var units := state.get_player_units(true)
	var max_units := state.get_deployment_max_units()
	var deployed_count := state.get_deployed_player_count()
	if roster_status_label != null:
		roster_status_label.text = "Sortie %d/%d" % [deployed_count, max_units]

	if units.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No officers"
		roster_list.add_child(empty_label)
		return

	for unit in units:
		var unit_id := str(unit.get("id", ""))
		var roster_unit_button := Button.new()
		roster_unit_button.text = _format_roster_unit_button_text(unit)
		var deployed := bool(unit.get("deployed", true))
		roster_unit_button.disabled = (
			bool(unit.get("required_deployment", false))
			or (not deployed and deployed_count >= max_units)
		)
		roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id))
		roster_list.add_child(roster_unit_button)


func _clear_roster_list() -> void:
	for child in roster_list.get_children():
		roster_list.remove_child(child)
		child.queue_free()


func _format_roster_unit_button_text(unit: Dictionary) -> String:
	var state_text := "Deploy" if bool(unit.get("deployed", true)) else "Reserve"
	var required_text := " Required" if bool(unit.get("required_deployment", false)) else ""
	var protected_text := " Protected" if not bool(unit.get("controllable", true)) else ""
	return "%s%s  Lv.%d %s  %s%s" % [
		str(unit.get("name", "Officer")),
		protected_text,
		int(unit.get("level", 1)),
		str(unit.get("class", "")),
		state_text,
		required_text
	]


func _on_roster_unit_pressed(unit_id: String) -> void:
	var unit := state.get_unit(unit_id)
	if unit.is_empty():
		return
	var next_deployed := not bool(unit.get("deployed", true))
	if state.try_set_unit_deployed(unit_id, next_deployed):
		_play_ui_confirm()
		if not state.get_unit(formation_unit_id).get("deployed", false):
			formation_unit_id = ""
		if not state.get_unit(armory_unit_id).get("deployed", false):
			armory_unit_id = ""
		_rebuild_roster_menu()
	_update_hud()
	queue_redraw()


func _rebuild_formation_menu() -> void:
	if formation_list == null:
		return
	_clear_formation_list()
	var units := state.get_controllable_player_units()
	if formation_unit_id.is_empty() or _find_unit_in_list(units, formation_unit_id).is_empty():
		formation_unit_id = str(units[0].get("id", "")) if not units.is_empty() else ""

	if formation_status_label != null:
		var selected := state.get_unit(formation_unit_id)
		if selected.is_empty():
			formation_status_label.text = "Formation"
		else:
			formation_status_label.text = "Formation: %s at %s" % [
				str(selected.get("name", "Officer")),
				_format_cell_label(selected.get("pos", Vector2i.ZERO))
			]

	if units.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No deployed officers"
		formation_list.add_child(empty_label)
		return

	for unit in units:
		var unit_id := str(unit.get("id", ""))
		var unit_button := Button.new()
		unit_button.text = _format_formation_unit_button_text(unit)
		unit_button.disabled = unit_id == formation_unit_id
		unit_button.pressed.connect(_on_formation_unit_pressed.bind(unit_id))
		formation_list.add_child(unit_button)


func _clear_formation_list() -> void:
	for child in formation_list.get_children():
		formation_list.remove_child(child)
		child.queue_free()


func _format_formation_unit_button_text(unit: Dictionary) -> String:
	var marker := "> " if str(unit.get("id", "")) == formation_unit_id else ""
	return "%s%s  %s" % [
		marker,
		str(unit.get("name", "Officer")),
		_format_cell_label(unit.get("pos", Vector2i.ZERO))
	]


func _format_cell_label(value) -> String:
	if typeof(value) == TYPE_VECTOR2I:
		return "%d,%d" % [value.x + 1, value.y + 1]
	return "-"


func _on_formation_unit_pressed(unit_id: String) -> void:
	_play_ui_click()
	formation_unit_id = unit_id
	_rebuild_formation_menu()
	_update_hud()
	queue_redraw()


func _handle_formation_board_click(screen_position: Vector2) -> void:
	if battle_started or _is_prebattle_prep_locked() or formation_unit_id.is_empty():
		return
	var cell := _cell_from_screen(screen_position)
	if not state.is_inside(cell):
		return
	if state.try_set_prebattle_formation(formation_unit_id, cell):
		_play_ui_confirm()
		_rebuild_formation_menu()
		_update_hud()
		queue_redraw()


func _rebuild_armory_menu() -> void:
	if armory_list == null:
		return
	_clear_armory_list()
	var units := state.get_controllable_player_units()
	if armory_unit_id.is_empty() or _find_unit_in_list(units, armory_unit_id).is_empty():
		armory_unit_id = str(units[0].get("id", "")) if not units.is_empty() else ""

	if armory_status_label != null:
		armory_status_label.text = _format_inventory_status_text()

	if units.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No deployed officers"
		armory_list.add_child(empty_label)
		return

	var unit_title := Label.new()
	unit_title.text = "Officers"
	armory_list.add_child(unit_title)

	for unit in units:
		var unit_id := str(unit.get("id", ""))
		var unit_button := Button.new()
		unit_button.text = _format_armory_unit_button_text(unit)
		unit_button.disabled = unit_id == armory_unit_id
		unit_button.pressed.connect(_on_armory_unit_pressed.bind(unit_id))
		armory_list.add_child(unit_button)

	var selected := state.get_unit(armory_unit_id)
	if selected.is_empty():
		return

	var equipment_label := Label.new()
	equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	equipment_label.text = "Equipped: %s" % _format_unit_equipment(selected)
	armory_list.add_child(equipment_label)

	var equipped_slots := state.get_equipped_equipment_slots(armory_unit_id)
	if not equipped_slots.is_empty():
		var unequip_title := Label.new()
		unequip_title.text = "Unequip"
		armory_list.add_child(unequip_title)
		var equipment := state.get_equipment_snapshot(armory_unit_id)
		for slot in equipped_slots:
			var normalized_slot := str(slot)
			var item_id := _equipment_item_id(equipment, normalized_slot)
			var item := state.get_item_def(item_id)
			var unequip_button := Button.new()
			unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
			unequip_button.pressed.connect(_on_armory_unequip_pressed.bind(normalized_slot))
			armory_list.add_child(unequip_button)

	var item_ids := state.get_equippable_item_ids(armory_unit_id)
	if item_ids.is_empty():
		var empty_equipment_label := Label.new()
		empty_equipment_label.text = "No compatible equipment in inventory"
		armory_list.add_child(empty_equipment_label)
	else:
		var inventory := state.get_inventory_snapshot()
		for item_id in item_ids:
			var normalized_id := str(item_id)
			var item := state.get_item_def(normalized_id)
			var armory_equip_button := Button.new()
			armory_equip_button.text = _format_equipment_option_text(
				selected,
				normalized_id,
				item,
				int(inventory.get(normalized_id, 0))
			)
			armory_equip_button.pressed.connect(_on_armory_equip_pressed.bind(normalized_id))
			armory_list.add_child(armory_equip_button)


func _clear_armory_list() -> void:
	for child in armory_list.get_children():
		armory_list.remove_child(child)
		child.queue_free()


func _find_unit_in_list(units: Array, unit_id: String) -> Dictionary:
	for unit in units:
		if str(unit.get("id", "")) == unit_id:
			return unit
	return {}


func _format_armory_unit_button_text(unit: Dictionary) -> String:
	var marker := "> " if str(unit.get("id", "")) == armory_unit_id else ""
	return "%s%s  Lv.%d  %s" % [
		marker,
		str(unit.get("name", "Officer")),
		int(unit.get("level", 1)),
		_format_unit_equipment(unit)
	]


func _on_armory_unit_pressed(unit_id: String) -> void:
	_play_ui_click()
	armory_unit_id = unit_id
	_rebuild_armory_menu()
	_update_hud()


func _on_armory_equip_pressed(item_id: String) -> void:
	if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
		return
	if not state.try_equip_item(armory_unit_id, item_id):
		_rebuild_armory_menu()
		_update_hud()
		return
	if not _save_prebattle_equipment_change():
		return
	_rebuild_armory_menu()
	_update_hud()
	queue_redraw()


func _on_armory_unequip_pressed(slot: String) -> void:
	if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
		return
	if not state.try_unequip_item(armory_unit_id, slot):
		_rebuild_armory_menu()
		_update_hud()
		return
	if not _save_prebattle_equipment_change():
		return
	_rebuild_armory_menu()
	_update_hud()
	queue_redraw()


func _save_prebattle_equipment_change() -> bool:
	if campaign_state.try_save_prebattle_loadout(state.get_player_roster_snapshot(), state.get_inventory_snapshot()):
		_on_log_added("Saved pre-battle equipment.")
		return true
	_play_ui_cancel()
	_on_log_added("Could not save pre-battle equipment.")
	_load_scenario(active_scenario_id)
	_show_briefing()
	return false


func _rebuild_shop_menu() -> void:
	if shop_list == null:
		return
	_clear_shop_list()
	if shop_status_label != null:
		var mode_text := "Sell" if shop_sell_mode else "Buy"
		shop_status_label.text = "%s | Gold %d | %s" % [
			mode_text,
			campaign_state.gold,
			_format_inventory_status_text()
		]
	if shop_buy_button != null:
		shop_buy_button.disabled = not shop_sell_mode
	if shop_sell_button != null:
		shop_sell_button.disabled = shop_sell_mode or _get_sellable_item_ids().is_empty()

	if not shop_sell_mode:
		_add_shop_merchant_notice()

	if shop_sell_mode:
		_rebuild_shop_sell_list()
	else:
		_rebuild_shop_buy_list()


func _rebuild_shop_buy_list() -> void:
	var item_ids := state.get_shop_item_ids()
	if item_ids.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No shop stock"
		shop_list.add_child(empty_label)
		return

	var inventory := campaign_state.get_inventory_snapshot()
	for item_id in item_ids:
		var normalized_id := str(item_id)
		var item := state.get_item_def(normalized_id)
		var price := int(item.get("price", 0))
		var owned := int(inventory.get(normalized_id, 0))
		var stock_limit := state.get_shop_stock_limit(normalized_id)
		var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, normalized_id)
		var remaining := stock_limit - purchased if stock_limit >= 0 else -1
		var buy_button := Button.new()
		buy_button.text = _format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining)
		_apply_item_button_icon(buy_button, item)
		buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0)
		buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id))
		shop_list.add_child(buy_button)


func _rebuild_shop_sell_list() -> void:
	var item_ids := _get_sellable_item_ids()
	if item_ids.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No sellable stock"
		shop_list.add_child(empty_label)
		return

	var inventory := campaign_state.get_inventory_snapshot()
	for item_id in item_ids:
		var normalized_id := str(item_id)
		var item := state.get_item_def(normalized_id)
		var sale_price := _sale_price_for_item(item)
		var owned := int(inventory.get(normalized_id, 0))
		var sell_button := Button.new()
		sell_button.text = _format_shop_sell_item_button_text(normalized_id, item, sale_price, owned)
		_apply_item_button_icon(sell_button, item)
		sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id))
		shop_list.add_child(sell_button)


func _clear_shop_list() -> void:
	for child in shop_list.get_children():
		shop_list.remove_child(child)
		child.queue_free()


func _add_shop_merchant_notice() -> void:
	var merchant := state.get_shop_merchant()
	if merchant.is_empty():
		return
	var lines := []
	for line in merchant.get("lines", []):
		var text := str(line).strip_edges()
		if not text.is_empty():
			lines.append(text)
	if lines.is_empty():
		return
	var merchant_label := Label.new()
	merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	merchant_label.custom_minimum_size = Vector2(560, 0)
	merchant_label.text = "%s: %s" % [
		str(merchant.get("name", "Merchant")),
		_join_strings(lines, " ")
	]
	shop_list.add_child(merchant_label)


func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String:
	var unavailable := ""
	if stock_limit >= 0 and remaining <= 0:
		unavailable = " - sold out"
	elif campaign_state.gold < price:
		unavailable = " - need %dG" % (price - campaign_state.gold)
	var stock_text := ""
	if stock_limit >= 0:
		stock_text = "  Stock %d/%d" % [max(0, remaining), stock_limit]
	return "%s  %dG  %s  Owned x%d%s%s" % [
		_item_display_name(item_id),
		price,
		_format_shop_item_effect_text(item),
		owned,
		stock_text,
		unavailable
	]


func _format_shop_sell_item_button_text(item_id: String, item: Dictionary, sale_price: int, owned: int) -> String:
	return "%s  Sell %dG  %s  Owned x%d" % [
		_item_display_name(item_id),
		sale_price,
		_format_shop_item_effect_text(item),
		owned
	]


func _format_shop_item_effect_text(item: Dictionary) -> String:
	var kind := str(item.get("kind", "item"))
	if kind == "consumable":
		return _format_item_effect_text(item)
	if kind == "weapon" or kind == "armor" or kind == "accessory":
		var parts := []
		var equipment_type := str(item.get("%s_type" % kind, ""))
		if not equipment_type.is_empty():
			parts.append(equipment_type.capitalize())
		if item.has("range"):
			var range_value: Array = item.get("range", [])
			if range_value.size() >= 2:
				parts.append("RNG %d-%d" % [int(range_value[0]), int(range_value[1])])
		parts.append(_format_equipment_bonus_text(item))
		return _join_strings(parts, ", ")
	return kind.capitalize()


func _apply_item_button_icon(button: Button, item: Dictionary) -> void:
	var icon := _load_art_texture(str(item.get("icon", "")))
	if icon == null:
		return
	button.icon = icon
	button.expand_icon = true
	button.icon_max_width = 30


func _on_shop_item_pressed(item_id: String) -> void:
	if battle_started or _is_prebattle_prep_locked():
		return
	var item := state.get_item_def(item_id)
	var price := int(item.get("price", 0))
	var item_name := _item_display_name(item_id)
	if item.is_empty() or price <= 0:
		return
	var stock_limit := state.get_shop_stock_limit(item_id)
	var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, item_id)
	if stock_limit >= 0 and purchased >= stock_limit:
		_play_ui_cancel()
		_on_log_added("%s is sold out." % item_name)
		_rebuild_shop_menu()
		return
	if campaign_state.try_buy_item(item_id, price, active_scenario_id, stock_limit):
		state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
		_on_log_added("Bought %s for %d gold." % [item_name, price])
	else:
		_play_ui_cancel()
		_on_log_added("Could not buy %s." % item_name)
	_rebuild_shop_menu()
	_update_hud()


func _on_shop_sell_item_pressed(item_id: String) -> void:
	if battle_started or _is_prebattle_prep_locked():
		return
	var item := state.get_item_def(item_id)
	var sale_price := _sale_price_for_item(item)
	var item_name := _item_display_name(item_id)
	if item.is_empty() or sale_price <= 0:
		return
	if campaign_state.try_sell_item(item_id, sale_price):
		state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
		_on_log_added("Sold %s for %d gold." % [item_name, sale_price])
	else:
		_play_ui_cancel()
		_on_log_added("Could not sell %s." % item_name)
	if _get_sellable_item_ids().is_empty():
		shop_sell_mode = false
	_rebuild_shop_menu()
	_update_hud()


func _on_shop_buy_mode_pressed() -> void:
	if not shop_sell_mode:
		return
	_play_ui_click()
	shop_sell_mode = false
	_rebuild_shop_menu()
	_update_hud()


func _on_shop_sell_mode_pressed() -> void:
	if shop_sell_mode or _get_sellable_item_ids().is_empty():
		return
	_play_ui_click()
	shop_sell_mode = true
	_rebuild_shop_menu()
	_update_hud()


func _get_sellable_item_ids() -> Array:
	var item_ids := []
	var inventory := campaign_state.get_inventory_snapshot()
	for item_id in inventory.keys():
		var normalized_id := str(item_id)
		if normalized_id.is_empty() or int(inventory.get(normalized_id, 0)) <= 0:
			continue
		var item := state.get_item_def(normalized_id)
		if _sale_price_for_item(item) <= 0:
			continue
		item_ids.append(normalized_id)
	item_ids.sort()
	return item_ids


func _sale_price_for_item(item: Dictionary) -> int:
	var price := int(item.get("price", 0))
	if price <= 0:
		return 0
	return max(1, int(floor(float(price) * 0.5)))


func _is_input_locked() -> bool:
	return campaign_complete_screen or _is_dialogue_visible() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE


func _apply_battle_result_once() -> void:
	if battle_result_applied:
		return
	battle_result_summary = campaign_state.apply_battle_result(state)
	battle_result_applied = true


func _try_show_post_battle_dialogue() -> bool:
	if post_battle_dialogue_started:
		return false
	var lines := _normalized_dialogue_lines(state.get_post_battle_dialogue())
	if lines.is_empty():
		post_battle_dialogue_started = true
		return false
	post_battle_dialogue_started = true
	dialogue_queue.clear()
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_refresh_ranges()
	active_dialogue_lines = lines
	active_dialogue_index = 0
	_render_dialogue_line()
	return true


func _rebuild_result_choices() -> void:
	if result_choice_list == null:
		return
	_clear_result_choices()
	if bool(battle_result_summary.get("choice_applied", true)):
		return
	var choices: Array = battle_result_summary.get("post_battle_choices", [])
	for choice in choices:
		if typeof(choice) != TYPE_DICTIONARY:
			continue
		var choice_button := Button.new()
		choice_button.text = _format_post_battle_choice_text(choice)
		choice_button.pressed.connect(_on_post_battle_choice_pressed.bind(choice))
		result_choice_list.add_child(choice_button)


func _clear_result_choices() -> void:
	if result_choice_list == null:
		return
	for child in result_choice_list.get_children():
		result_choice_list.remove_child(child)
		child.queue_free()


func _format_post_battle_choice_text(choice: Dictionary) -> String:
	var label := str(choice.get("label", choice.get("text", choice.get("id", "Choice"))))
	var description := str(choice.get("description", ""))
	var reward_text := _format_post_battle_choice_reward_text(choice)
	var text := label
	if description.is_empty():
		text = label
	else:
		text = "%s - %s" % [label, description]
	if not reward_text.is_empty():
		text += "\nRewards: %s" % reward_text
	return text


func _format_post_battle_choice_reward_text(choice: Dictionary) -> String:
	var parts := []
	var gold: int = maxi(0, int(choice.get("gold", 0)))
	if gold > 0:
		parts.append("+%dG" % gold)
	if typeof(choice.get("items", [])) == TYPE_ARRAY:
		var items: Array = choice.get("items", [])
		if not items.is_empty():
			parts.append(_format_reward_items(items))
	var joined_text := _format_officer_id_list(choice.get("join_officers", []))
	if not joined_text.is_empty():
		parts.append("Join %s" % joined_text)
	var left_text := _format_officer_id_list(choice.get("leave_officers", []))
	if not left_text.is_empty():
		parts.append("Leave %s" % left_text)
	return _join_strings(parts, ", ")


func _on_post_battle_choice_pressed(choice: Dictionary) -> void:
	if battle_result_summary.is_empty() or bool(battle_result_summary.get("choice_applied", true)):
		return
	var scenario_id := str(battle_result_summary.get("scenario_id", state.battle_id))
	if campaign_state.try_apply_post_battle_choice(choice, scenario_id):
		_play_ui_confirm()
		battle_result_summary["pending_choice"] = false
		battle_result_summary["choice_applied"] = true
		battle_result_summary["saved"] = true
		battle_result_summary["choice_label"] = str(choice.get("label", choice.get("id", "")))
		var choice_gold: int = maxi(0, int(choice.get("gold", 0)))
		battle_result_summary["gold"] = int(battle_result_summary.get("gold", 0)) + choice_gold
		_append_result_items(choice.get("items", []))
		_append_unique_result_values("joined_officers", choice.get("join_officers", []))
		_append_unique_result_values("left_officers", choice.get("leave_officers", []))
		_on_log_added("Campaign choice saved: %s." % battle_result_summary["choice_label"])
	else:
		_play_ui_cancel()
		_on_log_added("Could not save campaign choice.")
	_rebuild_result_choices()
	_update_hud()


func _append_result_items(values) -> void:
	if typeof(values) != TYPE_ARRAY:
		return
	var current: Array = battle_result_summary.get("items", [])
	for value in values:
		var normalized := str(value)
		if normalized.is_empty():
			continue
		current.append(normalized)
	battle_result_summary["items"] = current


func _append_unique_result_values(key: String, values) -> void:
	if typeof(values) != TYPE_ARRAY:
		return
	var current: Array = battle_result_summary.get(key, [])
	for value in values:
		var normalized := str(value)
		if normalized.is_empty() or current.has(normalized):
			continue
		current.append(normalized)
	battle_result_summary[key] = current


func _format_battle_result_summary() -> String:
	if battle_result_summary.is_empty():
		return ""
	if battle_result_summary.get("pending_choice", false):
		var pending_next := str(battle_result_summary.get("next_scenario_title", ""))
		var pending_text := "Campaign\n  Saved\nChoice\n  Select a campaign response."
		if not pending_next.is_empty():
			pending_text += "\nNext\n  %s" % pending_next
		return pending_text
	if battle_result_summary.get("already_completed", false):
		var already_saved := "Campaign unchanged." if battle_result_summary.get("saved", false) else "Campaign replay failed."
		var already_next := str(battle_result_summary.get("next_scenario_title", ""))
		var replay_lines := [
			"Rewards\n  Already claimed",
			"Campaign\n  %s" % already_saved
		]
		if not already_next.is_empty():
			replay_lines.append("Next\n  %s" % already_next)
		return _join_strings(replay_lines, "\n")

	var items: Array = battle_result_summary.get("items", [])
	var item_text := _format_reward_items(items)
	var reward_lines := []
	if bool(battle_result_summary.get("rewards_applied", false)):
		var gold_reward := int(battle_result_summary.get("gold", 0))
		if gold_reward > 0:
			reward_lines.append("Gold +%d" % gold_reward)
		if item_text != "none":
			reward_lines.append("Items: %s" % item_text)
	else:
		reward_lines.append("Not applied")

	var roster_lines := []
	var joined_officers: Array = battle_result_summary.get("joined_officers", [])
	if not joined_officers.is_empty():
		roster_lines.append("Joined: %s" % _format_officer_id_list(joined_officers))
	var left_officers: Array = battle_result_summary.get("left_officers", [])
	if not left_officers.is_empty():
		roster_lines.append("Left: %s" % _format_officer_id_list(left_officers))

	var sections := [
		"Rewards\n  %s" % _join_strings(reward_lines, "\n  ")
	]
	if not roster_lines.is_empty():
		sections.append("Roster\n  %s" % _join_strings(roster_lines, "\n  "))
	var progression_text := _format_progression_events(battle_result_summary.get("progression_events", []))
	if not progression_text.is_empty():
		sections.append("Growth\n  %s" % progression_text)
	var saved_text := "Campaign saved." if battle_result_summary.get("saved", false) else "Campaign save failed."
	var next_text := "Campaign complete."
	var next_title := str(battle_result_summary.get("next_scenario_title", ""))
	if not next_title.is_empty():
		next_text = "Next: %s" % next_title
	sections.append("Campaign\n  Total gold: %d\n  %s\n  %s" % [
		campaign_state.gold,
		saved_text,
		next_text
	])
	if not bool(battle_result_summary.get("choice_applied", true)):
		sections.append("Choice\n  Select a campaign response.")
	elif not str(battle_result_summary.get("choice_label", "")).is_empty():
		sections.append("Choice\n  %s" % str(battle_result_summary.get("choice_label", "")))
	return _join_strings(sections, "\n")


func _format_progression_events(events) -> String:
	if typeof(events) != TYPE_ARRAY:
		return ""
	var parts := []
	var hidden_count := 0
	for event in events:
		if typeof(event) != TYPE_DICTIONARY:
			continue
		if parts.size() >= 4:
			hidden_count += 1
			continue
		var event_type := str(event.get("type", ""))
		var unit_name := str(event.get("name", "Unit"))
		if event_type == "promotion":
			parts.append("%s -> %s" % [unit_name, str(event.get("to_class", "Promoted"))])
		elif event_type == "level_up":
			parts.append("%s Lv.%d" % [unit_name, int(event.get("to_level", 1))])
	if hidden_count > 0:
		parts.append("+%d more" % hidden_count)
	if parts.is_empty():
		return ""
	return _join_strings(parts, "; ")


func _format_inventory_status_text() -> String:
	var inventory := state.get_inventory_snapshot()
	if campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE:
		inventory = campaign_state.get_inventory_snapshot()
	return "Inventory: %s" % _format_inventory_by_category(inventory)


func _format_reward_items(items: Array) -> String:
	var counts := {}
	for item_id in items:
		var key := str(item_id)
		if key.is_empty():
			continue
		counts[key] = int(counts.get(key, 0)) + 1
	return _format_inventory_items(counts)


func _format_inventory_items(inventory: Dictionary) -> String:
	var item_ids := []
	for item_id in inventory.keys():
		var key := str(item_id)
		if key.is_empty() or int(inventory.get(key, 0)) <= 0:
			continue
		item_ids.append(key)
	item_ids.sort()
	if item_ids.is_empty():
		return "none"

	var parts := []
	for item_id in item_ids:
		var key := str(item_id)
		parts.append("%s x%d" % [_item_inventory_display_name(key), int(inventory.get(key, 0))])
	return _join_strings(parts, ", ")


func _format_inventory_by_category(inventory: Dictionary) -> String:
	var consumables := {}
	var equipment := {}
	var other := {}
	for item_id in inventory.keys():
		var key := str(item_id)
		var count := int(inventory.get(key, 0))
		if key.is_empty() or count <= 0:
			continue
		var item := state.get_item_def(key)
		var kind := str(item.get("kind", ""))
		if kind == "consumable":
			consumables[key] = count
		elif kind == "weapon" or kind == "armor" or kind == "accessory":
			equipment[key] = count
		else:
			other[key] = count

	var sections := []
	if not consumables.is_empty():
		sections.append("Consumables: %s" % _format_inventory_items(consumables))
	if not equipment.is_empty():
		sections.append("Equipment: %s" % _format_inventory_items(equipment))
	if not other.is_empty():
		sections.append("Other: %s" % _format_inventory_items(other))
	if sections.is_empty():
		return "none"
	return _join_strings(sections, " | ")


func _format_officer_id_list(values) -> String:
	if typeof(values) != TYPE_ARRAY:
		return ""
	var labels := []
	for value in values:
		var label := _format_identifier_label(str(value))
		if label.is_empty():
			continue
		labels.append(label)
	return _join_strings(labels, ", ")


func _format_identifier_label(value: String) -> String:
	return value.strip_edges().replace("_", " ").capitalize()


func _item_display_name(item_id: String) -> String:
	var item := state.get_item_def(item_id)
	if item.is_empty():
		return item_id
	return str(item.get("name", item_id))


func _item_inventory_display_name(item_id: String) -> String:
	var item := state.get_item_def(item_id)
	if item.is_empty():
		return item_id
	var display_name := str(item.get("name", item_id))
	var rarity_text := _format_item_rarity_text(item)
	if rarity_text.is_empty():
		return display_name
	return "%s [%s]" % [display_name, rarity_text]


func _join_strings(values: Array, delimiter: String) -> String:
	var text := ""
	for value in values:
		if not text.is_empty():
			text += delimiter
		text += str(value)
	return text


func _on_next_battle_pressed() -> void:
	var next_scenario_id := str(battle_result_summary.get("next_scenario_id", ""))
	if next_scenario_id.is_empty():
		return
	_play_ui_confirm()
	if log_box != null:
		log_box.clear()
	_load_scenario(next_scenario_id)
	_show_briefing()
	_update_hud()


func _show_campaign_complete() -> void:
	campaign_complete_screen = true
	_play_bgm("menu")
	battle_started = false
	battle_result_applied = true
	battle_result_summary.clear()
	post_battle_dialogue_started = true
	floating_texts.clear()
	unit_motion_by_unit.clear()
	move_cells.clear()
	attack_cells.clear()
	skill_cells.clear()
	item_cells.clear()
	selected_skill_id = ""
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	_clear_dialogue()
	active_scenario_id = campaign_state.current_scenario_id
	if briefing_panel != null:
		briefing_panel.visible = false
	if log_box != null:
		log_box.append_text("Campaign complete.\n")


func _on_new_campaign_pressed() -> void:
	_play_ui_confirm()
	if log_box != null:
		log_box.clear()
	if not campaign_state.reset_save(campaign_state.get_start_scenario_id()):
		result_panel.visible = true
		result_label.text = "Unable to reset campaign save.\nClose the game and check file permissions."
		if result_restart_button != null:
			result_restart_button.visible = false
		if next_battle_button != null:
			next_battle_button.visible = false
		return
	campaign_complete_screen = false
	battle_started = false
	battle_result_applied = false
	battle_result_summary.clear()
	post_battle_dialogue_started = false
	selected_skill_id = ""
	selected_item_id = ""
	formation_unit_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_hide_chapter_menu()
	_hide_shop_menu()
	_hide_armory_menu()
	_hide_roster_menu()
	_hide_formation_menu()
	_hide_save_menu()
	_load_current_battle()
	_show_briefing()
	_update_hud()


func _update_tactic_button(selected: Dictionary) -> void:
	if tactic_button == null:
		return
	if selected.is_empty():
		tactic_button.text = "Tactic"
		tactic_button.disabled = true
		return

	var skill_ids := state.get_skill_ids(selected["id"])
	if skill_ids.is_empty():
		tactic_button.text = "Tactic"
		tactic_button.disabled = true
		return
	if state.is_unit_skill_sealed(selected["id"]):
		tactic_button.text = "Tactic - Sealed"
		tactic_button.disabled = true
		return

	if not selected_skill_id.is_empty():
		var skill := state.get_skill_def(selected_skill_id)
		tactic_button.text = "Tactic: %s" % str(skill.get("name", selected_skill_id))
	elif tactic_menu != null and tactic_menu.visible:
		tactic_button.text = "Close Tactic"
	else:
		tactic_button.text = "Tactic"
	tactic_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked()


func _show_tactic_menu(selected: Dictionary) -> void:
	if tactic_menu == null:
		return
	_hide_item_menu()
	_hide_equip_menu()
	_rebuild_tactic_menu(selected)
	tactic_menu.visible = true


func _hide_tactic_menu() -> void:
	if tactic_menu == null:
		return
	tactic_menu.visible = false


func _rebuild_tactic_menu(selected: Dictionary) -> void:
	if tactic_list == null:
		return
	_clear_tactic_list()
	var skill_ids := state.get_skill_ids(selected["id"])
	if skill_ids.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No tactics"
		tactic_list.add_child(empty_label)
		return
	var skill_sealed := state.is_unit_skill_sealed(selected["id"])
	if skill_sealed:
		var sealed_label := Label.new()
		sealed_label.text = "Tactics sealed"
		tactic_list.add_child(sealed_label)

	for skill_id in skill_ids:
		var skill := state.get_skill_def(str(skill_id))
		var skill_button := Button.new()
		skill_button.text = _format_tactic_button_text(str(skill_id), skill, selected)
		skill_button.disabled = skill_sealed or selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0))
		skill_button.pressed.connect(_on_tactic_skill_pressed.bind(str(skill_id)))
		tactic_list.add_child(skill_button)

	var cancel_button := Button.new()
	cancel_button.text = "Cancel"
	cancel_button.pressed.connect(_on_tactic_cancel_pressed)
	tactic_list.add_child(cancel_button)


func _clear_tactic_list() -> void:
	for child in tactic_list.get_children():
		tactic_list.remove_child(child)
		child.queue_free()


func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: Dictionary) -> String:
	var marker := "> " if selected_skill_id == skill_id else ""
	var skill_name := str(skill.get("name", skill_id))
	var mp_cost := int(skill.get("mp_cost", 0))
	var kind := str(skill.get("kind", "skill")).capitalize()
	var range_text := _format_tactic_range_text(skill)
	var effect_text := _format_tactic_effect_text(skill)
	var disabled_text := ""
	if state.is_unit_skill_sealed(selected["id"]):
		disabled_text = " - Sealed"
	elif int(selected.get("mp", 0)) < mp_cost:
		disabled_text = " - MP"
	return "%s%s  %dMP  %s  R%s  %s%s" % [marker, skill_name, mp_cost, kind, range_text, effect_text, disabled_text]


func _format_tactic_effect_text(skill: Dictionary) -> String:
	var area_text := _format_tactic_area_text(skill)
	if str(skill.get("kind", "")) == "support":
		var parts := []
		for effect in skill.get("effects", []):
			if typeof(effect) != TYPE_DICTIONARY:
				continue
			var effect_type := str(effect.get("type", ""))
			var amount := int(effect.get("amount", 0))
			if effect_type == "stat_bonus":
				if amount == 0:
					continue
				var sign := "+" if amount > 0 else ""
				parts.append("%s %s%d" % [str(effect.get("stat", "")).to_upper(), sign, amount])
			elif effect_type == "damage_over_time" and amount > 0:
				var status_name := str(effect.get("status", "status")).replace("_", " ").capitalize()
				parts.append("%s -%d" % [status_name, amount])
			elif effect_type == "action_lock":
				parts.append("%s %s" % [
					str(effect.get("status", "status")).replace("_", " ").capitalize(),
					_action_lock_effect_label(str(effect.get("action", "")))
				])
		if not parts.is_empty():
			var support_text := _join_strings(parts, ", ")
			return "%s, %s" % [support_text, area_text] if not area_text.is_empty() else support_text
	var power_text := "P%d" % int(skill.get("power", 0))
	return "%s, %s" % [power_text, area_text] if not area_text.is_empty() else power_text


func _format_tactic_area_text(skill: Dictionary) -> String:
	var shape := str(skill.get("area_shape", "single"))
	var radius := int(skill.get("area_radius", 0))
	if shape == "diamond" and radius > 0:
		return "Area %d" % radius
	return ""


func _format_tactic_range_text(skill: Dictionary) -> String:
	var value = skill.get("range", 1)
	if typeof(value) == TYPE_ARRAY:
		if value.size() >= 2:
			return "%d-%d" % [int(value[0]), int(value[1])]
		if value.size() == 1:
			return "%d" % int(value[0])
		return "1"
	if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
		return "%d" % int(value)
	return "1"


func _action_lock_effect_label(action: String) -> String:
	if action == "skill":
		return "tactics"
	if action == "move":
		return "movement"
	if action == "attack":
		return "attacks"
	return "action"


func _on_tactic_skill_pressed(skill_id: String) -> void:
	_play_ui_confirm()
	selected_skill_id = skill_id
	selected_item_id = ""
	_hide_tactic_menu()
	_hide_item_menu()
	_hide_equip_menu()
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_tactic_cancel_pressed() -> void:
	_play_ui_cancel()
	selected_skill_id = ""
	_hide_tactic_menu()
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _update_item_button(selected: Dictionary) -> void:
	if item_button == null:
		return
	if selected.is_empty():
		item_button.text = "Item"
		item_button.disabled = true
		return

	var item_ids := state.get_usable_item_ids()
	if item_ids.is_empty():
		item_button.text = "Item"
		item_button.disabled = true
		return

	if not selected_item_id.is_empty():
		var item := state.get_item_def(selected_item_id)
		item_button.text = "Item: %s" % str(item.get("name", selected_item_id))
	elif item_menu != null and item_menu.visible:
		item_button.text = "Close Item"
	else:
		item_button.text = "Item"
	item_button.disabled = selected.get("acted", false) or not state.can_player_act() or _is_input_locked()


func _show_item_menu(selected: Dictionary) -> void:
	if item_menu == null:
		return
	_hide_tactic_menu()
	_hide_equip_menu()
	_rebuild_item_menu(selected)
	item_menu.visible = true


func _hide_item_menu() -> void:
	if item_menu == null:
		return
	item_menu.visible = false


func _rebuild_item_menu(selected: Dictionary) -> void:
	if item_list == null:
		return
	_clear_item_list()
	var item_ids := state.get_usable_item_ids()
	if item_ids.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No items"
		item_list.add_child(empty_label)
		return

	var inventory := state.get_inventory_snapshot()
	for item_id in item_ids:
		var normalized_id := str(item_id)
		var item := state.get_item_def(normalized_id)
		var count := int(inventory.get(normalized_id, 0))
		var use_button := Button.new()
		use_button.text = _format_item_button_text(normalized_id, item, count)
		_apply_item_button_icon(use_button, item)
		use_button.disabled = selected.get("acted", false) or _is_input_locked() or count <= 0
		use_button.pressed.connect(_on_item_selected_pressed.bind(normalized_id))
		item_list.add_child(use_button)

	var cancel_button := Button.new()
	cancel_button.text = "Cancel"
	cancel_button.pressed.connect(_on_item_cancel_pressed)
	item_list.add_child(cancel_button)


func _clear_item_list() -> void:
	for child in item_list.get_children():
		item_list.remove_child(child)
		child.queue_free()


func _format_item_button_text(item_id: String, item: Dictionary, count: int) -> String:
	var marker := "> " if selected_item_id == item_id else ""
	var item_name := str(item.get("name", item_id))
	return "%s%s  x%d  %s" % [marker, item_name, count, _format_item_effect_text(item)]


func _format_item_effect_text(item: Dictionary) -> String:
	var parts := []
	for effect in item.get("effects", []):
		if typeof(effect) != TYPE_DICTIONARY:
			continue
		if str(effect.get("type", "")) == "heal_hp":
			parts.append("Heal %d" % int(effect.get("amount", 0)))
		elif str(effect.get("type", "")) == "heal_mp":
			parts.append("MP %d" % int(effect.get("amount", 0)))
		elif str(effect.get("type", "")) == "cure_status":
			parts.append("Cure %s" % str(effect.get("status", "status")).replace("_", " ").capitalize())
		elif str(effect.get("type", "")) == "cleanse_debuffs":
			parts.append("Cleanse debuffs")
	if parts.is_empty():
		return str(item.get("kind", "item")).capitalize()
	return _join_strings(parts, ", ")


func _on_item_selected_pressed(item_id: String) -> void:
	_play_ui_confirm()
	selected_item_id = item_id
	selected_skill_id = ""
	_hide_item_menu()
	_hide_tactic_menu()
	_hide_equip_menu()
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_item_cancel_pressed() -> void:
	_play_ui_cancel()
	selected_item_id = ""
	_hide_item_menu()
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _update_equip_button(selected: Dictionary) -> void:
	if equip_button == null:
		return
	if selected.is_empty():
		equip_button.text = "Equip"
		equip_button.disabled = true
		return

	if equip_menu != null and equip_menu.visible:
		equip_button.text = "Close Equip"
	else:
		equip_button.text = "Equip"
	equip_button.disabled = selected.get("moved", false) or selected.get("acted", false) or not state.can_player_act() or _is_input_locked()


func _show_equip_menu(selected: Dictionary) -> void:
	if equip_menu == null:
		return
	_hide_tactic_menu()
	_hide_item_menu()
	_rebuild_equip_menu(selected)
	equip_menu.visible = true


func _hide_equip_menu() -> void:
	if equip_menu == null:
		return
	equip_menu.visible = false


func _rebuild_equip_menu(selected: Dictionary) -> void:
	if equip_list == null:
		return
	_clear_equip_list()
	var equipment_label := Label.new()
	equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	equipment_label.text = "Equipped: %s" % _format_unit_equipment(selected)
	equip_list.add_child(equipment_label)

	var equipped_slots := state.get_equipped_equipment_slots(str(selected["id"]))
	if not equipped_slots.is_empty():
		var unequip_title := Label.new()
		unequip_title.text = "Unequip"
		equip_list.add_child(unequip_title)
		var equipment := state.get_equipment_snapshot(str(selected["id"]))
		for slot in equipped_slots:
			var normalized_slot := str(slot)
			var item_id := _equipment_item_id(equipment, normalized_slot)
			var item := state.get_item_def(item_id)
			var unequip_button := Button.new()
			unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
			_apply_item_button_icon(unequip_button, item)
			unequip_button.disabled = _is_input_locked()
			unequip_button.pressed.connect(_on_equip_unequip_pressed.bind(normalized_slot))
			equip_list.add_child(unequip_button)

	var item_ids := state.get_equippable_item_ids(selected["id"])
	if item_ids.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No compatible equipment"
		equip_list.add_child(empty_label)
	else:
		var inventory := state.get_inventory_snapshot()
		for item_id in item_ids:
			var normalized_id := str(item_id)
			var item := state.get_item_def(normalized_id)
			var equip_item_button := Button.new()
			equip_item_button.text = _format_equipment_option_text(
				selected,
				normalized_id,
				item,
				int(inventory.get(normalized_id, 0))
			)
			_apply_item_button_icon(equip_item_button, item)
			equip_item_button.disabled = _is_input_locked()
			equip_item_button.pressed.connect(_on_equip_item_pressed.bind(normalized_id))
			equip_list.add_child(equip_item_button)

	var cancel_button := Button.new()
	cancel_button.text = "Cancel"
	cancel_button.pressed.connect(_on_equip_cancel_pressed)
	equip_list.add_child(cancel_button)


func _clear_equip_list() -> void:
	for child in equip_list.get_children():
		equip_list.remove_child(child)
		child.queue_free()


func _format_unit_equipment(unit: Dictionary) -> String:
	var equipment := state.get_equipment_snapshot(unit["id"])
	var parts := []
	for slot in ["weapon", "armor", "accessory"]:
		var item_id := _equipment_item_id(equipment, slot)
		if item_id.is_empty():
			parts.append("%s -" % slot.capitalize())
		else:
			parts.append("%s %s" % [slot.capitalize(), _item_display_name(item_id)])
	return _join_strings(parts, ", ")


func _format_hover_unit_equipment(unit: Dictionary) -> String:
	var equipment := state.get_equipment_snapshot(unit["id"])
	var parts := []
	for slot in ["weapon", "armor", "accessory"]:
		var item_id := _equipment_item_id(equipment, slot)
		if item_id.is_empty():
			parts.append("-")
		else:
			parts.append(_item_display_name(item_id))
	return _join_strings(parts, " / ")


func _equipment_item_id(equipment: Dictionary, slot: String) -> String:
	var item_id = equipment.get(slot, "")
	if item_id == null:
		return ""
	return str(item_id)


func _format_equipment_option_text(unit: Dictionary, item_id: String, item: Dictionary, count: int) -> String:
	var parts := [
		_item_display_name(item_id),
		"x%d" % count,
		_format_equipment_bonus_text(item)
	]
	var change_text := _format_equipment_change_text(unit, item)
	if not change_text.is_empty():
		parts.append(change_text)
	return _join_strings(parts, "  ")


func _format_equipment_unequip_text(unit: Dictionary, slot: String, item_id: String, item: Dictionary) -> String:
	var parts := [
		"Unequip %s:" % slot.capitalize(),
		_item_display_name(item_id)
	]
	var change_text := _format_equipment_unequip_change_text(unit, slot, item)
	if not change_text.is_empty():
		parts.append(change_text)
	return _join_strings(parts, "  ")


func _format_equipment_unequip_change_text(unit: Dictionary, slot: String, item: Dictionary) -> String:
	if unit.is_empty() or item.is_empty():
		return ""
	var parts := []
	var current_bonuses := _equipment_bonus_map(item)
	for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
		var delta := -int(current_bonuses.get(stat, 0))
		if delta == 0:
			continue
		parts.append("%s %d" % [stat.to_upper(), delta])
	if slot == "weapon":
		var current_range := _format_equipment_item_range_text(item)
		if not current_range.is_empty():
			parts.append("RNG returns to class")
		var effectiveness_text := _format_equipment_effectiveness_text(item)
		if not effectiveness_text.is_empty():
			parts.append("Loses %s" % effectiveness_text)
	if parts.is_empty():
		return "Returns to inventory"
	return "Change %s" % _join_strings(parts, ", ")


func _format_equipment_change_text(unit: Dictionary, item: Dictionary) -> String:
	if unit.is_empty() or item.is_empty():
		return ""
	var slot := _equipment_slot_for_display(item)
	if slot.is_empty():
		return ""

	var equipment := state.get_equipment_snapshot(str(unit.get("id", "")))
	var current_item_id := _equipment_item_id(equipment, slot)
	var current_item := state.get_item_def(current_item_id)
	var parts := []
	var candidate_bonuses := _equipment_bonus_map(item)
	var current_bonuses := _equipment_bonus_map(current_item)
	for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
		var delta := int(candidate_bonuses.get(stat, 0)) - int(current_bonuses.get(stat, 0))
		if delta == 0:
			continue
		var sign := "+" if delta > 0 else ""
		parts.append("%s %s%d" % [stat.to_upper(), sign, delta])

	var range_text := _format_equipment_range_change_text(current_item, item)
	if not range_text.is_empty():
		parts.append(range_text)
	var effectiveness_text := _format_equipment_effectiveness_change_text(current_item, item)
	if not effectiveness_text.is_empty():
		parts.append(effectiveness_text)
	if parts.is_empty():
		return "Change none"
	return "Change %s" % _join_strings(parts, ", ")


func _equipment_slot_for_display(item: Dictionary) -> String:
	var kind := str(item.get("kind", ""))
	if kind == "weapon" or kind == "armor" or kind == "accessory":
		return kind
	return ""


func _equipment_bonus_map(item: Dictionary) -> Dictionary:
	if item.is_empty() or typeof(item.get("bonuses", {})) != TYPE_DICTIONARY:
		return {}
	return item.get("bonuses", {})


func _format_equipment_range_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
	if str(candidate_item.get("kind", "")) != "weapon":
		return ""
	var candidate_range := _format_equipment_item_range_text(candidate_item)
	var current_range := _format_equipment_item_range_text(current_item)
	if candidate_range.is_empty() or candidate_range == current_range:
		return ""
	if current_range.is_empty():
		current_range = "-"
	return "RNG %s->%s" % [current_range, candidate_range]


func _format_equipment_item_range_text(item: Dictionary) -> String:
	if item.is_empty() or not item.has("range"):
		return ""
	var value = item.get("range", 1)
	if typeof(value) == TYPE_ARRAY:
		if value.size() >= 2:
			return "%d-%d" % [int(value[0]), int(value[1])]
		if value.size() == 1:
			return "%d" % int(value[0])
		return ""
	if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
		return "%d" % int(value)
	return ""


func _format_equipment_effectiveness_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
	if str(candidate_item.get("kind", "")) != "weapon":
		return ""
	var candidate_text := _format_equipment_effectiveness_text(candidate_item)
	var current_text := _format_equipment_effectiveness_text(current_item)
	if candidate_text == current_text:
		return ""
	if current_text.is_empty():
		return "Gains %s" % candidate_text
	if candidate_text.is_empty():
		return "Loses %s" % current_text
	return "Effect changes"


func _format_equipment_bonus_text(item: Dictionary) -> String:
	var parts := []
	var rarity_text := _format_equipment_rarity_text(item)
	if not rarity_text.is_empty():
		parts.append(rarity_text)
	var bonuses: Dictionary = item.get("bonuses", {})
	for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
		var amount := int(bonuses.get(stat, 0))
		if amount == 0:
			continue
		var sign := "+" if amount > 0 else ""
		parts.append("%s %s%d" % [stat.to_upper(), sign, amount])
	var effective_text := _format_equipment_effectiveness_text(item)
	if not effective_text.is_empty():
		parts.append(effective_text)
	if parts.is_empty():
		return str(item.get("kind", "equipment")).capitalize()
	return _join_strings(parts, ", ")


func _format_equipment_rarity_text(item: Dictionary) -> String:
	return _format_item_rarity_text(item)


func _format_item_rarity_text(item: Dictionary) -> String:
	var rarity := str(item.get("rarity", ""))
	if rarity == "named":
		return "Named"
	return ""


func _format_equipment_effectiveness_text(item: Dictionary) -> String:
	if str(item.get("kind", "")) != "weapon":
		return ""
	var bonus := int(item.get("effective_bonus_damage", 0))
	if bonus <= 0:
		return ""
	var move_types = item.get("effective_vs_move_types", [])
	if typeof(move_types) != TYPE_ARRAY or move_types.is_empty():
		return ""
	var labels := []
	for move_type in move_types:
		var label := _format_move_type(str(move_type))
		if not label.is_empty():
			labels.append(label)
	if labels.is_empty():
		return ""
	return "Effective +%d vs %s" % [bonus, _join_strings(labels, "/")]


func _format_move_type(move_type: String) -> String:
	return move_type.strip_edges().replace("_", " ").capitalize()


func _on_equip_item_pressed(item_id: String) -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty():
		return
	if state.try_equip_item(selected["id"], item_id):
		_rebuild_equip_menu(state.get_selected_unit())
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_equip_unequip_pressed(slot: String) -> void:
	var selected := state.get_selected_unit()
	if selected.is_empty():
		return
	if state.try_unequip_item(selected["id"], slot):
		_rebuild_equip_menu(state.get_selected_unit())
	_refresh_ranges()
	_update_hud()
	queue_redraw()


func _on_equip_cancel_pressed() -> void:
	_play_ui_cancel()
	_hide_equip_menu()
	_update_hud()
	queue_redraw()
