Files
heros/scripts/scenes/battle_scene.gd

9685 lines
344 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 := 54
const BOARD_OFFSET := Vector2(48, 104)
const SIDE_PANEL_POSITION := Vector2(824, 104)
const SIDE_PANEL_SIZE := Vector2(424, 610)
const DEFAULT_BATTLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png"
const PLAYER_COLOR := Color(0.17, 0.38, 0.29)
const ENEMY_COLOR := Color(0.64, 0.085, 0.045)
const GRID_COLOR := Color(0.12, 0.070, 0.032, 0.36)
const MOVE_OVERLAY_COLOR := Color(0.72, 0.54, 0.24, 0.26)
const MOVE_BORDER_COLOR := Color(0.94, 0.73, 0.36, 0.48)
const ATTACK_OVERLAY_COLOR := Color(0.72, 0.055, 0.032, 0.24)
const ATTACK_BORDER_COLOR := Color(0.92, 0.34, 0.18, 0.50)
const SKILL_OVERLAY_COLOR := Color(0.10, 0.25, 0.20, 0.28)
const SKILL_AREA_OVERLAY_COLOR := Color(0.26, 0.34, 0.23, 0.30)
const ITEM_OVERLAY_COLOR := Color(0.24, 0.42, 0.25, 0.28)
const ITEM_BORDER_COLOR := Color(0.58, 0.74, 0.42, 0.44)
const FORMATION_OVERLAY_COLOR := Color(0.78, 0.54, 0.18, 0.32)
const OBJECTIVE_OVERLAY_COLOR := Color(0.88, 0.52, 0.14, 0.30)
const THREAT_OVERLAY_COLOR := Color(0.76, 0.060, 0.035, 0.20)
const THREAT_BORDER_COLOR := Color(0.86, 0.18, 0.10, 0.48)
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 UI_FONT_REGULAR_PATH := "res://art/ui/fonts/GowunBatang-Regular.ttf"
const UI_FONT_BOLD_PATH := "res://art/ui/fonts/GowunBatang-Bold.ttf"
const BGM_VOLUME_DB := -14.0
const SFX_VOLUME_DB := -4.0
const UI_SFX_VOLUME_DB := -8.0
const FLOATING_TEXT_LIFETIME := 1.0
const FLOATING_TEXT_RISE := 42.0
const OBJECTIVE_NOTICE_DURATION := 2.6
const EDGE_SCROLL_MARGIN := 48.0
const EDGE_SCROLL_OUTER_MARGIN := 28.0
const EDGE_SCROLL_MIN_SPEED := 80.0
const EDGE_SCROLL_MAX_SPEED := 430.0
const UNIT_MOVE_ANIMATION_DURATION := 0.18
const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22)
const LOW_HP_WARNING_RATIO := 0.35
const UNIT_STATUS_MARKER_RADIUS := 6.0
const UNIT_STATUS_MARKER_GAP := 14.0
const UNIT_STATUS_MARKER_OFFSET_Y := 13.0
const MAX_UNIT_STATUS_MARKERS := 4
const UNIT_ATTACK_ANIMATION_DURATION := 0.30
const UNIT_ATTACK_LUNGE_DISTANCE := 15.0
const UNIT_INFANTRY_ATTACK_DURATION := 0.32
const UNIT_CAVALRY_ATTACK_DURATION := 0.42
const UNIT_ARROW_ATTACK_DURATION := 0.36
const UNIT_COMMAND_ATTACK_DURATION := 0.38
const UNIT_HEAVY_ATTACK_DURATION := 0.34
const UNIT_TACTIC_CAST_DURATION := 0.42
const DIALOGUE_PANEL_POSITION := Vector2(40, 324)
const DIALOGUE_PANEL_SIZE := Vector2(1200, 356)
const DIALOGUE_PORTRAIT_SIZE := Vector2(220, 310)
const DIALOGUE_COLUMN_SIZE := Vector2(850, 318)
const DIALOGUE_TEXT_SIZE := Vector2(830, 164)
const OBJECTIVE_HUD_FONT_SIZE := 13
const OBJECTIVE_HUD_MIN_FONT_SIZE := 11
const OBJECTIVE_NOTICE_FONT_SIZE := 15
const OBJECTIVE_NOTICE_MIN_FONT_SIZE := 12
const BRIEFING_OBJECTIVE_FONT_SIZE := 15
const BRIEFING_OBJECTIVE_MIN_FONT_SIZE := 12
const DIALOGUE_SPEAKER_FONT_SIZE := 18
const DIALOGUE_SPEAKER_MIN_FONT_SIZE := 15
const DIALOGUE_TEXT_FONT_SIZE := 17
const DIALOGUE_TEXT_MIN_FONT_SIZE := 14
const LOCAL_COMMAND_TITLE_FONT_SIZE := 14
const LOCAL_COMMAND_TITLE_MIN_FONT_SIZE := 10
const LOCAL_COMMAND_DETAIL_FONT_SIZE := 12
const LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE := 10
const BRIEFING_CAMP_THUMBNAIL_SIZE := Vector2(188, 86)
const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58)
const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50)
const SHOP_ITEM_ICON_SIZE := Vector2(48, 48)
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
const POST_MOVE_MENU_SIZE := Vector2(240, 146)
const POST_MOVE_TITLE_SIZE := Vector2(196, 22)
const POST_MOVE_PICKER_PANEL_SIZE := Vector2(336, 236)
const POST_MOVE_PICKER_TITLE_SIZE := Vector2(296, 22)
const POST_MOVE_PICKER_DETAIL_SIZE := Vector2(296, 32)
const TARGETING_HINT_PANEL_SIZE := Vector2(254, 118)
const TARGETING_HINT_TITLE_SIZE := Vector2(220, 22)
const TARGETING_HINT_DETAIL_SIZE := Vector2(220, 28)
const LOCAL_COMMAND_PANEL_GAP := 8.0
const LOCAL_COMMAND_PANEL_BOARD_PADDING := 4.0
const UI_RICE_PAPER := Color(0.58, 0.45, 0.25, 0.98)
const UI_RICE_PAPER_LIGHT := Color(0.71, 0.56, 0.31, 0.99)
const UI_RICE_PAPER_DARK := Color(0.29, 0.17, 0.075, 0.98)
const UI_AGED_INK := Color(0.09, 0.055, 0.032, 1.0)
const UI_DARK_LACQUER := Color(0.085, 0.025, 0.012, 0.988)
const UI_LACQUER_EDGE := Color(0.035, 0.014, 0.008, 1.0)
const UI_CINNABAR := Color(0.52, 0.045, 0.025, 1.0)
const UI_CINNABAR_DARK := Color(0.32, 0.025, 0.016, 1.0)
const UI_OLD_BRONZE := Color(0.78, 0.58, 0.27, 1.0)
const UI_TARNISHED_BRONZE := Color(0.50, 0.36, 0.16, 1.0)
const UI_BAMBOO := Color(0.43, 0.27, 0.10, 1.0)
const UI_BAMBOO_DARK := Color(0.20, 0.11, 0.045, 1.0)
const UI_MUTED_JADE := Color(0.16, 0.30, 0.23, 1.0)
const UI_PARCHMENT_TEXT := Color(0.95, 0.82, 0.55, 1.0)
const UI_DISABLED_TEXT := Color(0.48, 0.40, 0.29, 1.0)
const UI_SILK_WASH := Color(0.80, 0.64, 0.37, 1.0)
const UI_SILK_FIBER := Color(0.18, 0.10, 0.045, 1.0)
const UI_SOOT_EDGE := Color(0.035, 0.018, 0.012, 1.0)
const UI_BONE_PAPER := Color(0.66, 0.52, 0.31, 0.995)
const UI_INK_WASH := Color(0.10, 0.065, 0.040, 1.0)
const UI_DARK_JADE := Color(0.055, 0.18, 0.13, 1.0)
const UI_JADE_EDGE := Color(0.12, 0.36, 0.25, 1.0)
const UI_SEAL_RED := Color(0.64, 0.055, 0.032, 1.0)
const UI_SEAL_RED_DARK := Color(0.28, 0.020, 0.014, 1.0)
const UI_CHARCOAL_WASH := Color(0.045, 0.026, 0.016, 1.0)
const UI_SCROLL_CENTER := Color(0.74, 0.58, 0.32, 0.996)
const UI_SCROLL_EDGE := Color(0.43, 0.25, 0.095, 0.996)
const UI_OLD_SILK := Color(0.61, 0.43, 0.20, 0.997)
const UI_BURNT_PAPER := Color(0.33, 0.19, 0.075, 0.996)
const UI_BRUSH_RULE := Color(0.12, 0.060, 0.026, 0.98)
const MAP_SCROLL_FRAME_PADDING := 12.0
const SPEAKER_DISPLAY_NAMES := {
"Cao Cao": "조조",
"Xiahou Dun": "하후돈",
"Xiahou Yuan": "하후연",
"Cao Ren": "조인",
"Dian Wei": "전위",
"Guo Jia": "곽가",
"Zhang He": "장합",
"Zhang Liao": "장료",
"Zhang Xiu": "장수",
"Jia Xu": "가후",
"Cao Ang": "조앙",
"Xu Rong": "서영",
"Li Jue": "이각",
"Hua Xiong": "화웅",
"Zhang Mancheng": "장만성",
"Chen Gong": "진궁",
"Gao Shun": "고순",
"Lu Bu": "여포",
"Qingzhou Chief": "청주 두령",
"Yan Liang": "안량",
"Wen Chou": "문추",
"Yuan Shao": "원소",
"Ju Shou": "저수",
"Chunyu Qiong": "순우경",
"Yuan Guard": "원소군 호위병",
"Yuan Field Advisor": "원소군 군사",
"Yuan Rally Advisor": "원소 수습군사",
"Yuan Tan Guard": "원담 호위병",
"Yuan Shang Guard": "원상 호위병",
"Ye Guard": "업성 수비병",
"Yuan Shang Inner Guard": "원상 내성위",
"Inner City Advisor": "내성 군사",
"Ye Last Loyalist": "업성 결사항전병",
"Palace Advisor": "궁도 군사",
"Puyang Night Trader": "복양 야상",
"Dingtao Field Sutler": "정도 야상",
"Escort Road Sutler": "호송 야상",
"Wan Castle Sutler": "완성 야상",
"Xiapi Flood-Gate Sutler": "하비 수문 야상",
"White Horse Road Sutler": "백마 야상",
"Imperial Envoy": "천자 사자",
"Imperial Guard": "천자 금위",
"Wan Scout": "완성 척후",
"Yuan Scout": "원소 척후",
"Coalition Merchant": "군막 상인",
"Coalition Quartermaster": "연합 군수관",
"Camp Merchant": "군막 상인",
"Scout": "척후"
}
const SPEAKER_CANONICAL_NAMES := {
"조조": "Cao Cao",
"하후돈": "Xiahou Dun",
"하후연": "Xiahou Yuan",
"조인": "Cao Ren",
"전위": "Dian Wei",
"곽가": "Guo Jia",
"장합": "Zhang He",
"장료": "Zhang Liao",
"장수": "Zhang Xiu",
"가후": "Jia Xu",
"조앙": "Cao Ang",
"서영": "Xu Rong",
"이각": "Li Jue",
"화웅": "Hua Xiong",
"장만성": "Zhang Mancheng",
"진궁": "Chen Gong",
"고순": "Gao Shun",
"여포": "Lu Bu",
"청주 두령": "Qingzhou Chief",
"안량": "Yan Liang",
"문추": "Wen Chou",
"원소": "Yuan Shao",
"저수": "Ju Shou",
"순우경": "Chunyu Qiong",
"원소군 호위병": "Yuan Guard",
"원소군 군사": "Yuan Field Advisor",
"원소 수습군사": "Yuan Rally Advisor",
"원담 호위병": "Yuan Tan Guard",
"원상 호위병": "Yuan Shang Guard",
"업성 수비병": "Ye Guard",
"원상 내성위": "Yuan Shang Inner Guard",
"내성 군사": "Inner City Advisor",
"업성 결사항전병": "Ye Last Loyalist",
"궁도 군사": "Palace Advisor",
"원소 척후": "Yuan Scout",
"천자 금위": "Imperial Guard",
"완성 척후": "Wan Scout"
}
const DIALOGUE_RIGHT_SIDE_SPEAKERS := {
"Camp Merchant": true,
"Coalition Merchant": true,
"Coalition Quartermaster": true,
"Dingtao Field Sutler": true,
"Escort Road Sutler": true,
"Puyang Night Trader": true,
"Wan Castle Sutler": true,
"Xiapi Flood-Gate Sutler": true,
"White Horse Road Sutler": true,
"Scout": true,
"Wan Scout": true,
"Yuan Scout": true,
"Imperial Envoy": true,
"Imperial Guard": true,
"Hua Xiong": true,
"Zhang Mancheng": true,
"Xu Rong": true,
"Li Jue": true,
"Zhang Xiu": true,
"Jia Xu": true,
"Zhang Liao": true,
"Chen Gong": true,
"Gao Shun": true,
"Lu Bu": true,
"Qingzhou Chief": true,
"Yuan Shao": true,
"Yan Liang": true,
"Wen Chou": true,
"Ju Shou": true,
"Chunyu Qiong": true,
"Yuan Guard": true,
"Yuan Field Advisor": true,
"Yuan Rally Advisor": true,
"Yuan Tan Guard": true,
"Yuan Shang Guard": true,
"Ye Guard": true,
"Yuan Shang Inner Guard": true,
"Inner City Advisor": true,
"Ye Last Loyalist": true,
"Palace Advisor": true
}
var state: BattleState = BattleStateScript.new()
var campaign_state: CampaignState = CampaignStateScript.new()
var hover_cell := Vector2i(-1, -1)
var move_cells: Array[Vector2i] = []
var attack_cells: Array[Vector2i] = []
var skill_cells: Array[Vector2i] = []
var item_cells: Array[Vector2i] = []
var threat_cells: Array[Vector2i] = []
var selected_skill_id := ""
var selected_item_id := ""
var basic_attack_targeting := false
var pending_move_unit_id := ""
var pending_move_from_cell := Vector2i(-1, -1)
var pending_move_to_cell := Vector2i(-1, -1)
var show_threat_overlay := false
var mission_detail_collapsed := false
var briefing_objective_collapsed := false
var active_scenario_id := ""
var battle_started := false
var battle_result_applied := false
var battle_result_summary := {}
var post_battle_dialogue_started := false
var campaign_complete_screen := false
var status_label: Label
var objective_label: Label
var campaign_status_label: Label
var mission_title_label: Label
var mission_toggle_button: Button
var mission_detail_panel: PanelContainer
var mission_detail_label: Label
var objective_notice_panel: PanelContainer
var objective_notice_label: Label
var ui_root_control: Control
var screen_backdrop: ColorRect
var hud_unit_portrait_panel: PanelContainer
var hud_unit_portrait_texture: TextureRect
var hud_unit_portrait_label: Label
var selected_label: Label
var cell_info_label: Label
var forecast_label: Label
var inventory_label: Label
var briefing_panel: PanelContainer
var briefing_title_label: Label
var briefing_location_label: Label
var briefing_seal_ribbon: HBoxContainer
var briefing_objective_panel: PanelContainer
var briefing_objective_label: Label
var briefing_objective_toggle_button: Button
var briefing_camp_overview_panel: PanelContainer
var briefing_camp_overview_row: HBoxContainer
var briefing_camp_overview_texture: TextureRect
var briefing_camp_overview_fallback_label: Label
var briefing_camp_overview_label: Label
var briefing_label: Label
var briefing_start_button: Button
var chapter_button: Button
var chapter_menu: VBoxContainer
var chapter_list: VBoxContainer
var chapter_status_label: Label
var shop_button: Button
var talk_button: Button
var talk_menu: VBoxContainer
var talk_list: VBoxContainer
var talk_status_label: Label
var shop_menu: VBoxContainer
var shop_buy_button: Button
var shop_sell_button: Button
var shop_list: VBoxContainer
var shop_status_label: Label
var shop_sell_mode := false
var armory_button: Button
var armory_menu: VBoxContainer
var armory_list: VBoxContainer
var armory_status_label: Label
var armory_unit_id := ""
var roster_button: Button
var roster_menu: VBoxContainer
var roster_list: VBoxContainer
var roster_status_label: Label
var formation_button: Button
var formation_menu: VBoxContainer
var formation_list: VBoxContainer
var formation_status_label: Label
var formation_unit_id := ""
var save_button: Button
var save_menu: VBoxContainer
var save_status_label: Label
var manual_save_button: Button
var manual_load_button: Button
var dialogue_row: HBoxContainer
var dialogue_panel: PanelContainer
var dialogue_left_tassel: VBoxContainer
var dialogue_right_tassel: VBoxContainer
var dialogue_portrait_panel: PanelContainer
var dialogue_portrait_texture: TextureRect
var dialogue_portrait_label: Label
var dialogue_column: VBoxContainer
var dialogue_speaker_panel: PanelContainer
var dialogue_speaker_label: Label
var dialogue_text_panel: PanelContainer
var dialogue_text_label: Label
var dialogue_seal_ribbon: HBoxContainer
var dialogue_progress_label: Label
var dialogue_previous_button: Button
var dialogue_continue_button: Button
var result_panel: PanelContainer
var result_label: Label
var result_seal_ribbon: HBoxContainer
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 post_move_menu: PanelContainer
var post_move_title_label: Label
var post_move_attack_button: Button
var post_move_tactic_button: Button
var post_move_item_button: Button
var post_move_wait_button: Button
var post_move_cancel_button: Button
var post_move_picker_panel: PanelContainer
var post_move_picker_title_label: Label
var post_move_picker_detail_label: Label
var post_move_picker_list: VBoxContainer
var post_move_picker_back_button: Button
var post_move_picker_cancel_move_button: Button
var post_move_picker_mode := ""
var targeting_hint_panel: PanelContainer
var targeting_hint_title_label: Label
var targeting_hint_detail_label: Label
var targeting_hint_back_button: Button
var targeting_hint_cancel_move_button: Button
var threat_button: Button
var restart_button: Button
var new_campaign_button: Button
var log_box: RichTextLabel
var dialogue_queue: Array = []
var active_dialogue_lines: Array = []
var active_dialogue_index := 0
var portrait_texture_cache: Dictionary = {}
var missing_portrait_paths := {}
var bgm_player: AudioStreamPlayer
var current_bgm_key := ""
var audio_stream_cache := {}
var last_announced_battle_status := ""
var floating_texts: Array[Dictionary] = []
var objective_notice_timer := 0.0
var unit_motion_by_unit: Dictionary = {}
var unit_action_motion_by_unit: Dictionary = {}
var battle_background_path := ""
var battle_background_texture: Texture2D
var ui_regular_font: Font
var ui_bold_font: Font
var board_scroll_offset := Vector2.ZERO
func _ready() -> void:
_create_hud()
_create_audio()
state.changed.connect(_on_state_changed)
state.log_added.connect(_on_log_added)
state.dialogue_requested.connect(_on_dialogue_requested)
state.combat_feedback_requested.connect(_on_combat_feedback_requested)
state.unit_motion_requested.connect(_on_unit_motion_requested)
state.unit_action_motion_requested.connect(_on_unit_action_motion_requested)
state.objective_updated.connect(_on_objective_updated)
campaign_state.load_campaign(CAMPAIGN_PATH)
campaign_state.load_or_start(campaign_state.get_start_scenario_id())
if campaign_state.has_pending_post_battle_choice():
_load_pending_post_battle_choice()
elif campaign_state.is_campaign_complete():
_show_campaign_complete()
else:
_load_current_battle()
_show_briefing()
_update_hud()
func _unhandled_input(event: InputEvent) -> void:
if _is_dialogue_visible():
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_advance_dialogue()
elif event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_LEFT or event.keycode == KEY_BACKSPACE:
_retreat_dialogue()
elif event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE or event.keycode == KEY_RIGHT:
_advance_dialogue()
return
if event is InputEventMouseMotion:
var next_hover := _cell_from_screen(event.position)
if next_hover != hover_cell:
hover_cell = next_hover
_update_hud()
queue_redraw()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_handle_board_click(event.position)
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_handle_cancel_input()
elif event is InputEventKey and event.pressed and not event.echo:
_handle_key(event)
func _draw() -> void:
_draw_map()
_draw_overlays()
_draw_units()
_draw_objective_foreground_markers()
_draw_target_selection_markers()
_draw_attack_effects()
_draw_target_preview_badge()
_draw_floating_texts()
func _make_panel_style(fill: Color, border: Color, border_width: int, radius: int, margin: int, shadow_size: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = fill
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
style.content_margin_left = margin
style.content_margin_right = margin
style.content_margin_top = margin
style.content_margin_bottom = margin
style.anti_aliasing = false
if shadow_size > 0:
style.shadow_color = Color(0.0, 0.0, 0.0, 0.48)
style.shadow_offset = Vector2(3, 4)
style.shadow_size = shadow_size
return style
func _age_panel_style(style: StyleBoxFlat, variant: String, border_width: int) -> void:
if style == null:
return
match variant:
"paper", "dialogue", "result_tablet", "result_victory_tablet", "result_defeat_tablet":
style.set_border_width(SIDE_TOP, border_width + 4)
style.set_border_width(SIDE_BOTTOM, border_width + 4)
style.set_border_width(SIDE_LEFT, border_width + 1)
style.set_border_width(SIDE_RIGHT, border_width + 1)
style.content_margin_top += 2
style.content_margin_bottom += 2
"edict", "notice_edict", "speech_scroll", "bamboo_slips", "silk_map", "command_seal":
style.set_border_width(SIDE_TOP, border_width + 2)
style.set_border_width(SIDE_BOTTOM, border_width + 2)
"edict_compact":
style.set_border_width(SIDE_TOP, border_width + 1)
style.set_border_width(SIDE_BOTTOM, border_width + 1)
"seal", "speaker_seal", "caption":
style.set_border_width(SIDE_LEFT, border_width + 2)
style.set_border_width(SIDE_RIGHT, border_width + 2)
_:
pass
func _apply_panel_style(panel: PanelContainer, variant: String) -> void:
if panel == null:
return
var fill := UI_DARK_LACQUER
var border := UI_OLD_BRONZE
var border_width := 3
var radius := 0
var margin := 10
var shadow_size := 6
match variant:
"paper":
fill = Color(0.54, 0.36, 0.14, 0.997)
border = UI_LACQUER_EDGE
border_width = 15
margin = 24
shadow_size = 24
"edict":
fill = Color(0.67, 0.49, 0.24, 0.998)
border = UI_BRUSH_RULE
border_width = 10
margin = 18
shadow_size = 12
"edict_compact":
fill = Color(0.55, 0.36, 0.15, 0.996)
border = UI_BRUSH_RULE
border_width = 4
margin = 6
shadow_size = 4
"notice_edict":
fill = Color(0.62, 0.42, 0.17, 0.998)
border = UI_LACQUER_EDGE
border_width = 9
margin = 14
shadow_size = 14
"seal":
fill = Color(0.12, 0.014, 0.008, 1.0)
border = UI_OLD_BRONZE
border_width = 5
margin = 10
"speaker_seal":
fill = Color(0.36, 0.025, 0.016, 0.995)
border = UI_OLD_BRONZE
border_width = 4
margin = 9
"portrait":
fill = UI_LACQUER_EDGE
border = UI_OLD_BRONZE
border_width = 5
margin = 3
"dialogue":
fill = Color(0.070, 0.024, 0.012, 0.986)
border = Color(0.37, 0.20, 0.070, 1.0)
border_width = 13
margin = 22
shadow_size = 22
"speech_scroll":
fill = Color(0.72, 0.53, 0.25, 0.998)
border = UI_BRUSH_RULE
border_width = 9
margin = 17
shadow_size = 9
"notice":
fill = Color(0.070, 0.020, 0.014, 0.982)
border = UI_SEAL_RED
border_width = 3
margin = 9
"map":
fill = Color(0.11, 0.060, 0.024, 1.0)
border = UI_INK_WASH
border_width = 6
margin = 4
"silk_map":
fill = Color(0.42, 0.27, 0.095, 0.995)
border = UI_BRUSH_RULE
border_width = 7
margin = 10
shadow_size = 10
"compact":
fill = Color(0.10, 0.055, 0.030, 0.93)
border = UI_TARNISHED_BRONZE
border_width = 4
margin = 8
shadow_size = 8
"bamboo_slips":
fill = Color(0.43, 0.28, 0.11, 0.992)
border = UI_BRUSH_RULE
border_width = 5
margin = 10
shadow_size = 6
"caption":
fill = Color(0.17, 0.030, 0.017, 0.988)
border = UI_OLD_BRONZE
border_width = 3
margin = 5
shadow_size = 2
"result_tablet":
fill = Color(0.47, 0.29, 0.10, 0.997)
border = UI_LACQUER_EDGE
border_width = 15
margin = 24
shadow_size = 24
"result_victory_tablet":
fill = Color(0.54, 0.36, 0.15, 0.997)
border = UI_OLD_BRONZE
border_width = 14
margin = 24
shadow_size = 22
"result_defeat_tablet":
fill = Color(0.13, 0.032, 0.014, 0.997)
border = UI_SEAL_RED_DARK
border_width = 14
margin = 24
shadow_size = 24
"command_seal":
fill = Color(0.065, 0.020, 0.012, 0.992)
border = UI_TARNISHED_BRONZE
border_width = 5
margin = 10
shadow_size = 9
_:
pass
var style := _make_panel_style(fill, border, border_width, radius, margin, shadow_size)
_age_panel_style(style, variant, border_width)
panel.add_theme_stylebox_override("panel", style)
func _make_button_style(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style := _make_panel_style(fill, border, border_width, 0, 10, 0)
style.set_border_width(SIDE_TOP, border_width + 2)
style.set_border_width(SIDE_BOTTOM, border_width + 2)
style.set_border_width(SIDE_LEFT, border_width + 1)
style.set_border_width(SIDE_RIGHT, border_width + 1)
return style
func _ui_font(bold: bool = false) -> Font:
if bold:
if ui_bold_font == null:
ui_bold_font = _load_ui_font(UI_FONT_BOLD_PATH)
return ui_bold_font
if ui_regular_font == null:
ui_regular_font = _load_ui_font(UI_FONT_REGULAR_PATH)
return ui_regular_font
func _load_ui_font(font_path: String) -> Font:
if font_path.is_empty():
return null
if ResourceLoader.exists(font_path):
var resource := ResourceLoader.load(font_path)
if resource is Font:
return resource as Font
var font := FontFile.new()
if font.load_dynamic_font(font_path) != OK:
return null
return font
func _make_ui_theme() -> Theme:
var theme := Theme.new()
var regular := _ui_font(false)
if regular != null:
theme.default_font = regular
theme.set_font("normal_font", "RichTextLabel", regular)
theme.set_font("italics_font", "RichTextLabel", regular)
theme.set_font("mono_font", "RichTextLabel", regular)
var bold := _ui_font(true)
if bold != null:
theme.set_font("bold_font", "RichTextLabel", bold)
theme.set_font("bold_italics_font", "RichTextLabel", bold)
return theme
func _draw_ui_font(bold: bool = false) -> Font:
var font := _ui_font(bold)
if font != null:
return font
return ThemeDB.fallback_font
func _draw_ink_text(font: Font, position: Vector2, text: String, alignment: HorizontalAlignment, width: float, font_size: int, color: Color, outline_color := Color(0.02, 0.012, 0.006, 0.95)) -> void:
if text.is_empty():
return
for offset in [Vector2(-1, 0), Vector2(1, 0), Vector2(0, -1), Vector2(0, 1), Vector2(1, 1)]:
draw_string(font, position + offset, text, alignment, width, font_size, outline_color)
draw_string(font, position, text, alignment, width, font_size, color)
func _apply_control_font(control: Control, bold: bool = false) -> void:
if control == null:
return
var font := _ui_font(bold)
if font == null:
return
control.add_theme_font_override("font", font)
func _apply_button_style(button: Button, important: bool = false) -> void:
if button == null:
return
var normal_fill := Color(0.36, 0.035, 0.020, 0.99) if important else Color(0.20, 0.095, 0.035, 0.98)
var hover_fill := Color(0.46, 0.075, 0.035, 1.0) if important else Color(0.30, 0.16, 0.060, 1.0)
var pressed_fill := Color(0.070, 0.024, 0.014, 1.0)
button.add_theme_stylebox_override("normal", _make_button_style(normal_fill, UI_TARNISHED_BRONZE, 2))
button.add_theme_stylebox_override("hover", _make_button_style(hover_fill, UI_OLD_BRONZE, 2))
button.add_theme_stylebox_override("pressed", _make_button_style(pressed_fill, UI_OLD_BRONZE, 2))
button.add_theme_stylebox_override("focus", _make_button_style(Color(0.31, 0.16, 0.060, 0.98), UI_OLD_BRONZE, 2))
button.add_theme_stylebox_override("disabled", _make_button_style(Color(0.10, 0.065, 0.040, 0.88), UI_RICE_PAPER_DARK, 1))
button.add_theme_color_override("font_color", UI_PARCHMENT_TEXT)
button.add_theme_color_override("font_hover_color", UI_PARCHMENT_TEXT)
button.add_theme_color_override("font_pressed_color", UI_PARCHMENT_TEXT)
button.add_theme_color_override("font_focus_color", UI_PARCHMENT_TEXT)
button.add_theme_color_override("font_disabled_color", UI_DISABLED_TEXT)
_apply_control_font(button, important)
func _apply_label_style(label: Label, font_color: Color, font_size: int = 0) -> void:
if label == null:
return
label.add_theme_color_override("font_color", font_color)
if font_size > 0:
label.add_theme_font_size_override("font_size", font_size)
_apply_control_font(label, font_size >= 16)
func _fit_label_font_size_to_text(label: Label, text: String, base_size: int, min_size: int, available_size: Vector2 = Vector2.ZERO) -> int:
if label == null:
return base_size
var fit_size := available_size
if fit_size.x <= 0.0:
fit_size.x = label.custom_minimum_size.x
if fit_size.y <= 0.0:
fit_size.y = label.custom_minimum_size.y
var font_size := _fit_font_size_for_text(text, base_size, min_size, fit_size)
label.add_theme_font_size_override("font_size", font_size)
return font_size
func _set_fitted_label_text(label: Label, text: String, base_size: int, min_size: int, available_size: Vector2 = Vector2.ZERO) -> void:
if label == null:
return
label.text = text
_fit_label_font_size_to_text(label, text, base_size, min_size, available_size)
func _fit_font_size_for_text(text: String, base_size: int, min_size: int, available_size: Vector2) -> int:
if text.strip_edges().is_empty():
return base_size
var safe_base: int = maxi(base_size, min_size)
var safe_min: int = maxi(1, mini(base_size, min_size))
for font_size in range(safe_base, safe_min - 1, -1):
if _estimated_wrapped_text_height(text, available_size.x, font_size) <= available_size.y:
return font_size
return safe_min
func _estimated_wrapped_text_height(text: String, width: float, font_size: int) -> float:
var line_count := _estimated_wrapped_line_count(text, width, font_size)
return float(line_count) * _estimated_line_height(font_size)
func _estimated_wrapped_line_count(text: String, width: float, font_size: int) -> int:
var capacity := maxf(1.0, floorf(width / maxf(1.0, float(font_size) * 0.92)))
var count := 0
for paragraph in text.split("\n"):
var weighted_length := _weighted_text_length(str(paragraph))
count += maxi(1, int(ceil(weighted_length / capacity)))
return maxi(1, count)
func _estimated_line_height(font_size: int) -> float:
return ceilf(float(font_size) * 1.36)
func _weighted_text_length(text: String) -> float:
var total := 0.0
for index in range(text.length()):
var code := text.unicode_at(index)
if code == 10:
continue
if code == 32:
total += 0.35
elif code < 128:
total += 0.56
else:
total += 1.0
return total
func _make_separator(width: float = 0.0, height: float = 2.0) -> ColorRect:
var separator := ColorRect.new()
separator.color = UI_OLD_BRONZE
separator.custom_minimum_size = Vector2(width, height)
separator.size_flags_horizontal = Control.SIZE_EXPAND_FILL
separator.mouse_filter = Control.MOUSE_FILTER_IGNORE
return separator
func _make_bamboo_divider(width: float = 0.0, height: float = 4.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 2)
for index in range(3):
var strip := ColorRect.new()
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
strip.custom_minimum_size = Vector2(8 if index != 1 else width, height)
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 1 else Control.SIZE_SHRINK_CENTER
strip.color = UI_BAMBOO if index == 1 else UI_BAMBOO_DARK
row.add_child(strip)
return row
func _make_scroll_rod(width: float = 0.0, height: float = 4.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 2)
var cap_width: float = maxf(8.0, height * 3.0)
for index in range(5):
var strip := ColorRect.new()
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
strip.custom_minimum_size = Vector2(cap_width if index != 2 else width, height)
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 2 else Control.SIZE_SHRINK_CENTER
if index == 0 or index == 4:
strip.color = UI_CINNABAR_DARK
elif index == 1 or index == 3:
strip.color = UI_OLD_BRONZE
else:
strip.color = UI_BAMBOO
row.add_child(strip)
return row
func _make_tablet_binding(width: float = 0.0, height: float = 18.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 3)
row.add_child(_make_binding_end_cap(height))
row.add_child(_make_binding_strip(height, UI_BAMBOO_DARK, 14.0))
row.add_child(_make_binding_strip(height, UI_TARNISHED_BRONZE, 0.0))
row.add_child(_make_seal_knot(height))
row.add_child(_make_binding_strip(height, UI_TARNISHED_BRONZE, 0.0))
row.add_child(_make_binding_strip(height, UI_BAMBOO_DARK, 14.0))
row.add_child(_make_binding_end_cap(height))
return row
func _make_binding_end_cap(size: float) -> ColorRect:
var cap := ColorRect.new()
cap.color = UI_LACQUER_EDGE
cap.custom_minimum_size = Vector2(size * 1.15, size)
cap.mouse_filter = Control.MOUSE_FILTER_IGNORE
return cap
func _make_binding_strip(height: float, color: Color, width: float) -> ColorRect:
var strip := ColorRect.new()
strip.color = color
strip.custom_minimum_size = Vector2(width, height)
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width <= 0.0 else Control.SIZE_SHRINK_CENTER
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
return strip
func _make_seal_knot(height: float) -> HBoxContainer:
var knot := HBoxContainer.new()
knot.custom_minimum_size = Vector2(height * 2.0 + 3.0, height)
knot.mouse_filter = Control.MOUSE_FILTER_IGNORE
knot.add_theme_constant_override("separation", 3)
for index in range(2):
var tile := ColorRect.new()
tile.color = UI_SEAL_RED if index == 0 else UI_SEAL_RED_DARK
tile.custom_minimum_size = Vector2(height, height)
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
knot.add_child(tile)
return knot
func _make_ink_wash_rule(width: float = 0.0, height: float = 7.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 2)
for index in range(5):
var strip := ColorRect.new()
strip.custom_minimum_size = Vector2(10 if index != 2 else width, height)
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 2 else Control.SIZE_SHRINK_CENTER
strip.color = UI_INK_WASH if index == 2 else Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.96)
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(strip)
return row
func _make_edict_title_strip(labels: Array, width: float = 0.0, height: float = 24.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 4)
row.add_child(_make_lacquer_clasp(height))
row.add_child(_make_binding_strip(5.0, UI_TARNISHED_BRONZE, 0.0))
for label_text in labels:
row.add_child(_make_seal_tile(str(label_text), height))
row.add_child(_make_binding_strip(5.0, UI_TARNISHED_BRONZE, 0.0))
row.add_child(_make_lacquer_clasp(height))
return row
func _make_seal_tile(text: String, size: float = 24.0) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(size, size)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
panel.add_theme_stylebox_override("panel", _make_panel_style(UI_SEAL_RED_DARK, UI_OLD_BRONZE, 2, 0, 3, 0))
var label := Label.new()
label.text = text
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.custom_minimum_size = Vector2(size - 6.0, size - 6.0)
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
panel.add_child(label)
return panel
func _make_seal_ribbon(width: float = 0.0, height: float = 18.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 4)
row.add_child(_make_lacquer_clasp(height))
row.add_child(_make_bronze_ribbon_strip(height))
row.add_child(_make_seal_cluster(height))
row.add_child(_make_bronze_ribbon_strip(height))
row.add_child(_make_lacquer_clasp(height))
return row
func _make_lacquer_clasp(size: float) -> ColorRect:
var clasp := ColorRect.new()
clasp.color = UI_CINNABAR_DARK
clasp.custom_minimum_size = Vector2(size, size)
clasp.mouse_filter = Control.MOUSE_FILTER_IGNORE
return clasp
func _make_bronze_ribbon_strip(height: float) -> ColorRect:
var strip := ColorRect.new()
strip.color = Color(UI_TARNISHED_BRONZE.r, UI_TARNISHED_BRONZE.g, UI_TARNISHED_BRONZE.b, 0.92)
strip.custom_minimum_size = Vector2(12, height)
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
return strip
func _make_seal_cluster(height: float) -> HBoxContainer:
var cluster := HBoxContainer.new()
cluster.custom_minimum_size = Vector2(height * 3.0 + 4.0, height)
cluster.add_theme_constant_override("separation", 2)
for index in range(3):
var tile := ColorRect.new()
tile.color = UI_CINNABAR if index == 1 else UI_CINNABAR_DARK
tile.custom_minimum_size = Vector2(height, height)
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
cluster.add_child(tile)
return cluster
func _make_map_lattice_overlay(size: Vector2) -> Control:
var overlay := Control.new()
overlay.name = "MapLatticeOverlay"
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.custom_minimum_size = size
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
var silk_wash := ColorRect.new()
silk_wash.name = "SilkMapWash"
silk_wash.set_anchors_preset(Control.PRESET_FULL_RECT)
silk_wash.color = Color(UI_SILK_WASH.r, UI_SILK_WASH.g, UI_SILK_WASH.b, 0.10)
silk_wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
overlay.add_child(silk_wash)
for fraction in [0.25, 0.50, 0.75]:
var vertical := _make_map_lattice_line(Vector2(size.x * fraction, 0.0), Vector2(1.0, size.y), 0.22)
overlay.add_child(vertical)
for fraction in [0.33, 0.66]:
var horizontal := _make_map_lattice_line(Vector2(0.0, size.y * fraction), Vector2(size.x, 1.0), 0.18)
overlay.add_child(horizontal)
var corner_specs := [
{"text": "", "pos": Vector2(4, 4)},
{"text": "", "pos": Vector2(size.x - 24, 4)},
{"text": "", "pos": Vector2(4, size.y - 24)},
{"text": "", "pos": Vector2(size.x - 24, size.y - 24)}
]
for spec in corner_specs:
var seal := _make_seal_tile(str(spec["text"]), 20)
seal.name = "MapCornerSeal"
seal.position = spec["pos"]
seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
overlay.add_child(seal)
return overlay
func _make_map_lattice_line(position: Vector2, size: Vector2, alpha: float) -> ColorRect:
var line := ColorRect.new()
line.position = position
line.size = size
line.custom_minimum_size = size
line.color = Color(UI_INK_WASH.r, UI_INK_WASH.g, UI_INK_WASH.b, alpha)
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
return line
func _make_section_caption(text: String, width: float = 0.0) -> Control:
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(width, 24)
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_panel_style(panel, "caption")
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
panel.add_child(row)
row.add_child(_make_seal_tick())
var label := Label.new()
label.text = text
label.custom_minimum_size = Vector2(maxf(0.0, width - 68.0), 18)
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
row.add_child(label)
row.add_child(_make_seal_tick())
return panel
func _make_seal_tick() -> ColorRect:
var tick := ColorRect.new()
tick.color = UI_CINNABAR
tick.custom_minimum_size = Vector2(18, 18)
tick.mouse_filter = Control.MOUSE_FILTER_IGNORE
return tick
func _make_bamboo_gutter(width: float = 18.0, height: float = 0.0) -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(width, height)
row.size_flags_vertical = Control.SIZE_EXPAND_FILL
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 2)
var strip_width := maxf(3.0, floorf((width - 4.0) / 3.0))
for index in range(3):
var strip := ColorRect.new()
strip.color = UI_BAMBOO if index == 1 else UI_BAMBOO_DARK
strip.custom_minimum_size = Vector2(strip_width, height)
strip.size_flags_vertical = Control.SIZE_EXPAND_FILL
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(strip)
return row
func _make_ornament_bar(width: float = 4.0, height: float = 0.0) -> ColorRect:
var bar := ColorRect.new()
bar.color = UI_CINNABAR
bar.custom_minimum_size = Vector2(width, height)
bar.size_flags_vertical = Control.SIZE_EXPAND_FILL
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
return bar
func _make_edict_marker_stack(labels: Array, height: float = 84.0) -> VBoxContainer:
var stack := VBoxContainer.new()
stack.custom_minimum_size = Vector2(30, height)
stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
stack.add_theme_constant_override("separation", 3)
for index in range(labels.size()):
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(30, 22)
panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_apply_panel_style(panel, "speaker_seal" if index == 0 else "caption")
stack.add_child(panel)
var label := Label.new()
label.text = str(labels[index])
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.custom_minimum_size = Vector2(18, 16)
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
panel.add_child(label)
return stack
func _make_hanging_tassel(height: float = 262.0) -> VBoxContainer:
var tassel := VBoxContainer.new()
tassel.custom_minimum_size = Vector2(18, height)
tassel.size_flags_vertical = Control.SIZE_EXPAND_FILL
tassel.mouse_filter = Control.MOUSE_FILTER_IGNORE
tassel.add_theme_constant_override("separation", 4)
var top_seal := ColorRect.new()
top_seal.color = UI_SEAL_RED
top_seal.custom_minimum_size = Vector2(18, 18)
top_seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
tassel.add_child(top_seal)
var cords := HBoxContainer.new()
cords.custom_minimum_size = Vector2(18, maxf(0.0, height - 44.0))
cords.size_flags_vertical = Control.SIZE_EXPAND_FILL
cords.mouse_filter = Control.MOUSE_FILTER_IGNORE
cords.add_theme_constant_override("separation", 4)
for index in range(2):
var cord := ColorRect.new()
cord.color = UI_TARNISHED_BRONZE if index == 0 else UI_CINNABAR_DARK
cord.custom_minimum_size = Vector2(6, height - 44.0)
cord.size_flags_vertical = Control.SIZE_EXPAND_FILL
cord.mouse_filter = Control.MOUSE_FILTER_IGNORE
cords.add_child(cord)
tassel.add_child(cords)
var bottom_seal := ColorRect.new()
bottom_seal.color = UI_SEAL_RED_DARK
bottom_seal.custom_minimum_size = Vector2(18, 18)
bottom_seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
tassel.add_child(bottom_seal)
return tassel
func _make_screen_backdrop() -> ColorRect:
var backdrop := ColorRect.new()
backdrop.visible = false
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.color = Color(0.018, 0.009, 0.004, 0.74)
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
return backdrop
func _refresh_screen_backdrop_visibility() -> void:
if screen_backdrop == null:
return
screen_backdrop.visible = (
(briefing_panel != null and briefing_panel.visible)
or (result_panel != null and result_panel.visible)
)
func _create_hud() -> void:
var layer := CanvasLayer.new()
add_child(layer)
var root := Control.new()
ui_root_control = root
root.theme = _make_ui_theme()
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, 14)
top_bar.size = Vector2(1192, 72)
_apply_panel_style(top_bar, "compact")
root.add_child(top_bar)
var top_row := HBoxContainer.new()
top_row.add_theme_constant_override("separation", 8)
top_bar.add_child(top_row)
status_label = Label.new()
status_label.custom_minimum_size = Vector2(154, 48)
_apply_label_style(status_label, UI_PARCHMENT_TEXT)
top_row.add_child(status_label)
var objective_top_panel := PanelContainer.new()
objective_top_panel.custom_minimum_size = Vector2(460, 54)
_apply_panel_style(objective_top_panel, "edict_compact")
top_row.add_child(objective_top_panel)
var objective_top_row := HBoxContainer.new()
objective_top_row.add_theme_constant_override("separation", 7)
objective_top_panel.add_child(objective_top_row)
objective_top_row.add_child(_make_bamboo_gutter(14, 36))
objective_label = Label.new()
objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
objective_label.custom_minimum_size = Vector2(382, 36)
objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(objective_label, UI_AGED_INK, OBJECTIVE_HUD_FONT_SIZE)
objective_top_row.add_child(objective_label)
objective_top_row.add_child(_make_bamboo_gutter(14, 36))
campaign_status_label = Label.new()
campaign_status_label.custom_minimum_size = Vector2(260, 48)
_apply_label_style(campaign_status_label, UI_PARCHMENT_TEXT)
top_row.add_child(campaign_status_label)
restart_button = Button.new()
restart_button.text = "재정비"
restart_button.pressed.connect(_on_restart_pressed)
top_row.add_child(restart_button)
new_campaign_button = Button.new()
new_campaign_button.text = "새 전기 열기"
new_campaign_button.pressed.connect(_on_new_campaign_pressed)
top_row.add_child(new_campaign_button)
objective_notice_panel = PanelContainer.new()
objective_notice_panel.visible = false
objective_notice_panel.position = Vector2(338, 88)
objective_notice_panel.size = Vector2(604, 154)
objective_notice_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_apply_panel_style(objective_notice_panel, "notice_edict")
root.add_child(objective_notice_panel)
var objective_notice_column := VBoxContainer.new()
objective_notice_column.add_theme_constant_override("separation", 5)
objective_notice_panel.add_child(objective_notice_column)
objective_notice_column.add_child(_make_scroll_rod(548, 4))
objective_notice_column.add_child(_make_seal_ribbon(548, 14))
objective_notice_label = Label.new()
objective_notice_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
objective_notice_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
objective_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
objective_notice_label.custom_minimum_size = Vector2(548, 80)
_apply_label_style(objective_notice_label, UI_AGED_INK, OBJECTIVE_NOTICE_FONT_SIZE)
objective_notice_column.add_child(objective_notice_label)
objective_notice_column.add_child(_make_scroll_rod(548, 3))
var side_panel := PanelContainer.new()
side_panel.position = SIDE_PANEL_POSITION
side_panel.size = SIDE_PANEL_SIZE
_apply_panel_style(side_panel, "compact")
root.add_child(side_panel)
var side_column := VBoxContainer.new()
side_column.add_theme_constant_override("separation", 6)
side_panel.add_child(side_column)
var selected_row := HBoxContainer.new()
selected_row.custom_minimum_size = Vector2(420, 126)
selected_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
selected_row.add_theme_constant_override("separation", 8)
side_column.add_child(selected_row)
hud_unit_portrait_panel = PanelContainer.new()
hud_unit_portrait_panel.custom_minimum_size = HUD_UNIT_PORTRAIT_SIZE
_apply_panel_style(hud_unit_portrait_panel, "portrait")
selected_row.add_child(hud_unit_portrait_panel)
var hud_unit_portrait_stack := Control.new()
hud_unit_portrait_stack.custom_minimum_size = HUD_UNIT_PORTRAIT_STACK_SIZE
hud_unit_portrait_panel.add_child(hud_unit_portrait_stack)
hud_unit_portrait_texture = TextureRect.new()
hud_unit_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
hud_unit_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
hud_unit_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
hud_unit_portrait_stack.add_child(hud_unit_portrait_texture)
hud_unit_portrait_label = Label.new()
hud_unit_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
hud_unit_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hud_unit_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
hud_unit_portrait_label.add_theme_font_size_override("font_size", 24)
_apply_label_style(hud_unit_portrait_label, UI_PARCHMENT_TEXT)
hud_unit_portrait_stack.add_child(hud_unit_portrait_label)
selected_label = Label.new()
selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
selected_label.custom_minimum_size = Vector2(320, 126)
selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(selected_label, UI_PARCHMENT_TEXT)
selected_row.add_child(selected_label)
var mission_title_row := HBoxContainer.new()
mission_title_row.add_theme_constant_override("separation", 8)
side_column.add_child(mission_title_row)
mission_title_label = Label.new()
mission_title_label.text = "전투 목표"
mission_title_label.custom_minimum_size = Vector2(296, 28)
mission_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
mission_title_label.add_theme_font_size_override("font_size", 14)
_apply_label_style(mission_title_label, UI_OLD_BRONZE)
mission_title_row.add_child(mission_title_label)
mission_toggle_button = Button.new()
mission_toggle_button.text = "접기"
mission_toggle_button.tooltip_text = "전투 목표 판을 접거나 펼칩니다."
mission_toggle_button.custom_minimum_size = Vector2(78, 28)
mission_toggle_button.pressed.connect(_on_mission_toggle_pressed)
mission_title_row.add_child(mission_toggle_button)
mission_detail_panel = PanelContainer.new()
mission_detail_panel.custom_minimum_size = Vector2(420, 134)
mission_detail_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_panel_style(mission_detail_panel, "bamboo_slips")
side_column.add_child(mission_detail_panel)
var mission_detail_row := HBoxContainer.new()
mission_detail_row.add_theme_constant_override("separation", 8)
mission_detail_panel.add_child(mission_detail_row)
mission_detail_row.add_child(_make_bamboo_gutter(12, 112))
mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112))
mission_detail_label = Label.new()
mission_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
mission_detail_label.custom_minimum_size = Vector2(292, 112)
mission_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(mission_detail_label, UI_PARCHMENT_TEXT)
mission_detail_row.add_child(mission_detail_label)
mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112))
mission_detail_row.add_child(_make_bamboo_gutter(12, 112))
cell_info_label = Label.new()
cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
cell_info_label.custom_minimum_size = Vector2(420, 78)
_apply_label_style(cell_info_label, UI_PARCHMENT_TEXT)
side_column.add_child(cell_info_label)
forecast_label = Label.new()
forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
forecast_label.custom_minimum_size = Vector2(420, 60)
_apply_label_style(forecast_label, UI_PARCHMENT_TEXT)
side_column.add_child(forecast_label)
inventory_label = Label.new()
inventory_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
inventory_label.custom_minimum_size = Vector2(420, 34)
_apply_label_style(inventory_label, UI_PARCHMENT_TEXT)
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_button.pressed.connect(_on_wait_pressed)
button_row.add_child(wait_button)
tactic_button = Button.new()
tactic_button.text = "책략첩"
tactic_button.pressed.connect(_on_tactic_pressed)
button_row.add_child(tactic_button)
item_button = Button.new()
item_button.text = "보급첩"
item_button.pressed.connect(_on_item_pressed)
button_row.add_child(item_button)
equip_button = Button.new()
equip_button.text = "병장"
equip_button.pressed.connect(_on_equip_pressed)
button_row.add_child(equip_button)
threat_button = Button.new()
threat_button.text = "적진도"
threat_button.toggle_mode = true
threat_button.pressed.connect(_on_threat_pressed)
button_row.add_child(threat_button)
end_turn_button = Button.new()
end_turn_button.text = "군령 봉함"
end_turn_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 = "책략첩"
_apply_label_style(tactic_title, UI_OLD_BRONZE, 14)
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 = "보급첩"
_apply_label_style(item_title, UI_OLD_BRONZE, 14)
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 = "병장 장부"
_apply_label_style(equip_title, UI_OLD_BRONZE, 14)
equip_menu.add_child(equip_title)
equip_list = VBoxContainer.new()
equip_list.add_theme_constant_override("separation", 4)
equip_menu.add_child(equip_list)
post_move_menu = PanelContainer.new()
post_move_menu.visible = false
post_move_menu.mouse_filter = Control.MOUSE_FILTER_STOP
post_move_menu.custom_minimum_size = POST_MOVE_MENU_SIZE
post_move_menu.size = POST_MOVE_MENU_SIZE
_apply_panel_style(post_move_menu, "command_seal")
root.add_child(post_move_menu)
var post_move_column := VBoxContainer.new()
post_move_column.add_theme_constant_override("separation", 5)
post_move_menu.add_child(post_move_column)
post_move_column.add_child(_make_seal_ribbon(196, 10))
post_move_title_label = Label.new()
post_move_title_label.text = "군령 선택"
post_move_title_label.custom_minimum_size = POST_MOVE_TITLE_SIZE
post_move_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_apply_label_style(post_move_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
post_move_column.add_child(post_move_title_label)
var post_move_grid := GridContainer.new()
post_move_grid.columns = 2
post_move_grid.add_theme_constant_override("h_separation", 6)
post_move_grid.add_theme_constant_override("v_separation", 4)
post_move_column.add_child(post_move_grid)
post_move_attack_button = Button.new()
post_move_attack_button.text = "타격령"
post_move_attack_button.tooltip_text = "이 자리에서 칠 적군을 지목합니다."
post_move_attack_button.pressed.connect(_on_post_move_attack_pressed)
post_move_grid.add_child(post_move_attack_button)
post_move_tactic_button = Button.new()
post_move_tactic_button.text = "책략첩"
post_move_tactic_button.tooltip_text = "군령에 올릴 책략 죽간을 고릅니다."
post_move_tactic_button.pressed.connect(_on_post_move_tactic_pressed)
post_move_grid.add_child(post_move_tactic_button)
post_move_item_button = Button.new()
post_move_item_button.text = "보급첩"
post_move_item_button.tooltip_text = "군령에 올릴 보급 물자를 고릅니다."
post_move_item_button.pressed.connect(_on_post_move_item_pressed)
post_move_grid.add_child(post_move_item_button)
post_move_wait_button = Button.new()
post_move_wait_button.text = "대기령"
post_move_wait_button.tooltip_text = "행군을 확정하고 군령을 봉인합니다."
post_move_wait_button.pressed.connect(_on_post_move_wait_pressed)
post_move_grid.add_child(post_move_wait_button)
post_move_cancel_button = Button.new()
post_move_cancel_button.text = "발걸음 거두기"
post_move_cancel_button.tooltip_text = "처음 있던 칸으로 돌아갑니다."
post_move_cancel_button.pressed.connect(_on_post_move_cancel_pressed)
post_move_column.add_child(post_move_cancel_button)
post_move_picker_panel = PanelContainer.new()
post_move_picker_panel.visible = false
post_move_picker_panel.mouse_filter = Control.MOUSE_FILTER_STOP
post_move_picker_panel.custom_minimum_size = POST_MOVE_PICKER_PANEL_SIZE
post_move_picker_panel.size = POST_MOVE_PICKER_PANEL_SIZE
_apply_panel_style(post_move_picker_panel, "command_seal")
root.add_child(post_move_picker_panel)
var picker_column := VBoxContainer.new()
picker_column.add_theme_constant_override("separation", 5)
post_move_picker_panel.add_child(picker_column)
picker_column.add_child(_make_seal_ribbon(296, 10))
post_move_picker_title_label = Label.new()
post_move_picker_title_label.text = "군령 선택"
post_move_picker_title_label.custom_minimum_size = POST_MOVE_PICKER_TITLE_SIZE
post_move_picker_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_apply_label_style(post_move_picker_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
picker_column.add_child(post_move_picker_title_label)
post_move_picker_detail_label = Label.new()
post_move_picker_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
post_move_picker_detail_label.custom_minimum_size = POST_MOVE_PICKER_DETAIL_SIZE
_apply_label_style(post_move_picker_detail_label, UI_PARCHMENT_TEXT, LOCAL_COMMAND_DETAIL_FONT_SIZE)
picker_column.add_child(post_move_picker_detail_label)
var picker_scroll := ScrollContainer.new()
picker_scroll.custom_minimum_size = Vector2(296, 98)
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
picker_column.add_child(picker_scroll)
post_move_picker_list = VBoxContainer.new()
post_move_picker_list.add_theme_constant_override("separation", 4)
post_move_picker_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
picker_scroll.add_child(post_move_picker_list)
var picker_button_row := HBoxContainer.new()
picker_button_row.add_theme_constant_override("separation", 6)
picker_column.add_child(picker_button_row)
post_move_picker_back_button = Button.new()
post_move_picker_back_button.text = "죽간 거두기"
post_move_picker_back_button.tooltip_text = "군령 선택으로 되돌립니다."
post_move_picker_back_button.pressed.connect(_on_post_move_picker_back_pressed)
picker_button_row.add_child(post_move_picker_back_button)
post_move_picker_cancel_move_button = Button.new()
post_move_picker_cancel_move_button.text = "발걸음 거두기"
post_move_picker_cancel_move_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
post_move_picker_cancel_move_button.pressed.connect(_on_post_move_picker_cancel_move_pressed)
picker_button_row.add_child(post_move_picker_cancel_move_button)
targeting_hint_panel = PanelContainer.new()
targeting_hint_panel.visible = false
targeting_hint_panel.mouse_filter = Control.MOUSE_FILTER_STOP
targeting_hint_panel.custom_minimum_size = TARGETING_HINT_PANEL_SIZE
targeting_hint_panel.size = TARGETING_HINT_PANEL_SIZE
_apply_panel_style(targeting_hint_panel, "command_seal")
root.add_child(targeting_hint_panel)
var targeting_column := VBoxContainer.new()
targeting_column.add_theme_constant_override("separation", 4)
targeting_hint_panel.add_child(targeting_column)
targeting_column.add_child(_make_seal_ribbon(218, 9))
targeting_hint_title_label = Label.new()
targeting_hint_title_label.text = "적장 지목"
targeting_hint_title_label.custom_minimum_size = TARGETING_HINT_TITLE_SIZE
targeting_hint_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_apply_label_style(targeting_hint_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
targeting_column.add_child(targeting_hint_title_label)
targeting_hint_detail_label = Label.new()
targeting_hint_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
targeting_hint_detail_label.custom_minimum_size = TARGETING_HINT_DETAIL_SIZE
_apply_label_style(targeting_hint_detail_label, UI_PARCHMENT_TEXT, LOCAL_COMMAND_DETAIL_FONT_SIZE)
targeting_column.add_child(targeting_hint_detail_label)
var targeting_button_row := HBoxContainer.new()
targeting_button_row.add_theme_constant_override("separation", 6)
targeting_column.add_child(targeting_button_row)
targeting_hint_back_button = Button.new()
targeting_hint_back_button.text = "죽간 거두기"
targeting_hint_back_button.tooltip_text = "군령 선택으로 되돌립니다."
targeting_hint_back_button.pressed.connect(_on_targeting_back_pressed)
targeting_button_row.add_child(targeting_hint_back_button)
targeting_hint_cancel_move_button = Button.new()
targeting_hint_cancel_move_button.text = "발걸음 거두기"
targeting_hint_cancel_move_button.tooltip_text = "부대를 원래 자리로 되돌립니다."
targeting_hint_cancel_move_button.pressed.connect(_on_targeting_cancel_move_pressed)
targeting_button_row.add_child(targeting_hint_cancel_move_button)
log_box = RichTextLabel.new()
log_box.custom_minimum_size = Vector2(420, 70)
log_box.fit_content = false
log_box.add_theme_color_override("default_color", UI_PARCHMENT_TEXT)
log_box.add_theme_stylebox_override("normal", _make_panel_style(Color(0.11, 0.07, 0.045, 0.78), UI_RICE_PAPER_DARK, 1, 1, 6, 0))
side_column.add_child(log_box)
screen_backdrop = _make_screen_backdrop()
root.add_child(screen_backdrop)
briefing_panel = PanelContainer.new()
briefing_panel.visible = false
briefing_panel.position = Vector2(212, 16)
briefing_panel.size = Vector2(856, 688)
_apply_panel_style(briefing_panel, "paper")
root.add_child(briefing_panel)
var briefing_shell := HBoxContainer.new()
briefing_shell.custom_minimum_size = Vector2(800, 632)
briefing_shell.add_theme_constant_override("separation", 10)
briefing_panel.add_child(briefing_shell)
briefing_shell.add_child(_make_bamboo_gutter(22, 632))
var briefing_column := VBoxContainer.new()
briefing_column.add_theme_constant_override("separation", 5)
briefing_shell.add_child(briefing_column)
briefing_shell.add_child(_make_bamboo_gutter(22, 632))
briefing_column.add_child(_make_scroll_rod(680, 6))
briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 24))
briefing_column.add_child(_make_tablet_binding(680, 16))
var briefing_title_panel := PanelContainer.new()
briefing_title_panel.custom_minimum_size = Vector2(680, 46)
_apply_panel_style(briefing_title_panel, "seal")
briefing_column.add_child(briefing_title_panel)
briefing_title_label = Label.new()
briefing_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
briefing_title_label.custom_minimum_size = Vector2(656, 34)
briefing_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
briefing_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_apply_label_style(briefing_title_label, UI_PARCHMENT_TEXT, 22)
briefing_title_panel.add_child(briefing_title_label)
briefing_seal_ribbon = _make_seal_ribbon(680, 18)
briefing_column.add_child(briefing_seal_ribbon)
briefing_location_label = Label.new()
briefing_location_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
briefing_location_label.custom_minimum_size = Vector2(680, 22)
briefing_location_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_apply_label_style(briefing_location_label, UI_INK_WASH, 14)
briefing_column.add_child(briefing_location_label)
briefing_start_button = Button.new()
briefing_start_button.text = "전투 시작"
briefing_start_button.tooltip_text = "브리핑을 닫고 전투를 시작합니다."
briefing_start_button.pressed.connect(_on_begin_battle_pressed)
briefing_column.add_child(briefing_start_button)
briefing_column.add_child(_make_scroll_rod(680, 5))
var briefing_objective_header := HBoxContainer.new()
briefing_objective_header.add_theme_constant_override("separation", 8)
briefing_column.add_child(briefing_objective_header)
briefing_objective_header.add_child(_make_section_caption("승리와 패배", 548))
briefing_objective_toggle_button = Button.new()
briefing_objective_toggle_button.text = "닫기"
briefing_objective_toggle_button.tooltip_text = "승리와 패배 조건을 닫거나 펼칩니다."
briefing_objective_toggle_button.custom_minimum_size = Vector2(124, 28)
briefing_objective_toggle_button.pressed.connect(_on_briefing_objective_toggle_pressed)
briefing_objective_header.add_child(briefing_objective_toggle_button)
briefing_objective_panel = PanelContainer.new()
briefing_objective_panel.custom_minimum_size = Vector2(680, 124)
_apply_panel_style(briefing_objective_panel, "edict")
briefing_column.add_child(briefing_objective_panel)
var briefing_objective_row := HBoxContainer.new()
briefing_objective_row.add_theme_constant_override("separation", 8)
briefing_objective_panel.add_child(briefing_objective_row)
briefing_objective_row.add_child(_make_bamboo_gutter(18, 104))
briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104))
briefing_objective_label = Label.new()
briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
briefing_objective_label.custom_minimum_size = Vector2(500, 104)
briefing_objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(briefing_objective_label, UI_AGED_INK, BRIEFING_OBJECTIVE_FONT_SIZE)
briefing_objective_row.add_child(briefing_objective_label)
briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104))
briefing_objective_row.add_child(_make_bamboo_gutter(18, 104))
briefing_column.add_child(_make_ink_wash_rule(680, 8))
briefing_column.add_child(_make_section_caption("전장도와 군막 보고", 680))
briefing_camp_overview_panel = PanelContainer.new()
briefing_camp_overview_panel.custom_minimum_size = Vector2(680, 108)
_apply_panel_style(briefing_camp_overview_panel, "silk_map")
briefing_column.add_child(briefing_camp_overview_panel)
var briefing_camp_overview_shell := HBoxContainer.new()
briefing_camp_overview_shell.add_theme_constant_override("separation", 10)
briefing_camp_overview_panel.add_child(briefing_camp_overview_shell)
briefing_camp_overview_shell.add_child(_make_edict_marker_stack(["", ""], 86))
briefing_camp_overview_row = HBoxContainer.new()
briefing_camp_overview_row.custom_minimum_size = Vector2(580, 92)
briefing_camp_overview_row.add_theme_constant_override("separation", 10)
briefing_camp_overview_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
briefing_camp_overview_shell.add_child(briefing_camp_overview_row)
var camp_thumbnail_panel := PanelContainer.new()
camp_thumbnail_panel.custom_minimum_size = BRIEFING_CAMP_THUMBNAIL_SIZE
_apply_panel_style(camp_thumbnail_panel, "map")
briefing_camp_overview_row.add_child(camp_thumbnail_panel)
var camp_thumbnail_stack := Control.new()
camp_thumbnail_stack.custom_minimum_size = BRIEFING_CAMP_THUMBNAIL_SIZE
camp_thumbnail_panel.add_child(camp_thumbnail_stack)
briefing_camp_overview_texture = TextureRect.new()
briefing_camp_overview_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
briefing_camp_overview_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
briefing_camp_overview_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
briefing_camp_overview_texture.modulate = Color(0.90, 0.78, 0.56, 0.95)
camp_thumbnail_stack.add_child(briefing_camp_overview_texture)
briefing_camp_overview_fallback_label = Label.new()
briefing_camp_overview_fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT)
briefing_camp_overview_fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
briefing_camp_overview_fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
briefing_camp_overview_fallback_label.add_theme_font_size_override("font_size", 18)
_apply_label_style(briefing_camp_overview_fallback_label, UI_OLD_BRONZE)
camp_thumbnail_stack.add_child(briefing_camp_overview_fallback_label)
camp_thumbnail_stack.add_child(_make_map_lattice_overlay(BRIEFING_CAMP_THUMBNAIL_SIZE))
briefing_camp_overview_label = Label.new()
briefing_camp_overview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
briefing_camp_overview_label.custom_minimum_size = Vector2(360, 86)
briefing_camp_overview_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_label_style(briefing_camp_overview_label, UI_AGED_INK, 14)
briefing_camp_overview_row.add_child(briefing_camp_overview_label)
briefing_camp_overview_shell.add_child(_make_bamboo_gutter(14, 86))
briefing_column.add_child(_make_scroll_rod(680, 4))
var briefing_scroll := ScrollContainer.new()
briefing_scroll.custom_minimum_size = Vector2(680, 66)
briefing_column.add_child(briefing_scroll)
briefing_label = Label.new()
briefing_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
briefing_label.custom_minimum_size = Vector2(656, 0)
_apply_label_style(briefing_label, UI_AGED_INK, 14)
briefing_scroll.add_child(briefing_label)
briefing_column.add_child(_make_tablet_binding(680, 14))
briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 22))
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 = "군기록"
chapter_button.pressed.connect(_on_chapter_pressed)
prep_button_row.add_child(chapter_button)
shop_button = Button.new()
shop_button.text = "군상"
shop_button.pressed.connect(_on_shop_pressed)
prep_button_row.add_child(shop_button)
talk_button = Button.new()
talk_button.text = "군막 회의"
talk_button.pressed.connect(_on_talk_pressed)
prep_button_row.add_child(talk_button)
armory_button = Button.new()
armory_button.text = "병장고"
armory_button.pressed.connect(_on_armory_pressed)
prep_button_row.add_child(armory_button)
roster_button = Button.new()
roster_button.text = "출진 명부"
roster_button.pressed.connect(_on_roster_pressed)
prep_button_row.add_child(roster_button)
formation_button = Button.new()
formation_button.text = "진형 배치"
formation_button.pressed.connect(_on_formation_pressed)
prep_button_row.add_child(formation_button)
save_button = Button.new()
save_button.text = "전기록"
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)
_apply_label_style(chapter_status_label, UI_AGED_INK, 14)
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)
_apply_label_style(shop_status_label, UI_AGED_INK, 14)
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 = "사들이기"
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 = "내다팔기"
shop_sell_button.pressed.connect(_on_shop_sell_mode_pressed)
shop_mode_row.add_child(shop_sell_button)
var shop_scroll := ScrollContainer.new()
shop_scroll.custom_minimum_size = Vector2(600, 190)
shop_menu.add_child(shop_scroll)
shop_list = VBoxContainer.new()
shop_list.custom_minimum_size = Vector2(580, 0)
shop_list.add_theme_constant_override("separation", 4)
shop_scroll.add_child(shop_list)
talk_menu = VBoxContainer.new()
talk_menu.visible = false
talk_menu.add_theme_constant_override("separation", 6)
briefing_column.add_child(talk_menu)
talk_status_label = Label.new()
talk_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
talk_status_label.custom_minimum_size = Vector2(600, 22)
_apply_label_style(talk_status_label, UI_AGED_INK, 14)
talk_menu.add_child(talk_status_label)
var talk_scroll := ScrollContainer.new()
talk_scroll.custom_minimum_size = Vector2(600, 150)
talk_menu.add_child(talk_scroll)
talk_list = VBoxContainer.new()
talk_list.custom_minimum_size = Vector2(580, 0)
talk_list.add_theme_constant_override("separation", 4)
talk_scroll.add_child(talk_list)
armory_menu = VBoxContainer.new()
armory_menu.visible = false
armory_menu.add_theme_constant_override("separation", 6)
briefing_column.add_child(armory_menu)
armory_status_label = Label.new()
armory_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
armory_status_label.custom_minimum_size = Vector2(600, 22)
_apply_label_style(armory_status_label, UI_AGED_INK, 14)
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)
_apply_label_style(roster_status_label, UI_AGED_INK, 14)
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)
_apply_label_style(formation_status_label, UI_AGED_INK, 14)
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)
_apply_label_style(save_status_label, UI_AGED_INK, 14)
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 = "기록 봉인"
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 = "봉인 해제"
manual_load_button.pressed.connect(_on_manual_load_pressed)
save_action_row.add_child(manual_load_button)
dialogue_panel = PanelContainer.new()
dialogue_panel.visible = false
dialogue_panel.position = DIALOGUE_PANEL_POSITION
dialogue_panel.size = DIALOGUE_PANEL_SIZE
dialogue_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_apply_panel_style(dialogue_panel, "dialogue")
root.add_child(dialogue_panel)
dialogue_row = HBoxContainer.new()
dialogue_row.add_theme_constant_override("separation", 10)
dialogue_panel.add_child(dialogue_row)
dialogue_left_tassel = _make_hanging_tassel(DIALOGUE_PORTRAIT_SIZE.y)
dialogue_row.add_child(dialogue_left_tassel)
dialogue_portrait_panel = PanelContainer.new()
dialogue_portrait_panel.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
_apply_panel_style(dialogue_portrait_panel, "portrait")
dialogue_row.add_child(dialogue_portrait_panel)
var dialogue_portrait_stack := Control.new()
dialogue_portrait_stack.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
dialogue_portrait_panel.add_child(dialogue_portrait_stack)
dialogue_portrait_texture = TextureRect.new()
dialogue_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
dialogue_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
dialogue_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dialogue_portrait_stack.add_child(dialogue_portrait_texture)
dialogue_portrait_label = Label.new()
dialogue_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
dialogue_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
dialogue_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
dialogue_portrait_label.add_theme_font_size_override("font_size", 30)
_apply_label_style(dialogue_portrait_label, UI_PARCHMENT_TEXT)
dialogue_portrait_stack.add_child(dialogue_portrait_label)
dialogue_column = VBoxContainer.new()
dialogue_column.custom_minimum_size = DIALOGUE_COLUMN_SIZE
dialogue_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
dialogue_column.add_theme_constant_override("separation", 4)
dialogue_row.add_child(dialogue_column)
dialogue_right_tassel = _make_hanging_tassel(DIALOGUE_PORTRAIT_SIZE.y)
dialogue_row.add_child(dialogue_right_tassel)
dialogue_column.add_child(_make_tablet_binding(DIALOGUE_TEXT_SIZE.x, 13))
dialogue_column.add_child(_make_edict_title_strip(["", "", ""], DIALOGUE_TEXT_SIZE.x, 22))
dialogue_seal_ribbon = _make_seal_ribbon(DIALOGUE_TEXT_SIZE.x, 18)
dialogue_column.add_child(dialogue_seal_ribbon)
dialogue_speaker_panel = PanelContainer.new()
dialogue_speaker_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, 34)
_apply_panel_style(dialogue_speaker_panel, "speaker_seal")
dialogue_column.add_child(dialogue_speaker_panel)
var dialogue_speaker_row := HBoxContainer.new()
dialogue_speaker_row.add_theme_constant_override("separation", 8)
dialogue_speaker_panel.add_child(dialogue_speaker_row)
dialogue_speaker_row.add_child(_make_seal_tile("", 24))
dialogue_speaker_label = Label.new()
dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 88, 24)
dialogue_speaker_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
dialogue_speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_apply_label_style(dialogue_speaker_label, UI_PARCHMENT_TEXT, DIALOGUE_SPEAKER_FONT_SIZE)
dialogue_speaker_row.add_child(dialogue_speaker_label)
dialogue_speaker_row.add_child(_make_seal_tile("", 24))
dialogue_text_panel = PanelContainer.new()
dialogue_text_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, DIALOGUE_TEXT_SIZE.y)
_apply_panel_style(dialogue_text_panel, "speech_scroll")
dialogue_column.add_child(dialogue_text_panel)
var dialogue_text_row := HBoxContainer.new()
dialogue_text_row.add_theme_constant_override("separation", 8)
dialogue_text_panel.add_child(dialogue_text_row)
dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20))
dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20))
dialogue_text_label = Label.new()
dialogue_text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
dialogue_text_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 164, DIALOGUE_TEXT_SIZE.y - 20)
dialogue_text_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
dialogue_text_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
_apply_label_style(dialogue_text_label, UI_AGED_INK, DIALOGUE_TEXT_FONT_SIZE)
dialogue_text_row.add_child(dialogue_text_label)
dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20))
dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20))
dialogue_column.add_child(_make_scroll_rod(DIALOGUE_TEXT_SIZE.x, 3))
dialogue_column.add_child(_make_ink_wash_rule(DIALOGUE_TEXT_SIZE.x, 6))
var dialogue_control_row := HBoxContainer.new()
dialogue_control_row.add_theme_constant_override("separation", 8)
dialogue_column.add_child(dialogue_control_row)
dialogue_progress_label = Label.new()
dialogue_progress_label.custom_minimum_size = Vector2(520, 32)
dialogue_progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
dialogue_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_apply_label_style(dialogue_progress_label, UI_OLD_BRONZE)
dialogue_control_row.add_child(dialogue_progress_label)
dialogue_previous_button = Button.new()
dialogue_previous_button.text = "이전"
dialogue_previous_button.disabled = true
dialogue_previous_button.custom_minimum_size = Vector2(88, 32)
dialogue_previous_button.pressed.connect(_retreat_dialogue)
dialogue_control_row.add_child(dialogue_previous_button)
dialogue_continue_button = Button.new()
dialogue_continue_button.text = "다음"
dialogue_continue_button.custom_minimum_size = Vector2(120, 32)
dialogue_continue_button.pressed.connect(_advance_dialogue)
dialogue_control_row.add_child(dialogue_continue_button)
result_panel = PanelContainer.new()
result_panel.visible = false
result_panel.position = Vector2(260, 132)
result_panel.size = Vector2(600, 452)
_apply_panel_style(result_panel, "result_tablet")
root.add_child(result_panel)
var result_column := VBoxContainer.new()
result_column.alignment = BoxContainer.ALIGNMENT_CENTER
result_panel.add_child(result_column)
result_column.add_child(_make_tablet_binding(520, 16))
result_column.add_child(_make_scroll_rod(520, 4))
result_seal_ribbon = _make_seal_ribbon(520, 18)
result_column.add_child(result_seal_ribbon)
result_column.add_child(_make_section_caption("전공과 승패 기록", 520))
var result_body_panel := PanelContainer.new()
result_body_panel.custom_minimum_size = Vector2(520, 236)
_apply_panel_style(result_body_panel, "edict")
result_column.add_child(result_body_panel)
var result_body_row := HBoxContainer.new()
result_body_row.add_theme_constant_override("separation", 8)
result_body_panel.add_child(result_body_row)
result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198))
result_label = Label.new()
result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
result_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
result_label.custom_minimum_size = Vector2(436, 198)
result_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
result_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
_apply_label_style(result_label, UI_AGED_INK, 16)
result_body_row.add_child(result_label)
result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198))
result_column.add_child(_make_scroll_rod(520, 3))
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 = "군세 재정비"
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_button.pressed.connect(_on_next_battle_pressed)
result_column.add_child(next_battle_button)
for hud_button in [
restart_button,
new_campaign_button,
wait_button,
tactic_button,
item_button,
equip_button,
mission_toggle_button,
threat_button,
end_turn_button,
post_move_attack_button,
post_move_tactic_button,
post_move_item_button,
post_move_wait_button,
post_move_cancel_button,
post_move_picker_back_button,
post_move_picker_cancel_move_button,
targeting_hint_back_button,
targeting_hint_cancel_move_button,
chapter_button,
shop_button,
talk_button,
armory_button,
roster_button,
formation_button,
save_button,
briefing_start_button,
briefing_objective_toggle_button,
shop_buy_button,
shop_sell_button,
manual_save_button,
manual_load_button,
dialogue_previous_button,
dialogue_continue_button,
result_restart_button,
next_battle_button
]:
_apply_button_style(hud_button)
_apply_button_style(briefing_start_button, true)
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.") or message.contains("헛쳤다"):
_play_sfx("guard_block")
elif message.begins_with("Objective updated:") or message.begins_with("Defeat condition updated:") or message.begins_with("군령 갱신:") or message.begins_with("패배 조건 갱신:"):
_play_ui_confirm()
elif message.ends_with(" withdraws from the battlefield.") or message.ends_with("전장을 물러났다."):
_play_ui_confirm()
elif message.contains(" attacks ") or message.contains(" counterattacks ") or message.contains(" 공격 ") or message.contains(" 반격 "):
if message.contains(" for ") or message.contains("피해"):
_play_sfx("hit_heavy")
elif message.contains(" casts ") or message.contains(" 시전 ") or message.contains("펼쳐"):
if message.contains("restoring") or message.contains(" until ") or message.contains("회복") or message.contains("지원"):
_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 ") or message.contains("진군") or message.contains("진형을 잡았다") or message.contains("압박") or message.contains("당도"):
_play_sfx("footstep")
elif message.contains(" uses ") or message.contains(" equips ") or message.contains(" 사용:") or message.contains("장착") or message.contains("해제") or message.contains(" 매입:") or message.contains(" 매각:") or message.contains("군자금") or message.begins_with("Received ") or message.begins_with("전리품:"):
_play_sfx("item_pickup")
elif message.contains("공적") or message.contains("품계") or message.contains("승급"):
_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 = ""
basic_attack_targeting = false
_clear_pending_move_state()
state.select_unit(clicked_unit["id"])
return
if not selected_item_id.is_empty():
if item_cells.has(cell) and _commit_pending_move_before_action() and state.try_use_selected_item_on_cell(selected_item_id, cell):
selected_item_id = ""
basic_attack_targeting = false
_clear_pending_move_state()
return
if _has_pending_move():
return
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit):
selected_item_id = ""
basic_attack_targeting = false
state.select_unit(clicked_unit["id"])
return
if not selected_skill_id.is_empty():
if skill_cells.has(cell) and _commit_pending_move_before_action() and state.try_cast_selected_skill(selected_skill_id, cell):
selected_skill_id = ""
basic_attack_targeting = false
_clear_pending_move_state()
return
if _has_pending_move():
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 = ""
basic_attack_targeting = false
state.select_unit(clicked_unit["id"])
return
if not clicked_unit.is_empty() and clicked_unit.get("team", "") != selected_unit.get("team", ""):
if _has_pending_move() and not basic_attack_targeting:
return
var target_cell: Vector2i = clicked_unit.get("pos", cell)
if attack_cells.has(target_cell) and _commit_pending_move_before_action() and state.try_attack_selected(clicked_unit["id"]):
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_clear_pending_move_state()
return
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""):
if _has_pending_move():
return
if _can_select(clicked_unit):
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_clear_pending_move_state()
state.select_unit(clicked_unit["id"])
return
var selected_start_cell: Vector2i = selected_unit.get("pos", Vector2i(-1, -1))
if not _has_pending_move() and state.try_move_selected(cell, true):
pending_move_unit_id = str(selected_unit.get("id", ""))
pending_move_from_cell = selected_start_cell
pending_move_to_cell = cell
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_refresh_ranges()
_show_post_move_menu()
_update_hud()
queue_redraw()
return
if _can_select(clicked_unit):
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_clear_pending_move_state()
state.select_unit(clicked_unit["id"])
func _has_pending_move() -> bool:
return (
not pending_move_unit_id.is_empty()
and pending_move_from_cell != Vector2i(-1, -1)
and pending_move_to_cell != Vector2i(-1, -1)
)
func _clear_pending_move_state(hide_menu := true) -> void:
pending_move_unit_id = ""
pending_move_from_cell = Vector2i(-1, -1)
pending_move_to_cell = Vector2i(-1, -1)
basic_attack_targeting = false
if hide_menu:
_hide_post_move_menu()
_hide_post_move_picker()
_hide_targeting_hint_panel()
func _commit_pending_move_before_action() -> bool:
if not _has_pending_move():
return true
var unit_id := pending_move_unit_id
_clear_pending_move_state()
var still_active := state.commit_pending_move_events(unit_id)
return still_active and state.can_player_act() and not state.get_selected_unit().is_empty() and not _is_input_locked()
func _cancel_pending_move() -> bool:
if not _has_pending_move():
return false
var unit_id := pending_move_unit_id
var from_cell := pending_move_from_cell
var to_cell := pending_move_to_cell
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_menu()
_hide_post_move_picker()
if state.cancel_pending_move(unit_id, from_cell, to_cell):
_clear_pending_move_state()
_play_ui_cancel()
_refresh_ranges()
_update_hud()
queue_redraw()
return true
return false
func _handle_cancel_input() -> bool:
if _has_pending_move():
return _cancel_pending_move()
if post_move_picker_panel != null and post_move_picker_panel.visible:
_on_post_move_picker_back_pressed()
return true
if tactic_menu != null and tactic_menu.visible:
_play_ui_cancel()
_hide_tactic_menu()
_update_hud()
queue_redraw()
return true
if item_menu != null and item_menu.visible:
_play_ui_cancel()
_hide_item_menu()
_update_hud()
queue_redraw()
return true
if equip_menu != null and equip_menu.visible:
_play_ui_cancel()
_hide_equip_menu()
_update_hud()
queue_redraw()
return true
if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting:
_play_ui_cancel()
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_targeting_hint_panel()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
return true
if not state.get_selected_unit().is_empty():
_play_ui_cancel()
state.clear_selection()
return true
return false
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 talk_menu != null and talk_menu.visible:
_hide_talk_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 briefing_panel != null and briefing_panel.visible:
if _set_briefing_objective_collapsed(true):
_play_ui_cancel()
return
_on_begin_battle_pressed()
elif post_move_picker_panel != null and post_move_picker_panel.visible:
_on_post_move_picker_back_pressed()
elif tactic_menu != null and tactic_menu.visible:
_hide_tactic_menu()
if _has_pending_move():
_show_post_move_menu()
_update_hud()
queue_redraw()
elif item_menu != null and item_menu.visible:
_hide_item_menu()
if _has_pending_move():
_show_post_move_menu()
_update_hud()
queue_redraw()
elif equip_menu != null and equip_menu.visible:
_hide_equip_menu()
else:
_handle_cancel_input()
elif event.keycode == KEY_SPACE and not _is_input_locked():
_on_end_turn_pressed()
elif event.keycode == KEY_R and not _is_prep_menu_visible():
_on_restart_pressed()
elif event.keycode == KEY_T and not _is_input_locked():
_on_tactic_pressed()
elif event.keycode == KEY_I and not _is_input_locked():
_on_item_pressed()
elif event.keycode == KEY_E and not _is_input_locked():
_on_equip_pressed()
elif event.keycode == KEY_Y and not _is_prep_menu_visible():
_on_threat_pressed()
func _process(delta: float) -> void:
var needs_redraw := false
if battle_started and state.battle_status == BattleState.STATUS_ACTIVE and not _is_dialogue_visible():
needs_redraw = true
if _update_edge_scroll(delta):
needs_redraw = true
if objective_notice_timer > 0.0:
objective_notice_timer = maxf(0.0, objective_notice_timer - delta)
if objective_notice_timer <= 0.0 and objective_notice_panel != null:
objective_notice_panel.visible = false
if not floating_texts.is_empty():
var active_texts: Array[Dictionary] = []
for popup in floating_texts:
var next_popup := popup.duplicate(true)
next_popup["age"] = float(next_popup.get("age", 0.0)) + delta
if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)):
active_texts.append(next_popup)
floating_texts = active_texts
needs_redraw = true
if not unit_motion_by_unit.is_empty():
var active_motion := {}
for unit_id in unit_motion_by_unit.keys():
var motion: Dictionary = unit_motion_by_unit[unit_id]
var next_motion := motion.duplicate(true)
next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)):
active_motion[unit_id] = next_motion
unit_motion_by_unit = active_motion
needs_redraw = true
if not unit_action_motion_by_unit.is_empty():
var active_actions := {}
for unit_id in unit_action_motion_by_unit.keys():
var motion: Dictionary = unit_action_motion_by_unit[unit_id]
var next_motion := motion.duplicate(true)
next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)):
active_actions[unit_id] = next_motion
unit_action_motion_by_unit = active_actions
needs_redraw = true
if needs_redraw:
queue_redraw()
func _can_select(unit: Dictionary) -> bool:
if unit.is_empty():
return false
return (
unit.get("team", "") == BattleState.TEAM_PLAYER
and bool(unit.get("controllable", true))
and not unit.get("acted", false)
)
func _draw_map() -> void:
var board_rect := _board_rect()
var background := _current_battle_background_texture()
var has_background := background != null
if background != null:
_draw_texture_cover(background, board_rect, Color(1.0, 1.0, 1.0, 0.96))
draw_rect(board_rect, Color(0.02, 0.025, 0.03, 0.12))
draw_rect(board_rect, Color(0.35, 0.22, 0.10, 0.10))
else:
draw_rect(board_rect, Color(0.18, 0.26, 0.18))
for y in range(state.map_size.y):
for x in range(state.map_size.x):
var cell := Vector2i(x, y)
var rect := _rect_for_cell(cell)
var terrain_key := state.get_terrain_key(cell)
draw_rect(rect, _terrain_fill_color(cell, terrain_key, has_background))
_draw_terrain_detail(cell, rect, terrain_key)
_draw_terrain_edge_blend(cell, rect, terrain_key)
draw_rect(rect, _map_grid_color(has_background), false, 1.0)
_draw_silk_map_patina(board_rect, has_background)
_draw_silk_map_frame(board_rect)
func _draw_silk_map_patina(board_rect: Rect2, has_background: bool) -> void:
var wash := UI_SILK_WASH
wash.a = 0.08 if has_background else 0.14
draw_rect(board_rect, wash)
var fiber := UI_SILK_FIBER
fiber.a = 0.12 if has_background else 0.18
for index in range(7):
var y := board_rect.position.y + 18.0 + float(index) * (board_rect.size.y - 36.0) / 6.0
var drift := 4.0 + _cell_noise(Vector2i(index, state.map_size.y), 211) * 10.0
draw_line(
Vector2(board_rect.position.x + 12.0, y),
Vector2(board_rect.end.x - 12.0, y + drift),
fiber,
1.0
)
for index in range(5):
var x := board_rect.position.x + 20.0 + float(index) * (board_rect.size.x - 40.0) / 4.0
var drift := 5.0 + _cell_noise(Vector2i(state.map_size.x, index), 227) * 9.0
draw_line(
Vector2(x, board_rect.position.y + 12.0),
Vector2(x + drift, board_rect.end.y - 12.0),
Color(fiber.r, fiber.g, fiber.b, fiber.a * 0.65),
1.0
)
var corner_stain := Color(0.12, 0.06, 0.025, 0.12 if has_background else 0.18)
draw_circle(board_rect.position + Vector2(20, 18), 26.0, corner_stain)
draw_circle(board_rect.end - Vector2(24, 22), 30.0, corner_stain)
func _draw_silk_map_frame(board_rect: Rect2) -> void:
var padding := MAP_SCROLL_FRAME_PADDING
var top_rod := Rect2(board_rect.position + Vector2(-padding, -padding), Vector2(board_rect.size.x + padding * 2.0, 7.0))
var bottom_rod := Rect2(Vector2(board_rect.position.x - padding, board_rect.end.y + padding - 7.0), top_rod.size)
var left_rod := Rect2(board_rect.position + Vector2(-padding, -padding), Vector2(7.0, board_rect.size.y + padding * 2.0))
var right_rod := Rect2(Vector2(board_rect.end.x + padding - 7.0, board_rect.position.y - padding), left_rod.size)
for rod in [top_rod, bottom_rod, left_rod, right_rod]:
draw_rect(rod, UI_BAMBOO_DARK)
draw_rect(rod, Color(0.62, 0.43, 0.18, 0.80), false, 1.0)
var hanger_y := top_rod.position.y - 8.0
draw_line(
Vector2(top_rod.position.x + 32.0, hanger_y),
Vector2(top_rod.end.x - 32.0, hanger_y),
Color(UI_TARNISHED_BRONZE.r, UI_TARNISHED_BRONZE.g, UI_TARNISHED_BRONZE.b, 0.82),
2.0
)
for x in [top_rod.position.x + 54.0, top_rod.end.x - 54.0]:
draw_line(Vector2(x, hanger_y), Vector2(x, top_rod.position.y + 7.0), Color(UI_BAMBOO_DARK.r, UI_BAMBOO_DARK.g, UI_BAMBOO_DARK.b, 0.88), 2.0)
draw_circle(Vector2(x, hanger_y), 4.0, UI_SEAL_RED_DARK)
for corner in [
top_rod.position - Vector2(3, 3),
Vector2(top_rod.end.x - 11.0, top_rod.position.y - 3.0),
Vector2(bottom_rod.position.x - 3.0, bottom_rod.end.y - 11.0),
bottom_rod.end - Vector2(11, 11)
]:
draw_rect(Rect2(corner, Vector2(14, 14)), UI_CINNABAR_DARK)
draw_rect(Rect2(corner + Vector2(2, 2), Vector2(10, 10)), UI_OLD_BRONZE, false, 1.0)
draw_rect(board_rect.grow(4.0), UI_LACQUER_EDGE, false, 5.0)
draw_rect(board_rect.grow(1.0), UI_OLD_BRONZE, false, 2.0)
var seal_rect := Rect2(board_rect.end - Vector2(58, 54), Vector2(34, 34))
draw_rect(seal_rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.34))
draw_rect(seal_rect.grow(-5.0), Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.68), false, 2.0)
draw_line(seal_rect.position + Vector2(8, seal_rect.size.y * 0.5), seal_rect.end - Vector2(8, seal_rect.size.y * 0.5), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42), 1.2)
draw_line(seal_rect.position + Vector2(seal_rect.size.x * 0.5, 8), seal_rect.end - Vector2(seal_rect.size.x * 0.5, 8), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34), 1.2)
func _current_battle_background_texture() -> Texture2D:
var next_path := state.get_map_background_path()
if next_path.is_empty():
next_path = DEFAULT_BATTLE_BACKGROUND_PATH
if next_path == battle_background_path:
return battle_background_texture
battle_background_path = next_path
battle_background_texture = _load_art_texture(next_path)
return battle_background_texture
func _draw_texture_cover(texture: Texture2D, rect: Rect2, modulate: Color) -> void:
var texture_size := texture.get_size()
if texture_size.x <= 0.0 or texture_size.y <= 0.0 or rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
var target_ratio := rect.size.x / rect.size.y
var source_ratio := texture_size.x / texture_size.y
var source_rect := Rect2(Vector2.ZERO, texture_size)
if source_ratio > target_ratio:
var source_width := texture_size.y * target_ratio
source_rect.position.x = (texture_size.x - source_width) * 0.5
source_rect.size.x = source_width
else:
var source_height := texture_size.x / target_ratio
source_rect.position.y = (texture_size.y - source_height) * 0.5
source_rect.size.y = source_height
draw_texture_rect_region(texture, rect, source_rect, modulate)
func _terrain_fill_color(cell: Vector2i, terrain_key: String, has_background: bool) -> Color:
var color := state.get_terrain_color(cell)
var alpha := 0.13 if has_background else 0.34
if terrain_key == "W":
alpha += 0.04
elif terrain_key == "C" or terrain_key == "H" or terrain_key == "T":
alpha += 0.02
elif terrain_key == "D":
alpha += 0.015
color.a = alpha
return color
func _map_grid_color(has_background: bool) -> Color:
var color := GRID_COLOR
color.a = 0.09 if has_background else 0.34
return color
func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> void:
if terrain_key == "G":
for index in range(3):
var point := _terrain_detail_point(cell, rect, index + 15)
var tint := Color(0.74, 0.88, 0.42, 0.24 + _cell_noise(cell, index + 101) * 0.10)
draw_line(point, point + Vector2(3, -7), tint, 1.2)
draw_line(point + Vector2(5, 3), point + Vector2(9, -3), Color(0.46, 0.66, 0.28, 0.18), 1.0)
elif terrain_key == "F":
for index in range(4):
var point := _terrain_detail_point(cell, rect, index)
draw_circle(point, 4.5 + _cell_noise(cell, index + 11) * 2.0, Color(0.05, 0.20, 0.09, 0.42))
draw_line(point + Vector2(0, 4), point + Vector2(0, 9), Color(0.08, 0.11, 0.06, 0.34), 1.3)
elif terrain_key == "H":
for index in range(3):
var point := _terrain_detail_point(cell, rect, index + 8)
var peak := point + Vector2(0, -9)
draw_line(point + Vector2(-12, 9), peak, Color(0.92, 0.86, 0.68, 0.46), 2.2)
draw_line(peak, point + Vector2(12, 9), Color(0.20, 0.18, 0.14, 0.42), 2.2)
draw_line(point + Vector2(-5, 5), point + Vector2(5, 6), Color(0.10, 0.09, 0.07, 0.20), 1.1)
elif terrain_key == "D":
_draw_wasteland_detail(cell, rect)
elif terrain_key == "R":
_draw_road_detail(cell, rect)
elif terrain_key == "W":
for index in range(3):
var y := rect.position.y + 15.0 + float(index) * 14.0
draw_line(rect.position + Vector2(10, y - rect.position.y), rect.position + Vector2(rect.size.x - 10, y - rect.position.y + 4), Color(0.70, 0.88, 1.0, 0.32), 1.6)
_draw_water_shoreline(cell, rect)
elif terrain_key == "C":
_draw_castle_detail(rect)
elif terrain_key == "T":
_draw_town_detail(rect)
else:
for index in range(2):
var point := _terrain_detail_point(cell, rect, index + 15)
draw_line(point, point + Vector2(3, -7), Color(0.78, 0.88, 0.50, 0.26), 1.2)
func _draw_road_detail(cell: Vector2i, rect: Rect2) -> void:
var road_color := Color(0.82, 0.69, 0.48, 0.34)
var rut_color := Color(0.36, 0.28, 0.18, 0.24)
var center := rect.position + rect.size * 0.5
var flags := _terrain_connection_flags(cell, "R")
var north := bool(flags.get("north", false))
var south := bool(flags.get("south", false))
var west := bool(flags.get("west", false))
var east := bool(flags.get("east", false))
if north or south or not (west or east):
var from_y := rect.position.y if north else center.y
var to_y := rect.end.y if south else center.y
draw_rect(Rect2(Vector2(center.x - rect.size.x * 0.14, from_y), Vector2(rect.size.x * 0.28, to_y - from_y)), road_color)
draw_line(Vector2(center.x - 5.0, from_y + 5.0), Vector2(center.x - 2.0, to_y - 5.0), rut_color, 1.2)
draw_line(Vector2(center.x + 5.0, from_y + 5.0), Vector2(center.x + 2.0, to_y - 5.0), rut_color, 1.2)
if west or east:
var from_x := rect.position.x if west else center.x
var to_x := rect.end.x if east else center.x
draw_rect(Rect2(Vector2(from_x, center.y - rect.size.y * 0.14), Vector2(to_x - from_x, rect.size.y * 0.28)), road_color)
draw_line(Vector2(from_x + 5.0, center.y - 5.0), Vector2(to_x - 5.0, center.y - 2.0), rut_color, 1.2)
draw_line(Vector2(from_x + 5.0, center.y + 5.0), Vector2(to_x - 5.0, center.y + 2.0), rut_color, 1.2)
draw_circle(center, 5.0, Color(0.84, 0.70, 0.48, 0.24))
func _draw_water_shoreline(cell: Vector2i, rect: Rect2) -> void:
var shore := Color(0.82, 0.88, 0.72, 0.24)
if _terrain_key_at(cell + Vector2i(0, -1)) != "W":
draw_line(rect.position + Vector2(7, 4), rect.position + Vector2(rect.size.x - 7, 6), shore, 2.0)
if _terrain_key_at(cell + Vector2i(0, 1)) != "W":
draw_line(rect.position + Vector2(7, rect.size.y - 6), rect.position + Vector2(rect.size.x - 7, rect.size.y - 4), shore, 2.0)
if _terrain_key_at(cell + Vector2i(-1, 0)) != "W":
draw_line(rect.position + Vector2(4, 7), rect.position + Vector2(6, rect.size.y - 7), shore, 2.0)
if _terrain_key_at(cell + Vector2i(1, 0)) != "W":
draw_line(rect.position + Vector2(rect.size.x - 6, 7), rect.position + Vector2(rect.size.x - 4, rect.size.y - 7), shore, 2.0)
func _draw_wasteland_detail(cell: Vector2i, rect: Rect2) -> void:
var crack := Color(0.16, 0.10, 0.055, 0.32)
var dust := Color(0.86, 0.68, 0.42, 0.20)
for index in range(3):
var point := _terrain_detail_point(cell, rect, index + 31)
var end := point + Vector2(10.0 + _cell_noise(cell, index + 41) * 8.0, -2.0 + _cell_noise(cell, index + 43) * 5.0)
draw_line(point, end, crack, 1.2)
draw_line(point + Vector2(2, 5), point + Vector2(9, 7), dust, 1.0)
for index in range(2):
var pebble := _terrain_detail_point(cell, rect, index + 61)
draw_circle(pebble, 1.6 + _cell_noise(cell, index + 63) * 1.2, Color(0.24, 0.18, 0.12, 0.24))
func _draw_castle_detail(rect: Rect2) -> void:
draw_rect(rect.grow(-6.0), Color(0.12, 0.115, 0.105, 0.32), false, 2.2)
draw_rect(rect.grow(-12.0), Color(0.86, 0.80, 0.68, 0.12))
for y in [15.0, 30.0, 45.0]:
draw_line(rect.position + Vector2(9, y), rect.position + Vector2(rect.size.x - 9, y + 1.5), Color(0.86, 0.82, 0.74, 0.34), 1.2)
for x in [18.0, 34.0, 50.0]:
draw_line(rect.position + Vector2(x, 10), rect.position + Vector2(x + 1.5, rect.size.y - 10), Color(0.04, 0.04, 0.04, 0.26), 1.1)
for index in range(3):
var notch := Rect2(rect.position + Vector2(11.0 + float(index) * 16.0, 7.0), Vector2(8.0, 5.0))
draw_rect(notch, Color(0.84, 0.80, 0.72, 0.28))
func _draw_town_detail(rect: Rect2) -> void:
var wall := Color(0.20, 0.12, 0.055, 0.30)
var roof := Color(0.58, 0.22, 0.10, 0.34)
var straw := Color(0.88, 0.70, 0.34, 0.28)
for index in range(2):
var base := rect.position + Vector2(12.0 + float(index) * 24.0, 31.0)
var body := Rect2(base, Vector2(18.0, 13.0))
var roof_points := PackedVector2Array([
base + Vector2(-3.0, 1.0),
base + Vector2(9.0, -9.0),
base + Vector2(21.0, 1.0)
])
draw_colored_polygon(roof_points, roof)
draw_rect(body, straw)
draw_rect(body, wall, false, 1.1)
draw_line(rect.position + Vector2(8, 50), rect.position + Vector2(rect.size.x - 8, 48), Color(0.26, 0.16, 0.08, 0.28), 1.4)
func _draw_terrain_edge_blend(cell: Vector2i, rect: Rect2, terrain_key: String) -> void:
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, -1))
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(1, 0))
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, 1))
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(-1, 0))
func _draw_terrain_edge(cell: Vector2i, rect: Rect2, terrain_key: String, offset: Vector2i) -> void:
var neighbor_key := _terrain_key_at(cell + offset)
if not _should_draw_terrain_edge(terrain_key, neighbor_key, offset):
return
var color := _terrain_edge_color(terrain_key, neighbor_key)
if color.a <= 0.0:
return
var start := Vector2.ZERO
var end := Vector2.ZERO
if offset == Vector2i(0, -1):
start = rect.position + Vector2(3, 3)
end = rect.position + Vector2(rect.size.x - 3, 4 + _cell_noise(cell, 73) * 2.0)
elif offset == Vector2i(1, 0):
start = rect.position + Vector2(rect.size.x - 4, 3)
end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3)
elif offset == Vector2i(0, 1):
start = rect.position + Vector2(3, rect.size.y - 4)
end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3)
else:
start = rect.position + Vector2(3, 3)
end = rect.position + Vector2(4 + _cell_noise(cell, 91) * 2.0, rect.size.y - 3)
draw_line(start, end, color, _terrain_edge_width(terrain_key, neighbor_key))
func _should_draw_terrain_edge(terrain_key: String, neighbor_key: String, offset: Vector2i) -> bool:
if neighbor_key.is_empty() or neighbor_key == terrain_key:
return false
var priority := _terrain_edge_priority(terrain_key)
var neighbor_priority := _terrain_edge_priority(neighbor_key)
if priority != neighbor_priority:
return priority > neighbor_priority
return offset.x > 0 or offset.y > 0
func _terrain_edge_priority(terrain_key: String) -> int:
if terrain_key == "W":
return 60
if terrain_key == "C":
return 50
if terrain_key == "T":
return 45
if terrain_key == "F":
return 40
if terrain_key == "H":
return 30
if terrain_key == "D":
return 25
if terrain_key == "R":
return 20
return 10
func _terrain_edge_color(terrain_key: String, neighbor_key: String) -> Color:
if terrain_key == "F":
return Color(0.03, 0.16, 0.06, 0.34)
if terrain_key == "H":
return Color(0.88, 0.78, 0.48, 0.26)
if terrain_key == "D":
return Color(0.82, 0.58, 0.32, 0.28)
if terrain_key == "W":
return Color(0.72, 0.90, 1.0, 0.28)
if terrain_key == "C":
return Color(0.92, 0.86, 0.72, 0.24)
if terrain_key == "T":
return Color(0.92, 0.70, 0.40, 0.26)
if neighbor_key == "W":
return Color(0.08, 0.18, 0.20, 0.18)
return Color(0.96, 0.92, 0.62, 0.12)
func _terrain_edge_width(terrain_key: String, neighbor_key: String) -> float:
if terrain_key == "W" or neighbor_key == "W":
return 2.4
if terrain_key == "C":
return 2.0
if terrain_key == "T":
return 1.8
return 1.4
func _terrain_key_at(cell: Vector2i) -> String:
if not state.is_inside(cell):
return ""
return state.get_terrain_key(cell)
func _terrain_connection_flags(cell: Vector2i, terrain_key: String) -> Dictionary:
return {
"north": _terrain_key_at(cell + Vector2i(0, -1)) == terrain_key,
"south": _terrain_key_at(cell + Vector2i(0, 1)) == terrain_key,
"west": _terrain_key_at(cell + Vector2i(-1, 0)) == terrain_key,
"east": _terrain_key_at(cell + Vector2i(1, 0)) == terrain_key
}
func _terrain_detail_point(cell: Vector2i, rect: Rect2, salt: int) -> Vector2:
return rect.position + Vector2(
10.0 + _cell_noise(cell, salt) * (rect.size.x - 20.0),
10.0 + _cell_noise(cell, salt + 37) * (rect.size.y - 20.0)
)
func _cell_noise(cell: Vector2i, salt: int) -> float:
return fposmod(sin(float(cell.x * 91 + cell.y * 193 + salt * 53)) * 43758.5453, 1.0)
func _draw_objective_seal_marker(rect: Rect2) -> void:
var seal_rect := rect.grow(-9.0)
var pole_x := rect.position.x + rect.size.x * 0.24
var top_y := rect.position.y + 9.0
var bottom_y := rect.end.y - 8.0
draw_rect(rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.16))
draw_line(Vector2(pole_x, top_y), Vector2(pole_x, bottom_y), Color(0.12, 0.065, 0.030, 0.78), 2.2)
var banner_points := PackedVector2Array([
Vector2(pole_x + 1.0, top_y + 2.0),
Vector2(rect.end.x - 10.0, top_y + 8.0),
Vector2(rect.end.x - 16.0, top_y + 26.0),
Vector2(pole_x + 1.0, top_y + 24.0)
])
var banner_outline := PackedVector2Array([
banner_points[0],
banner_points[1],
banner_points[2],
banner_points[3],
banner_points[0]
])
draw_colored_polygon(banner_points, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.54))
draw_polyline(banner_outline, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.64), 1.6)
draw_rect(seal_rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.24))
draw_rect(seal_rect.grow(-5.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.76), false, 1.8)
draw_line(seal_rect.position + Vector2(8, seal_rect.size.y * 0.5), seal_rect.end - Vector2(8, seal_rect.size.y * 0.5), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42), 1.2)
draw_line(seal_rect.position + Vector2(seal_rect.size.x * 0.5, 8), seal_rect.end - Vector2(seal_rect.size.x * 0.5, 8), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34), 1.2)
func _draw_objective_foreground_markers() -> void:
var font := _draw_ui_font(true)
for cell in state.get_objective_cells():
if not state.is_inside(cell):
continue
var rect := _rect_for_cell(cell)
_draw_objective_corner_brackets(rect)
var label_rect := Rect2(rect.position + Vector2(rect.size.x - 20.0, 3.0), Vector2(17.0, 16.0))
draw_rect(label_rect, Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.86))
draw_rect(label_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88), false, 1.2)
_draw_ink_text(font, label_rect.position + Vector2(0, 12), "", HORIZONTAL_ALIGNMENT_CENTER, label_rect.size.x, 11, UI_PARCHMENT_TEXT)
func _draw_objective_corner_brackets(rect: Rect2) -> void:
var bracket := 12.0
var inset := 4.0
var line_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.95)
var shadow := Color(0.02, 0.012, 0.006, 0.88)
var corners := [
rect.position + Vector2(inset, inset),
Vector2(rect.end.x - inset, rect.position.y + inset),
rect.end - Vector2(inset, inset),
Vector2(rect.position.x + inset, rect.end.y - inset)
]
_draw_objective_corner(corners[0], Vector2.RIGHT, Vector2.DOWN, bracket, shadow, 4.0)
_draw_objective_corner(corners[1], Vector2.LEFT, Vector2.DOWN, bracket, shadow, 4.0)
_draw_objective_corner(corners[2], Vector2.LEFT, Vector2.UP, bracket, shadow, 4.0)
_draw_objective_corner(corners[3], Vector2.RIGHT, Vector2.UP, bracket, shadow, 4.0)
_draw_objective_corner(corners[0], Vector2.RIGHT, Vector2.DOWN, bracket, line_color, 2.0)
_draw_objective_corner(corners[1], Vector2.LEFT, Vector2.DOWN, bracket, line_color, 2.0)
_draw_objective_corner(corners[2], Vector2.LEFT, Vector2.UP, bracket, line_color, 2.0)
_draw_objective_corner(corners[3], Vector2.RIGHT, Vector2.UP, bracket, line_color, 2.0)
func _draw_objective_corner(origin: Vector2, x_dir: Vector2, y_dir: Vector2, length: float, color: Color, width: float) -> void:
draw_line(origin, origin + x_dir * length, color, width)
draw_line(origin, origin + y_dir * length, color, width)
func _draw_overlays() -> void:
for cell in state.get_objective_cells():
_draw_objective_seal_marker(_rect_for_cell(cell))
for cell in threat_cells:
var threat_rect := _rect_for_cell(cell)
draw_rect(threat_rect, THREAT_OVERLAY_COLOR)
draw_rect(threat_rect.grow(-6.0), THREAT_BORDER_COLOR, false, 1.5)
for cell in move_cells:
var move_rect := _rect_for_cell(cell)
draw_rect(move_rect, MOVE_OVERLAY_COLOR)
draw_rect(move_rect.grow(-8.0), MOVE_BORDER_COLOR, false, 1.2)
if _has_pending_move() and state.is_inside(pending_move_to_cell):
var pending_rect := _rect_for_cell(pending_move_to_cell)
draw_rect(pending_rect.grow(-4.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34))
draw_rect(pending_rect.grow(-7.0), Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.72), false, 2.0)
for cell in attack_cells:
var attack_rect := _rect_for_cell(cell)
draw_rect(attack_rect, ATTACK_OVERLAY_COLOR)
draw_rect(attack_rect.grow(-7.0), ATTACK_BORDER_COLOR, false, 1.4)
for cell in skill_cells:
var skill_rect := _rect_for_cell(cell)
draw_rect(skill_rect, SKILL_OVERLAY_COLOR)
draw_rect(skill_rect.grow(-8.0), Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.54), false, 1.2)
if not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
var area_selected := state.get_selected_unit()
if not area_selected.is_empty():
for cell in state.get_skill_area_cells(area_selected["id"], selected_skill_id, hover_cell):
var area_rect := _rect_for_cell(cell)
draw_rect(area_rect, SKILL_AREA_OVERLAY_COLOR)
draw_rect(area_rect.grow(-7.0), Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.62), false, 1.5)
for cell in item_cells:
var item_rect := _rect_for_cell(cell)
draw_rect(item_rect, ITEM_OVERLAY_COLOR)
draw_rect(item_rect.grow(-8.0), ITEM_BORDER_COLOR, false, 1.2)
if formation_menu != null and formation_menu.visible:
for cell in state.get_formation_cells():
var formation_rect := _rect_for_cell(cell)
draw_rect(formation_rect, FORMATION_OVERLAY_COLOR)
draw_rect(formation_rect.grow(-7.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.58), false, 1.4)
if state.is_inside(hover_cell):
var hover_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42)
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(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.66)
elif not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
hover_color = Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.58)
elif not selected_item_id.is_empty() and item_cells.has(hover_cell):
hover_color = Color(0.52, 0.70, 0.30, 0.54)
elif not hover_unit.is_empty() and not selected.is_empty() and hover_unit.get("team", "") != selected.get("team", ""):
hover_color = Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.52)
draw_rect(_rect_for_cell(hover_cell), hover_color, false, 3.0)
func _draw_units() -> void:
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 acted := bool(unit.get("acted", false))
var sprite_modulate := _unit_sprite_modulate(unit, acted)
draw_circle(center + Vector2(0, 17), 20, Color(0.0, 0.0, 0.0, 0.30))
_draw_unit_class_emblem(rect, center, unit, team_color)
_draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate)
_draw_unit_identity_marks(rect, center, unit, team_color)
_draw_unit_class_badge(rect, unit, team_color)
var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"])))
var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5))
var hp_front := Rect2(hp_back.position, Vector2(hp_back.size.x * hp_ratio, hp_back.size.y))
draw_rect(hp_back, Color(0.08, 0.08, 0.09))
draw_rect(hp_front, _unit_hp_color(hp_ratio))
_draw_unit_status_markers(center, hp_ratio, unit)
if unit.get("id", "") == state.selected_unit_id:
draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0)
_draw_unit_nameplate(rect, _short_name(String(unit["name"])))
func _draw_target_selection_markers() -> void:
if not _is_targeting_mode():
return
var font := _draw_ui_font(true)
for marker in _target_selection_marker_entries():
var cell: Vector2i = marker.get("cell", Vector2i(-1, -1))
if not state.is_inside(cell):
continue
var rect := _rect_for_cell(cell)
var marker_rect := Rect2(rect.position + Vector2(7.0, TILE_SIZE - 24.0), Vector2(TILE_SIZE - 14.0, 18.0))
var color := _target_preview_badge_color(str(marker.get("kind", "damage")))
draw_rect(rect.grow(-3.0), Color(color.r, color.g, color.b, 0.18))
draw_rect(rect.grow(-6.0), color, false, 2.2)
draw_rect(marker_rect, Color(0.025, 0.028, 0.034, 0.88))
draw_rect(marker_rect, color, false, 1.4)
_draw_ink_text(font, marker_rect.position + Vector2(0, 13), str(marker.get("text", "")), HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 12, color)
func _draw_unit_nameplate(rect: Rect2, label: String) -> void:
if label.is_empty():
return
var font := _draw_ui_font(true)
var label_rect := Rect2(rect.position + Vector2(4.0, TILE_SIZE - 21.0), Vector2(TILE_SIZE - 8.0, 13.0))
draw_rect(label_rect, Color(0.025, 0.018, 0.012, 0.76))
draw_rect(label_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.48), false, 1.0)
_draw_ink_text(font, label_rect.position + Vector2(0, 10), label, HORIZONTAL_ALIGNMENT_CENTER, label_rect.size.x, 9, UI_PARCHMENT_TEXT)
func _target_selection_marker_entries() -> Array[Dictionary]:
if basic_attack_targeting:
return _attack_target_marker_entries()
if not selected_skill_id.is_empty():
return _skill_target_marker_entries()
if not selected_item_id.is_empty():
return _item_target_marker_entries()
return []
func _attack_target_marker_entries() -> Array[Dictionary]:
var result: Array[Dictionary] = []
if not basic_attack_targeting:
return result
var selected := state.get_selected_unit()
if selected.is_empty():
return result
for cell in attack_cells:
var target := state.get_unit_at(cell)
if target.is_empty() or target.get("team", "") == selected.get("team", ""):
continue
var preview := state.get_damage_preview(str(selected.get("id", "")), str(target.get("id", "")))
if preview.is_empty() or not bool(preview.get("in_range", false)) or bool(preview.get("attack_locked", false)):
continue
var hit_chance := int(preview.get("hit_chance", 100))
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
result.append({
"cell": cell,
"target_id": str(target.get("id", "")),
"text": "%s %d%%" % [result_text, hit_chance],
"kind": _attack_preview_badge_kind(preview)
})
return result
func _skill_target_marker_entries() -> Array[Dictionary]:
var result: Array[Dictionary] = []
if selected_skill_id.is_empty():
return result
var selected := state.get_selected_unit()
if selected.is_empty():
return result
for cell in skill_cells:
var target := state.get_unit_at(cell)
var badge := _skill_target_preview_badge_for_cell(selected, target, cell)
if badge.is_empty() or str(badge.get("kind", "")) == "invalid":
continue
result.append({
"cell": cell,
"target_id": str(target.get("id", "")) if not target.is_empty() else "",
"text": str(badge.get("text", "")),
"kind": str(badge.get("kind", "support"))
})
return result
func _item_target_marker_entries() -> Array[Dictionary]:
var result: Array[Dictionary] = []
if selected_item_id.is_empty():
return result
var selected := state.get_selected_unit()
if selected.is_empty():
return result
for cell in item_cells:
var target := state.get_unit_at(cell)
var badge := _item_target_preview_badge_for_cell(selected, target, cell)
if badge.is_empty() or str(badge.get("kind", "")) == "invalid":
continue
result.append({
"cell": cell,
"target_id": str(target.get("id", "")) if not target.is_empty() else "",
"text": str(badge.get("text", "")),
"kind": str(badge.get("kind", "support"))
})
return result
func _has_basic_attack_target(unit_id: String) -> bool:
var unit := state.get_unit(unit_id)
if unit.is_empty():
return false
for cell in state.get_attack_cells(unit_id):
var target := state.get_unit_at(cell)
if target.is_empty() or target.get("team", "") == unit.get("team", ""):
continue
var preview := state.get_damage_preview(unit_id, str(target.get("id", "")))
if not preview.is_empty() and bool(preview.get("in_range", false)) and not bool(preview.get("attack_locked", false)):
return true
return false
func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2:
var texture_size := texture.get_size()
if texture_size.x <= 0.0 or texture_size.y <= 0.0:
return target
var scale := minf(target.size.x / texture_size.x, target.size.y / texture_size.y)
var fit_size := texture_size * scale
return Rect2(target.position + (target.size - fit_size) * 0.5, fit_size)
func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color:
if acted:
return Color(0.60, 0.62, 0.66, 0.82)
if bool(unit.get("uses_enemy_sprite", false)):
return Color(1.0, 1.0, 1.0, 0.98)
if _is_generic_enemy_unit(unit):
return Color(1.0, 0.82, 0.74, 0.94)
return Color.WHITE
func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color) -> void:
var profile := _pixel_unit_sprite_profile(unit)
var family := str(profile.get("family", _unit_class_family(unit)))
var phase := _unit_idle_phase(unit)
var bob := _unit_idle_bob(family, phase)
var step := _unit_idle_step(family, phase)
var pixel := float(profile.get("pixel", 3.0))
var origin := Vector2(roundf(rect.position.x + 12.0), roundf(rect.position.y + 5.0 + bob))
var outline := _unit_pixel_color(Color(0.035, 0.026, 0.018, 1.0), modulate)
var skin := _unit_pixel_color(Color(0.86, 0.62, 0.38, 1.0), modulate)
var cloth := _unit_pixel_color(team_color.lightened(0.05), modulate)
var cloth_dark := _unit_pixel_color(team_color.darkened(0.34), modulate)
var class_color := _unit_pixel_color(_unit_class_badge_color(unit, team_color), modulate)
var accent := _unit_pixel_color(Color(0.98, 0.76, 0.28, 1.0) if not str(unit.get("officer_id", "")).is_empty() else class_color.lightened(0.25), modulate)
_draw_pixel_rect(origin, 2, 11, 8, 2, pixel, Color(0.0, 0.0, 0.0, 0.22 * modulate.a))
if family == "cavalry":
_draw_pixel_cavalry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
elif family == "ranged":
_draw_pixel_archer(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
elif family == "tactic":
_draw_pixel_strategist(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
elif family == "heavy":
_draw_pixel_heavy(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
else:
_draw_pixel_infantry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
func _unit_idle_phase(unit: Dictionary) -> float:
var seed := 0
var unit_id := str(unit.get("id", "unit"))
for index in range(unit_id.length()):
seed += unit_id.unicode_at(index) * (index + 1)
return float(Time.get_ticks_msec()) * 0.0024 + float(seed % 360) * 0.01745
func _unit_idle_bob(family: String, phase: float) -> float:
if family == "cavalry":
return roundf(sin(phase * 1.55) * 0.75)
if family == "ranged":
return roundf(sin(phase * 1.10) * 0.75)
if family == "tactic":
return roundf(sin(phase * 0.82) * 0.65)
if family == "heavy":
return roundf(sin(phase * 0.72) * 0.45)
return roundf(sin(phase) * 1.15)
func _unit_idle_step(family: String, phase: float) -> float:
var rate := 1.65
if family == "cavalry":
rate = 2.15
elif family == "ranged":
rate = 1.25
elif family == "tactic":
rate = 0.95
elif family == "heavy":
rate = 0.78
return 1.0 if sin(phase * rate) >= 0.0 else -1.0
func _pixel_unit_sprite_profile(unit: Dictionary) -> Dictionary:
var family := _unit_class_family(unit)
if family == "cavalry":
return {
"family": family,
"pixel": 3.0,
"min_x": 0.0,
"max_x": 15.0,
"max_y": 14.0,
"weapon": "banner",
"idle": "hoof_cycle"
}
if family == "ranged":
return {
"family": family,
"pixel": 3.0,
"min_x": 1.0,
"max_x": 14.0,
"max_y": 13.0,
"weapon": "bow",
"idle": "bow_sway"
}
if family == "tactic":
return {
"family": family,
"pixel": 3.0,
"min_x": 1.0,
"max_x": 13.0,
"max_y": 14.0,
"weapon": "staff",
"idle": "robe_pulse"
}
if family == "heavy":
return {
"family": family,
"pixel": 3.0,
"min_x": 1.0,
"max_x": 14.0,
"max_y": 14.0,
"weapon": "heavy_blade",
"idle": "weight_shift"
}
return {
"family": family,
"pixel": 3.0,
"min_x": 1.0,
"max_x": 12.0,
"max_y": 13.0,
"weapon": "shield_spear",
"idle": "march_step"
}
func _unit_pixel_color(color: Color, modulate: Color) -> Color:
return Color(
color.r * modulate.r,
color.g * modulate.g,
color.b * modulate.b,
color.a * modulate.a
)
func _draw_pixel_rect(origin: Vector2, x: float, y: float, width: float, height: float, pixel: float, color: Color) -> void:
draw_rect(Rect2(origin + Vector2(x, y) * pixel, Vector2(width, height) * pixel), color)
func _draw_pixel_shield(origin: Vector2, x: float, y: float, pixel: float, outline: Color, face: Color, trim: Color) -> void:
_draw_pixel_rect(origin, x + 1, y, 2, 1, pixel, outline)
_draw_pixel_rect(origin, x, y + 1, 4, 3, pixel, outline)
_draw_pixel_rect(origin, x + 1, y + 4, 2, 1, pixel, outline)
_draw_pixel_rect(origin, x + 1, y + 1, 2, 3, pixel, face)
_draw_pixel_rect(origin, x + 2, y + 1, 1, 3, pixel, trim)
func _draw_pixel_spear(origin: Vector2, x: float, y: float, pixel: float, shaft: Color, blade: Color) -> void:
_draw_pixel_rect(origin, x, y, 1, 9, pixel, shaft)
_draw_pixel_rect(origin, x - 1, y - 1, 3, 1, pixel, blade)
_draw_pixel_rect(origin, x, y - 2, 1, 1, pixel, blade.lightened(0.20))
func _draw_pixel_bow(origin: Vector2, x: float, y: float, pixel: float, wood: Color, string_color: Color, arrow: Color) -> void:
_draw_pixel_rect(origin, x + 1, y, 2, 1, pixel, wood)
_draw_pixel_rect(origin, x + 3, y + 1, 1, 2, pixel, wood)
_draw_pixel_rect(origin, x + 4, y + 3, 1, 3, pixel, wood)
_draw_pixel_rect(origin, x + 3, y + 6, 1, 2, pixel, wood)
_draw_pixel_rect(origin, x + 1, y + 8, 2, 1, pixel, wood)
_draw_pixel_rect(origin, x + 1, y + 1, 1, 7, pixel, string_color)
_draw_pixel_rect(origin, x - 3, y + 4, 6, 1, pixel, arrow)
_draw_pixel_rect(origin, x + 3, y + 3, 1, 3, pixel, arrow.lightened(0.10))
func _draw_pixel_horse_tack(origin: Vector2, pixel: float, cloth: Color, trim: Color) -> void:
_draw_pixel_rect(origin, 4, 6, 4, 1, pixel, trim)
_draw_pixel_rect(origin, 4, 7, 4, 2, pixel, cloth)
_draw_pixel_rect(origin, 8, 7, 1, 2, pixel, trim)
func _draw_pixel_banner(origin: Vector2, x: float, y: float, pixel: float, pole: Color, cloth: Color, trim: Color) -> void:
_draw_pixel_rect(origin, x, y, 1, 9, pixel, pole)
_draw_pixel_rect(origin, x + 1, y, 3, 2, pixel, cloth)
_draw_pixel_rect(origin, x + 1, y + 2, 2, 1, pixel, cloth.darkened(0.12))
_draw_pixel_rect(origin, x + 3, y + 2, 1, 1, pixel, trim)
func _draw_pixel_staff(origin: Vector2, x: float, y: float, pixel: float, staff: Color, tassel: Color) -> void:
_draw_pixel_rect(origin, x, y, 1, 9, pixel, staff)
_draw_pixel_rect(origin, x - 1, y, 3, 1, pixel, tassel)
_draw_pixel_rect(origin, x, y - 1, 1, 1, pixel, tassel.lightened(0.18))
_draw_pixel_rect(origin, x + 1, y + 2, 1, 2, pixel, tassel.darkened(0.18))
func _draw_pixel_heavy_blade(origin: Vector2, x: float, y: float, pixel: float, handle: Color, blade: Color) -> void:
_draw_pixel_rect(origin, x, y + 1, 1, 7, pixel, handle)
_draw_pixel_rect(origin, x - 1, y, 3, 1, pixel, handle.darkened(0.10))
_draw_pixel_rect(origin, x - 2, y - 1, 3, 2, pixel, blade)
_draw_pixel_rect(origin, x - 3, y, 1, 2, pixel, blade.darkened(0.10))
_draw_pixel_rect(origin, x - 1, y + 1, 2, 1, pixel, blade.lightened(0.16))
func _draw_pixel_infantry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
_draw_pixel_rect(origin, 4, 1, 4, 1, pixel, accent)
_draw_pixel_rect(origin, 4, 2, 4, 2, pixel, outline)
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, outline)
_draw_pixel_rect(origin, 4, 5, 4, 4, pixel, cloth)
_draw_pixel_rect(origin, 5, 6, 2, 3, pixel, class_color)
_draw_pixel_shield(origin, 1, 5, pixel, outline, class_color.darkened(0.12), accent)
_draw_pixel_spear(origin, 10 + step, 2, pixel, accent.darkened(0.12), accent.lightened(0.18))
_draw_pixel_rect(origin, 4 + step, 10, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 7 - step, 10, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 4 + step, 10, 2, 1, pixel, cloth_dark)
_draw_pixel_rect(origin, 7 - step, 10, 2, 1, pixel, cloth_dark)
func _draw_pixel_archer(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
_draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline)
_draw_pixel_rect(origin, 4, 2, 4, 1, pixel, cloth_dark)
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, outline)
_draw_pixel_rect(origin, 4, 5, 4, 4, pixel, cloth)
_draw_pixel_rect(origin, 2, 6, 2, 2, pixel, cloth_dark)
_draw_pixel_rect(origin, 5, 6, 2, 3, pixel, class_color)
_draw_pixel_rect(origin, 4 + step, 10, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 7 - step, 10, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 1, 4, 1, 5, pixel, accent.darkened(0.22))
_draw_pixel_rect(origin, 1, 5, 2, 1, pixel, accent)
_draw_pixel_bow(origin, 8 + step, 2, pixel, accent, outline.lightened(0.28), accent.lightened(0.16))
func _draw_pixel_cavalry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
var horse := _unit_pixel_color(Color(0.33, 0.20, 0.11, 1.0), Color(1.0, 1.0, 1.0, outline.a))
var mane := _unit_pixel_color(Color(0.12, 0.075, 0.04, 1.0), Color(1.0, 1.0, 1.0, outline.a))
_draw_pixel_rect(origin, 0, 8, 10, 3, pixel, outline)
_draw_pixel_rect(origin, 1, 8, 8, 2, pixel, horse)
_draw_pixel_rect(origin, 8, 6, 4, 3, pixel, outline)
_draw_pixel_rect(origin, 8, 7, 3, 2, pixel, horse)
_draw_pixel_rect(origin, 7, 6, 1, 3, pixel, mane)
_draw_pixel_rect(origin, 0, 7, 2, 1, pixel, mane)
_draw_pixel_rect(origin, 1 + step, 11, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 4 - step, 11, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 7 + step, 11, 2, 3, pixel, outline)
_draw_pixel_horse_tack(origin, pixel, class_color, accent)
_draw_pixel_rect(origin, 4, 2, 4, 2, pixel, outline)
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
_draw_pixel_rect(origin, 4, 5, 4, 3, pixel, cloth)
_draw_pixel_rect(origin, 5, 5, 2, 2, pixel, class_color)
_draw_pixel_banner(origin, 11, 0, pixel, accent.darkened(0.22), class_color, accent)
func _draw_pixel_strategist(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
_draw_pixel_rect(origin, 4, 0, 4, 1, pixel, class_color.lightened(0.20))
_draw_pixel_rect(origin, 3, 1, 6, 2, pixel, outline)
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
_draw_pixel_rect(origin, 3, 5, 6, 7, pixel, outline)
_draw_pixel_rect(origin, 4, 5, 4, 6, pixel, cloth)
_draw_pixel_rect(origin, 3, 7, 2, 3, pixel, class_color)
_draw_pixel_rect(origin, 7, 7, 2, 3, pixel, class_color.darkened(0.08))
_draw_pixel_rect(origin, 4 + step, 12, 2, 2, pixel, outline)
_draw_pixel_rect(origin, 7 - step, 12, 2, 2, pixel, outline)
_draw_pixel_rect(origin, 1, 6, 2, 4, pixel, cloth_dark)
_draw_pixel_rect(origin, 2, 10, 3, 1, pixel, accent.lightened(0.10))
_draw_pixel_staff(origin, 10 + step, 3, pixel, accent.darkened(0.22), accent)
func _draw_pixel_heavy(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
_draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline)
_draw_pixel_rect(origin, 3, 2, 6, 1, pixel, accent.darkened(0.08))
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
_draw_pixel_rect(origin, 2, 5, 8, 6, pixel, outline)
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, cloth)
_draw_pixel_rect(origin, 4, 6, 4, 4, pixel, class_color)
_draw_pixel_rect(origin, 1, 6, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 9, 6, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 3 + step, 11, 2, 3, pixel, outline)
_draw_pixel_rect(origin, 8 - step, 11, 2, 3, pixel, outline)
_draw_pixel_heavy_blade(origin, 11 + step, 2, pixel, accent.darkened(0.22), accent.lightened(0.12))
func _draw_unit_identity_marks(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void:
if _is_generic_enemy_unit(unit):
var sash := ENEMY_COLOR.lightened(0.22)
sash.a = 0.48
draw_line(rect.position + Vector2(12, 48), rect.position + Vector2(50, 14), sash, 4.0)
draw_circle(center + Vector2(18, -17), 5.0, Color(0.05, 0.02, 0.02, 0.78))
draw_circle(center + Vector2(18, -17), 3.2, ENEMY_COLOR.lightened(0.18))
return
var officer_id := str(unit.get("officer_id", ""))
if not officer_id.is_empty():
draw_circle(center + Vector2(18, -17), 5.0, Color(0.06, 0.045, 0.02, 0.82))
draw_circle(center + Vector2(18, -17), 3.0, Color(1.0, 0.84, 0.34, 0.88))
draw_arc(center, 25.0, -PI * 0.18, PI * 0.32, 18, team_color.lightened(0.34), 1.8)
func _is_generic_enemy_unit(unit: Dictionary) -> bool:
return unit.get("team", "") == BattleState.TEAM_ENEMY and str(unit.get("officer_id", "")).is_empty()
func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> void:
var badge_rect := Rect2(rect.position + Vector2(4, 4), Vector2(20, 20))
var class_color := _unit_class_badge_color(unit, team_color)
draw_rect(badge_rect, Color(0.035, 0.018, 0.012, 0.88))
draw_rect(badge_rect.grow(-2.0), Color(class_color.r, class_color.g, class_color.b, 0.78))
draw_rect(badge_rect, UI_OLD_BRONZE, false, 1.3)
var font := _draw_ui_font(true)
draw_string(font, badge_rect.position + Vector2(0, 15), _unit_class_abbrev(unit), HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 13, UI_PARCHMENT_TEXT)
func _draw_unit_class_emblem(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void:
var family := _unit_class_family(unit)
var class_color := _unit_class_badge_color(unit, team_color)
var emblem := class_color.lightened(0.36)
emblem.a = 0.72
var shadow := Color(0.02, 0.016, 0.012, 0.72)
if family == "cavalry":
var pole_start := center + Vector2(16, -24)
var pole_end := center + Vector2(16, 16)
draw_line(pole_start + Vector2(1, 1), pole_end + Vector2(1, 1), shadow, 2.2)
draw_line(pole_start, pole_end, emblem, 2.0)
var pennant := PackedVector2Array([
pole_start,
pole_start + Vector2(22, 7),
pole_start + Vector2(5, 16)
])
draw_colored_polygon(pennant, Color(class_color.r, class_color.g, class_color.b, 0.50))
draw_polyline(PackedVector2Array([pennant[0], pennant[1], pennant[2], pennant[0]]), emblem, 1.2)
elif family == "ranged":
var bow_center := center + Vector2(-17, -5)
draw_arc(bow_center + Vector2(1, 1), 17.0, -PI * 0.55, PI * 0.55, 24, shadow, 2.4)
draw_arc(bow_center, 17.0, -PI * 0.55, PI * 0.55, 24, emblem, 2.2)
draw_line(bow_center + Vector2(6, -14), bow_center + Vector2(6, 14), Color(0.92, 0.82, 0.56, 0.58), 1.2)
draw_line(center + Vector2(-14, -5), center + Vector2(8, -5), emblem, 1.6)
elif family == "tactic":
draw_arc(center + Vector2(0, -3), 20.0, 0.0, TAU, 40, Color(emblem.r, emblem.g, emblem.b, 0.42), 1.8)
draw_arc(center + Vector2(0, -3), 12.0, -PI * 0.25, PI * 1.25, 30, emblem, 1.6)
draw_line(center + Vector2(-11, -3), center + Vector2(11, -3), Color(1.0, 0.88, 0.38, 0.42), 1.2)
draw_line(center + Vector2(0, -14), center + Vector2(0, 8), Color(1.0, 0.88, 0.38, 0.32), 1.2)
elif family == "heavy":
draw_line(center + Vector2(-19, -16), center + Vector2(17, 18), shadow, 4.4)
draw_line(center + Vector2(-19, -16), center + Vector2(17, 18), emblem, 3.0)
draw_line(center + Vector2(14, -17), center + Vector2(-16, 16), Color(1.0, 0.82, 0.32, 0.48), 2.4)
draw_circle(center + Vector2(15, 18), 4.5, Color(class_color.r, class_color.g, class_color.b, 0.42))
else:
var shield := Rect2(center + Vector2(-13, -17), Vector2(26, 28))
draw_rect(Rect2(shield.position + Vector2(1, 1), shield.size), shadow)
draw_rect(shield, Color(class_color.r, class_color.g, class_color.b, 0.38))
draw_rect(shield.grow(-4.0), Color(0.82, 0.76, 0.58, 0.28), false, 1.4)
draw_line(shield.position + Vector2(shield.size.x * 0.5, 3), shield.position + Vector2(shield.size.x * 0.5, shield.size.y - 3), emblem, 1.6)
func _unit_class_abbrev(unit: Dictionary) -> String:
var family := _unit_class_family(unit)
if family == "cavalry":
return ""
if family == "ranged":
return ""
if family == "tactic":
return ""
if family == "heavy":
return ""
return ""
func _unit_class_family(unit: Dictionary) -> String:
var class_id := str(unit.get("class_id", ""))
if class_id.contains("cavalry"):
return "cavalry"
if class_id.contains("archer") or class_id.contains("marksman"):
return "ranged"
if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"):
return "tactic"
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
return "heavy"
return "infantry"
func _unit_class_badge_color(unit: Dictionary, team_color: Color) -> Color:
var family := _unit_class_family(unit)
if family == "cavalry":
return Color(0.78, 0.34, 0.16, 1.0)
if family == "ranged":
return Color(0.16, 0.42, 0.26, 1.0)
if family == "tactic":
return Color(0.32, 0.26, 0.62, 1.0)
if family == "heavy":
return Color(0.62, 0.16, 0.12, 1.0)
return team_color.darkened(0.10)
func _unit_hp_color(hp_ratio: float) -> Color:
if hp_ratio <= LOW_HP_WARNING_RATIO:
return Color(1.0, 0.24, 0.18)
if hp_ratio <= 0.55:
return Color(1.0, 0.72, 0.24)
return Color(0.28, 0.82, 0.36)
func _draw_unit_status_markers(center: Vector2, hp_ratio: float, unit: Dictionary) -> void:
if hp_ratio <= LOW_HP_WARNING_RATIO:
draw_arc(center, 27, 0.0, TAU, 48, Color(1.0, 0.28, 0.14), 3.0)
var marker_kinds := _unit_status_marker_kinds(unit)
if marker_kinds.is_empty():
return
var start_x := -float(marker_kinds.size() - 1) * UNIT_STATUS_MARKER_GAP * 0.5
var font := _draw_ui_font(true)
for index in range(marker_kinds.size()):
var marker_kind := marker_kinds[index]
var marker_center := center + Vector2(start_x + float(index) * UNIT_STATUS_MARKER_GAP, UNIT_STATUS_MARKER_OFFSET_Y)
draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS + 1.5, Color(0.02, 0.022, 0.028, 0.88))
draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS, _unit_status_marker_color(marker_kind))
_draw_ink_text(
font,
marker_center + Vector2(-UNIT_STATUS_MARKER_RADIUS, 3.0),
_unit_status_marker_glyph(marker_kind),
HORIZONTAL_ALIGNMENT_CENTER,
UNIT_STATUS_MARKER_RADIUS * 2.0,
8,
_unit_status_marker_text_color(marker_kind),
Color(0.02, 0.012, 0.006, 0.80)
)
func _unit_status_marker_kinds(unit: Dictionary) -> Array[String]:
var result: Array[String] = []
var status_effects = unit.get("status_effects", [])
if typeof(status_effects) != TYPE_ARRAY:
return result
for effect in status_effects:
if typeof(effect) != TYPE_DICTIONARY:
continue
if int(effect.get("remaining_phases", 0)) <= 0:
continue
var marker_kind := _unit_status_marker_kind(effect)
if marker_kind.is_empty() or result.has(marker_kind):
continue
result.append(marker_kind)
if result.size() >= MAX_UNIT_STATUS_MARKERS:
break
return result
func _unit_status_marker_kind(effect: Dictionary) -> String:
var effect_type := str(effect.get("type", "stat_bonus"))
if effect_type == "action_lock":
return str(effect.get("status", "status"))
if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
return str(effect.get("status", "poison"))
if effect_type == "stat_bonus":
var amount := int(effect.get("amount", 0))
if amount > 0:
return "support"
if amount < 0:
return "debuff"
return ""
func _unit_status_marker_color(marker_kind: String) -> Color:
if marker_kind == "support":
return Color(1.0, 0.88, 0.30)
if marker_kind == "debuff":
return Color(0.78, 0.55, 1.0)
if marker_kind == "poison":
return Color(0.54, 0.95, 0.36)
if marker_kind == "seal":
return Color(0.44, 0.86, 1.0)
if marker_kind == "snare":
return Color(1.0, 0.64, 0.22)
if marker_kind == "disarm":
return Color(1.0, 0.48, 0.42)
return Color(0.72, 0.86, 1.0)
func _unit_status_marker_glyph(marker_kind: String) -> String:
if marker_kind == "support":
return ""
if marker_kind == "debuff":
return ""
if marker_kind == "poison":
return ""
if marker_kind == "seal":
return ""
if marker_kind == "snare":
return ""
if marker_kind == "disarm":
return ""
return ""
func _unit_status_marker_text_color(marker_kind: String) -> Color:
if marker_kind == "poison" or marker_kind == "support":
return UI_AGED_INK
return UI_PARCHMENT_TEXT
func _visual_rect_for_unit(unit: Dictionary) -> Rect2:
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
var rect := _rect_for_cell(cell)
var unit_id := str(unit.get("id", ""))
if unit_id.is_empty():
return rect
if unit_motion_by_unit.has(unit_id):
var move_motion: Dictionary = unit_motion_by_unit[unit_id]
var move_from_cell: Vector2i = move_motion.get("from", cell)
var move_to_cell: Vector2i = move_motion.get("to", cell)
var move_duration: float = maxf(0.01, float(move_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
var move_age := float(move_motion.get("age", 0.0))
var move_progress := clampf(move_age / move_duration, 0.0, 1.0)
var smooth_progress := move_progress * move_progress * (3.0 - 2.0 * move_progress)
var from_rect := _rect_for_cell(move_from_cell)
var to_rect := _rect_for_cell(move_to_cell)
rect.position = from_rect.position.lerp(to_rect.position, smooth_progress)
if unit_action_motion_by_unit.has(unit_id):
var action_motion: Dictionary = unit_action_motion_by_unit[unit_id]
var action_from_cell: Vector2i = action_motion.get("from", cell)
var action_to_cell: Vector2i = action_motion.get("to", cell)
var direction := _cell_center(action_to_cell) - _cell_center(action_from_cell)
if direction.length() > 0.01:
direction = direction.normalized()
var action_duration: float = maxf(0.01, float(action_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
var action_age := float(action_motion.get("age", 0.0))
var action_progress := clampf(action_age / action_duration, 0.0, 1.0)
var profile := str(action_motion.get("profile", "slash"))
var lunge_scale := _action_motion_lunge_scale(profile)
rect.position += direction * _action_motion_lunge_curve(profile, action_progress) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale
return rect
func _cell_center(cell: Vector2i) -> Vector2:
var rect := _rect_for_cell(cell)
return rect.position + rect.size * 0.5
func _draw_attack_effects() -> void:
if unit_action_motion_by_unit.is_empty():
return
for unit_id in unit_action_motion_by_unit.keys():
var motion: Dictionary = unit_action_motion_by_unit[unit_id]
var duration: float = maxf(0.01, float(motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
var age := float(motion.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var profile := str(motion.get("profile", "slash"))
var peak := _action_motion_effect_peak(profile, progress)
if peak <= 0.0:
continue
var from_cell: Vector2i = motion.get("from", Vector2i(-1, -1))
var to_cell: Vector2i = motion.get("to", Vector2i(-1, -1))
if not state.is_inside(from_cell) or not state.is_inside(to_cell):
continue
var start := _cell_center(from_cell)
var end := _cell_center(to_cell)
var team_color := PLAYER_COLOR
var unit := state.get_unit(str(unit_id))
if not unit.is_empty() and unit.get("team", "") == BattleState.TEAM_ENEMY:
team_color = ENEMY_COLOR
var impact_cue := str(motion.get("impact_cue", _action_motion_impact_cue(profile)))
var impact_radius := float(motion.get("impact_radius", _action_motion_impact_radius(profile)))
_draw_action_effect(profile, start, end, progress, peak, team_color)
_draw_action_impact_cue(impact_cue, start, end, progress, peak, team_color, impact_radius)
func _draw_action_effect(profile: String, start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
if profile == "arrow":
_draw_arrow_attack_effect(start, end, progress, peak, team_color)
elif profile == "cavalry_charge":
_draw_cavalry_attack_effect(start, end, progress, peak, team_color)
elif profile == "infantry_strike":
_draw_infantry_attack_effect(end, peak, team_color)
elif profile == "command_strike":
_draw_command_attack_effect(start, end, progress, peak, team_color)
elif profile == "heavy_strike":
_draw_heavy_attack_effect(end, peak, team_color)
elif profile == "tactic_damage":
_draw_tactic_damage_effect(start, end, progress, peak, team_color)
elif profile == "tactic_heal":
_draw_tactic_heal_effect(start, end, progress, peak, team_color)
elif profile == "tactic_support":
_draw_tactic_support_effect(start, end, progress, peak, team_color)
else:
_draw_slash_attack_effect(end, peak, team_color)
func _draw_arrow_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var direction := end - start
if direction.length() <= 0.01:
return
direction = direction.normalized()
var side := Vector2(-direction.y, direction.x)
var head := start.lerp(end, clampf(progress + 0.20, 0.0, 1.0))
var tail := start.lerp(end, clampf(progress - 0.18, 0.0, 1.0))
var shaft := Color(1.0, 0.86, 0.42, 0.76 * peak)
draw_line(tail, head, shaft, 3.0)
draw_line(head, head - direction * 9.0 + side * 4.5, Color(1.0, 0.96, 0.66, 0.70 * peak), 2.0)
draw_line(head, head - direction * 9.0 - side * 4.5, Color(1.0, 0.96, 0.66, 0.70 * peak), 2.0)
var wake := team_color.lightened(0.48)
wake.a = 0.22 * peak
draw_line(tail - side * 5.0, head - side * 5.0, wake, 1.5)
draw_line(tail + side * 5.0, head + side * 5.0, wake, 1.5)
draw_circle(head, 3.5 + 4.0 * peak, Color(1.0, 0.92, 0.52, 0.36 * peak))
func _draw_cavalry_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var direction := end - start
if direction.length() <= 0.01:
return
direction = direction.normalized()
var side := Vector2(-direction.y, direction.x)
for index in range(3):
var lane_offset := side * float(index - 1) * 7.0
var dust_head := start.lerp(end, clampf(progress - float(index) * 0.05, 0.0, 1.0))
var dust_tail := dust_head - direction * (16.0 + float(index) * 5.0)
draw_line(dust_tail + lane_offset, dust_head + lane_offset, Color(0.86, 0.72, 0.42, 0.24 * peak), 2.0)
var lance := team_color.lightened(0.52)
lance.a = 0.68 * peak
draw_line(end - direction * 22.0, end + direction * 12.0, lance, 4.0)
draw_line(end - side * 15.0, end + side * 15.0, Color(1.0, 0.88, 0.34, 0.34 * peak), 2.0)
draw_circle(end, 9.0 + 13.0 * peak, Color(1.0, 0.66, 0.22, 0.18 * peak))
func _draw_infantry_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
var guard_flash := team_color.lightened(0.48)
guard_flash.a = 0.58 * peak
draw_line(end + Vector2(-16, -10), end + Vector2(17, 11), guard_flash, 3.0)
draw_line(end + Vector2(-14, 10), end + Vector2(14, -10), Color(1.0, 0.88, 0.42, 0.32 * peak), 2.0)
draw_rect(Rect2(end + Vector2(-9, -14), Vector2(18, 20)), Color(0.72, 0.82, 0.94, 0.16 * peak), false, 2.0)
draw_arc(end, 8.0 + 9.0 * peak, -PI * 0.80, PI * 0.20, 24, Color(1.0, 0.92, 0.54, 0.34 * peak), 2.0)
func _draw_command_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var beam := Color(0.72, 0.92, 1.0, 0.34 * peak)
draw_line(start, end, beam, 2.0)
var command_color := team_color.lightened(0.58)
command_color.a = 0.56 * peak
draw_arc(end, 10.0 + 12.0 * peak, 0.0, TAU, 48, command_color, 2.0)
draw_arc(end, 20.0 - 6.0 * peak, progress * TAU, progress * TAU + PI * 1.35, 36, Color(1.0, 0.96, 0.62, 0.42 * peak), 2.0)
draw_circle(end, 6.0 + 8.0 * peak, Color(0.72, 0.92, 1.0, 0.18 * peak))
func _draw_heavy_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
var heavy := team_color.lightened(0.42)
heavy.a = 0.62 * peak
draw_line(end + Vector2(-20, -16), end + Vector2(20, 16), heavy, 5.0)
draw_line(end + Vector2(18, -18), end + Vector2(-18, 18), Color(1.0, 0.82, 0.28, 0.48 * peak), 4.0)
draw_arc(end, 10.0 + 18.0 * peak, 0.0, TAU, 48, Color(1.0, 0.68, 0.24, 0.26 * peak), 3.0)
func _draw_tactic_damage_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var caster_color := team_color.lightened(0.58)
caster_color.a = 0.34 * peak
var fire_color := Color(1.0, 0.38, 0.18, 0.52 * peak)
draw_arc(start, 12.0 + 7.0 * peak, progress * TAU, progress * TAU + PI * 1.55, 36, caster_color, 2.0)
draw_line(start, end, Color(1.0, 0.62, 0.28, 0.30 * peak), 2.0)
for index in range(3):
var angle := progress * TAU + float(index) * TAU / 3.0
var spark := Vector2(cos(angle), sin(angle))
draw_line(end - spark * (5.0 + 4.0 * peak), end + spark * (12.0 + 6.0 * peak), fire_color, 2.0)
draw_circle(end, 6.0 + 10.0 * peak, Color(1.0, 0.50, 0.18, 0.18 * peak))
func _draw_tactic_heal_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var line_color := Color(0.48, 1.0, 0.60, 0.24 * peak)
if start.distance_to(end) > 0.01:
draw_line(start, end, line_color, 2.0)
var ring_color := Color(0.42, 1.0, 0.54, 0.42 * peak)
draw_arc(end, 9.0 + 12.0 * peak, -progress * TAU, -progress * TAU + PI * 1.70, 44, ring_color, 2.0)
draw_arc(end, 19.0 - 5.0 * peak, progress * TAU, progress * TAU + PI * 1.25, 40, Color(0.88, 1.0, 0.72, 0.32 * peak), 2.0)
draw_line(end + Vector2(-7, 0), end + Vector2(7, 0), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5)
draw_line(end + Vector2(0, -7), end + Vector2(0, 7), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5)
var caster_glow := team_color.lightened(0.62)
caster_glow.a = 0.20 * peak
draw_circle(start, 6.0 + 4.0 * peak, caster_glow)
func _draw_tactic_support_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
var banner := team_color.lightened(0.48)
banner.a = 0.46 * peak
if start.distance_to(end) > 0.01:
draw_line(start, end, Color(1.0, 0.86, 0.30, 0.20 * peak), 1.5)
draw_line(end + Vector2(-10, -16), end + Vector2(-10, 12), banner, 2.5)
draw_rect(Rect2(end + Vector2(-8, -15), Vector2(20, 12)), Color(1.0, 0.82, 0.22, 0.28 * peak))
draw_arc(end, 12.0 + 8.0 * peak, progress * TAU, progress * TAU + PI * 1.45, 36, Color(1.0, 0.92, 0.38, 0.36 * peak), 2.0)
for index in range(3):
var angle := -PI * 0.5 + float(index - 1) * 0.52
var ray := Vector2(cos(angle), sin(angle))
draw_line(end + ray * 7.0, end + ray * (18.0 + 5.0 * peak), Color(1.0, 0.96, 0.62, 0.25 * peak), 1.5)
func _draw_slash_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
var flash := team_color.lightened(0.45)
flash.a = 0.55 * peak
draw_line(end + Vector2(-18, -13), end + Vector2(18, 13), flash, 3.0)
draw_line(end + Vector2(16, -14), end + Vector2(-14, 15), Color(1.0, 0.88, 0.42, 0.38 * peak), 2.0)
draw_circle(end, 8.0 + 8.0 * peak, Color(1.0, 0.82, 0.34, 0.20 * peak))
func _draw_action_impact_cue(cue: String, start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color, radius: float) -> void:
var direction := end - start
if direction.length() <= 0.01:
direction = Vector2.RIGHT
else:
direction = direction.normalized()
var side := Vector2(-direction.y, direction.x)
var accent := team_color.lightened(0.55)
accent.a = 0.34 * peak
var hot := Color(1.0, 0.90, 0.42, 0.42 * peak)
if cue == "projectile":
draw_circle(end, 3.5 + radius * 0.22 * peak, Color(1.0, 0.96, 0.62, 0.28 * peak))
draw_line(end - direction * radius * 0.55, end + direction * radius * 0.30, hot, 2.0)
draw_line(end - side * radius * 0.22, end + side * radius * 0.22, Color(0.92, 0.96, 1.0, 0.26 * peak), 1.5)
elif cue == "charge":
for index in range(3):
var offset := side * float(index - 1) * radius * 0.34
draw_circle(end - direction * radius * (0.34 + float(index) * 0.10) + offset, 3.0 + radius * 0.12 * peak, Color(0.76, 0.58, 0.30, 0.18 * peak))
draw_line(end - side * radius * 0.70, end + side * radius * 0.70, hot, 2.5)
draw_arc(end, radius * (0.58 + progress * 0.20), -PI * 0.12, PI * 1.12, 32, accent, 2.0)
elif cue == "guard":
draw_rect(Rect2(end - Vector2(radius * 0.32, radius * 0.42), Vector2(radius * 0.64, radius * 0.84)), Color(0.80, 0.90, 1.0, 0.14 * peak), false, 2.0)
draw_line(end - side * radius * 0.52 - direction * radius * 0.22, end + side * radius * 0.52 + direction * radius * 0.22, hot, 2.0)
draw_line(end - direction * radius * 0.48, end + direction * radius * 0.48, accent, 2.0)
elif cue == "command":
draw_arc(end, radius * (0.72 + 0.20 * peak), progress * TAU, progress * TAU + PI * 1.20, 40, Color(0.72, 0.94, 1.0, 0.34 * peak), 2.0)
draw_arc(end, radius * 0.44, -progress * TAU, -progress * TAU + PI * 1.55, 40, hot, 2.0)
for index in range(3):
var angle := progress * TAU + float(index) * TAU / 3.0
var ray := Vector2(cos(angle), sin(angle))
draw_line(end + ray * radius * 0.18, end + ray * radius * 0.58, accent, 1.5)
elif cue == "crush":
draw_arc(end, radius * (0.42 + peak * 0.42), 0.0, TAU, 48, Color(1.0, 0.68, 0.24, 0.30 * peak), 3.0)
for index in range(4):
var angle := float(index) * PI * 0.5 + progress * PI * 0.25
var crack := Vector2(cos(angle), sin(angle))
draw_line(end + crack * radius * 0.16, end + crack * radius * (0.54 + 0.16 * peak), Color(0.96, 0.82, 0.42, 0.34 * peak), 2.0)
elif cue == "tactic_damage":
draw_arc(end, radius * (0.35 + peak * 0.38), 0.0, TAU, 44, Color(1.0, 0.34, 0.18, 0.28 * peak), 3.0)
draw_arc(end, radius * 0.62, progress * TAU, progress * TAU + PI * 1.20, 36, Color(1.0, 0.82, 0.32, 0.34 * peak), 2.0)
elif cue == "tactic_heal":
draw_arc(end, radius * (0.40 + peak * 0.24), 0.0, TAU, 48, Color(0.48, 1.0, 0.54, 0.30 * peak), 2.5)
draw_circle(end, radius * 0.14 + radius * 0.10 * peak, Color(0.78, 1.0, 0.74, 0.22 * peak))
elif cue == "tactic_support":
draw_arc(end, radius * (0.48 + peak * 0.18), -PI * 0.10, PI * 1.10, 40, Color(1.0, 0.88, 0.28, 0.30 * peak), 2.5)
draw_line(end + Vector2(-radius * 0.38, radius * 0.26), end + Vector2(radius * 0.38, radius * 0.26), Color(1.0, 0.94, 0.50, 0.30 * peak), 2.0)
else:
draw_line(end - side * radius * 0.48, end + side * radius * 0.48, hot, 2.0)
draw_circle(end, radius * 0.32 + radius * 0.18 * peak, Color(1.0, 0.82, 0.34, 0.18 * peak))
func _draw_floating_texts() -> void:
if floating_texts.is_empty():
return
var font := _draw_ui_font(true)
for popup in floating_texts:
var duration: float = maxf(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME)))
var age := float(popup.get("age", 0.0))
var progress := clampf(age / duration, 0.0, 1.0)
var alpha := 1.0 - progress
var color := _floating_text_color(str(popup.get("kind", "")))
color.a = alpha
var shadow := Color(0.02, 0.02, 0.025, 0.82 * alpha)
var center: Vector2 = popup.get("pos", Vector2.ZERO)
var text := str(popup.get("text", ""))
var width: float = 118.0
var draw_position: Vector2 = center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE)
draw_string(font, draw_position + Vector2(1, 1), text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, shadow)
draw_string(font, draw_position, text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, color)
func _floating_text_color(kind: String) -> Color:
if kind == "damage":
return Color(1.0, 0.30, 0.20)
if kind == "heal":
return Color(0.36, 1.0, 0.46)
if kind == "mp":
return Color(0.48, 0.74, 1.0)
if kind == "item":
return Color(0.98, 0.76, 0.28)
if kind == "priority":
return Color(1.0, 0.54, 0.22)
if kind == "support":
return Color(1.0, 0.88, 0.28)
if kind == "debuff":
return Color(0.80, 0.52, 1.0)
if kind == "poison":
return Color(0.54, 0.95, 0.36)
if kind == "seal":
return Color(0.44, 0.86, 1.0)
if kind == "snare":
return Color(1.0, 0.64, 0.22)
if kind == "disarm":
return Color(1.0, 0.48, 0.42)
if kind == "miss" or kind == "fade":
return Color(0.82, 0.84, 0.90)
if kind == "defeat":
return Color(1.0, 0.18, 0.16)
if kind == "progress":
return Color(1.0, 0.78, 0.28)
return Color.WHITE
func _draw_target_preview_badge() -> void:
var badge := _target_preview_badge()
if badge.is_empty():
return
var cell: Vector2i = badge.get("cell", hover_cell)
if not state.is_inside(cell):
return
var size := TARGET_PREVIEW_BADGE_SIZE
var badge_rect := _target_preview_badge_rect(cell, 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 := _draw_ui_font(true)
var text := str(badge.get("text", ""))
draw_rect(badge_rect, background)
draw_rect(badge_rect, color, false, 2.0)
_draw_ink_text(font, badge_rect.position + Vector2(0, 16), text, HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 14, color)
func _target_preview_badge_rect(cell: Vector2i, size: Vector2 = TARGET_PREVIEW_BADGE_SIZE) -> Rect2:
var cell_rect := _rect_for_cell(cell)
var position := cell_rect.position + Vector2((TILE_SIZE - size.x) * 0.5, 5.0)
var board := _board_rect()
var min_x := board.position.x + 2.0
var min_y := board.position.y + 2.0
var max_x := maxf(min_x, board.end.x - size.x - 2.0)
var max_y := maxf(min_y, board.end.y - size.y - 2.0)
position.x = clampf(position.x, min_x, max_x)
position.y = clampf(position.y, min_y, max_y)
return Rect2(position, size)
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)
if _has_pending_move() and not basic_attack_targeting:
return {}
var attack_badge := _attack_target_preview_badge(selected, target)
if not attack_badge.is_empty():
return attack_badge
return _hover_intent_preview_badge(selected, target)
func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
if target.is_empty() or target.get("team", "") == selected.get("team", ""):
return {}
var preview := state.get_damage_preview(selected["id"], target["id"])
if preview.is_empty():
return {}
if bool(preview.get("attack_locked", false)):
return _make_target_preview_badge("무장해제", "disarm")
if not bool(preview.get("in_range", false)):
return _make_target_preview_badge("거리밖", "invalid")
var hit_chance := int(preview.get("hit_chance", 100))
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
var counter_text := _format_attack_counter_badge_suffix(preview)
return _make_target_preview_badge("%s %d%%%s" % [result_text, hit_chance, counter_text], _attack_preview_badge_kind(preview))
func _format_attack_counter_badge_suffix(preview: Dictionary) -> String:
if not bool(preview.get("counter_in_range", false)):
return ""
if bool(preview.get("counter_would_defeat", false)):
return " 반격!"
return "%d" % int(preview.get("counter_damage", 0))
func _attack_preview_badge_kind(preview: Dictionary) -> String:
if bool(preview.get("counter_would_defeat", false)):
return "danger"
if bool(preview.get("counter_in_range", false)):
return "counter"
return "damage"
func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
return _skill_target_preview_badge_for_cell(selected, target, hover_cell)
func _skill_target_preview_badge_for_cell(selected: Dictionary, target: Dictionary, cell: Vector2i) -> Dictionary:
var preview := state.get_skill_preview(selected["id"], selected_skill_id, cell)
if preview.is_empty():
return {}
if bool(preview.get("skill_locked", false)):
return _make_target_preview_badge_at("봉인", "seal", cell)
if not bool(preview.get("in_range", false)):
return _make_target_preview_badge_at("거리밖", "invalid", cell)
if not bool(preview.get("has_mp", false)):
return _make_target_preview_badge_at("기력부족", "invalid", cell)
if not bool(preview.get("valid_target", false)):
return _make_target_preview_badge_at("표식없음" if bool(preview.get("has_area", false)) else "불가", "invalid", cell)
if target.is_empty() and not bool(preview.get("has_area", false)):
return {}
var kind := str(preview.get("kind", "damage"))
var target_count := int(preview.get("target_count", 1))
if kind == "heal":
var heal := int(preview.get("heal", 0))
var total_heal := int(preview.get("total_heal", heal))
if total_heal <= 0:
return _make_target_preview_badge_at("가득", "invalid", cell)
if bool(preview.get("has_area", false)):
return _make_target_preview_badge_at("+%d %d" % [total_heal, target_count], "heal", cell)
return _make_target_preview_badge_at("+%d 병력" % heal, "heal", cell)
if kind == "support":
var effect_text := str(preview.get("effect_text", "지원"))
var badge_kind := _support_preview_badge_kind(effect_text)
if bool(preview.get("has_area", false)):
return _make_target_preview_badge_at("%s %d" % [_compact_support_preview_text(effect_text), target_count], badge_kind, cell)
return _make_target_preview_badge_at(_compact_support_preview_text(effect_text), badge_kind, cell)
if bool(preview.get("has_area", false)):
var defeat_count := int(preview.get("defeat_count", 0))
if defeat_count > 0:
return _make_target_preview_badge_at("퇴각 %d" % defeat_count, "damage", cell)
return _make_target_preview_badge_at("-%d %d" % [int(preview.get("total_damage", 0)), target_count], "damage", cell)
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
return _make_target_preview_badge_at(result_text, "damage", cell)
func _item_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
return _item_target_preview_badge_for_cell(selected, target, hover_cell)
func _item_target_preview_badge_for_cell(selected: Dictionary, target: Dictionary, cell: Vector2i) -> Dictionary:
var preview := state.get_item_preview(selected["id"], selected_item_id, cell)
if preview.is_empty() or target.is_empty():
return {}
if not bool(preview.get("valid_target", false)):
return _make_target_preview_badge_at("불가", "invalid", cell)
if not bool(preview.get("in_range", false)):
return _make_target_preview_badge_at("거리밖", "invalid", cell)
if int(preview.get("count", 0)) <= 0:
return _make_target_preview_badge_at("없음", "invalid", cell)
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_at("무효", "invalid", cell)
if not cure_statuses.is_empty():
return _make_target_preview_badge_at("치료", "support", cell)
if hp_heal > 0 and mp_heal > 0:
return _make_target_preview_badge_at("+%d/%d" % [hp_heal, mp_heal], "heal", cell)
if hp_heal > 0:
return _make_target_preview_badge_at("+%d 병력" % hp_heal, "heal", cell)
return _make_target_preview_badge_at("+%d 기력" % mp_heal, "mp", cell)
func _hover_intent_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
if selected.is_empty():
return {}
if _has_pending_move():
return {}
if not state.can_player_act():
return {}
if not bool(selected.get("controllable", true)) or bool(selected.get("acted", false)):
return {}
if not target.is_empty():
if target.get("team", "") == selected.get("team", "") and _can_select(target):
return _make_target_preview_badge("선택", "select")
return {}
if move_cells.has(hover_cell):
return _make_target_preview_badge("행군", "move")
return {}
func _make_target_preview_badge(text: String, kind: String) -> Dictionary:
return _make_target_preview_badge_at(text, kind, hover_cell)
func _make_target_preview_badge_at(text: String, kind: String, cell: Vector2i) -> Dictionary:
if text.is_empty():
return {}
return {
"text": text,
"kind": kind,
"cell": 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)
var duration_index := part.find(", ")
if duration_index >= 0:
part = part.substr(0, duration_index)
if not part.is_empty() and part != "No support effect." and part != "효험 없음":
parts.append(_compact_support_badge_text(part))
if parts.is_empty():
return "지원"
if parts.size() > 1:
return "%s+" % parts[0]
return str(parts[0])
func _compact_support_badge_text(text: String) -> String:
if text.contains("Disarm") or text.contains("무장해제"):
return "무장해제"
if text.contains("Snare") or text.contains("발묶임"):
return "속박"
if text.contains("Seal") or text.contains("봉인"):
return "봉인"
if text.contains("Poison") or text.contains(""):
return ""
if text.contains("Cleanse") or text.contains("해소"):
return "해소"
return text
func _support_preview_badge_kind(effect_text: String) -> String:
if effect_text.contains("Disarm") or effect_text.contains("무장해제"):
return "disarm"
if effect_text.contains("Snare") or effect_text.contains("발묶임"):
return "snare"
if effect_text.contains("Seal") or effect_text.contains("봉인"):
return "seal"
if effect_text.contains("Poison") or effect_text.contains(""):
return "poison"
if effect_text.contains("-"):
return "debuff"
return "support"
func _target_preview_badge_color(kind: String) -> Color:
if kind == "damage":
return Color(1.0, 0.38, 0.26)
if kind == "counter":
return Color(1.0, 0.64, 0.22)
if kind == "danger":
return Color(1.0, 0.18, 0.14)
if kind == "move":
return Color(0.38, 0.70, 1.0)
if kind == "select":
return Color(1.0, 0.86, 0.34)
if kind == "heal":
return Color(0.44, 1.0, 0.52)
if kind == "mp":
return Color(0.54, 0.78, 1.0)
if kind == "support":
return Color(1.0, 0.88, 0.30)
if kind == "debuff":
return Color(0.78, 0.55, 1.0)
if kind == "poison":
return Color(0.54, 0.95, 0.36)
if kind == "seal":
return Color(0.44, 0.86, 1.0)
if kind == "snare":
return Color(1.0, 0.64, 0.22)
if kind == "disarm":
return Color(1.0, 0.48, 0.42)
return Color(0.78, 0.80, 0.86)
func _cell_from_screen(screen_position: Vector2) -> Vector2i:
var local := screen_position - _board_origin()
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_origin() + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE))
func _board_rect() -> Rect2:
return Rect2(_board_origin(), _board_size())
func _board_origin() -> Vector2:
return BOARD_OFFSET + board_scroll_offset
func _board_size() -> Vector2:
return Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE
func _map_view_rect() -> Rect2:
var viewport_size := _viewport_size_for_layout()
var right_edge := minf(SIDE_PANEL_POSITION.x - 18.0, viewport_size.x - 18.0)
var bottom_edge := viewport_size.y - 20.0
return Rect2(
BOARD_OFFSET,
Vector2(maxf(160.0, right_edge - BOARD_OFFSET.x), maxf(160.0, bottom_edge - BOARD_OFFSET.y))
)
func _viewport_size_for_layout() -> Vector2:
if is_inside_tree():
return get_viewport_rect().size
var window_size := Vector2(DisplayServer.window_get_size())
if window_size.x > 0.0 and window_size.y > 0.0:
return window_size
return Vector2(1280, 720)
func _reset_board_scroll() -> void:
board_scroll_offset = _clamped_board_scroll_offset(Vector2.ZERO)
func _clamped_board_scroll_offset(offset: Vector2) -> Vector2:
var view_rect := _map_view_rect()
var board_size := _board_size()
var min_x := minf(0.0, view_rect.size.x - board_size.x)
var min_y := minf(0.0, view_rect.size.y - board_size.y)
return Vector2(clampf(offset.x, min_x, 0.0), clampf(offset.y, min_y, 0.0))
func _edge_scroll_velocity_for_position(mouse_position: Vector2, view_rect: Rect2) -> Vector2:
if not _edge_scroll_activation_rect(view_rect).has_point(mouse_position):
return Vector2.ZERO
var velocity := Vector2(
_edge_scroll_axis_velocity(mouse_position.x, view_rect.position.x, view_rect.end.x, 1.0),
_edge_scroll_axis_velocity(mouse_position.y, view_rect.position.y, view_rect.end.y, 1.0)
)
return _capped_edge_scroll_velocity(velocity)
func _edge_scroll_activation_rect(view_rect: Rect2) -> Rect2:
return view_rect.grow(EDGE_SCROLL_OUTER_MARGIN)
func _capped_edge_scroll_velocity(velocity: Vector2) -> Vector2:
if velocity == Vector2.ZERO or velocity.length() <= EDGE_SCROLL_MAX_SPEED:
return velocity
return velocity.normalized() * EDGE_SCROLL_MAX_SPEED
func _edge_scroll_axis_velocity(position: float, start: float, end: float, direction_multiplier: float) -> float:
var left_pressure := _edge_scroll_pressure(start + EDGE_SCROLL_MARGIN - position)
if left_pressure > 0.0:
return _edge_scroll_speed_for_pressure(left_pressure) * direction_multiplier
var right_pressure := _edge_scroll_pressure(position - (end - EDGE_SCROLL_MARGIN))
if right_pressure > 0.0:
return -_edge_scroll_speed_for_pressure(right_pressure) * direction_multiplier
return 0.0
func _edge_scroll_pressure(distance_inside_margin: float) -> float:
return clampf(distance_inside_margin / EDGE_SCROLL_MARGIN, 0.0, 1.0)
func _edge_scroll_speed_for_pressure(pressure: float) -> float:
var eased := pressure * pressure * (3.0 - 2.0 * pressure)
return lerpf(EDGE_SCROLL_MIN_SPEED, EDGE_SCROLL_MAX_SPEED, eased)
func _edge_scroll_next_offset(current_offset: Vector2, mouse_position: Vector2, view_rect: Rect2, delta: float) -> Vector2:
var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect)
if velocity == Vector2.ZERO:
return current_offset
return _clamped_board_scroll_offset(current_offset + velocity * delta)
func _update_edge_scroll(delta: float) -> bool:
if _is_input_locked():
return false
var view_rect := _map_view_rect()
var mouse_position := get_viewport().get_mouse_position()
var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect)
if velocity == Vector2.ZERO:
return false
var previous_offset := board_scroll_offset
board_scroll_offset = _edge_scroll_next_offset(board_scroll_offset, mouse_position, view_rect, delta)
if previous_offset.distance_squared_to(board_scroll_offset) <= 0.01:
return false
var next_hover := _cell_from_screen(mouse_position)
if next_hover != hover_cell:
hover_cell = next_hover
_update_hud()
return true
func _short_name(unit_name: String) -> String:
if unit_name.length() <= 9:
return unit_name
return unit_name.substr(0, 8) + "."
func _refresh_ranges() -> void:
move_cells.clear()
attack_cells.clear()
skill_cells.clear()
item_cells.clear()
threat_cells.clear()
if show_threat_overlay and battle_started and state.battle_status == BattleState.STATUS_ACTIVE:
threat_cells = state.get_threat_cells(BattleState.TEAM_ENEMY)
var selected := state.get_selected_unit()
if selected.is_empty():
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
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 = ""
if not _has_pending_move() or basic_attack_targeting:
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 = ""
if not _has_pending_move() or basic_attack_targeting:
attack_cells = state.get_attack_cells(selected["id"])
else:
if not _has_pending_move() or basic_attack_targeting:
attack_cells = state.get_attack_cells(selected["id"])
func _update_hud() -> void:
status_label.text = state.get_status_text()
objective_label.text = _format_objective_hud_text()
_fit_label_font_size_to_text(
objective_label,
objective_label.text,
OBJECTIVE_HUD_FONT_SIZE,
OBJECTIVE_HUD_MIN_FONT_SIZE
)
campaign_status_label.text = campaign_state.get_progress_text()
_update_mission_panel()
_update_cell_info()
_update_forecast()
_update_result_panel()
if inventory_label != null:
inventory_label.text = _format_inventory_status_text()
var selected := state.get_selected_unit()
if selected.is_empty():
var hovered_unit := _hovered_unit_for_hud_portrait()
_update_hud_unit_portrait(hovered_unit)
if hovered_unit.is_empty():
selected_label.text = "표식 없는 빈 전장입니다."
else:
selected_label.text = _format_unit_focus_text(hovered_unit, "Hover")
_update_wait_button({})
_update_tactic_button({})
_hide_tactic_menu()
_update_item_button({})
_hide_item_menu()
_update_equip_button({})
_hide_equip_menu()
else:
_update_hud_unit_portrait(selected)
selected_label.text = _format_unit_focus_text(selected, "Selected")
_update_wait_button(selected)
_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)
_update_end_turn_button()
restart_button.disabled = campaign_complete_screen
_update_threat_button()
_update_post_move_menu()
_update_post_move_picker()
_update_targeting_hint_panel()
func _format_objective_hud_text() -> String:
var lines := []
var victory := String(state.objectives.get("victory", "적군을 모두 격파하라."))
if not victory.is_empty():
lines.append("승리: %s" % victory)
var defeat := String(state.objectives.get("defeat", ""))
if not defeat.is_empty():
lines.append("패배: %s" % defeat)
if lines.is_empty():
return "목표 대기"
return _join_strings(lines, "\n")
func _format_unit_focus_text(unit: Dictionary, prefix: String) -> String:
if unit.is_empty():
return "표식 없는 빈 전장입니다."
var roster_mark := " *" if unit.get("loaded_from_roster", false) else ""
var control_label := " 호위" if not bool(unit.get("controllable", true)) else ""
var lines := []
lines.append("%s: %s%s%s" % [
_unit_focus_prefix_text(prefix),
str(unit.get("name", "부대")),
roster_mark,
control_label
])
lines.append("%s 품계 %d %s %s" % [
_unit_team_text(str(unit.get("team", ""))),
int(unit.get("level", 1)),
_unit_class_display_name(unit),
_unit_role_text(unit)
])
lines.append("병력 %d/%d 기력 %d/%d 전공 %d" % [
int(unit.get("hp", 0)),
int(unit.get("max_hp", 1)),
int(unit.get("mp", 0)),
int(unit.get("max_mp", 0)),
int(unit.get("exp", 0))
])
lines.append("무위 %d 방비 %d 지략 %d 기동 %d" % [
int(unit.get("atk", 0)),
int(unit.get("def", 0)),
int(unit.get("int", 0)),
int(unit.get("agi", 0))
])
lines.append("행군 %d (%s) 타격 %d-%d" % [
int(unit.get("move", 0)),
_format_move_type(str(unit.get("move_type", "foot"))),
int(unit.get("min_range", 1)),
int(unit.get("range", 1))
])
lines.append(_unit_action_status_text(unit))
var terrain_text := _unit_current_terrain_text(unit)
if not terrain_text.is_empty():
lines.append(terrain_text)
var status_effects := state.get_status_effect_summary(str(unit.get("id", "")))
if not status_effects.is_empty():
lines.append("상태: %s" % status_effects)
return _join_strings(lines, "\n")
func _unit_focus_prefix_text(prefix: String) -> String:
if prefix == "Selected":
return "군기"
if prefix == "Hover":
return "관측"
return prefix
func _unit_action_status_text(unit: Dictionary) -> String:
var unit_id := str(unit.get("id", ""))
if unit_id.is_empty():
return "군령: 봉인"
var team := str(unit.get("team", ""))
if team != BattleState.TEAM_PLAYER:
return "군령: 적군 군기"
if not bool(unit.get("controllable", true)):
return "군령: 호위 대상"
if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)):
return "군령: 봉인"
if bool(unit.get("acted", false)):
return "군령: 봉인"
if not state.can_player_act():
return "군령: 차례 대기"
var actions: Array[String] = []
if not bool(unit.get("moved", false)) and not state.get_movement_range(unit_id).is_empty():
actions.append("행군")
if not state.get_attack_cells(unit_id).is_empty():
actions.append("타격")
var skill_id := state.get_first_usable_skill_id(unit_id)
if not skill_id.is_empty() and not state.get_skill_cells(unit_id, skill_id).is_empty():
actions.append("책략")
if _unit_has_usable_item_target(unit_id):
actions.append("도구")
if not bool(unit.get("moved", false)):
actions.append("병장")
actions.append("대기")
var suffix := " (행군 완료)" if bool(unit.get("moved", false)) else ""
return "군령: %s%s" % [_join_strings(actions, ", "), suffix]
func _unit_has_usable_item_target(unit_id: String) -> bool:
for item_id in state.get_usable_item_ids():
if _item_has_valid_target(unit_id, str(item_id)):
return true
return false
func _unit_has_usable_skill_target(unit_id: String) -> bool:
for skill_id in state.get_usable_skill_ids(unit_id):
if _skill_has_valid_target(unit_id, str(skill_id)):
return true
return false
func _skill_has_valid_target(unit_id: String, skill_id: String) -> bool:
for cell in state.get_skill_cells(unit_id, skill_id):
var preview := state.get_skill_preview(unit_id, skill_id, cell)
if bool(preview.get("valid_target", false)):
return true
return false
func _item_has_valid_target(unit_id: String, item_id: String) -> bool:
for cell in state.get_item_target_cells(unit_id, item_id):
var preview := state.get_item_preview(unit_id, item_id, cell)
if _item_preview_can_apply(preview):
return true
return false
func _item_preview_can_apply(preview: Dictionary) -> bool:
if preview.is_empty() or not bool(preview.get("in_range", false)) or not bool(preview.get("valid_target", false)):
return false
if int(preview.get("count", 0)) <= 0:
return false
if int(preview.get("heal", 0)) > 0 or int(preview.get("mp_heal", 0)) > 0:
return true
var cured = preview.get("cure_statuses", [])
return typeof(cured) == TYPE_ARRAY and not cured.is_empty()
func _unit_role_text(unit: Dictionary) -> String:
var class_id := str(unit.get("class_id", ""))
if class_id.contains("cavalry"):
return "돌격 기병"
if class_id.contains("archer") or class_id.contains("marksman"):
return "원거리 견제"
if class_id.contains("strategist") or class_id.contains("advisor"):
return "책략 참모"
if class_id.contains("hero") or class_id.contains("commander"):
return "군령 지휘"
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
return "선봉 돌파"
return "전열 보병"
func _unit_team_text(team: String) -> String:
if team == BattleState.TEAM_PLAYER:
return "아군"
if team == BattleState.TEAM_ENEMY:
return "적군"
return team.capitalize()
func _unit_class_display_name(unit: Dictionary) -> String:
var class_id := str(unit.get("class_id", ""))
if class_id.contains("cavalry"):
return "기병"
if class_id.contains("archer") or class_id.contains("marksman"):
return "궁병"
if class_id.contains("strategist") or class_id.contains("advisor"):
return "책사"
if class_id.contains("hero") or class_id.contains("commander"):
return "지휘관"
if class_id.contains("warrior") or class_id.contains("champion"):
return "무장"
if class_id.contains("bandit"):
return "산적"
return str(unit.get("class", "부대"))
func _unit_current_terrain_text(unit: Dictionary) -> String:
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
if not state.is_inside(cell):
return ""
var text := "지형 %d,%d %s 행군 %d 방비 +%d 회피 +%d%%" % [
cell.x + 1,
cell.y + 1,
_terrain_display_name(state.get_terrain_name(cell)),
state.get_move_cost(cell, str(unit.get("move_type", "foot"))),
state.get_terrain_defense(cell),
state.get_terrain_avoid(cell)
]
var heal := state.get_terrain_heal(cell)
if heal > 0:
text += " 회복 +%d" % heal
return text
func _update_mission_panel() -> void:
if mission_detail_panel != null:
mission_detail_panel.visible = not mission_detail_collapsed
if mission_toggle_button != null:
mission_toggle_button.text = "보기" if mission_detail_collapsed else "접기"
if mission_detail_label == null or mission_detail_collapsed:
return
mission_detail_label.text = _format_mission_panel_text()
func _format_mission_panel_text() -> String:
var lines := []
var victory := str(state.objectives.get("victory", ""))
if not victory.is_empty():
lines.append("승리: %s" % victory)
var defeat := str(state.objectives.get("defeat", ""))
if not defeat.is_empty():
lines.append("패배: %s" % defeat)
var progress_lines := state.get_objective_progress_lines(false, false)
if not progress_lines.is_empty():
lines.append("진행: %s" % _join_strings(progress_lines, " · "))
var risk_lines := state.get_defeat_progress_lines()
if not risk_lines.is_empty():
lines.append("주의: %s" % _join_strings(risk_lines, " · "))
if lines.is_empty():
return "열린 목표 없음"
return _join_strings(lines, "\n")
func _hovered_unit_for_hud_portrait() -> Dictionary:
if not state.is_inside(hover_cell):
return {}
return state.get_unit_at(hover_cell)
func _update_hud_unit_portrait(unit: Dictionary) -> void:
if hud_unit_portrait_panel == null:
return
if unit.is_empty():
hud_unit_portrait_panel.visible = false
hud_unit_portrait_texture.texture = null
hud_unit_portrait_texture.visible = false
hud_unit_portrait_label.text = ""
hud_unit_portrait_label.visible = false
return
hud_unit_portrait_panel.visible = true
var texture := _load_portrait_texture(str(unit.get("portrait", "")))
if texture == null:
texture = _load_art_texture(str(unit.get("sprite", "")))
var has_portrait := texture != null
hud_unit_portrait_texture.texture = texture
hud_unit_portrait_texture.visible = has_portrait
hud_unit_portrait_label.text = _dialogue_portrait_initials(str(unit.get("name", "")))
hud_unit_portrait_label.visible = not has_portrait
func _update_cell_info() -> void:
if not state.is_inside(hover_cell):
cell_info_label.text = "지형: -"
return
var summary := state.get_cell_summary(hover_cell)
var cell: Vector2i = summary["cell"]
var text := "지형 %d,%d %s\n행군 %d 방비 +%d 회피 +%d%%" % [
cell.x + 1,
cell.y + 1,
_terrain_display_name(str(summary["terrain"])),
summary["move_cost"],
summary["defense"],
summary.get("avoid", 0)
]
var heal := int(summary.get("heal", 0))
if heal > 0:
text += " 회복 +%d" % heal
var objective_cell_label := state.get_objective_cell_label(cell)
if not objective_cell_label.is_empty():
text += "\n목표: %s" % objective_cell_label
if show_threat_overlay:
var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
if not threat_names.is_empty():
text += "\n감시: %s" % _compact_name_list(threat_names, 3)
var unit: Dictionary = summary["unit"]
if not unit.is_empty():
var control_label := " 호위" if not bool(unit.get("controllable", true)) else ""
text += "\n군기: %s%s %s 품계 %d\n%s %s 병력 %d/%d 기력 %d/%d\n병장: %s" % [
unit["name"],
control_label,
_unit_team_text(str(unit["team"])),
unit["level"],
_unit_class_display_name(unit),
_unit_role_text(unit),
unit["hp"],
unit["max_hp"],
unit.get("mp", 0),
unit.get("max_mp", 0),
_format_hover_unit_equipment(unit)
]
var status_effects := state.get_status_effect_summary(unit["id"])
if not status_effects.is_empty():
text += "\n상태: %s" % status_effects
if show_threat_overlay:
var physical_threats := state.get_physical_threat_previews(cell, BattleState.TEAM_ENEMY)
if not physical_threats.is_empty():
text += "\n병장 위협: %s" % _format_physical_threat_preview_text(physical_threats, 3)
var skill_threats := state.get_skill_threat_previews(cell, BattleState.TEAM_ENEMY)
if not skill_threats.is_empty():
text += "\n책략 위협: %s" % _format_skill_threat_preview_text(skill_threats, 3)
cell_info_label.text = text
func _format_physical_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
var parts := []
var safe_limit: int = maxi(1, limit)
for index in range(mini(previews.size(), safe_limit)):
var preview: Dictionary = previews[index]
var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
var effective_text := ""
var effective_bonus := int(preview.get("effective_bonus", 0))
if effective_bonus > 0:
var effective_type := _format_move_type(str(preview.get("effective_target_type", "")))
var effective_target_text := "" if effective_type.is_empty() else "%s" % effective_type
effective_text = " 특효 +%d%s" % [effective_bonus, effective_target_text]
parts.append("%s 피해 %d/명중 %d%%%s%s" % [
str(preview.get("source_name", "부대")),
int(preview.get("damage", 0)),
int(preview.get("hit_chance", 0)),
effective_text,
defeat_text
])
if previews.size() > safe_limit:
parts.append("+%d" % (previews.size() - safe_limit))
return _join_strings(parts, "; ")
func _format_skill_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
var parts := []
var safe_limit: int = maxi(1, limit)
for index in range(mini(previews.size(), safe_limit)):
var preview: Dictionary = previews[index]
var source_text := str(preview.get("source_name", "부대"))
var skill_text := str(preview.get("skill_name", "책략"))
var area_text := _format_skill_threat_area_text(preview)
if str(preview.get("kind", "")) == "damage":
var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
parts.append("%s %s 피해 %d%s%s" % [
source_text,
skill_text,
int(preview.get("damage", 0)),
area_text,
defeat_text
])
else:
var active_text := " 지속" if bool(preview.get("already_active", false)) else ""
parts.append("%s %s %s%s%s" % [
source_text,
skill_text,
str(preview.get("effect_text", "Effect")),
area_text,
active_text
])
if previews.size() > safe_limit:
parts.append("+%d" % (previews.size() - safe_limit))
return _join_strings(parts, "; ")
func _format_skill_threat_area_text(preview: Dictionary) -> String:
var target_count := int(preview.get("target_count", 0))
if target_count <= 1:
return ""
return " %d" % target_count
func _compact_name_list(names: Array[String], limit: int) -> String:
var visible_names: Array[String] = []
var safe_limit: int = maxi(1, limit)
for index in range(mini(names.size(), safe_limit)):
visible_names.append(names[index])
if names.size() > safe_limit:
visible_names.append("+%d" % (names.size() - safe_limit))
return _join_strings(visible_names, ", ")
func _update_forecast() -> void:
var selected := state.get_selected_unit()
if selected.is_empty() or not state.is_inside(hover_cell):
forecast_label.text = "전황: -"
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 = "전황: -"
return
var preview := state.get_damage_preview(selected["id"], target["id"])
if preview.is_empty():
forecast_label.text = "전황: -"
return
if bool(preview.get("attack_locked", false)):
forecast_label.text = "전황: 병장 봉인\n무기로 공격할 수 없습니다."
return
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
var defeat_text := " 퇴각" if preview["would_defeat"] else ""
var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type")
var counter_text := ""
if preview["counter_in_range"]:
var counter_defeat := " 퇴각" if preview["counter_would_defeat"] else ""
var counter_effective_text := _format_effective_forecast_text(preview, "counter_effective_bonus", "counter_effective_target_type")
counter_text = "\n반격 %d%s, 명중 %d%%, 아군 병력 %d/%d.%s" % [
preview["counter_damage"],
counter_effective_text,
preview.get("counter_hit_chance", 0),
preview["attacker_hp_after"],
selected["max_hp"],
counter_defeat
]
forecast_label.text = "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [
range_text,
preview["damage"],
effective_text,
preview.get("hit_chance", 100),
preview["target_hp_after"],
target["max_hp"],
defeat_text,
counter_text
]
func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, type_key: String) -> String:
var bonus := int(preview.get(bonus_key, 0))
if bonus <= 0:
return ""
var move_type := _format_move_type(str(preview.get(type_key, "")))
if move_type.is_empty():
move_type = "대상"
return " (특효 +%d%s)" % [bonus, move_type]
func _update_skill_forecast(selected: Dictionary) -> void:
var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell)
if preview.is_empty():
forecast_label.text = "전황: -"
return
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
var mp_text := "" if preview["has_mp"] else " 기력이 부족합니다."
if bool(preview.get("skill_locked", false)):
forecast_label.text = "%s: 봉인, 기력 %d.\n책략을 펼칠 수 없습니다." % [
preview["skill_name"],
preview["mp_cost"]
]
return
if not preview["valid_target"]:
forecast_label.text = "%s: %s, 기력 %d.%s\n지목할 대상이 없습니다." % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text
]
return
var target := state.get_unit(str(preview.get("target_id", "")))
var target_max_hp := int(target.get("max_hp", 1)) if not target.is_empty() else 1
var target_count := int(preview.get("target_count", 1))
var has_area := bool(preview.get("has_area", false))
var area_text := " 범위 대상: %d." % target_count if has_area else ""
if preview["kind"] == "heal":
var total_heal := int(preview.get("total_heal", preview.get("heal", 0)))
if not has_area:
forecast_label.text = "%s: %s, 기력 %d.%s\n병력 %d 회복, 군기 병력 %d/%d." % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["heal"],
preview["target_hp_after"],
target_max_hp
]
return
forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 병력 %d 회복, 지목 병력 %d/%d." % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
area_text,
total_heal,
preview["target_hp_after"],
target_max_hp
]
elif preview["kind"] == "support":
var refresh_text := " 갱신." if bool(preview.get("already_active", false)) else ""
forecast_label.text = "%s: %s, 기력 %d.%s%s\n%s.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
area_text,
preview.get("effect_text", "지원"),
refresh_text
]
else:
var defeat_count := int(preview.get("defeat_count", 0))
var defeat_text := " 퇴각 %d" % defeat_count if defeat_count > 0 else ""
var total_damage := int(preview.get("total_damage", preview.get("damage", 0)))
if not has_area:
var single_defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
forecast_label.text = "%s: %s, 기력 %d.%s\n피해 %d, 적 병력 %d/%d.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
preview["damage"],
preview["target_hp_after"],
target_max_hp,
single_defeat_text
]
return
forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 피해 %d, 지목 병력 %d/%d.%s" % [
preview["skill_name"],
range_text,
preview["mp_cost"],
mp_text,
area_text,
total_damage,
preview["target_hp_after"],
target_max_hp,
defeat_text
]
func _update_item_forecast(selected: Dictionary) -> void:
var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell)
if preview.is_empty():
forecast_label.text = "전황: -"
return
var target := state.get_unit_at(hover_cell)
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
var count_text := "잔량 %d" % int(preview.get("count", 0))
if target.is_empty() or not preview["valid_target"]:
forecast_label.text = "%s: %s, %s\n지목할 대상이 없습니다." % [
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\n보급 효과가 필요하지 않습니다." % [
preview["item_name"],
range_text,
count_text
]
return
var recovery_parts := []
if hp_heal > 0:
recovery_parts.append("병력 %d 회복, 군기 병력 %d/%d" % [
hp_heal,
preview["target_hp_after"],
target["max_hp"]
])
if mp_heal > 0:
recovery_parts.append("기력 %d 회복, 군기 기력 %d/%d" % [
mp_heal,
preview["target_mp_after"],
target.get("max_mp", 0)
])
if not cure_statuses.is_empty():
recovery_parts.append("치료 %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()
_apply_panel_style(result_panel, "result_victory_tablet")
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 전기 완결\n현 병서는 모두 정리되었습니다.\n군자금 %d\n새 전기를 열어 다시 진행합니다." % [
campaign_state.campaign_title,
campaign_state.gold
]
_refresh_screen_backdrop_visibility()
return
if state.battle_status == BattleState.STATUS_VICTORY:
if _try_show_post_battle_dialogue():
_clear_result_choices()
result_panel.visible = false
_refresh_screen_backdrop_visibility()
return
if _is_dialogue_visible():
_clear_result_choices()
result_panel.visible = false
_refresh_screen_backdrop_visibility()
return
_apply_battle_result_once()
_apply_panel_style(result_panel, "result_victory_tablet")
result_panel.visible = true
_rebuild_result_choices()
if result_restart_button != null:
result_restart_button.visible = true
_update_result_next_button_visibility()
result_label.text = _format_victory_result_text()
elif state.battle_status == BattleState.STATUS_DEFEAT:
_clear_dialogue()
_clear_result_choices()
_apply_panel_style(result_panel, "result_defeat_tablet")
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 = _format_defeat_result_text()
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
_refresh_screen_backdrop_visibility()
func _format_victory_result_text() -> String:
var lines := ["승리"]
_append_edict_clause(lines, "승리 조건", "완료", str(state.objectives.get("victory", "")))
var summary := _format_battle_result_summary()
if not summary.is_empty():
lines.append(summary)
return _join_strings(lines, "\n")
func _format_defeat_result_text() -> String:
var lines := ["패배"]
_append_edict_clause(lines, "패배 조건", "확인", str(state.objectives.get("defeat", "")))
return _join_strings(lines, "\n")
func _on_state_changed() -> void:
if state.get_selected_unit().is_empty():
selected_skill_id = ""
selected_item_id = ""
_clear_pending_move_state()
_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(_format_log_entry_text(message) + "\n")
log_box.scroll_to_line(max(0, log_box.get_line_count() - 1))
func _format_log_entry_text(message: String) -> String:
if message.begins_with("Objective updated:"):
return "군령 갱신:%s" % message.substr("Objective updated:".length())
if message.begins_with("Defeat condition updated:"):
return "패배 조건 갱신:%s" % message.substr("Defeat condition updated:".length())
return message
func _format_hanja_number(value: int) -> String:
return str(value)
func _on_objective_updated(victory: String, defeat: String) -> void:
var notice_lines := ["군령 갱신"]
if not victory.is_empty():
notice_lines.append("목표: %s" % victory)
if not defeat.is_empty():
notice_lines.append("주의: %s" % defeat)
if objective_notice_label != null:
objective_notice_label.text = "\n".join(notice_lines)
_fit_label_font_size_to_text(
objective_notice_label,
objective_notice_label.text,
OBJECTIVE_NOTICE_FONT_SIZE,
OBJECTIVE_NOTICE_MIN_FONT_SIZE
)
if objective_notice_panel != null:
objective_notice_panel.visible = true
objective_notice_timer = OBJECTIVE_NOTICE_DURATION
_update_hud()
queue_redraw()
func _on_dialogue_requested(lines: Array) -> void:
var normalized_lines := _normalized_dialogue_lines(lines)
if normalized_lines.is_empty():
return
dialogue_queue.append(normalized_lines)
if _can_show_dialogue_now():
_show_next_dialogue()
func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) -> void:
var unit := state.get_unit(unit_id)
if unit.is_empty():
return
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
if not state.is_inside(cell):
return
var jitter := float((floating_texts.size() % 3) - 1) * 12.0
floating_texts.append({
"text": text,
"kind": kind,
"pos": _floating_text_origin(cell) + Vector2(jitter, 0),
"age": 0.0,
"duration": FLOATING_TEXT_LIFETIME
})
queue_redraw()
func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> void:
if unit_id.is_empty() or from_cell == to_cell:
return
unit_motion_by_unit[unit_id] = {
"from": from_cell,
"to": to_cell,
"age": 0.0,
"duration": UNIT_MOVE_ANIMATION_DURATION
}
queue_redraw()
func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void:
if unit_id.is_empty():
return
var unit := state.get_unit(unit_id)
var profile := _unit_action_motion_profile(unit, from_cell, to_cell, action_kind)
if from_cell == to_cell and not _action_motion_allows_same_cell(profile):
return
unit_action_motion_by_unit[unit_id] = {
"from": from_cell,
"to": to_cell,
"kind": action_kind,
"profile": profile,
"attack_family": profile,
"style": _action_motion_style(profile),
"vfx_signature": _action_motion_vfx_signature(profile),
"impact_cue": _action_motion_impact_cue(profile),
"impact_radius": _action_motion_impact_radius(profile),
"age": 0.0,
"duration": _action_motion_duration(profile)
}
if is_inside_tree():
_play_sfx(_action_motion_sfx(profile))
queue_redraw()
func _unit_action_motion_profile(unit: Dictionary, from_cell: Vector2i, to_cell: Vector2i, _action_kind: String) -> String:
if _action_kind == "skill_heal":
return "tactic_heal"
if _action_kind == "skill_support":
return "tactic_support"
if _action_kind.begins_with("skill_"):
return "tactic_damage"
var class_id := str(unit.get("class_id", ""))
var distance := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y)
if class_id.contains("archer") or class_id.contains("marksman") or (int(unit.get("range", 1)) > 1 and distance > 1):
return "arrow"
if class_id.contains("cavalry"):
return "cavalry_charge"
if class_id == "infantry" or class_id == "guard_captain":
return "infantry_strike"
if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"):
return "command_strike"
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
return "heavy_strike"
return "slash"
func _action_motion_style(profile: String) -> String:
if profile == "arrow":
return "ranged"
if profile.begins_with("tactic_"):
return "tactic"
return "melee"
func _action_motion_duration(profile: String) -> float:
if profile == "arrow":
return UNIT_ARROW_ATTACK_DURATION
if profile == "cavalry_charge":
return UNIT_CAVALRY_ATTACK_DURATION
if profile == "infantry_strike":
return UNIT_INFANTRY_ATTACK_DURATION
if profile == "command_strike":
return UNIT_COMMAND_ATTACK_DURATION
if profile == "heavy_strike":
return UNIT_HEAVY_ATTACK_DURATION
if profile.begins_with("tactic_"):
return UNIT_TACTIC_CAST_DURATION
return UNIT_ATTACK_ANIMATION_DURATION
func _action_motion_lunge_scale(profile: String) -> float:
if profile == "arrow":
return 0.22
if profile == "cavalry_charge":
return 1.65
if profile == "infantry_strike":
return 0.95
if profile == "command_strike":
return 0.20
if profile == "heavy_strike":
return 1.15
if profile.begins_with("tactic_"):
return 0.12
return 1.0
func _action_motion_lunge_curve(profile: String, progress: float) -> float:
var p := clampf(progress, 0.0, 1.0)
if profile == "arrow":
return sin(p * PI) * 0.32
if profile == "cavalry_charge":
if p < 0.75:
return _action_motion_smoothstep(p / 0.75)
return 1.0 - _action_motion_smoothstep((p - 0.75) / 0.25)
if profile == "infantry_strike":
return sin(p * PI) * (0.72 + 0.28 * (1.0 - p))
if profile == "command_strike" or profile.begins_with("tactic_"):
return sin(p * PI) * 0.30
if profile == "heavy_strike":
var late := _action_motion_smoothstep(clampf((p - 0.18) / 0.72, 0.0, 1.0))
var recovery := 1.0 - _action_motion_smoothstep(clampf((p - 0.72) / 0.28, 0.0, 1.0))
return late * recovery
return sin(p * PI)
func _action_motion_effect_peak(profile: String, progress: float) -> float:
var p := clampf(progress, 0.0, 1.0)
if profile == "arrow":
return pow(1.0 - abs(p - 0.34) / 0.34, 2.0) if p <= 0.68 else 0.0
if profile == "cavalry_charge":
return _action_motion_smoothstep(p) * (1.0 - _action_motion_smoothstep(clampf((p - 0.82) / 0.18, 0.0, 1.0)) * 0.22)
if profile == "infantry_strike":
return sin(p * PI)
if profile == "command_strike":
return 0.42 + 0.58 * sin(p * PI)
if profile == "heavy_strike":
return pow(maxf(0.0, sin(p * PI)), 0.62) * _action_motion_smoothstep(clampf((p - 0.18) / 0.40, 0.0, 1.0))
if profile == "tactic_damage":
return 0.28 + 0.72 * sin(p * PI)
if profile == "tactic_heal":
return 0.36 + 0.64 * sin(p * PI)
if profile == "tactic_support":
return 0.32 + 0.68 * sin(p * PI)
return sin(p * PI)
func _action_motion_smoothstep(value: float) -> float:
var p := clampf(value, 0.0, 1.0)
return p * p * (3.0 - 2.0 * p)
func _action_motion_allows_same_cell(profile: String) -> bool:
return profile.begins_with("tactic_")
func _action_motion_vfx_signature(profile: String) -> String:
if profile == "arrow":
return "piercing_trace"
if profile == "cavalry_charge":
return "charge_dust"
if profile == "infantry_strike":
return "shield_spark"
if profile == "command_strike":
return "command_ring"
if profile == "heavy_strike":
return "shock_crack"
if profile == "tactic_damage":
return "tactic_flare"
if profile == "tactic_heal":
return "healing_sigil"
if profile == "tactic_support":
return "order_banner"
return "slash_flash"
func _action_motion_impact_cue(profile: String) -> String:
if profile == "arrow":
return "projectile"
if profile == "cavalry_charge":
return "charge"
if profile == "infantry_strike":
return "guard"
if profile == "command_strike":
return "command"
if profile == "heavy_strike":
return "crush"
if profile == "tactic_damage":
return "tactic_damage"
if profile == "tactic_heal":
return "tactic_heal"
if profile == "tactic_support":
return "tactic_support"
return "slash"
func _action_motion_impact_radius(profile: String) -> float:
if profile == "arrow":
return 14.0
if profile == "cavalry_charge":
return 25.0
if profile == "infantry_strike":
return 18.0
if profile == "command_strike":
return 24.0
if profile == "heavy_strike":
return 28.0
if profile == "tactic_damage":
return 26.0
if profile == "tactic_heal":
return 23.0
if profile == "tactic_support":
return 24.0
return 16.0
func _action_motion_sfx(profile: String) -> String:
if profile == "arrow":
return "bow_release"
if profile == "cavalry_charge":
return "footstep"
if profile == "infantry_strike":
return "slash"
if profile == "command_strike":
return "skill_cast"
if profile == "heavy_strike":
return "slash"
if profile.begins_with("tactic_"):
return "skill_cast"
return "slash"
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
var speaker := str(line.get("speaker", ""))
var display_speaker := _localized_speaker_name(str(line.get("display_speaker", speaker)))
var portrait := str(line.get("portrait", ""))
if portrait.is_empty() and not speaker.strip_edges().is_empty():
portrait = state.data_catalog.get_portrait_for_speaker(_canonical_speaker_name(speaker))
result.append({
"speaker": display_speaker,
"text": text,
"portrait": portrait,
"side": _dialogue_side_for_line(line)
})
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 _dialogue_side_for_line(line: Dictionary) -> String:
var explicit_side := str(line.get("side", "")).strip_edges()
if not explicit_side.is_empty():
return _normalized_dialogue_side(explicit_side)
var speaker := _canonical_speaker_name(str(line.get("speaker", line.get("display_speaker", ""))))
if DIALOGUE_RIGHT_SIDE_SPEAKERS.has(speaker):
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 = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_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 display_speaker := _localized_speaker_name(str(line.get("display_speaker", speaker)))
var side := _dialogue_side_for_line(line)
_apply_dialogue_side(side)
dialogue_speaker_label.text = display_speaker if not display_speaker.is_empty() else ""
dialogue_speaker_label.visible = not display_speaker.is_empty()
_fit_label_font_size_to_text(
dialogue_speaker_label,
dialogue_speaker_label.text,
DIALOGUE_SPEAKER_FONT_SIZE,
DIALOGUE_SPEAKER_MIN_FONT_SIZE
)
dialogue_text_label.text = _format_dialogue_body_text(str(line.get("text", "")))
_fit_label_font_size_to_text(
dialogue_text_label,
dialogue_text_label.text,
DIALOGUE_TEXT_FONT_SIZE,
DIALOGUE_TEXT_MIN_FONT_SIZE
)
_update_dialogue_portrait(display_speaker, str(line.get("portrait", "")))
_update_dialogue_controls()
dialogue_panel.visible = true
_update_hud()
queue_redraw()
func _format_dialogue_body_text(text: String) -> String:
var stripped := text.strip_edges()
if stripped.is_empty():
return ""
if stripped.begins_with("") or stripped.begins_with(""):
return stripped
return "%s" % stripped
func _update_dialogue_controls() -> void:
if dialogue_progress_label != null:
dialogue_progress_label.text = "대화 %d/%d" % [active_dialogue_index + 1, active_dialogue_lines.size()]
if dialogue_previous_button != null:
dialogue_previous_button.disabled = active_dialogue_index <= 0
dialogue_continue_button.text = "닫기" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "다음"
func _apply_dialogue_side(side: String) -> void:
if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null:
return
var portrait_index: int = maxi(0, dialogue_row.get_child_count() - 2) if side == "right" else 1
dialogue_row.move_child(dialogue_portrait_panel, portrait_index)
dialogue_speaker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT
dialogue_text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT
func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void:
if dialogue_portrait_panel == null:
return
var texture := _load_portrait_texture(portrait_path)
var has_portrait := texture != null
var has_placeholder := not speaker.is_empty()
dialogue_portrait_panel.visible = has_portrait or has_placeholder
dialogue_portrait_texture.texture = texture
dialogue_portrait_texture.visible = has_portrait
dialogue_portrait_label.text = _dialogue_portrait_initials(speaker)
dialogue_portrait_label.visible = not has_portrait and has_placeholder
func _localized_speaker_name(speaker: String) -> String:
var normalized_speaker := speaker.strip_edges()
if normalized_speaker.is_empty():
return ""
return str(SPEAKER_DISPLAY_NAMES.get(normalized_speaker, normalized_speaker))
func _canonical_speaker_name(speaker: String) -> String:
var normalized_speaker := speaker.strip_edges()
if normalized_speaker.is_empty():
return ""
return str(SPEAKER_CANONICAL_NAMES.get(normalized_speaker, normalized_speaker))
func _load_art_texture(texture_path: String) -> Texture2D:
return _load_portrait_texture(texture_path)
func _load_portrait_texture(portrait_path: String) -> Texture2D:
var normalized_path := portrait_path.strip_edges()
if normalized_path.is_empty():
return null
if not normalized_path.begins_with("res://") and not normalized_path.begins_with("user://"):
normalized_path = "res://%s" % normalized_path
if missing_portrait_paths.has(normalized_path):
return null
if portrait_texture_cache.has(normalized_path):
return portrait_texture_cache[normalized_path] as Texture2D
if ResourceLoader.exists(normalized_path, "Texture2D"):
var imported_texture := load(normalized_path) as Texture2D
if imported_texture != null:
portrait_texture_cache[normalized_path] = imported_texture
return imported_texture
if not FileAccess.file_exists(normalized_path):
missing_portrait_paths[normalized_path] = true
return null
var image := Image.new()
if image.load(normalized_path) != OK:
missing_portrait_paths[normalized_path] = true
return null
var raw_texture := ImageTexture.create_from_image(image)
portrait_texture_cache[normalized_path] = raw_texture
return raw_texture
func _dialogue_portrait_initials(speaker: String) -> String:
var normalized_speaker := speaker.strip_edges()
if normalized_speaker.is_empty():
return ""
var parts := normalized_speaker.split(" ", false)
var initials := ""
for part in parts:
if str(part).is_empty():
continue
initials += str(part).substr(0, 1).to_upper()
if initials.length() >= 2:
break
if initials.is_empty():
return normalized_speaker.substr(0, 1).to_upper()
return initials
func _advance_dialogue() -> void:
if not _is_dialogue_visible():
return
_play_ui_click()
active_dialogue_index += 1
if active_dialogue_index < active_dialogue_lines.size():
_render_dialogue_line()
return
_hide_dialogue_panel()
if _can_show_dialogue_now():
_show_next_dialogue()
else:
_update_hud()
queue_redraw()
func _retreat_dialogue() -> void:
if not _is_dialogue_visible() or active_dialogue_index <= 0:
return
_play_ui_click()
active_dialogue_index -= 1
_render_dialogue_line()
func _hide_dialogue_panel() -> void:
if dialogue_panel != null:
dialogue_panel.visible = false
active_dialogue_lines.clear()
active_dialogue_index = 0
if dialogue_progress_label != null:
dialogue_progress_label.text = ""
if dialogue_previous_button != null:
dialogue_previous_button.disabled = true
func _clear_dialogue() -> void:
dialogue_queue.clear()
_hide_dialogue_panel()
func _is_dialogue_visible() -> bool:
return dialogue_panel != null and dialogue_panel.visible
func _on_wait_pressed() -> void:
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if _commit_pending_move_before_action() and state.try_wait_selected():
_play_ui_confirm()
func _on_end_turn_pressed() -> void:
if state.can_player_act():
_play_ui_confirm()
if _has_pending_move():
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if not (_commit_pending_move_before_action() and state.try_wait_selected()):
return
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
state.end_player_turn()
func _on_tactic_pressed() -> void:
var selected := state.get_selected_unit()
if selected.is_empty():
return
if _has_pending_move():
_on_post_move_tactic_pressed()
return
if state.is_unit_skill_sealed(selected["id"]):
_play_ui_cancel()
selected_skill_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_post_move_picker()
_refresh_ranges()
_update_hud()
queue_redraw()
return
_play_ui_click()
if tactic_menu != null and tactic_menu.visible:
_hide_tactic_menu()
if _has_pending_move():
_show_post_move_menu()
else:
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_post_move_picker()
_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
if _has_pending_move():
_on_post_move_item_pressed()
return
_play_ui_click()
if item_menu != null and item_menu.visible:
_hide_item_menu()
if _has_pending_move():
_show_post_move_menu()
else:
selected_skill_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_post_move_picker()
_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 = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_post_move_menu()
_hide_post_move_picker()
_show_equip_menu(selected)
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_threat_pressed() -> void:
if not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen:
if threat_button != null:
threat_button.button_pressed = show_threat_overlay
return
show_threat_overlay = not show_threat_overlay
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_mission_toggle_pressed() -> void:
_play_ui_click()
mission_detail_collapsed = not mission_detail_collapsed
_update_mission_panel()
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 = ""
_clear_pending_move_state()
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("군의 논의가 이어지고 있다.\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 = ""
_clear_pending_move_state()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_clear_dialogue()
state.load_battle(
scenario_path,
campaign_state.get_roster_overrides(),
campaign_state.get_inventory_snapshot(),
campaign_state.get_flags_snapshot(),
campaign_state.get_joined_officers_snapshot(),
campaign_state.get_scenario_title(active_scenario_id)
)
_reset_board_scroll()
func _format_briefing_title(briefing: Dictionary) -> String:
var title := campaign_state.get_scenario_title(active_scenario_id)
if title.is_empty():
title = str(briefing.get("title", state.battle_name))
if title.is_empty():
title = state.battle_name
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 "%s/%s · %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 "전장 %s/%s · %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 := campaign_state.get_scenario_title(active_scenario_id)
if title.is_empty():
title = str(briefing.get("title", state.battle_name))
if title.is_empty():
title = state.battle_name
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 "전장 %s/%s · %s" % [position, count, title]
return title
func _append_edict_clause(lines: Array, heading: String, seal: String, text: String) -> void:
var body := text.strip_edges()
if body.is_empty():
return
lines.append(heading)
lines.append(" %s%s" % [seal, body])
func _format_briefing_objectives() -> String:
var lines := []
var victory := str(state.objectives.get("victory", ""))
if not victory.is_empty():
lines.append("승리: %s" % victory)
var defeat := str(state.objectives.get("defeat", ""))
if not defeat.is_empty():
lines.append("패배: %s" % defeat)
var rewards_text := _format_briefing_rewards()
if not rewards_text.is_empty():
lines.append("보상: %s" % rewards_text)
return _join_strings(lines, "\n")
func _format_briefing_rewards() -> String:
if campaign_state.is_scenario_completed(active_scenario_id):
return "이미 수령"
var rewards := state.get_rewards()
var parts := []
var gold: int = maxi(0, int(rewards.get("gold", 0)))
if gold > 0:
parts.append("군자금 %d" % gold)
var items = rewards.get("items", [])
if typeof(items) == TYPE_ARRAY and not items.is_empty():
var item_text := _format_reward_items(items)
if item_text != "없음":
parts.append(item_text)
var joined_text := _format_officer_id_list(rewards.get("join_officers", []))
if not joined_text.is_empty():
parts.append("합류 %s" % joined_text)
var left_text := _format_officer_id_list(rewards.get("leave_officers", []))
if not left_text.is_empty():
parts.append("이탈 %s" % left_text)
return _join_strings(parts, ", ")
func _update_briefing_camp_overview(briefing: Dictionary, prep_locked: bool) -> void:
if briefing_camp_overview_row == null:
return
var overview_text := _format_briefing_camp_overview_text(briefing, prep_locked)
briefing_camp_overview_row.visible = not overview_text.is_empty()
if briefing_camp_overview_panel != null:
briefing_camp_overview_panel.visible = not overview_text.is_empty()
if briefing_camp_overview_label != null:
briefing_camp_overview_label.text = overview_text
var texture := _current_battle_background_texture()
var has_texture := texture != null
if briefing_camp_overview_texture != null:
briefing_camp_overview_texture.texture = texture
briefing_camp_overview_texture.visible = has_texture
if briefing_camp_overview_fallback_label != null:
briefing_camp_overview_fallback_label.text = "비단 전장도"
briefing_camp_overview_fallback_label.visible = not has_texture
func _format_briefing_camp_overview_text(briefing: Dictionary, prep_locked: bool) -> String:
var lines := []
var location := str(briefing.get("location", "")).strip_edges()
if not location.is_empty():
lines.append("위치: %s" % location)
var battlefield_text := _format_briefing_battlefield_overview_text()
if not battlefield_text.is_empty():
lines.append("전장: %s" % battlefield_text)
var camp_parts := ["군자금 %d" % campaign_state.gold]
var talk_text := _format_briefing_talk_overview_text()
if not talk_text.is_empty():
camp_parts.append(talk_text)
var shop_text := _format_briefing_shop_overview_text()
if not shop_text.is_empty():
camp_parts.append(shop_text)
if not camp_parts.is_empty():
lines.append("군막: %s" % _join_strings(camp_parts, " · "))
var prep_parts := []
var roster_text := _format_briefing_roster_overview_text()
if not roster_text.is_empty():
prep_parts.append(roster_text)
var formation_text := _format_briefing_formation_overview_text()
if not formation_text.is_empty():
prep_parts.append(formation_text)
if not state.get_controllable_player_units().is_empty():
prep_parts.append("비축 물자")
if not prep_parts.is_empty():
lines.append("준비: %s" % _join_strings(prep_parts, " · "))
if prep_locked:
lines.append("회상 전투")
return _join_strings(lines, "\n")
func _format_briefing_battlefield_overview_text() -> String:
var parts := []
var battle_map_size := state.get_map_size()
if battle_map_size.x > 0 and battle_map_size.y > 0:
parts.append("%d칸 x %d" % [battle_map_size.x, battle_map_size.y])
var enemy_text := _format_briefing_enemy_force_text()
if not enemy_text.is_empty():
parts.append(enemy_text)
var recovery_text := _format_briefing_recovery_terrain_text()
if not recovery_text.is_empty():
parts.append(recovery_text)
return _join_strings(parts, " · ")
func _format_briefing_enemy_force_text() -> String:
var enemies := state.get_living_units(BattleState.TEAM_ENEMY)
if enemies.is_empty():
return ""
var class_order: Array[String] = []
var class_counts := {}
for enemy in enemies:
var class_label := _unit_class_display_name(enemy)
if class_label.is_empty():
class_label = "부대"
if not class_counts.has(class_label):
class_order.append(class_label)
class_counts[class_label] = 0
class_counts[class_label] = int(class_counts[class_label]) + 1
var class_parts := []
for class_label in class_order:
class_parts.append("%s %d" % [class_label, int(class_counts.get(class_label, 0))])
return "적세 %d명 (%s)" % [enemies.size(), _join_strings(class_parts, ", ")]
func _format_briefing_recovery_terrain_text() -> String:
var recovery_summaries := state.get_recovery_terrain_summaries()
if recovery_summaries.is_empty():
return ""
var parts := []
for summary in recovery_summaries:
var name := _terrain_display_name(str(summary.get("name", "지형")))
var count := int(summary.get("count", 0))
var heal := int(summary.get("heal", 0))
if count <= 0 or heal <= 0:
continue
parts.append("%s %d칸 +%d" % [name, count, heal])
if parts.is_empty():
return ""
return "회복지 %s" % _join_strings(parts, ", ")
func _format_briefing_talk_overview_text() -> String:
var conversations := _camp_conversation_entries()
if conversations.is_empty():
return ""
var supply_counts := _camp_conversation_supply_counts(conversations)
var ready := int(supply_counts.get("ready", 0))
var claimed := int(supply_counts.get("claimed", 0))
var parts := ["군막 회의 · %d" % conversations.size()]
if ready > 0:
parts.append("보급 대기 %d" % ready)
if claimed > 0:
parts.append("수령 완료 %d" % claimed)
return _join_strings(parts, " · ")
func _format_briefing_shop_overview_text() -> String:
var buy_count := state.get_shop_item_ids().size()
var sell_count := _get_sellable_item_ids().size()
if buy_count <= 0 and sell_count <= 0:
return ""
if sell_count > 0:
return "군상 물품 · %d종, 매각 · %d" % [buy_count, sell_count]
return "군상 물품 · %d" % buy_count
func _format_briefing_roster_overview_text() -> String:
if not state.has_deployment_roster():
return ""
var deployed_count := state.get_deployed_player_count()
var max_units := state.get_deployment_max_units()
if max_units > 0:
return "출진 명부 %d명 중 %d" % [max_units, deployed_count]
return "출진 명부 %d" % deployed_count
func _format_briefing_formation_overview_text() -> String:
var formation_count := state.get_formation_cells().size()
if formation_count <= 0:
return ""
return "진형 배치 %d" % formation_count
func _camp_conversation_supply_counts(conversations: Array) -> Dictionary:
var counts := {"ready": 0, "claimed": 0}
for conversation in conversations:
if typeof(conversation) != TYPE_DICTIONARY:
continue
var effects: Array = conversation.get("effects", [])
var has_supply := false
for effect in effects:
if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == "grant_item":
has_supply = true
break
if not has_supply:
continue
var conversation_id := str(conversation.get("id", ""))
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
counts["claimed"] = int(counts.get("claimed", 0)) + 1
else:
counts["ready"] = int(counts.get("ready", 0)) + 1
return counts
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_collapsed = false
briefing_objective_label.text = _format_briefing_objectives()
_fit_label_font_size_to_text(
briefing_objective_label,
briefing_objective_label.text,
BRIEFING_OBJECTIVE_FONT_SIZE,
BRIEFING_OBJECTIVE_MIN_FONT_SIZE
)
briefing_objective_label.visible = not briefing_objective_label.text.is_empty()
_update_briefing_objective_visibility()
briefing_label.text = body
briefing_panel.visible = true
_refresh_screen_backdrop_visibility()
battle_started = false
var prep_locked := _is_prebattle_prep_locked()
_update_briefing_camp_overview(briefing, prep_locked)
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_clear_pending_move_state()
if chapter_button != null:
chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty()
if shop_button != null:
shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty())
if talk_button != null:
talk_button.disabled = prep_locked or _camp_conversation_entries().is_empty()
if armory_button != null:
armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty()
if roster_button != null:
roster_button.disabled = prep_locked or not state.has_deployment_roster_choices()
if formation_button != null:
formation_button.disabled = prep_locked or state.get_formation_cells().is_empty()
if save_button != null:
save_button.disabled = prep_locked
func _on_briefing_objective_toggle_pressed() -> void:
_play_ui_click()
_set_briefing_objective_collapsed(not briefing_objective_collapsed)
func _set_briefing_objective_collapsed(collapsed: bool) -> bool:
if briefing_objective_label == null or briefing_objective_label.text.strip_edges().is_empty():
return false
if briefing_objective_collapsed == collapsed:
return false
briefing_objective_collapsed = collapsed
_update_briefing_objective_visibility()
return true
func _update_briefing_objective_visibility() -> void:
var has_text := briefing_objective_label != null and not briefing_objective_label.text.strip_edges().is_empty()
if briefing_objective_panel != null:
briefing_objective_panel.visible = has_text and not briefing_objective_collapsed
if briefing_objective_toggle_button != null:
briefing_objective_toggle_button.visible = has_text
briefing_objective_toggle_button.text = "펼치기" if briefing_objective_collapsed else "닫기"
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
_refresh_screen_backdrop_visibility()
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_clear_pending_move_state()
state.run_battle_begin_events()
_show_next_dialogue()
_update_hud()
func _on_chapter_pressed() -> void:
if battle_started or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if chapter_menu != null and chapter_menu.visible:
_hide_chapter_menu()
else:
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_chapter_menu()
chapter_menu.visible = true
_update_hud()
func _hide_chapter_menu() -> void:
if chapter_menu == null:
return
chapter_menu.visible = false
func _rebuild_chapter_menu() -> void:
if chapter_list == null:
return
for child in chapter_list.get_children():
chapter_list.remove_child(child)
child.queue_free()
var entries := campaign_state.get_chapter_progress_entries()
if chapter_status_label != null:
chapter_status_label.text = "%s 병서 · 봉인 전장 %d/%d" % [
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 = "아직 엮인 병서가 없습니다."
_apply_label_style(empty_label, UI_AGED_INK)
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 := "현 전장" if current else ("봉인 완료" if total > 0 and completed >= total else "미개봉")
var current_title := str(entry.get("current_scenario_title", ""))
var line := "병서 %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 += "\n현 전장: %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
_apply_label_style(chapter_label, UI_AGED_INK)
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 := "현 전장" if current else ("회상 가능" if completed else "미개봉")
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
_apply_button_style(scenario_button)
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
_apply_label_style(scenario_label, UI_AGED_INK)
chapter_list.add_child(scenario_label)
func _on_chapter_scenario_pressed(scenario_id: String) -> void:
if battle_started or briefing_panel == null or not briefing_panel.visible:
return
if scenario_id.is_empty():
return
if not campaign_state.is_scenario_completed(scenario_id) and scenario_id != campaign_state.current_scenario_id:
_play_ui_cancel()
return
_play_ui_confirm()
if log_box != null:
log_box.clear()
_load_scenario(scenario_id)
_show_briefing()
_update_hud()
func _on_shop_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if shop_menu != null and shop_menu.visible:
_hide_shop_menu()
else:
shop_sell_mode = state.get_shop_item_ids().is_empty() and not _get_sellable_item_ids().is_empty()
_hide_chapter_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_shop_menu()
shop_menu.visible = true
_update_hud()
func _hide_shop_menu() -> void:
if shop_menu == null:
return
shop_menu.visible = false
func _on_talk_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
if _camp_conversation_entries().is_empty():
_play_ui_cancel()
return
_play_ui_click()
if talk_menu != null and talk_menu.visible:
_hide_talk_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_talk_menu()
talk_menu.visible = true
_update_hud()
func _hide_talk_menu() -> void:
if talk_menu == null:
return
talk_menu.visible = false
func _rebuild_talk_menu() -> void:
if talk_list == null:
return
_clear_talk_list()
var conversations := _camp_conversation_entries()
if talk_status_label != null:
talk_status_label.text = _format_talk_status_text(conversations)
if conversations.is_empty():
var empty_label := Label.new()
empty_label.text = "군막에 들을 말이 없습니다."
_apply_label_style(empty_label, UI_AGED_INK)
talk_list.add_child(empty_label)
return
for conversation in conversations:
_add_camp_conversation_row(conversation)
func _add_camp_conversation_row(conversation: Dictionary) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(580, 64)
row.add_theme_constant_override("separation", 8)
talk_list.add_child(row)
row.add_child(_make_camp_conversation_portrait(conversation))
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_theme_constant_override("separation", 3)
row.add_child(column)
var conversation_id := str(conversation.get("id", ""))
var button := Button.new()
button.text = _format_camp_conversation_row_title(conversation)
button.custom_minimum_size = Vector2(500, 32)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_button_style(button)
button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id))
column.add_child(button)
var preview_label := Label.new()
preview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
preview_label.custom_minimum_size = Vector2(500, 24)
preview_label.text = _format_camp_conversation_preview_text(conversation)
_apply_label_style(preview_label, UI_AGED_INK)
column.add_child(preview_label)
func _make_camp_conversation_portrait(conversation: Dictionary) -> PanelContainer:
var portrait_panel := PanelContainer.new()
portrait_panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
_apply_panel_style(portrait_panel, "portrait")
var stack := Control.new()
stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
portrait_panel.add_child(stack)
var texture_rect := TextureRect.new()
texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
stack.add_child(texture_rect)
var initials_label := Label.new()
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
initials_label.add_theme_font_size_override("font_size", 18)
_apply_label_style(initials_label, UI_PARCHMENT_TEXT)
stack.add_child(initials_label)
var speaker := _camp_conversation_preview_speaker(conversation)
var texture := _load_art_texture(_camp_conversation_preview_portrait_path(conversation))
var has_texture := texture != null
texture_rect.texture = texture
texture_rect.visible = has_texture
initials_label.text = _dialogue_portrait_initials(speaker)
initials_label.visible = not has_texture
return portrait_panel
func _format_talk_status_text(conversations: Array) -> String:
var supply_available := 0
var supply_claimed := 0
for conversation in conversations:
if typeof(conversation) != TYPE_DICTIONARY:
continue
var effects: Array = conversation.get("effects", [])
if effects.is_empty():
continue
var has_supply := false
for effect in effects:
if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == "grant_item":
has_supply = true
break
if not has_supply:
continue
var conversation_id := str(conversation.get("id", ""))
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
supply_claimed += 1
else:
supply_available += 1
var parts := ["군막 회의", "%d" % conversations.size()]
if supply_available > 0:
parts.append("보급 대기 %d" % supply_available)
if supply_claimed > 0:
parts.append("수령 완료 %d" % supply_claimed)
return _join_strings(parts, " · ")
func _clear_talk_list() -> void:
for child in talk_list.get_children():
talk_list.remove_child(child)
child.queue_free()
func _camp_conversation_entries() -> Array:
var briefing := state.get_briefing()
var conversations = briefing.get("camp_conversations", [])
if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty():
return conversations
var lines = briefing.get("camp_dialogue", [])
if typeof(lines) == TYPE_ARRAY and not lines.is_empty():
return [{
"id": "camp_dialogue",
"group": "topic",
"officer_id": "",
"label": "군막 회의",
"speaker": "",
"summary": "눈앞의 정세를 살핍니다.",
"lines": lines
}]
return []
func _format_camp_conversation_button_text(conversation: Dictionary) -> String:
var label := str(conversation.get("label", "군막 담화"))
var speaker := _localized_speaker_name(str(conversation.get("speaker", "")))
var summary := str(conversation.get("summary", ""))
var group := _camp_conversation_group_text(str(conversation.get("group", "topic")))
var parts := [group, label]
if not speaker.is_empty():
parts.append(speaker)
if not summary.is_empty():
parts.append(summary)
var effects_text := _format_camp_conversation_effects_text(conversation)
if not effects_text.is_empty():
parts.append(effects_text)
return _join_strings(parts, " · ")
func _format_camp_conversation_row_title(conversation: Dictionary) -> String:
var label := str(conversation.get("label", "군막 담화"))
var speaker := _camp_conversation_preview_speaker(conversation)
var group := _camp_conversation_group_text(str(conversation.get("group", "topic")))
var parts := [group, label]
if not speaker.is_empty():
parts.append(speaker)
var effects_text := _format_camp_conversation_effects_text(conversation)
if not effects_text.is_empty():
parts.append(effects_text)
return _join_strings(parts, " · ")
func _format_camp_conversation_preview_text(conversation: Dictionary) -> String:
var parts := []
var summary := str(conversation.get("summary", "")).strip_edges()
if not summary.is_empty():
parts.append(summary)
var first_text := _camp_conversation_preview_line(conversation)
if not first_text.is_empty():
parts.append(_short_preview_text(first_text, 96))
if parts.is_empty():
return "아직 적힌 말이 없습니다."
return _join_strings(parts, " · ")
func _camp_conversation_group_text(group: String) -> String:
if group == "officer":
return "장수"
if group == "topic":
return "의제"
return _format_identifier_label(group)
func _camp_conversation_preview_speaker(conversation: Dictionary) -> String:
var speaker := str(conversation.get("speaker", "")).strip_edges()
if not speaker.is_empty():
return _localized_speaker_name(speaker)
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
speaker = str(line.get("speaker", "")).strip_edges()
if not speaker.is_empty():
return _localized_speaker_name(speaker)
return ""
func _camp_conversation_preview_line(conversation: Dictionary) -> String:
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
var text := str(line.get("text", "")).strip_edges()
if not text.is_empty():
return text
return ""
func _camp_conversation_preview_portrait_path(conversation: Dictionary) -> String:
var portrait := str(conversation.get("portrait", "")).strip_edges()
if not portrait.is_empty():
return portrait
for line in conversation.get("lines", []):
if typeof(line) != TYPE_DICTIONARY:
continue
portrait = str(line.get("portrait", "")).strip_edges()
if not portrait.is_empty():
return portrait
var officer_id := str(conversation.get("officer_id", "")).strip_edges()
if not officer_id.is_empty():
portrait = state.data_catalog.get_portrait_for_officer(officer_id)
if not portrait.is_empty():
return portrait
var speaker := _camp_conversation_preview_speaker(conversation)
if not speaker.is_empty():
return state.data_catalog.get_portrait_for_speaker(_canonical_speaker_name(speaker))
return ""
func _short_preview_text(text: String, max_length: int) -> String:
var normalized := text.strip_edges()
if max_length <= 3 or normalized.length() <= max_length:
return normalized
return "%s..." % normalized.substr(0, max_length - 3).strip_edges()
func _on_camp_conversation_pressed(conversation_id: String) -> void:
var conversation := _camp_conversation_by_id(conversation_id)
if conversation.is_empty():
_play_ui_cancel()
return
_play_ui_confirm()
_hide_talk_menu()
_show_camp_dialogue(conversation.get("lines", []))
_apply_camp_conversation_effects(conversation)
_update_hud()
func _format_camp_conversation_effects_text(conversation: Dictionary) -> String:
var effects: Array = conversation.get("effects", [])
if effects.is_empty():
return ""
var parts := []
for effect in effects:
if typeof(effect) != TYPE_DICTIONARY:
continue
if str(effect.get("type", "")) != "grant_item":
continue
var item_id := str(effect.get("item_id", ""))
var item := state.get_item_def(item_id)
if item.is_empty():
continue
var count := maxi(1, int(effect.get("count", 1)))
var count_text := "" if count == 1 else " %d" % count
parts.append("보급 %s%s" % [_item_display_name(item_id), count_text])
if parts.is_empty():
return ""
var conversation_id := str(conversation.get("id", ""))
var claimed_text := "수령 완료" if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id) else "수령 가능"
return "%s (%s)" % [_join_strings(parts, ", "), claimed_text]
func _apply_camp_conversation_effects(conversation: Dictionary) -> void:
if battle_started or _is_prebattle_prep_locked():
return
var conversation_id := str(conversation.get("id", ""))
if conversation_id.is_empty():
return
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
return
var effects: Array = conversation.get("effects", [])
if effects.is_empty():
return
if campaign_state.try_claim_camp_conversation_effects(active_scenario_id, conversation):
state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
_on_log_added(_format_camp_conversation_effect_claim_text(conversation))
if talk_menu != null and talk_menu.visible:
_rebuild_talk_menu()
return
_play_ui_cancel()
_on_log_added("군막 보급을 거두지 못했습니다.")
func _format_camp_conversation_effect_claim_text(conversation: Dictionary) -> String:
var messages := []
var effects: Array = conversation.get("effects", [])
for effect in effects:
if typeof(effect) != TYPE_DICTIONARY:
continue
if str(effect.get("type", "")) != "grant_item":
continue
var message := str(effect.get("text", "")).strip_edges()
if not message.is_empty():
messages.append(message)
continue
var item_id := str(effect.get("item_id", ""))
if item_id.is_empty():
continue
var count := maxi(1, int(effect.get("count", 1)))
var count_text := "" if count == 1 else " %d" % count
messages.append("군막 보급 수령: %s%s." % [_item_display_name(item_id), count_text])
if messages.is_empty():
return "군막 보급을 수령했습니다."
return _join_strings(messages, " ")
func _camp_conversation_by_id(conversation_id: String) -> Dictionary:
for conversation in _camp_conversation_entries():
if str(conversation.get("id", "")) == conversation_id:
return conversation
return {}
func _show_camp_dialogue(lines: Array) -> void:
var normalized_lines := _normalized_dialogue_lines(lines)
if normalized_lines.is_empty():
return
dialogue_queue.clear()
active_dialogue_lines = normalized_lines
active_dialogue_index = 0
_render_dialogue_line()
func _on_armory_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if armory_menu != null and armory_menu.visible:
_hide_armory_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_armory_menu()
armory_menu.visible = true
_update_hud()
func _hide_armory_menu() -> void:
if armory_menu == null:
return
armory_menu.visible = false
func _on_roster_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or not state.has_deployment_roster_choices() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if roster_menu != null and roster_menu.visible:
_hide_roster_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_formation_menu()
_hide_save_menu()
_rebuild_roster_menu()
roster_menu.visible = true
_update_hud()
func _hide_roster_menu() -> void:
if roster_menu == null:
return
roster_menu.visible = false
func _on_formation_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if formation_menu != null and formation_menu.visible:
_hide_formation_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_save_menu()
_rebuild_formation_menu()
formation_menu.visible = true
_update_hud()
queue_redraw()
func _hide_formation_menu() -> void:
if formation_menu == null:
return
formation_menu.visible = false
func _on_save_pressed() -> void:
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
return
_play_ui_click()
if save_menu != null and save_menu.visible:
_hide_save_menu()
else:
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_rebuild_save_menu()
save_menu.visible = true
_update_hud()
func _hide_save_menu() -> void:
if save_menu == null:
return
save_menu.visible = false
func _rebuild_save_menu() -> void:
if save_status_label != null:
save_status_label.text = "%s\n%s" % [
_format_current_checkpoint_text(),
_format_manual_checkpoint_text()
]
if manual_load_button != null:
manual_load_button.disabled = not campaign_state.has_manual_save()
func _format_current_checkpoint_text() -> String:
var title := campaign_state.get_scenario_title(campaign_state.current_scenario_id)
if title.is_empty():
title = campaign_state.get_scenario_title(campaign_state.get_start_scenario_id())
return "현재 봉인: %s · %d/%d 봉인 · 군자금 %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 "수동 봉인: 비어 있음"
var title := str(summary.get("current_scenario_title", ""))
if title.is_empty():
title = str(summary.get("current_scenario_id", "Unknown"))
var pending_suffix := " · 회의 미결" if bool(summary.get("pending_choice", false)) else ""
return "수동 봉인: %s · %d/%d 봉인 · 군자금 %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("수동 전기가 봉인되었다.")
else:
_on_log_added("수동 전기를 봉하지 못했다.")
_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("수동 봉인을 열지 못했다.")
_rebuild_save_menu()
return
if log_box != null:
log_box.clear()
log_box.append_text("수동 봉인을 열었다.\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 = ""
_clear_pending_move_state()
armory_unit_id = ""
formation_unit_id = ""
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_chapter_menu()
_hide_shop_menu()
_hide_talk_menu()
_hide_armory_menu()
_hide_roster_menu()
_hide_formation_menu()
_hide_save_menu()
if result_panel != null:
result_panel.visible = false
if campaign_state.has_pending_post_battle_choice():
_load_pending_post_battle_choice()
_update_result_panel()
elif campaign_state.is_campaign_complete():
_show_campaign_complete()
else:
_load_current_battle()
_show_briefing()
_update_hud()
func _is_prep_menu_visible() -> bool:
return (
(chapter_menu != null and chapter_menu.visible)
or (shop_menu != null and shop_menu.visible)
or (talk_menu != null and talk_menu.visible)
or (armory_menu != null and armory_menu.visible)
or (roster_menu != null and roster_menu.visible)
or (formation_menu != null and formation_menu.visible)
or (save_menu != null and save_menu.visible)
)
func _is_prebattle_prep_locked() -> bool:
return campaign_state.is_scenario_completed(active_scenario_id)
func _rebuild_roster_menu() -> void:
if roster_list == null:
return
_clear_roster_list()
var units := state.get_player_units(true)
var max_units := state.get_deployment_max_units()
var deployed_count := state.get_deployed_player_count()
if roster_status_label != null:
roster_status_label.text = "출진 명부 %d/%d" % [deployed_count, max_units]
if units.is_empty():
var empty_label := Label.new()
empty_label.text = "장수가 없습니다"
_apply_label_style(empty_label, UI_AGED_INK)
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)
_apply_button_style(roster_unit_button)
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 := "출진" if bool(unit.get("deployed", true)) else "예비"
var required_text := " 필수" if bool(unit.get("required_deployment", false)) else ""
var protected_text := " 호위" if not bool(unit.get("controllable", true)) else ""
return "%s%s 품계 %d %s %s%s" % [
str(unit.get("name", "장수")),
protected_text,
int(unit.get("level", 1)),
_unit_class_display_name(unit),
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 = "진형"
else:
formation_status_label.text = "진형: %s · %s" % [
str(selected.get("name", "장수")),
_format_cell_label(selected.get("pos", Vector2i.ZERO))
]
if units.is_empty():
var empty_label := Label.new()
empty_label.text = "출진 장수가 없습니다"
_apply_label_style(empty_label, UI_AGED_INK)
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)
_apply_button_style(unit_button)
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", "장수")),
_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 = "출진 장수가 없습니다"
_apply_label_style(empty_label, UI_AGED_INK)
armory_list.add_child(empty_label)
return
var unit_title := Label.new()
unit_title.text = "장수 명부"
_apply_label_style(unit_title, UI_CINNABAR, 14)
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)
_apply_button_style(unit_button)
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 = "소지 병장: %s" % _format_unit_equipment(selected)
_apply_label_style(equipment_label, UI_AGED_INK)
armory_list.add_child(equipment_label)
var equipped_slots := state.get_equipped_equipment_slots(armory_unit_id)
if not equipped_slots.is_empty():
var unequip_title := Label.new()
unequip_title.text = "병장 해제"
_apply_label_style(unequip_title, UI_CINNABAR, 14)
armory_list.add_child(unequip_title)
var equipment := state.get_equipment_snapshot(armory_unit_id)
for slot in equipped_slots:
var normalized_slot := str(slot)
var item_id := _equipment_item_id(equipment, normalized_slot)
var item := state.get_item_def(item_id)
var unequip_button := Button.new()
unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
_apply_button_style(unequip_button)
unequip_button.pressed.connect(_on_armory_unequip_pressed.bind(normalized_slot))
armory_list.add_child(unequip_button)
var item_ids := state.get_equippable_item_ids(armory_unit_id)
if item_ids.is_empty():
var empty_equipment_label := Label.new()
empty_equipment_label.text = "군수고에 맞는 병장이 없습니다"
_apply_label_style(empty_equipment_label, UI_AGED_INK)
armory_list.add_child(empty_equipment_label)
else:
var inventory := state.get_inventory_snapshot()
for item_id in item_ids:
var normalized_id := str(item_id)
var item := state.get_item_def(normalized_id)
var armory_equip_button := Button.new()
armory_equip_button.text = _format_equipment_option_text(
selected,
normalized_id,
item,
int(inventory.get(normalized_id, 0))
)
_apply_button_style(armory_equip_button)
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 품계 %d %s" % [
marker,
str(unit.get("name", "장수")),
int(unit.get("level", 1)),
_format_unit_equipment(unit)
]
func _on_armory_unit_pressed(unit_id: String) -> void:
_play_ui_click()
armory_unit_id = unit_id
_rebuild_armory_menu()
_update_hud()
func _on_armory_equip_pressed(item_id: String) -> void:
if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
return
if not state.try_equip_item(armory_unit_id, item_id):
_rebuild_armory_menu()
_update_hud()
return
if not _save_prebattle_equipment_change():
return
_rebuild_armory_menu()
_update_hud()
queue_redraw()
func _on_armory_unequip_pressed(slot: String) -> void:
if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
return
if not state.try_unequip_item(armory_unit_id, slot):
_rebuild_armory_menu()
_update_hud()
return
if not _save_prebattle_equipment_change():
return
_rebuild_armory_menu()
_update_hud()
queue_redraw()
func _save_prebattle_equipment_change() -> bool:
if campaign_state.try_save_prebattle_loadout(state.get_player_roster_snapshot(), state.get_inventory_snapshot()):
_on_log_added("출진 병장을 고쳐 봉하였다.")
return true
_play_ui_cancel()
_on_log_added("출진 병장 기록을 봉하지 못했다.")
_load_scenario(active_scenario_id)
_show_briefing()
return false
func _rebuild_shop_menu() -> void:
if shop_list == null:
return
_clear_shop_list()
if shop_status_label != null:
var mode_text := "매각" if shop_sell_mode else "매입"
shop_status_label.text = "%s · 군자금 %d냥 · %s" % [
mode_text,
campaign_state.gold,
_format_inventory_status_text()
]
if shop_buy_button != null:
shop_buy_button.disabled = not shop_sell_mode
if shop_sell_button != null:
shop_sell_button.disabled = shop_sell_mode or _get_sellable_item_ids().is_empty()
if not shop_sell_mode:
_add_shop_merchant_notice()
if shop_sell_mode:
_rebuild_shop_sell_list()
else:
_rebuild_shop_buy_list()
func _rebuild_shop_buy_list() -> void:
var item_ids := state.get_shop_item_ids()
if item_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "군상이 내놓은 물품이 없습니다."
_apply_label_style(empty_label, UI_AGED_INK)
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()
var detail_text := _format_shop_buy_item_detail_text(normalized_id, item, price, owned, stock_limit, remaining)
buy_button.text = _format_shop_buy_action_text(normalized_id, item, price)
buy_button.custom_minimum_size = Vector2(500, 32)
buy_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
buy_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining), detail_text)
buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0)
_apply_button_style(buy_button)
buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id))
_add_shop_item_row(normalized_id, item, buy_button, detail_text)
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 = "매각할 물품이 없습니다."
_apply_label_style(empty_label, UI_AGED_INK)
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()
var detail_text := _format_shop_sell_item_detail_text(normalized_id, item, sale_price, owned)
sell_button.text = _format_shop_sell_action_text(normalized_id, item, sale_price)
sell_button.custom_minimum_size = Vector2(500, 32)
sell_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sell_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_sell_item_button_text(normalized_id, item, sale_price, owned), detail_text)
_apply_button_style(sell_button)
sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id))
_add_shop_item_row(normalized_id, item, sell_button, detail_text)
func _clear_shop_list() -> void:
for child in shop_list.get_children():
shop_list.remove_child(child)
child.queue_free()
func _add_shop_merchant_notice() -> void:
var merchant := state.get_shop_merchant()
var notice_text := _format_shop_merchant_notice_text(merchant)
if notice_text.is_empty():
return
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(580, 58)
row.add_theme_constant_override("separation", 8)
shop_list.add_child(row)
row.add_child(_make_initials_badge(str(merchant.get("name", "야상")), SHOP_MERCHANT_BADGE_SIZE, 18))
var merchant_label := Label.new()
merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
merchant_label.custom_minimum_size = Vector2(500, 0)
merchant_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
merchant_label.text = notice_text
_apply_label_style(merchant_label, UI_AGED_INK)
row.add_child(merchant_label)
func _format_shop_merchant_notice_text(merchant: Dictionary) -> String:
if merchant.is_empty():
return ""
var lines := []
for line in merchant.get("lines", []):
var text := str(line).strip_edges()
if not text.is_empty():
lines.append(text)
if lines.is_empty():
return ""
return "%s: %s" % [
str(merchant.get("name", "야상")),
_join_strings(lines, " ")
]
func _make_initials_badge(label_text: String, badge_size: Vector2, font_size: int) -> PanelContainer:
var panel := PanelContainer.new()
panel.custom_minimum_size = badge_size
_apply_panel_style(panel, "portrait")
var label := Label.new()
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", font_size)
_apply_label_style(label, UI_PARCHMENT_TEXT)
label.text = _dialogue_portrait_initials(label_text)
panel.add_child(label)
return panel
func _add_shop_item_row(item_id: String, item: Dictionary, action_button: Button, detail_text: String) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(580, 58)
row.add_theme_constant_override("separation", 8)
shop_list.add_child(row)
row.add_child(_make_shop_item_icon_panel(item_id, item))
var column := VBoxContainer.new()
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
column.add_theme_constant_override("separation", 3)
row.add_child(column)
column.add_child(action_button)
var detail_label := Label.new()
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail_label.custom_minimum_size = Vector2(500, 20)
detail_label.text = detail_text
_apply_label_style(detail_label, UI_AGED_INK)
column.add_child(detail_label)
func _make_shop_item_icon_panel(item_id: String, item: Dictionary) -> PanelContainer:
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = SHOP_ITEM_ICON_SIZE
_apply_panel_style(icon_panel, "portrait")
var stack := Control.new()
stack.custom_minimum_size = SHOP_ITEM_ICON_SIZE
icon_panel.add_child(stack)
var icon_rect := TextureRect.new()
icon_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
stack.add_child(icon_rect)
var initials_label := Label.new()
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
initials_label.add_theme_font_size_override("font_size", 16)
_apply_label_style(initials_label, UI_PARCHMENT_TEXT)
stack.add_child(initials_label)
var texture := _load_art_texture(str(item.get("icon", "")))
var has_texture := texture != null
icon_rect.texture = texture
icon_rect.visible = has_texture
initials_label.text = _dialogue_portrait_initials(_item_display_name(item_id))
initials_label.visible = not has_texture
return icon_panel
func _format_shop_buy_action_text(item_id: String, _item: Dictionary, price: int) -> String:
return "매입 %s %d" % [_item_display_name(item_id), price]
func _format_shop_sell_action_text(item_id: String, _item: Dictionary, sale_price: int) -> String:
return "매각 %s %d" % [_item_display_name(item_id), sale_price]
func _format_shop_buy_item_detail_text(_item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String:
var parts := _shop_item_common_detail_parts(item, owned)
if stock_limit >= 0:
parts.append("잔량 %d/%d" % [max(0, remaining), stock_limit])
if stock_limit >= 0 and remaining <= 0:
parts.append("매진")
elif campaign_state.gold < price:
parts.append("부족 %d" % (price - campaign_state.gold))
return _join_strings(parts, " · ")
func _format_shop_sell_item_detail_text(_item_id: String, item: Dictionary, _sale_price: int, owned: int) -> String:
var parts := _shop_item_common_detail_parts(item, owned)
parts.append("반값으로 넘김")
return _join_strings(parts, " · ")
func _shop_item_common_detail_parts(item: Dictionary, owned: int) -> Array:
var parts := []
var effect_text := _format_shop_item_effect_text(item)
if not effect_text.is_empty():
parts.append(effect_text)
var kind := str(item.get("kind", "item")).capitalize()
if not kind.is_empty():
parts.append(_item_kind_text(kind))
parts.append("보유 %d" % owned)
return parts
func _format_shop_item_tooltip_text(primary_text: String, detail_text: String) -> String:
if detail_text.is_empty():
return primary_text
return "%s\n%s" % [primary_text, detail_text]
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 = " · 매진"
elif campaign_state.gold < price:
unavailable = " · 부족 %d" % (price - campaign_state.gold)
var stock_text := ""
if stock_limit >= 0:
stock_text = " 잔량 %d/%d" % [max(0, remaining), stock_limit]
return "%s %d%s 보유 %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 매각 %d%s 보유 %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_text(equipment_type))
if item.has("range"):
var range_value: Array = item.get("range", [])
if range_value.size() >= 2:
parts.append("사거리 %d-%d" % [int(range_value[0]), int(range_value[1])])
parts.append(_format_equipment_bonus_text(item))
return _join_strings(parts, ", ")
return _item_kind_text(kind)
func _item_kind_text(kind: String) -> String:
var normalized := kind.strip_edges().to_lower()
if normalized == "consumable":
return "소모 물자"
if normalized == "weapon":
return "무기"
if normalized == "armor":
return "갑옷"
if normalized == "accessory":
return "보물"
if normalized == "item":
return "도구"
return kind.capitalize()
func _equipment_type_text(equipment_type: String) -> String:
var normalized := equipment_type.strip_edges().to_lower()
if normalized == "sword":
return ""
if normalized == "spear":
return ""
if normalized == "bow":
return ""
if normalized == "axe":
return ""
if normalized == "robe":
return "도포"
if normalized == "light_armor":
return "경갑"
if normalized == "heavy_armor":
return "중갑"
if normalized == "accessory":
return "보물"
return _format_identifier_label(equipment_type)
func _apply_item_button_icon(button: Button, item: Dictionary) -> void:
var icon := _load_art_texture(str(item.get("icon", "")))
if icon == null:
return
button.icon = icon
button.expand_icon = true
button.add_theme_constant_override("icon_max_width", 30)
func _on_shop_item_pressed(item_id: String) -> void:
if battle_started or _is_prebattle_prep_locked():
return
var item := state.get_item_def(item_id)
var price := int(item.get("price", 0))
var item_name := _item_display_name(item_id)
if item.is_empty() or price <= 0:
return
var stock_limit := state.get_shop_stock_limit(item_id)
var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, item_id)
if stock_limit >= 0 and purchased >= stock_limit:
_play_ui_cancel()
_on_log_added("%s 매진." % 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("%s 매입: %d냥." % [item_name, price])
else:
_play_ui_cancel()
_on_log_added("%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("%s 매각: %d냥." % [item_name, sale_price])
else:
_play_ui_cancel()
_on_log_added("%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 = ""
_clear_pending_move_state()
_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)
_apply_button_style(choice_button, true)
choice_button.pressed.connect(_on_post_battle_choice_pressed.bind(choice))
result_choice_list.add_child(choice_button)
func _clear_result_choices() -> void:
if result_choice_list == null:
return
for child in result_choice_list.get_children():
result_choice_list.remove_child(child)
child.queue_free()
func _format_post_battle_choice_text(choice: Dictionary) -> String:
var label := str(choice.get("label", choice.get("text", choice.get("id", "Choice"))))
var description := str(choice.get("description", ""))
var reward_text := _format_post_battle_choice_reward_text(choice)
var text := label
if description.is_empty():
text = label
else:
text = "%s\n뜻: %s" % [label, description]
if not reward_text.is_empty():
text += "\n전리: %s" % reward_text
return text
func _format_post_battle_choice_reward_text(choice: Dictionary) -> String:
var parts := []
var gold: int = maxi(0, int(choice.get("gold", 0)))
if gold > 0:
parts.append("+%d" % gold)
if typeof(choice.get("items", [])) == TYPE_ARRAY:
var items: Array = choice.get("items", [])
if not items.is_empty():
parts.append(_format_reward_items(items))
var joined_text := _format_officer_id_list(choice.get("join_officers", []))
if not joined_text.is_empty():
parts.append("합류 %s" % joined_text)
var left_text := _format_officer_id_list(choice.get("leave_officers", []))
if not left_text.is_empty():
parts.append("이탈 %s" % left_text)
return _join_strings(parts, ", ")
func _on_post_battle_choice_pressed(choice: Dictionary) -> void:
if battle_result_summary.is_empty() or bool(battle_result_summary.get("choice_applied", true)):
return
var scenario_id := str(battle_result_summary.get("scenario_id", state.battle_id))
if campaign_state.try_apply_post_battle_choice(choice, scenario_id):
_play_ui_confirm()
battle_result_summary["pending_choice"] = false
battle_result_summary["choice_applied"] = true
battle_result_summary["saved"] = true
battle_result_summary["choice_label"] = str(choice.get("label", choice.get("id", "")))
var choice_gold: int = maxi(0, int(choice.get("gold", 0)))
battle_result_summary["gold"] = int(battle_result_summary.get("gold", 0)) + choice_gold
_append_result_items(choice.get("items", []))
_append_unique_result_values("joined_officers", choice.get("join_officers", []))
_append_unique_result_values("left_officers", choice.get("leave_officers", []))
_on_log_added("군령 채택: %s." % battle_result_summary["choice_label"])
else:
_play_ui_cancel()
_on_log_added("군령을 봉하지 못했다.")
_rebuild_result_choices()
_update_result_next_button_visibility()
if result_label != null and state.battle_status == BattleState.STATUS_VICTORY:
result_label.text = _format_victory_result_text()
_update_hud()
func _append_result_items(values) -> void:
if typeof(values) != TYPE_ARRAY:
return
var current: Array = battle_result_summary.get("items", [])
for value in values:
var normalized := str(value)
if normalized.is_empty():
continue
current.append(normalized)
battle_result_summary["items"] = current
func _append_unique_result_values(key: String, values) -> void:
if typeof(values) != TYPE_ARRAY:
return
var current: Array = battle_result_summary.get(key, [])
for value in values:
var normalized := str(value)
if normalized.is_empty() or current.has(normalized):
continue
current.append(normalized)
battle_result_summary[key] = current
func _format_battle_result_summary() -> String:
if battle_result_summary.is_empty():
return ""
if battle_result_summary.get("pending_choice", false):
var pending_next := str(battle_result_summary.get("next_scenario_title", ""))
var pending_text := "전기\n 봉인됨\n군막\n 응답을 고르십시오."
if not pending_next.is_empty():
pending_text += "\n다음 행군\n %s" % pending_next
return pending_text
if battle_result_summary.get("already_completed", false):
var already_saved := "전기는 변하지 않았습니다." if battle_result_summary.get("saved", false) else "전기 회상에 실패했습니다."
var already_next := str(battle_result_summary.get("next_scenario_title", ""))
var replay_lines := [
"전리품\n 이미 수령",
"전기\n %s" % already_saved
]
if not already_next.is_empty():
replay_lines.append("다음 행군\n %s" % already_next)
var replay_preview := _format_next_camp_preview_text(str(battle_result_summary.get("next_scenario_id", "")))
if not replay_preview.is_empty():
replay_lines.append("다음 군막\n %s" % replay_preview)
return _join_strings(replay_lines, "\n")
var items: Array = battle_result_summary.get("items", [])
var item_text := _format_reward_items(items)
var reward_lines := []
if bool(battle_result_summary.get("rewards_applied", false)):
var gold_reward := int(battle_result_summary.get("gold", 0))
if gold_reward > 0:
reward_lines.append("군자금 +%d" % gold_reward)
if item_text != "없음":
reward_lines.append("물자: %s" % item_text)
else:
reward_lines.append("아직 기록되지 않음")
var roster_lines := []
var joined_officers: Array = battle_result_summary.get("joined_officers", [])
if not joined_officers.is_empty():
roster_lines.append("합류: %s" % _format_officer_id_list(joined_officers))
var left_officers: Array = battle_result_summary.get("left_officers", [])
if not left_officers.is_empty():
roster_lines.append("이탈: %s" % _format_officer_id_list(left_officers))
var sections := [
"전리품\n %s" % _join_strings(reward_lines, "\n ")
]
if not roster_lines.is_empty():
sections.append("명부\n %s" % _join_strings(roster_lines, "\n "))
var progression_text := _format_progression_events(battle_result_summary.get("progression_events", []))
if not progression_text.is_empty():
sections.append("전공\n %s" % progression_text)
var saved_text := "전기가 봉인되었습니다." if battle_result_summary.get("saved", false) else "전기 봉인 실패."
var next_text := "전기 완결."
var next_title := str(battle_result_summary.get("next_scenario_title", ""))
if not next_title.is_empty():
next_text = "다음 행군: %s" % next_title
sections.append("전기\n 군자금: %d\n %s\n %s" % [
campaign_state.gold,
saved_text,
next_text
])
if not bool(battle_result_summary.get("choice_applied", true)):
sections.append("군막\n 응답을 고르십시오.")
elif not str(battle_result_summary.get("choice_label", "")).is_empty():
sections.append("군막\n %s" % str(battle_result_summary.get("choice_label", "")))
var next_preview := _format_next_camp_preview_text(str(battle_result_summary.get("next_scenario_id", "")))
if not next_preview.is_empty() and bool(battle_result_summary.get("saved", false)):
sections.append("다음 군막\n %s" % next_preview)
return _join_strings(sections, "\n")
func _update_result_next_button_visibility() -> void:
if next_battle_button == null:
return
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))
)
func _format_next_camp_preview_text(next_scenario_id: String) -> String:
var scenario_id := next_scenario_id.strip_edges()
if scenario_id.is_empty():
return ""
var scenario_path := campaign_state.get_scenario_path(scenario_id)
if scenario_path.is_empty():
return ""
var preview_state := BattleStateScript.new()
if not preview_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()
):
return ""
var parts := []
var briefing := preview_state.get_briefing()
var location := str(briefing.get("location", "")).strip_edges()
if not location.is_empty():
parts.append(location)
var talk_count := _briefing_camp_conversation_count(briefing)
if talk_count > 0:
parts.append("군막 회의 · %d" % talk_count)
var shop_count := preview_state.get_shop_item_ids().size()
if shop_count > 0:
parts.append("군상 물품 · %d" % shop_count)
if preview_state.has_deployment_roster():
parts.append("출진 명부 · %d/%d" % [
preview_state.get_deployed_player_count(),
preview_state.get_deployment_max_units()
])
var formation_count := preview_state.get_formation_cells().size()
if formation_count > 0:
parts.append("진형 배치 %d" % formation_count)
return _join_strings(parts, " - ")
func _briefing_camp_conversation_count(briefing: Dictionary) -> int:
var conversations = briefing.get("camp_conversations", [])
if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty():
return conversations.size()
var lines = briefing.get("camp_dialogue", [])
if typeof(lines) == TYPE_ARRAY and not lines.is_empty():
return 1
return 0
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", "부대"))
if event_type == "promotion":
parts.append("%s에서 %s로 승급" % [unit_name, str(event.get("to_class", "승급"))])
elif event_type == "level_up":
parts.append("%s 품계 %d" % [unit_name, int(event.get("to_level", 1))])
if hidden_count > 0:
parts.append("%d" % hidden_count)
if parts.is_empty():
return ""
return _join_strings(parts, "; ")
func _format_inventory_status_text() -> String:
var inventory := state.get_inventory_snapshot()
if campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE:
inventory = campaign_state.get_inventory_snapshot()
return "군수: %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 "없음"
var parts := []
for item_id in item_ids:
var key := str(item_id)
parts.append("%s %d" % [_item_inventory_display_name(key), int(inventory.get(key, 0))])
return _join_strings(parts, ", ")
func _format_inventory_by_category(inventory: Dictionary) -> String:
var consumables := {}
var equipment := {}
var other := {}
for item_id in inventory.keys():
var key := str(item_id)
var count := int(inventory.get(key, 0))
if key.is_empty() or count <= 0:
continue
var item := state.get_item_def(key)
var kind := str(item.get("kind", ""))
if kind == "consumable":
consumables[key] = count
elif kind == "weapon" or kind == "armor" or kind == "accessory":
equipment[key] = count
else:
other[key] = count
var sections := []
if not consumables.is_empty():
sections.append("소모품: %s" % _format_inventory_items(consumables))
if not equipment.is_empty():
sections.append("병장: %s" % _format_inventory_items(equipment))
if not other.is_empty():
sections.append("잡물: %s" % _format_inventory_items(other))
if sections.is_empty():
return "없음"
return _join_strings(sections, " · ")
func _format_officer_id_list(values) -> String:
if typeof(values) != TYPE_ARRAY:
return ""
var labels := []
for value in values:
var label := _officer_display_name_from_id(str(value))
if label.is_empty():
continue
labels.append(label)
return _join_strings(labels, ", ")
func _officer_display_name_from_id(officer_id: String) -> String:
match officer_id:
"cao_cao":
return "조조"
"xiahou_dun":
return "하후돈"
"xiahou_yuan":
return "하후연"
"cao_ren":
return "조인"
"dian_wei":
return "전위"
"guo_jia":
return "곽가"
"zhang_he":
return "장합"
_:
return _format_identifier_label(officer_id)
func _format_identifier_label(value: String) -> String:
return value.strip_edges().replace("_", " ").capitalize()
func _item_display_name(item_id: String) -> String:
var localized_name := _localized_item_name(item_id)
if not localized_name.is_empty():
return localized_name
var item := state.get_item_def(item_id)
if item.is_empty():
return item_id
return str(item.get("name", item_id))
func _item_inventory_display_name(item_id: String) -> String:
var item := state.get_item_def(item_id)
if item.is_empty():
return item_id
var display_name := _item_display_name(item_id)
var rarity_text := _format_item_rarity_text(item)
if rarity_text.is_empty():
return display_name
return "%s [%s]" % [display_name, rarity_text]
func _localized_item_name(item_id: String) -> String:
match item_id:
"bronze_sword":
return "청동검"
"iron_sword":
return "철검"
"training_spear":
return "연습창"
"short_bow":
return "단궁"
"hand_axe":
return "손도끼"
"war_axe":
return "전부"
"war_drum":
return "전고"
"imperial_seal":
return "옥새"
"yitian_sword":
return "의천검"
"dragon_spear":
return "용담창"
"wind_chaser_bow":
return "추풍궁"
"black_tortoise_robe":
return "현무도포"
"bean":
return ""
"antidote":
return "해독약"
"panacea":
return "만능약"
"wine":
return ""
_:
return ""
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 = ""
_clear_pending_move_state()
_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("전기가 여기서 매듭지어졌다.\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 = "전기 봉인을 새로 열지 못했습니다.\n저장 권한을 살펴본 뒤 다시 명하십시오."
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 = ""
_clear_pending_move_state()
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 _show_post_move_menu() -> void:
if post_move_menu == null or not _has_pending_move():
return
_update_post_move_menu()
post_move_menu.visible = true
post_move_menu.move_to_front()
func _hide_post_move_menu() -> void:
if post_move_menu == null:
return
post_move_menu.visible = false
func _update_post_move_menu() -> void:
if post_move_menu == null:
return
if not _has_pending_move():
_hide_post_move_menu()
return
var selected := state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id or bool(selected.get("acted", false)):
_hide_post_move_menu()
return
_position_post_move_menu()
if post_move_title_label != null:
_set_fitted_label_text(
post_move_title_label,
"%s: 군령 선택" % str(selected.get("name", "부대")),
LOCAL_COMMAND_TITLE_FONT_SIZE,
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
POST_MOVE_TITLE_SIZE
)
if post_move_attack_button != null:
var attack_blocked := not _has_basic_attack_target(pending_move_unit_id)
post_move_attack_button.disabled = attack_blocked
post_move_attack_button.tooltip_text = "사거리 안에 적 부대가 없습니다." if attack_blocked else "이 자리에서 칠 적군을 지목합니다."
if post_move_tactic_button != null:
var skill_blocked := not _unit_has_usable_skill_target(pending_move_unit_id)
post_move_tactic_button.disabled = skill_blocked
post_move_tactic_button.tooltip_text = "이 자리에서 닿는 책략 대상이 없습니다." if skill_blocked else "군령에 올릴 책략 죽간을 고릅니다."
if post_move_item_button != null:
var item_blocked := not _unit_has_usable_item_target(pending_move_unit_id)
post_move_item_button.disabled = item_blocked
post_move_item_button.tooltip_text = "이 자리에서 닿는 보급 대상이 없습니다." if item_blocked else "군령에 올릴 보급 물자를 고릅니다."
if post_move_wait_button != null:
post_move_wait_button.disabled = false
post_move_wait_button.tooltip_text = "행군을 확정하고 군령을 봉인합니다."
if post_move_cancel_button != null:
post_move_cancel_button.disabled = false
post_move_cancel_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
func _local_command_panel_position_for_cell(cell: Vector2i, panel_size: Vector2) -> Vector2:
if not state.is_inside(cell):
return _board_origin() + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
var cell_rect := _rect_for_cell(cell)
var centered_y := cell_rect.position.y + (TILE_SIZE - panel_size.y) * 0.5
var centered_x := cell_rect.position.x + (TILE_SIZE - panel_size.x) * 0.5
var candidates: Array[Vector2] = [
Vector2(cell_rect.end.x + LOCAL_COMMAND_PANEL_GAP, centered_y),
Vector2(cell_rect.position.x - panel_size.x - LOCAL_COMMAND_PANEL_GAP, centered_y),
Vector2(centered_x, cell_rect.end.y + LOCAL_COMMAND_PANEL_GAP),
Vector2(centered_x, cell_rect.position.y - panel_size.y - LOCAL_COMMAND_PANEL_GAP)
]
var avoid_rect := cell_rect.grow(2.0)
var best_position := _clamped_local_command_panel_position(candidates[0], panel_size)
var best_score := 1000000000.0
for candidate in candidates:
var next_position := _clamped_local_command_panel_position(candidate, panel_size)
var panel_rect := Rect2(next_position, panel_size)
var score := next_position.distance_squared_to(candidate)
if panel_rect.intersects(avoid_rect):
score += 1000000.0
if score < best_score:
best_score = score
best_position = next_position
if score < 1.0 and not panel_rect.intersects(avoid_rect):
break
return best_position
func _clamped_local_command_panel_position(position: Vector2, panel_size: Vector2) -> Vector2:
var board_rect := _board_rect()
var view_rect := _map_view_rect()
var board_min := Vector2(
maxf(board_rect.position.x, view_rect.position.x),
maxf(board_rect.position.y, view_rect.position.y)
) + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
var board_max := Vector2(
minf(board_rect.end.x, view_rect.end.x),
minf(board_rect.end.y, view_rect.end.y)
) - panel_size - Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
board_max.x = maxf(board_min.x, board_max.x)
board_max.y = maxf(board_min.y, board_max.y)
return Vector2(
clampf(position.x, board_min.x, board_max.x),
clampf(position.y, board_min.y, board_max.y)
)
func _position_post_move_menu() -> void:
if post_move_menu == null:
return
post_move_menu.position = _local_command_panel_position_for_cell(pending_move_to_cell, POST_MOVE_MENU_SIZE)
func _show_post_move_picker(mode: String) -> void:
if post_move_picker_panel == null or not _has_pending_move():
return
var selected := state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id:
return
post_move_picker_mode = mode
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_menu()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_rebuild_post_move_picker(selected)
post_move_picker_panel.visible = true
post_move_picker_panel.move_to_front()
func _hide_post_move_picker() -> void:
if post_move_picker_panel == null:
return
post_move_picker_panel.visible = false
post_move_picker_mode = ""
func _update_post_move_picker() -> void:
if post_move_picker_panel == null or not post_move_picker_panel.visible:
return
if not _has_pending_move():
_hide_post_move_picker()
return
var selected := state.get_selected_unit()
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id or bool(selected.get("acted", false)):
_hide_post_move_picker()
return
_rebuild_post_move_picker(selected)
func _rebuild_post_move_picker(selected: Dictionary) -> void:
if post_move_picker_panel == null or post_move_picker_list == null:
return
_clear_post_move_picker_list()
_position_post_move_picker()
if post_move_picker_title_label != null:
_set_fitted_label_text(
post_move_picker_title_label,
_post_move_picker_title_text(selected),
LOCAL_COMMAND_TITLE_FONT_SIZE,
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
POST_MOVE_PICKER_TITLE_SIZE
)
if post_move_picker_detail_label != null:
_set_fitted_label_text(
post_move_picker_detail_label,
_post_move_picker_detail_text(),
LOCAL_COMMAND_DETAIL_FONT_SIZE,
LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE,
POST_MOVE_PICKER_DETAIL_SIZE
)
if post_move_picker_mode == "tactic":
_rebuild_post_move_tactic_picker(selected)
elif post_move_picker_mode == "item":
_rebuild_post_move_item_picker(selected)
else:
var empty_label := Label.new()
empty_label.text = "열린 군령이 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
post_move_picker_list.add_child(empty_label)
func _clear_post_move_picker_list() -> void:
if post_move_picker_list == null:
return
for child in post_move_picker_list.get_children():
post_move_picker_list.remove_child(child)
child.queue_free()
func _position_post_move_picker() -> void:
if post_move_picker_panel == null:
return
post_move_picker_panel.position = _local_command_panel_position_for_cell(pending_move_to_cell, POST_MOVE_PICKER_PANEL_SIZE)
func _post_move_picker_title_text(selected: Dictionary) -> String:
var unit_name := str(selected.get("name", "부대"))
if post_move_picker_mode == "tactic":
return "%s: 책략 죽간" % unit_name
if post_move_picker_mode == "item":
return "%s: 보급 물자" % unit_name
return "%s: 군령 죽간" % unit_name
func _post_move_picker_detail_text() -> String:
if post_move_picker_mode == "tactic":
return "책략을 고른 뒤 빛나는 표식 칸을 지목합니다."
if post_move_picker_mode == "item":
return "보급을 고른 뒤 표식이 뜬 아군을 지목합니다."
return "군령에 올릴 죽간을 고릅니다."
func _rebuild_post_move_tactic_picker(selected: Dictionary) -> void:
var skill_ids := state.get_skill_ids(selected["id"])
if skill_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "사용할 책략이 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
post_move_picker_list.add_child(empty_label)
return
for skill_id_value in skill_ids:
var skill_id := str(skill_id_value)
var skill := state.get_skill_def(skill_id)
var skill_button := Button.new()
skill_button.text = _format_post_move_tactic_button_text(skill_id, skill, selected)
var disabled_reason := _post_move_tactic_disabled_reason(selected, skill_id, skill)
skill_button.disabled = not disabled_reason.is_empty()
skill_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "이 책략 죽간을 군령에 올립니다."
_apply_button_style(skill_button)
skill_button.pressed.connect(_on_post_move_tactic_option_pressed.bind(skill_id))
post_move_picker_list.add_child(skill_button)
func _rebuild_post_move_item_picker(selected: Dictionary) -> void:
var item_ids := state.get_usable_item_ids()
if item_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "쓸 보급이 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
post_move_picker_list.add_child(empty_label)
return
var inventory := state.get_inventory_snapshot()
for item_id_value in item_ids:
var item_id := str(item_id_value)
var item := state.get_item_def(item_id)
var count := int(inventory.get(item_id, 0))
var item_button := Button.new()
item_button.text = _format_post_move_item_button_text(item_id, item, count)
_apply_item_button_icon(item_button, item)
var disabled_reason := _post_move_item_disabled_reason(selected, item_id, count)
item_button.disabled = not disabled_reason.is_empty()
item_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "이 보급을 군령에 올립니다."
_apply_button_style(item_button)
item_button.pressed.connect(_on_post_move_item_option_pressed.bind(item_id))
post_move_picker_list.add_child(item_button)
func _format_post_move_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 := _skill_kind_text(str(skill.get("kind", "skill")))
var range_text := _format_tactic_range_text(skill)
var disabled_text := ""
if state.is_unit_skill_sealed(selected["id"]):
disabled_text = " - 봉인"
elif int(selected.get("mp", 0)) < mp_cost:
disabled_text = " - 기력"
elif not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
disabled_text = " - 표식 없음"
return "%s%s 기력 %d %s 사거리 %s%s" % [marker, skill_name, mp_cost, kind, range_text, disabled_text]
func _format_post_move_item_button_text(item_id: String, item: Dictionary, count: int) -> String:
var marker := "> " if selected_item_id == item_id else ""
var item_name := _item_display_name(item_id)
return "%s%s 잔량 %d %s" % [marker, item_name, count, _format_post_move_item_effect_text(item)]
func _format_post_move_item_effect_text(item: Dictionary) -> String:
var has_cure := false
for effect in item.get("effects", []):
if typeof(effect) != TYPE_DICTIONARY:
continue
var effect_type := str(effect.get("type", ""))
if effect_type == "heal_hp":
return "병력 +%d" % int(effect.get("amount", 0))
if effect_type == "heal_mp":
return "기력 +%d" % int(effect.get("amount", 0))
if effect_type == "cure_status" or effect_type == "cleanse_debuffs":
has_cure = true
if has_cure:
return "치료"
return _item_kind_text(str(item.get("kind", "item")))
func _post_move_tactic_disabled_reason(selected: Dictionary, skill_id: String, skill: Dictionary) -> String:
if skill.is_empty():
return "알 수 없는 책략입니다."
if state.is_unit_skill_sealed(selected["id"]):
return "이 군기는 책략이 봉인되었습니다."
if bool(selected.get("acted", false)):
return "이 군기는 이미 군령을 마쳤습니다."
if int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)):
return "기력이 부족합니다."
if not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
return "이 자리에서 지목할 대상이 없습니다."
return ""
func _post_move_item_disabled_reason(selected: Dictionary, item_id: String, count: int) -> String:
if count <= 0:
return "남은 보급이 없습니다."
if not _item_has_valid_target(str(selected.get("id", "")), item_id):
return "이 자리에서 지목할 대상이 없습니다."
return ""
func _on_post_move_tactic_option_pressed(skill_id: String) -> void:
_hide_post_move_picker()
_on_tactic_skill_pressed(skill_id)
func _on_post_move_item_option_pressed(item_id: String) -> void:
_hide_post_move_picker()
_on_item_selected_pressed(item_id)
func _on_post_move_picker_back_pressed() -> void:
_play_ui_cancel()
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_post_move_picker()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_picker_cancel_move_pressed() -> void:
_cancel_pending_move()
func _on_post_move_attack_pressed() -> void:
if not _has_pending_move():
return
if not _has_basic_attack_target(pending_move_unit_id):
_play_ui_cancel()
return
_play_ui_click()
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = true
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
_hide_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_tactic_pressed() -> void:
if not _has_pending_move():
return
if not _unit_has_usable_skill_target(pending_move_unit_id):
_play_ui_cancel()
return
_play_ui_click()
_show_post_move_picker("tactic")
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_item_pressed() -> void:
if not _has_pending_move():
return
if not _unit_has_usable_item_target(pending_move_unit_id):
_play_ui_cancel()
return
_play_ui_click()
_show_post_move_picker("item")
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_post_move_wait_pressed() -> void:
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_picker()
if _commit_pending_move_before_action() and state.try_wait_selected():
_play_ui_confirm()
func _on_post_move_cancel_pressed() -> void:
_cancel_pending_move()
func _is_targeting_mode() -> bool:
return basic_attack_targeting or not selected_skill_id.is_empty() or not selected_item_id.is_empty()
func _show_targeting_hint_panel() -> void:
if targeting_hint_panel == null:
return
_update_targeting_hint_panel()
if targeting_hint_panel != null and _is_targeting_mode():
targeting_hint_panel.visible = true
targeting_hint_panel.move_to_front()
func _hide_targeting_hint_panel() -> void:
if targeting_hint_panel == null:
return
targeting_hint_panel.visible = false
func _update_targeting_hint_panel() -> void:
if targeting_hint_panel == null:
return
if not _is_targeting_mode():
_hide_targeting_hint_panel()
return
var selected := state.get_selected_unit()
if selected.is_empty() or bool(selected.get("acted", false)) or _is_input_locked():
_hide_targeting_hint_panel()
return
_position_targeting_hint_panel(selected)
if targeting_hint_title_label != null:
_set_fitted_label_text(
targeting_hint_title_label,
_targeting_hint_title_text(),
LOCAL_COMMAND_TITLE_FONT_SIZE,
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
TARGETING_HINT_TITLE_SIZE
)
if targeting_hint_detail_label != null:
_set_fitted_label_text(
targeting_hint_detail_label,
_targeting_hint_detail_text(),
LOCAL_COMMAND_DETAIL_FONT_SIZE,
LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE,
TARGETING_HINT_DETAIL_SIZE
)
if targeting_hint_back_button != null:
targeting_hint_back_button.text = "죽간 거두기"
targeting_hint_back_button.tooltip_text = "군령 선택으로 되돌립니다." if _has_pending_move() else "지목을 거둡니다."
if targeting_hint_cancel_move_button != null:
targeting_hint_cancel_move_button.visible = _has_pending_move()
targeting_hint_cancel_move_button.disabled = not _has_pending_move()
targeting_hint_cancel_move_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
targeting_hint_panel.visible = true
func _position_targeting_hint_panel(selected: Dictionary) -> void:
if targeting_hint_panel == null:
return
var cell: Vector2i = selected.get("pos", Vector2i(-1, -1))
if not state.is_inside(cell):
return
targeting_hint_panel.position = _local_command_panel_position_for_cell(cell, TARGETING_HINT_PANEL_SIZE)
func _targeting_hint_title_text() -> String:
if basic_attack_targeting:
return "적장 지목"
if not selected_skill_id.is_empty():
var skill := state.get_skill_def(selected_skill_id)
return "지목: %s" % str(skill.get("name", selected_skill_id))
if not selected_item_id.is_empty():
var item := state.get_item_def(selected_item_id)
return "지목: %s" % str(item.get("name", selected_item_id))
return "대상 지목"
func _targeting_hint_detail_text() -> String:
if basic_attack_targeting:
var markers := _attack_target_marker_entries()
if markers.is_empty():
return "사거리 안에 적 부대가 없습니다."
return "적 표식 %d. 표시된 적을 공격하십시오." % markers.size()
if not selected_skill_id.is_empty():
var markers := _skill_target_marker_entries()
if markers.is_empty():
return "책략을 쓸 대상이 없습니다."
return "책략 표식 %d. 표시된 지점을 고르십시오." % markers.size()
if not selected_item_id.is_empty():
var markers := _item_target_marker_entries()
if markers.is_empty():
return "도구를 쓸 대상이 없습니다."
return "도구 표식 %d. 표시된 부대를 고르십시오." % markers.size()
return "표시된 대상을 지목합니다."
func _on_targeting_back_pressed() -> void:
_play_ui_cancel()
selected_skill_id = ""
selected_item_id = ""
basic_attack_targeting = false
_hide_targeting_hint_panel()
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_targeting_cancel_move_pressed() -> void:
_cancel_pending_move()
func _set_action_button_state(button: Button, label: String, disabled: bool, tooltip := "") -> void:
if button == null:
return
button.text = label
button.disabled = disabled
button.tooltip_text = tooltip
func _set_action_button_blocked(button: Button, base_label: String, reason_label: String, tooltip: String) -> void:
_set_action_button_state(button, "%s%s" % [base_label, reason_label], true, tooltip)
func _action_phase_block_reason() -> Dictionary:
if campaign_complete_screen:
return {
"label": "전기 봉인",
"tooltip": "전기가 완결되었습니다."
}
if not battle_started:
return {
"label": "전장도 미개봉",
"tooltip": "전장도를 펼친 뒤 군령을 내릴 수 있습니다."
}
if state.battle_status != BattleState.STATUS_ACTIVE:
return {
"label": "전장 종결",
"tooltip": "전투가 끝난 뒤에는 군령을 낼 수 없습니다."
}
if state.current_team != BattleState.TEAM_PLAYER:
return {
"label": "적군 차례",
"tooltip": "아군 군령 차례를 기다립니다."
}
if _is_input_locked():
return {
"label": "처리 중",
"tooltip": "현재 대화, 결과, 움직임을 먼저 마칩니다."
}
return {}
func _update_wait_button(selected: Dictionary) -> void:
if wait_button == null:
return
if selected.is_empty():
_set_action_button_blocked(wait_button, "대기령", "부대 미지정", "명령할 부대를 먼저 고릅니다.")
return
if bool(selected.get("acted", false)):
_set_action_button_blocked(wait_button, "대기령", "봉인됨", "이 부대는 이미 행동했습니다.")
return
var phase_block := _action_phase_block_reason()
if not phase_block.is_empty():
_set_action_button_blocked(wait_button, "대기령", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
return
_set_action_button_state(wait_button, "대기령", false, "이 부대의 군령을 봉인합니다.")
func _update_end_turn_button() -> void:
if end_turn_button == null:
return
var phase_block := _action_phase_block_reason()
if not phase_block.is_empty():
_set_action_button_blocked(end_turn_button, "군령 봉함", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
return
_set_action_button_state(end_turn_button, "군령 봉함", false, "아군 군령을 거두고 적군의 차례로 넘깁니다.")
func _update_threat_button() -> void:
if threat_button == null:
return
threat_button.button_pressed = show_threat_overlay
if campaign_complete_screen:
_set_action_button_blocked(threat_button, "적진도", "전기 봉인", "전기가 완결되었습니다.")
return
if not battle_started:
_set_action_button_blocked(threat_button, "적진도", "전장도 미개봉", "전장도를 펼친 뒤 적진을 살필 수 있습니다.")
return
if state.battle_status != BattleState.STATUS_ACTIVE:
_set_action_button_blocked(threat_button, "적진도", "전장 종결", "전투 중에만 적진을 살필 수 있습니다.")
return
_set_action_button_state(threat_button, "적진도", false, "적의 무기와 책략 사거리를 살핍니다.")
func _update_tactic_button(selected: Dictionary) -> void:
if tactic_button == null:
return
if selected.is_empty():
_set_action_button_blocked(tactic_button, "책략첩", "부대 미지정", "책략을 쓸 부대를 먼저 고릅니다.")
return
var skill_ids := state.get_skill_ids(selected["id"])
if skill_ids.is_empty():
_set_action_button_blocked(tactic_button, "책략첩", "없음", "이 부대는 사용할 책략이 없습니다.")
return
if state.is_unit_skill_sealed(selected["id"]):
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 책략을 쓸 수 없습니다.")
return
if bool(selected.get("acted", false)):
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 이미 행동했습니다.")
return
var phase_block := _action_phase_block_reason()
if not phase_block.is_empty():
_set_action_button_blocked(tactic_button, "책략첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
return
if not selected_skill_id.is_empty():
var skill := state.get_skill_def(selected_skill_id)
_set_action_button_state(tactic_button, "책략첩|%s" % str(skill.get("name", selected_skill_id)), false, "전장에 책략 대상을 지목합니다.")
elif tactic_menu != null and tactic_menu.visible:
_set_action_button_state(tactic_button, "책략첩 봉함", false, "책략 목록을 닫습니다.")
else:
_set_action_button_state(tactic_button, "책략첩", false, "책략 목록을 펼칩니다. %d개." % skill_ids.size())
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 = "사용할 책략이 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
tactic_list.add_child(empty_label)
return
var skill_sealed := state.is_unit_skill_sealed(selected["id"])
if skill_sealed:
var sealed_label := Label.new()
sealed_label.text = "책략 봉인"
_apply_label_style(sealed_label, UI_PARCHMENT_TEXT)
tactic_list.add_child(sealed_label)
for skill_id in skill_ids:
var skill := state.get_skill_def(str(skill_id))
var skill_button := Button.new()
skill_button.text = _format_tactic_button_text(str(skill_id), skill, selected)
skill_button.disabled = skill_sealed or selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0))
_apply_button_style(skill_button)
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 = "죽간 거두기"
_apply_button_style(cancel_button)
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 := _skill_kind_text(str(skill.get("kind", "skill")))
var range_text := _format_tactic_range_text(skill)
var effect_text := _format_tactic_effect_text(skill)
var disabled_text := ""
if state.is_unit_skill_sealed(selected["id"]):
disabled_text = " - 봉인"
elif int(selected.get("mp", 0)) < mp_cost:
disabled_text = " - 기력"
return "%s%s 기력 %d %s 사거리 %s %s%s" % [marker, skill_name, mp_cost, kind, range_text, effect_text, disabled_text]
func _format_tactic_effect_text(skill: Dictionary) -> String:
var area_text := _format_tactic_area_text(skill)
if str(skill.get("kind", "")) == "support":
var parts := []
for effect in skill.get("effects", []):
if typeof(effect) != TYPE_DICTIONARY:
continue
var effect_type := str(effect.get("type", ""))
var amount := int(effect.get("amount", 0))
if effect_type == "stat_bonus":
if amount == 0:
continue
var sign := "+" if amount > 0 else ""
parts.append("%s %s%d" % [_battle_stat_display_name(str(effect.get("stat", ""))), sign, amount])
elif effect_type == "damage_over_time" and amount > 0:
var status_name := _battle_status_display_name(str(effect.get("status", "status")))
parts.append("%s -%d" % [status_name, amount])
elif effect_type == "action_lock":
parts.append("%s %s" % [
_battle_status_display_name(str(effect.get("status", "status"))),
_action_lock_effect_label(str(effect.get("action", "")))
])
if not parts.is_empty():
var support_text := _join_strings(parts, ", ")
return "%s, %s" % [support_text, area_text] if not area_text.is_empty() else support_text
var power_text := "위력 %d" % int(skill.get("power", 0))
return "%s, %s" % [power_text, area_text] if not area_text.is_empty() else power_text
func _format_tactic_area_text(skill: Dictionary) -> String:
var shape := str(skill.get("area_shape", "single"))
var radius := int(skill.get("area_radius", 0))
if shape == "diamond" and radius > 0:
return "범위 %d" % radius
return ""
func _format_tactic_range_text(skill: Dictionary) -> String:
var value = skill.get("range", 1)
if typeof(value) == TYPE_ARRAY:
if value.size() >= 2:
return "%d-%d" % [int(value[0]), int(value[1])]
if value.size() == 1:
return "%d" % int(value[0])
return "1"
if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
return "%d" % int(value)
return "1"
func _action_lock_effect_label(action: String) -> String:
if action == "skill":
return "책략 봉인"
if action == "move":
return "행군 봉인"
if action == "attack":
return "타격 봉인"
return "군령 봉인"
func _battle_stat_display_name(stat: String) -> String:
var normalized := stat.strip_edges().to_lower()
if normalized == "hp":
return "병력"
if normalized == "mp":
return "기력"
if normalized == "atk":
return "무력"
if normalized == "def":
return "방비"
if normalized == "int":
return "지략"
if normalized == "agi":
return "순발"
if normalized == "move":
return "행군"
return _format_identifier_label(stat)
func _battle_status_display_name(status: String) -> String:
var normalized := status.strip_edges().to_lower()
if normalized == "poison":
return ""
if normalized == "seal":
return "봉인"
if normalized == "snare":
return "발묶임"
if normalized == "disarm":
return "무장해제"
if normalized == "debuff":
return "약화"
if normalized.is_empty():
return "상태"
return _format_identifier_label(status)
func _skill_kind_text(kind: String) -> String:
var normalized := kind.strip_edges().to_lower()
if normalized == "damage":
return "공격책"
if normalized == "heal":
return "회복책"
if normalized == "support":
return "보조책"
if normalized == "skill":
return "책략"
return kind.capitalize()
func _on_tactic_skill_pressed(skill_id: String) -> void:
_play_ui_confirm()
selected_skill_id = skill_id
selected_item_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
_hide_item_menu()
_hide_equip_menu()
_hide_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_tactic_cancel_pressed() -> void:
_play_ui_cancel()
selected_skill_id = ""
basic_attack_targeting = false
_hide_tactic_menu()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _update_item_button(selected: Dictionary) -> void:
if item_button == null:
return
if selected.is_empty():
_set_action_button_blocked(item_button, "보급첩", "부대 미지정", "도구를 쓸 부대를 먼저 고릅니다.")
return
if bool(selected.get("acted", false)):
_set_action_button_blocked(item_button, "보급첩", "봉인됨", "이 부대는 이미 행동했습니다.")
return
var phase_block := _action_phase_block_reason()
if not phase_block.is_empty():
_set_action_button_blocked(item_button, "보급첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
return
var item_ids := state.get_usable_item_ids()
if item_ids.is_empty():
_set_action_button_blocked(item_button, "보급첩", "비어 있음", "사용할 도구가 없습니다.")
return
if not selected_item_id.is_empty():
var item := state.get_item_def(selected_item_id)
_set_action_button_state(item_button, "보급첩|%s" % str(item.get("name", selected_item_id)), false, "전장에서 도구 대상을 지목합니다.")
elif item_menu != null and item_menu.visible:
_set_action_button_state(item_button, "보급첩 봉함", false, "도구 목록을 닫습니다.")
else:
_set_action_button_state(item_button, "보급첩", false, "도구 목록을 펼칩니다. %d종." % item_ids.size())
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 = "사용할 도구가 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
item_list.add_child(empty_label)
return
var inventory := state.get_inventory_snapshot()
for item_id in item_ids:
var normalized_id := str(item_id)
var item := state.get_item_def(normalized_id)
var count := int(inventory.get(normalized_id, 0))
var use_button := Button.new()
use_button.text = _format_item_button_text(normalized_id, item, count)
_apply_item_button_icon(use_button, item)
use_button.disabled = selected.get("acted", false) or _is_input_locked() or count <= 0
_apply_button_style(use_button)
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 = "죽간 거두기"
_apply_button_style(cancel_button)
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 := _item_display_name(item_id)
return "%s%s 잔량 %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("병력 +%d" % int(effect.get("amount", 0)))
elif str(effect.get("type", "")) == "heal_mp":
parts.append("기력 +%d" % int(effect.get("amount", 0)))
elif str(effect.get("type", "")) == "cure_status":
parts.append("치료 %s" % _battle_status_display_name(str(effect.get("status", "status"))))
elif str(effect.get("type", "")) == "cleanse_debuffs":
parts.append("약화 해소")
if parts.is_empty():
return _item_kind_text(str(item.get("kind", "item")))
return _join_strings(parts, ", ")
func _on_item_selected_pressed(item_id: String) -> void:
_play_ui_confirm()
selected_item_id = item_id
selected_skill_id = ""
basic_attack_targeting = false
_hide_item_menu()
_hide_tactic_menu()
_hide_equip_menu()
_hide_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_item_cancel_pressed() -> void:
_play_ui_cancel()
selected_item_id = ""
basic_attack_targeting = false
_hide_item_menu()
if _has_pending_move():
_show_post_move_menu()
_refresh_ranges()
_update_hud()
queue_redraw()
func _update_equip_button(selected: Dictionary) -> void:
if equip_button == null:
return
if selected.is_empty():
_set_action_button_blocked(equip_button, "병장", "부대 미지정", "병장을 바꿀 부대를 먼저 고릅니다.")
return
if bool(selected.get("acted", false)):
_set_action_button_blocked(equip_button, "병장", "봉인됨", "이 부대는 이미 행동했습니다.")
return
if bool(selected.get("moved", false)):
_set_action_button_blocked(equip_button, "병장", "행군 후", "병장은 행군 전에만 바꿀 수 있습니다.")
return
var phase_block := _action_phase_block_reason()
if not phase_block.is_empty():
_set_action_button_blocked(equip_button, "병장", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
return
if equip_menu != null and equip_menu.visible:
_set_action_button_state(equip_button, "병장 닫기", false, "병장 장부를 닫습니다.")
else:
_set_action_button_state(equip_button, "병장", false, "행군 전에 무기, 갑옷, 장신구를 바꿉니다.")
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 = "소지 병장: %s" % _format_unit_equipment(selected)
_apply_label_style(equipment_label, UI_PARCHMENT_TEXT)
equip_list.add_child(equipment_label)
var equipped_slots := state.get_equipped_equipment_slots(str(selected["id"]))
if not equipped_slots.is_empty():
var unequip_title := Label.new()
unequip_title.text = "병장 해제"
_apply_label_style(unequip_title, UI_OLD_BRONZE, 14)
equip_list.add_child(unequip_title)
var equipment := state.get_equipment_snapshot(str(selected["id"]))
for slot in equipped_slots:
var normalized_slot := str(slot)
var item_id := _equipment_item_id(equipment, normalized_slot)
var item := state.get_item_def(item_id)
var unequip_button := Button.new()
unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
_apply_item_button_icon(unequip_button, item)
unequip_button.disabled = _is_input_locked()
_apply_button_style(unequip_button)
unequip_button.pressed.connect(_on_equip_unequip_pressed.bind(normalized_slot))
equip_list.add_child(unequip_button)
var item_ids := state.get_equippable_item_ids(selected["id"])
if item_ids.is_empty():
var empty_label := Label.new()
empty_label.text = "맞는 병장이 없습니다"
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
equip_list.add_child(empty_label)
else:
var inventory := state.get_inventory_snapshot()
for item_id in item_ids:
var normalized_id := str(item_id)
var item := state.get_item_def(normalized_id)
var equip_item_button := Button.new()
equip_item_button.text = _format_equipment_option_text(
selected,
normalized_id,
item,
int(inventory.get(normalized_id, 0))
)
_apply_item_button_icon(equip_item_button, item)
equip_item_button.disabled = _is_input_locked()
_apply_button_style(equip_item_button)
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 = "죽간 거두기"
_apply_button_style(cancel_button)
cancel_button.pressed.connect(_on_equip_cancel_pressed)
equip_list.add_child(cancel_button)
func _clear_equip_list() -> void:
for child in equip_list.get_children():
equip_list.remove_child(child)
child.queue_free()
func _format_unit_equipment(unit: Dictionary) -> String:
var equipment := state.get_equipment_snapshot(unit["id"])
var parts := []
for slot in ["weapon", "armor", "accessory"]:
var item_id := _equipment_item_id(equipment, slot)
if item_id.is_empty():
parts.append("%s -" % _equipment_slot_display_name(slot))
else:
parts.append("%s %s" % [_equipment_slot_display_name(slot), _item_display_name(item_id)])
return _join_strings(parts, ", ")
func _format_hover_unit_equipment(unit: Dictionary) -> String:
var equipment := state.get_equipment_snapshot(unit["id"])
var parts := []
for slot in ["weapon", "armor", "accessory"]:
var item_id := _equipment_item_id(equipment, slot)
if item_id.is_empty():
parts.append("-")
else:
parts.append(_item_display_name(item_id))
return _join_strings(parts, " / ")
func _equipment_item_id(equipment: Dictionary, slot: String) -> String:
var item_id = equipment.get(slot, "")
if item_id == null:
return ""
return str(item_id)
func _format_equipment_option_text(unit: Dictionary, item_id: String, item: Dictionary, count: int) -> String:
var parts := [
_item_display_name(item_id),
"보유 %d" % count,
_format_equipment_bonus_text(item)
]
var change_text := _format_equipment_change_text(unit, item)
if not change_text.is_empty():
parts.append(change_text)
return _join_strings(parts, " ")
func _format_equipment_unequip_text(unit: Dictionary, slot: String, item_id: String, item: Dictionary) -> String:
var parts := [
"%s 해제:" % _equipment_slot_display_name(slot),
_item_display_name(item_id)
]
var change_text := _format_equipment_unequip_change_text(unit, slot, item)
if not change_text.is_empty():
parts.append(change_text)
return _join_strings(parts, " ")
func _format_equipment_unequip_change_text(unit: Dictionary, slot: String, item: Dictionary) -> String:
if unit.is_empty() or item.is_empty():
return ""
var parts := []
var current_bonuses := _equipment_bonus_map(item)
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
var delta := -int(current_bonuses.get(stat, 0))
if delta == 0:
continue
parts.append("%s %d" % [_battle_stat_display_name(stat), delta])
if slot == "weapon":
var current_range := _format_equipment_item_range_text(item)
if not current_range.is_empty():
parts.append("사거리 기본값")
var effectiveness_text := _format_equipment_effectiveness_text(item)
if not effectiveness_text.is_empty():
parts.append("%s 상실" % effectiveness_text)
if parts.is_empty():
return "군수고로 반환"
return "변화 %s" % _join_strings(parts, ", ")
func _format_equipment_change_text(unit: Dictionary, item: Dictionary) -> String:
if unit.is_empty() or item.is_empty():
return ""
var slot := _equipment_slot_for_display(item)
if slot.is_empty():
return ""
var equipment := state.get_equipment_snapshot(str(unit.get("id", "")))
var current_item_id := _equipment_item_id(equipment, slot)
var current_item := state.get_item_def(current_item_id)
var parts := []
var candidate_bonuses := _equipment_bonus_map(item)
var current_bonuses := _equipment_bonus_map(current_item)
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
var delta := int(candidate_bonuses.get(stat, 0)) - int(current_bonuses.get(stat, 0))
if delta == 0:
continue
var sign := "+" if delta > 0 else ""
parts.append("%s %s%d" % [_battle_stat_display_name(stat), sign, delta])
var range_text := _format_equipment_range_change_text(current_item, item)
if not range_text.is_empty():
parts.append(range_text)
var effectiveness_text := _format_equipment_effectiveness_change_text(current_item, item)
if not effectiveness_text.is_empty():
parts.append(effectiveness_text)
if parts.is_empty():
return "교체 없음"
return "교체 %s" % _join_strings(parts, ", ")
func _equipment_slot_for_display(item: Dictionary) -> String:
var kind := str(item.get("kind", ""))
if kind == "weapon" or kind == "armor" or kind == "accessory":
return kind
return ""
func _equipment_slot_display_name(slot: String) -> String:
var normalized := slot.strip_edges().to_lower()
if normalized == "weapon":
return "병장"
if normalized == "armor":
return "갑주"
if normalized == "accessory":
return "보물"
return _format_identifier_label(slot)
func _equipment_bonus_map(item: Dictionary) -> Dictionary:
if item.is_empty() or typeof(item.get("bonuses", {})) != TYPE_DICTIONARY:
return {}
return item.get("bonuses", {})
func _format_equipment_range_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
if str(candidate_item.get("kind", "")) != "weapon":
return ""
var candidate_range := _format_equipment_item_range_text(candidate_item)
var current_range := _format_equipment_item_range_text(current_item)
if candidate_range.is_empty() or candidate_range == current_range:
return ""
if current_range.is_empty():
current_range = "-"
return "사거리 %s에서 %s" % [current_range, candidate_range]
func _format_equipment_item_range_text(item: Dictionary) -> String:
if item.is_empty() or not item.has("range"):
return ""
var value = item.get("range", 1)
if typeof(value) == TYPE_ARRAY:
if value.size() >= 2:
return "%d-%d" % [int(value[0]), int(value[1])]
if value.size() == 1:
return "%d" % int(value[0])
return ""
if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
return "%d" % int(value)
return ""
func _format_equipment_effectiveness_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
if str(candidate_item.get("kind", "")) != "weapon":
return ""
var candidate_text := _format_equipment_effectiveness_text(candidate_item)
var current_text := _format_equipment_effectiveness_text(current_item)
if candidate_text == current_text:
return ""
if current_text.is_empty():
return "특효 획득 %s" % candidate_text
if candidate_text.is_empty():
return "특효 상실 %s" % current_text
return "특효 변화"
func _format_equipment_bonus_text(item: Dictionary) -> String:
var parts := []
var rarity_text := _format_equipment_rarity_text(item)
if not rarity_text.is_empty():
parts.append(rarity_text)
var bonuses: Dictionary = item.get("bonuses", {})
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
var amount := int(bonuses.get(stat, 0))
if amount == 0:
continue
var sign := "+" if amount > 0 else ""
parts.append("%s %s%d" % [_battle_stat_display_name(stat), sign, amount])
var effective_text := _format_equipment_effectiveness_text(item)
if not effective_text.is_empty():
parts.append(effective_text)
if parts.is_empty():
return _item_kind_text(str(item.get("kind", "item")))
return _join_strings(parts, ", ")
func _format_equipment_rarity_text(item: Dictionary) -> String:
return _format_item_rarity_text(item)
func _format_item_rarity_text(item: Dictionary) -> String:
var rarity := str(item.get("rarity", ""))
if rarity == "named":
return "명품"
return ""
func _format_equipment_effectiveness_text(item: Dictionary) -> String:
if str(item.get("kind", "")) != "weapon":
return ""
var bonus := int(item.get("effective_bonus_damage", 0))
if bonus <= 0:
return ""
var move_types = item.get("effective_vs_move_types", [])
if typeof(move_types) != TYPE_ARRAY or move_types.is_empty():
return ""
var labels := []
for move_type in move_types:
var label := _format_move_type(str(move_type))
if not label.is_empty():
labels.append(label)
if labels.is_empty():
return ""
return "%s 특효 +%d" % [_join_strings(labels, "/"), bonus]
func _format_move_type(move_type: String) -> String:
var normalized := move_type.strip_edges().to_lower()
if normalized == "foot":
return "보병"
if normalized == "cavalry" or normalized == "mounted":
return "기병"
if normalized == "archer":
return "궁병"
if normalized == "water":
return "수군"
if normalized == "flying":
return "비행"
return move_type.strip_edges().replace("_", " ").capitalize()
func _terrain_display_name(terrain_name: String) -> String:
var normalized := terrain_name.strip_edges().to_lower()
if normalized == "plain":
return "평지"
if normalized == "forest":
return "수풀"
if normalized == "hill":
return "산지"
if normalized == "water":
return "하천"
if normalized == "road":
return ""
if normalized == "castle":
return "성채"
if normalized == "town" or normalized == "village":
return "마을"
return terrain_name
func _on_equip_item_pressed(item_id: String) -> void:
var selected := state.get_selected_unit()
if selected.is_empty():
return
if state.try_equip_item(selected["id"], item_id):
_rebuild_equip_menu(state.get_selected_unit())
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_equip_unequip_pressed(slot: String) -> void:
var selected := state.get_selected_unit()
if selected.is_empty():
return
if state.try_unequip_item(selected["id"], slot):
_rebuild_equip_menu(state.get_selected_unit())
_refresh_ranges()
_update_hud()
queue_redraw()
func _on_equip_cancel_pressed() -> void:
_play_ui_cancel()
_hide_equip_menu()
_update_hud()
queue_redraw()