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()