Files
heros/scripts/scenes/battle_scene.gd

3539 lines
111 KiB
GDScript

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 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 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 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 UNIT_MOVE_ANIMATION_DURATION := 0.18
const TARGET_PREVIEW_BADGE_SIZE := Vector2(92, 22)
const LOW_HP_WARNING_RATIO := 0.35
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 selected_skill_id := ""
var selected_item_id := ""
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 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 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_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 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 bgm_player: AudioStreamPlayer
var current_bgm_key := ""
var audio_stream_cache := {}
var last_announced_battle_status := ""
var floating_texts: Array[Dictionary] = []
var unit_motion_by_unit: Dictionary = {}
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)
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_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE:
_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_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.custom_minimum_size = Vector2(300, 40)
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)
var side_panel := PanelContainer.new()
side_panel.position = Vector2(760, 104)
side_panel.size = Vector2(480, 580)
root.add_child(side_panel)
var side_column := VBoxContainer.new()
side_column.add_theme_constant_override("separation", 10)
side_panel.add_child(side_column)
selected_label = Label.new()
selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
selected_label.custom_minimum_size = Vector2(420, 92)
side_column.add_child(selected_label)
cell_info_label = Label.new()
cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
cell_info_label.custom_minimum_size = Vector2(420, 106)
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, 84)
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, 40)
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)
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, 170)
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)
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)
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 = Vector2(128, 474)
dialogue_panel.size = Vector2(760, 140)
root.add_child(dialogue_panel)
dialogue_row = HBoxContainer.new()
dialogue_row.add_theme_constant_override("separation", 14)
dialogue_panel.add_child(dialogue_row)
dialogue_portrait_panel = PanelContainer.new()
dialogue_portrait_panel.custom_minimum_size = Vector2(96, 112)
dialogue_row.add_child(dialogue_portrait_panel)
var dialogue_portrait_stack := Control.new()
dialogue_portrait_stack.custom_minimum_size = Vector2(96, 112)
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.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", 22)
dialogue_portrait_stack.add_child(dialogue_portrait_label)
dialogue_column = VBoxContainer.new()
dialogue_column.custom_minimum_size = Vector2(620, 112)
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(600, 22)
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 = Vector2(600, 56)
dialogue_column.add_child(dialogue_text_label)
dialogue_continue_button = Button.new()
dialogue_continue_button.text = "Next"
dialogue_continue_button.pressed.connect(_advance_dialogue)
dialogue_column.add_child(dialogue_continue_button)
result_panel = PanelContainer.new()
result_panel.visible = false
result_panel.position = Vector2(300, 182)
result_panel.size = Vector2(500, 340)
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_CENTER
result_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
result_label.custom_minimum_size = Vector2(460, 176)
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.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 "):
_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()
func _process(delta: float) -> void:
var needs_redraw := 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 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:
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)
draw_rect(rect, state.get_terrain_color(cell))
draw_rect(rect, GRID_COLOR, false, 1.0)
var board_rect := Rect2(BOARD_OFFSET, Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE)
draw_rect(board_rect, Color(0.02, 0.025, 0.03), false, 3.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 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)
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 body_color := team_color.darkened(0.35) if unit.get("acted", false) else team_color
draw_circle(center, 24, body_color)
draw_arc(center, 24, 0.0, TAU, 48, Color(0.03, 0.035, 0.04), 2.0)
var label := _short_name(String(unit["name"]))
draw_string(font, rect.position + Vector2(2, 25), label, HORIZONTAL_ALIGNMENT_CENTER, TILE_SIZE - 4, 13, 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 - 12), Vector2(TILE_SIZE - 16, 6))
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)
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 _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) -> 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)
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() or not unit_motion_by_unit.has(unit_id):
return rect
var motion: Dictionary = unit_motion_by_unit[unit_id]
var from_cell: Vector2i = motion.get("from", cell)
var to_cell: Vector2i = motion.get("to", cell)
var duration: float = maxf(0.01, float(motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
var age := float(motion.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var smooth_progress := progress * progress * (3.0 - 2.0 * progress)
var from_rect := _rect_for_cell(from_cell)
var to_rect := _rect_for_cell(to_cell)
rect.position = from_rect.position.lerp(to_rect.position, smooth_progress)
return rect
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 == "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 == "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 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() 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 not bool(preview.get("has_mp", false)):
return _make_target_preview_badge("NO MP", "invalid")
var kind := str(preview.get("kind", "damage"))
if kind == "heal":
var heal := int(preview.get("heal", 0))
if heal <= 0:
return _make_target_preview_badge("FULL", "invalid")
return _make_target_preview_badge("+%d HP" % heal, "heal")
if kind == "support":
var effect_text := str(preview.get("effect_text", "Support"))
var badge_kind := "poison" if effect_text.contains("Poison") else ("debuff" if effect_text.contains("-") else "support")
return _make_target_preview_badge(_compact_support_preview_text(effect_text), badge_kind)
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)
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()
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 = String(state.objectives.get("victory", "Defeat all enemies."))
campaign_status_label.text = campaign_state.get_progress_text()
_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():
selected_label.text = "No unit selected."
wait_button.disabled = true
_update_tactic_button({})
_hide_tactic_menu()
_update_item_button({})
_hide_item_menu()
_update_equip_button({})
_hide_equip_menu()
else:
var roster_mark := " *" if selected.get("loaded_from_roster", false) else ""
selected_label.text = "%s%s Lv.%d %s EXP %d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d\nMOV %d RNG %d-%d" % [
selected["name"],
roster_mark,
selected["level"],
selected["class"],
selected.get("exp", 0),
selected["hp"],
selected["max_hp"],
selected.get("mp", 0),
selected.get("max_mp", 0),
selected["atk"],
selected["def"],
selected.get("int", 0),
selected["move"],
selected.get("min_range", 1),
selected["range"]
]
var selected_effects := state.get_status_effect_summary(selected["id"])
if not selected_effects.is_empty():
selected_label.text += "\nEffects: %s" % selected_effects
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
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"
var unit: Dictionary = summary["unit"]
if not unit.is_empty():
var control_label := " Protected" if not bool(unit.get("controllable", true)) else ""
text += "\n%s%s %s Lv.%d\nHP %d/%d MP %d/%d ATK %d DEF %d INT %d RNG %d-%d" % [
unit["name"],
control_label,
str(unit["team"]).capitalize(),
unit["level"],
unit["hp"],
unit["max_hp"],
unit.get("mp", 0),
unit.get("max_mp", 0),
unit["atk"],
unit["def"],
unit.get("int", 0),
unit.get("min_range", 1),
unit["range"]
]
var status_effects := state.get_status_effect_summary(unit["id"])
if not status_effects.is_empty():
text += "\nEffects: %s" % status_effects
cell_info_label.text = text
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
var range_text := "ready" if preview["in_range"] else "out of range"
var defeat_text := " Defeat" if preview["would_defeat"] else ""
var counter_text := ""
if preview["counter_in_range"]:
var counter_defeat := " Defeat" if preview["counter_would_defeat"] else ""
counter_text = "\nCounter %d, Hit %d%%, your HP %d/%d.%s" % [
preview["counter_damage"],
preview.get("counter_hit_chance", 0),
preview["attacker_hp_after"],
selected["max_hp"],
counter_defeat
]
forecast_label.text = "Forecast: %s\n%d damage, Hit %d%%, target HP %d/%d.%s%s" % [
range_text,
preview["damage"],
preview.get("hit_chance", 100),
preview["target_hp_after"],
target["max_hp"],
defeat_text,
counter_text
]
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 target := state.get_unit_at(hover_cell)
var range_text := "ready" if preview["in_range"] else "out of range"
var mp_text := "" if preview["has_mp"] else " Not enough MP."
if target.is_empty() or 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
if preview["kind"] == "heal":
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"]
]
elif preview["kind"] == "support":
var refresh_text := " Refresh." if bool(preview.get("already_active", false)) else ""
forecast_label.text = "%s: %s, %d MP.%s\n%s.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview.get("effect_text", "Support effect"),
refresh_text
]
else:
var defeat_text := " Defeat" if preview["would_defeat"] 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"],
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_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 _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", "")))
dialogue_continue_button.text = "Close" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "Next"
dialogue_panel.visible = true
_update_hud()
queue_redraw()
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_dialogue_portrait(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_dialogue_portrait(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 not ResourceLoader.exists(normalized_path, "Texture2D"):
return null
return load(normalized_path) as Texture2D
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 _hide_dialogue_panel() -> void:
if dialogue_panel != null:
dialogue_panel.visible = false
active_dialogue_lines.clear()
active_dialogue_index = 0
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
_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_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_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
return text
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_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 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() or state.get_player_units(true).is_empty()
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_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_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_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_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_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() 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_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_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_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 (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 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 = "%s x%d %s" % [
_item_display_name(normalized_id),
int(inventory.get(normalized_id, 0)),
_format_equipment_bonus_text(item)
]
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 campaign_state.try_save_prebattle_loadout(state.get_player_roster_snapshot(), state.get_inventory_snapshot()):
_on_log_added("Saved pre-battle equipment.")
else:
_play_ui_cancel()
_on_log_added("Could not save pre-battle equipment.")
_load_scenario(active_scenario_id)
_show_briefing()
return
_rebuild_armory_menu()
_update_hud()
queue_redraw()
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 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)
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)
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 _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 _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", ""))
if description.is_empty():
return label
return "%s - %s" % [label, description]
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["choice_applied"] = true
battle_result_summary["saved"] = true
battle_result_summary["choice_label"] = str(choice.get("label", choice.get("id", "")))
_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_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 choice pending.\nCampaign saved."
if not pending_next.is_empty():
pending_text += "\nNext: %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", ""))
if already_next.is_empty():
return "Rewards already claimed.\n%s" % already_saved
return "Rewards already claimed.\nNext: %s\n%s" % [already_next, already_saved]
var items: Array = battle_result_summary.get("items", [])
var item_text := _format_reward_items(items)
var joined_text := ""
var joined_officers: Array = battle_result_summary.get("joined_officers", [])
if not joined_officers.is_empty():
joined_text = "\nJoined: %s" % _join_strings(joined_officers, ", ")
var left_text := ""
var left_officers: Array = battle_result_summary.get("left_officers", [])
if not left_officers.is_empty():
left_text = "\nLeft: %s" % _join_strings(left_officers, ", ")
var progression_text := _format_progression_events(battle_result_summary.get("progression_events", []))
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
var choice_text := ""
if not bool(battle_result_summary.get("choice_applied", true)):
choice_text = "\nChoose a campaign response."
elif not str(battle_result_summary.get("choice_label", "")).is_empty():
choice_text = "\nChoice: %s" % str(battle_result_summary.get("choice_label", ""))
return "Rewards: %d gold, %s%s%s%s\nTotal gold: %d\n%s\n%s%s" % [
battle_result_summary.get("gold", 0),
item_text,
joined_text,
left_text,
progression_text,
campaign_state.gold,
saved_text,
next_text,
choice_text
]
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 "\nProgress: %s" % _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_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 _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 _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 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
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 = 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 := " - MP" if int(selected.get("mp", 0)) < mp_cost else ""
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:
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])
if not parts.is_empty():
return _join_strings(parts, ", ")
return "P%d" % int(skill.get("power", 0))
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 _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)
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())
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 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 = "%s x%d %s" % [
_item_display_name(normalized_id),
int(inventory.get(normalized_id, 0)),
_format_equipment_bonus_text(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 := str(equipment.get(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_equipment_bonus_text(item: Dictionary) -> String:
var parts := []
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])
if parts.is_empty():
return str(item.get("kind", "equipment")).capitalize()
return _join_strings(parts, ", ")
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_cancel_pressed() -> void:
_play_ui_cancel()
_hide_equip_menu()
_update_hud()
queue_redraw()