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 TITLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png" const TITLE_PORTRAIT_PATH := "res://art/portraits/cao_cao.png" const SETTINGS_PATH := "user://heros_settings.json" const SETTINGS_SAVE_VERSION := 1 const OPENING_PROLOGUE_PAGES := [ { "title": "낙양의 그늘", "body": "젊은 조조는 재주와 결단으로 이름을 얻었지만, 조정 안팎의 권세와 부패를 가까이 보며 법만으로는 세상을 붙들기 어렵다는 것을 알았다." }, { "title": "하후돈과 첫 군세", "body": "가문의 친척 하후돈은 먼저 창을 들고 조조의 길에 섰다. 두 사람은 큰 명성보다 무너지는 고을을 구하는 첫 군령을 택했다." }, { "title": "영천으로", "body": "황건의 깃발이 영천 들판에 섰다. 마을과 성채를 기대어 버티는 잔당을 꺾고, 백성이 빠져나갈 길을 열어야 한다." } ] 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 AUDIO_MUTED_DB := -80.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 MINIMAP_SIZE := Vector2(178, 134) const MINIMAP_PADDING := 8.0 const MINIMAP_TITLE_HEIGHT := 18.0 const MINIMAP_MARGIN := Vector2(18, 18) const MAP_FOCUS_MARGIN := 96.0 const BATTLE_PRESENTATION_STEP_GAP := 0.08 const BATTLE_PRESENTATION_FEEDBACK_LAG := 0.10 const UNIT_MOVE_ANIMATION_DURATION := 0.18 const UNIT_IDLE_PHASE_SPEED := 2.4 const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22) const HOVER_INFO_BADGE_MAX_WIDTH := 196.0 const HOVER_INFO_BADGE_MIN_WIDTH := 126.0 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_MAP_SPRITE_TARGET_SIZE := Vector2(46, 48) 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 RESULT_REWARD_ICON_SIZE := Vector2(48, 48) const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104) const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100) const HUD_UNIT_INFO_SIZE := Vector2(320, 126) const HUD_UNIT_SUMMARY_SIZE := Vector2(320, 42) const HUD_UNIT_BADGE_ROW_SIZE := Vector2(320, 22) const HUD_UNIT_BADGE_SIZE := Vector2(26, 22) const HUD_UNIT_BAR_SIZE := Vector2(252, 12) const TOP_HUD_CHIP_ICON_SIZE := Vector2(28, 28) const SIDE_INFO_CHIP_ICON_SIZE := Vector2(26, 26) const HUD_COMMAND_GRID_SIZE := Vector2(420, 46) const HUD_COMMAND_BUTTON_SIZE := Vector2(72, 42) const HUD_COMMAND_GRID_COLUMNS := 4 const TOP_TOOL_BUTTON_SIZE := Vector2(42, 42) const BATTLE_UNIT_LIST_PANEL_SIZE := Vector2(420, 360) const BATTLE_UNIT_LIST_ROW_SIZE := Vector2(372, 42) const SETTINGS_PANEL_SIZE := Vector2(420, 286) const LOCAL_COMMAND_BUTTON_SIZE := Vector2(92, 38) const LOCAL_COMMAND_CANCEL_BUTTON_SIZE := Vector2(196, 30) 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 := true 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 top_status_chip: PanelContainer var top_objective_chip: PanelContainer var top_campaign_chip: PanelContainer 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 hud_unit_info_column: VBoxContainer var hud_unit_badge_row: HBoxContainer var hud_unit_hp_bar: ProgressBar var hud_unit_mp_bar: ProgressBar var selected_label: Label var cell_info_panel: PanelContainer var cell_info_icon: TextureRect var cell_info_label: Label var forecast_panel: PanelContainer var forecast_icon: TextureRect 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_reward_strip: HBoxContainer var result_label: Label var result_text_scroll: ScrollContainer var result_seal_ribbon: HBoxContainer var result_choice_list: VBoxContainer var result_restart_button: Button var next_battle_button: Button var auto_end_turn_prompt_panel: PanelContainer var auto_end_turn_prompt_label: Label var auto_end_turn_yes_button: Button var auto_end_turn_no_button: Button var auto_end_turn_prompt_declined_turn := -1 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 battle_unit_list_button: Button var battle_unit_list_panel: PanelContainer var battle_unit_list_title_label: Label var battle_unit_list_ally_button: Button var battle_unit_list_enemy_button: Button var battle_unit_list_status_label: Label var battle_unit_list_rows: VBoxContainer var battle_unit_list_mode := "ally" var top_save_button: Button var top_load_button: Button var top_settings_button: Button var settings_panel: PanelContainer var settings_bgm_toggle: CheckButton var settings_sfx_toggle: CheckButton var settings_edge_scroll_toggle: CheckButton var settings_bgm_volume_slider: HSlider var settings_sfx_volume_slider: HSlider var settings_bgm_volume_value_label: Label var settings_sfx_volume_value_label: Label var restart_button: Button var new_campaign_button: Button var opening_prologue_root: Control var opening_prologue_title_label: Label var opening_prologue_body_label: Label var opening_prologue_step_label: Label var opening_prologue_next_button: Button var opening_prologue_skip_button: Button var opening_prologue_index := 0 var title_screen_root: Control var title_background_texture_rect: TextureRect var title_portrait_texture_rect: TextureRect var title_panel: PanelContainer var title_caption_label: Label var title_subtitle_label: Label var title_start_button: Button var title_load_button: Button var title_settings_button: Button var title_status_label: Label var title_load_panel: PanelContainer var title_load_auto_button: Button var title_load_manual_button: Button var title_load_back_button: Button var title_settings_panel: PanelContainer var title_bgm_toggle: CheckButton var title_sfx_toggle: CheckButton var title_edge_scroll_toggle: CheckButton var title_bgm_volume_slider: HSlider var title_sfx_volume_slider: HSlider var title_bgm_volume_value_label: Label var title_sfx_volume_value_label: Label var title_settings_back_button: Button var title_screen_visible := false var title_bgm_enabled := true var title_sfx_enabled := true var title_edge_scroll_enabled := true var title_bgm_volume_percent := 100 var title_sfx_volume_percent := 100 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_presentation_sequence_active := false var battle_presentation_delay_cursor := 0.0 var battle_presentation_feedback_delay_by_unit := {} 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 var minimap_dragging := false var unit_idle_time := 0.0 func _ready() -> void: texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR _load_title_settings() _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 not campaign_state.is_campaign_complete(): if campaign_state.has_pending_post_battle_choice(): _load_scenario(campaign_state.get_pending_post_battle_choice_scenario_id()) else: _load_current_battle() _show_title_menu() _update_hud() func _unhandled_input(event: InputEvent) -> void: if _is_title_screen_visible(): if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE: if _is_title_load_panel_visible(): _on_title_load_back_pressed() elif title_settings_panel != null and title_settings_panel.visible: _on_title_settings_back_pressed() elif event.keycode == KEY_ENTER and not _is_title_load_panel_visible() and not _is_title_settings_panel_visible(): _on_title_start_pressed() return if _is_opening_prologue_visible(): if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: _on_opening_prologue_next_pressed() elif event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE: _on_opening_prologue_skip_pressed() elif event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_RIGHT: _on_opening_prologue_next_pressed() return 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: if _handle_minimap_mouse_motion(event): return 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: if _handle_minimap_mouse_button(event): return _handle_board_click(event.position) elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if _handle_minimap_mouse_button(event): return elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed: minimap_dragging = false _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_recovery_terrain_markers(_visible_cell_bounds()) _draw_units() _draw_objective_foreground_markers() _draw_event_foreground_markers() _draw_target_selection_markers() _draw_attack_effects() _draw_target_preview_badge() _draw_hover_info_badge() _draw_floating_texts() _draw_map_view_chrome() _draw_minimap() 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.055, 0.028, 0.016, 0.982) border = Color(0.30, 0.18, 0.075, 1.0) border_width = 11 margin = 20 shadow_size = 18 "speech_scroll": fill = Color(0.61, 0.47, 0.26, 0.996) border = Color(0.17, 0.085, 0.034, 0.98) border_width = 7 margin = 15 shadow_size = 6 "hud_info": fill = Color(0.075, 0.050, 0.034, 0.88) border = Color(0.31, 0.22, 0.12, 0.96) border_width = 3 margin = 7 shadow_size = 3 "target_hint": fill = Color(0.11, 0.070, 0.040, 0.94) border = UI_TARNISHED_BRONZE border_width = 4 margin = 9 shadow_size = 6 "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 _suppress_local_command_tooltip(button: Button) -> void: if button == null: return button.tooltip_text = button.text.strip_edges() func _configure_hud_command_button(button: Button, icon_kind: String = "") -> void: if button == null: return button.custom_minimum_size = HUD_COMMAND_BUTTON_SIZE button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.add_theme_font_size_override("font_size", 1) if not icon_kind.is_empty(): button.text = "" button.icon = _make_toolbar_icon(icon_kind) button.expand_icon = false button.set_meta("icon_only", true) button.set_meta("command_icon", icon_kind) func _configure_top_tool_button(button: Button, node_name: String, icon_kind: String, tooltip: String) -> void: if button == null: return button.name = node_name button.text = "" button.tooltip_text = tooltip button.custom_minimum_size = TOP_TOOL_BUTTON_SIZE button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER button.size_flags_vertical = Control.SIZE_SHRINK_CENTER button.icon = _make_toolbar_icon(icon_kind) button.expand_icon = true button.set_meta("icon_only", true) button.add_theme_font_size_override("font_size", 1) func _configure_local_command_button(button: Button, icon_kind: String, label: String, hint: String, minimum_size := LOCAL_COMMAND_BUTTON_SIZE) -> void: if button == null: return button.text = "" button.tooltip_text = _format_local_command_tooltip(label, hint) button.custom_minimum_size = minimum_size button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.icon = _make_toolbar_icon(icon_kind) button.expand_icon = false button.add_theme_font_size_override("font_size", 1) button.set_meta("icon_only", true) button.set_meta("command_icon", icon_kind) button.set_meta("command_label", label) button.set_meta("command_hint", hint) button.mouse_entered.connect(_on_local_command_button_mouse_entered.bind(button)) button.mouse_exited.connect(_on_local_command_button_mouse_exited) func _make_hud_unit_resource_row(label_text: String, fill_color: Color) -> Dictionary: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(HUD_UNIT_INFO_SIZE.x, 18.0) row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_theme_constant_override("separation", 6) var row_label := Label.new() row_label.text = label_text row_label.custom_minimum_size = Vector2(42, 16) row_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(row_label, UI_OLD_BRONZE, 11) row.add_child(row_label) var bar := ProgressBar.new() bar.custom_minimum_size = HUD_UNIT_BAR_SIZE bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL bar.min_value = 0.0 bar.max_value = 1.0 bar.value = 0.0 bar.show_percentage = false bar.add_theme_stylebox_override("background", _make_panel_style(Color(0.045, 0.024, 0.014, 0.95), UI_LACQUER_EDGE, 1, 0, 0, 0)) bar.add_theme_stylebox_override("fill", _make_panel_style(fill_color, fill_color.lightened(0.28), 1, 0, 0, 0)) row.add_child(bar) return { "row": row, "bar": bar } func _clear_hud_unit_badges() -> void: if hud_unit_badge_row == null: return for child in hud_unit_badge_row.get_children(): hud_unit_badge_row.remove_child(child) child.queue_free() func _update_hud_unit_badges(unit: Dictionary, detail_text: String) -> void: if hud_unit_badge_row == null: return _clear_hud_unit_badges() hud_unit_badge_row.tooltip_text = detail_text hud_unit_badge_row.visible = not unit.is_empty() if unit.is_empty(): return hud_unit_badge_row.add_child(_make_hud_unit_badge( _hud_unit_team_badge_text(unit), _hud_unit_badge_tooltip(_unit_team_text(str(unit.get("team", ""))), detail_text), _hud_unit_team_badge_color(unit) )) hud_unit_badge_row.add_child(_make_hud_unit_badge( _hud_unit_class_badge_text(unit), _hud_unit_badge_tooltip("%s · %s" % [_unit_class_display_name(unit), _unit_role_text(unit)], detail_text), _hud_unit_class_badge_color(unit) )) hud_unit_badge_row.add_child(_make_hud_unit_badge( "동" if _unit_pixel_facing(unit) >= 0 else "서", _hud_unit_badge_tooltip("시선 %s\n등 뒤를 잡히면 피해가 커집니다." % _unit_facing_text(unit), detail_text), Color(0.24, 0.20, 0.12, 0.98) )) var hp_max := maxi(1, int(unit.get("max_hp", 1))) var hp_ratio := float(clampi(int(unit.get("hp", 0)), 0, hp_max)) / float(hp_max) if hp_ratio <= LOW_HP_WARNING_RATIO: hud_unit_badge_row.add_child(_make_hud_unit_badge( "위", _hud_unit_badge_tooltip("병력이 크게 낮습니다.", detail_text), Color(0.56, 0.055, 0.028, 0.99) )) var marker_kinds := _unit_status_marker_kinds(unit) if not marker_kinds.is_empty(): var marker_kind := marker_kinds[0] hud_unit_badge_row.add_child(_make_hud_unit_badge( _unit_status_marker_glyph(marker_kind), _hud_unit_badge_tooltip("상태 표식\n자세한 효과는 아래 상세를 확인합니다.", detail_text), _unit_status_marker_color(marker_kind), "", _unit_status_marker_text_color(marker_kind) )) for action_badge in _hud_unit_action_badges(unit, detail_text): var action_color: Color = action_badge.get("color", Color(0.18, 0.10, 0.04, 0.98)) hud_unit_badge_row.add_child(_make_hud_unit_badge( str(action_badge.get("text", "")), str(action_badge.get("tooltip", "")), action_color, str(action_badge.get("icon", "")) )) func _make_hud_unit_badge( label_text: String, tooltip: String, fill_color: Color, icon_kind: String = "", text_color: Color = UI_PARCHMENT_TEXT ) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = HUD_UNIT_BADGE_SIZE panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER panel.tooltip_text = tooltip panel.add_theme_stylebox_override( "panel", _make_panel_style(fill_color, fill_color.lightened(0.34), 1, 2, 2, 0) ) if not icon_kind.strip_edges().is_empty(): var icon := TextureRect.new() icon.custom_minimum_size = Vector2(22, 18) icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture = _make_toolbar_icon(icon_kind) icon.tooltip_text = tooltip panel.add_child(icon) return panel var label := Label.new() label.text = label_text label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.custom_minimum_size = Vector2(HUD_UNIT_BADGE_SIZE.x - 4.0, HUD_UNIT_BADGE_SIZE.y - 4.0) label.tooltip_text = tooltip _apply_label_style(label, text_color, 11) panel.add_child(label) return panel func _hud_unit_badge_tooltip(primary_text: String, detail_text: String) -> String: if detail_text.strip_edges().is_empty(): return primary_text return "%s\n%s" % [primary_text, detail_text] func _hud_unit_team_badge_text(unit: Dictionary) -> String: var team := str(unit.get("team", "")) if team == BattleState.TEAM_PLAYER: return "아" if team == BattleState.TEAM_ENEMY: return "적" return "중" func _hud_unit_team_badge_color(unit: Dictionary) -> Color: var team := str(unit.get("team", "")) if team == BattleState.TEAM_PLAYER: return Color(0.11, 0.30, 0.22, 0.99) if team == BattleState.TEAM_ENEMY: return Color(0.48, 0.045, 0.028, 0.99) return Color(0.24, 0.22, 0.16, 0.99) func _hud_unit_class_badge_text(unit: Dictionary) -> String: var class_id := str(unit.get("class_id", "")).to_lower() 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 _hud_unit_class_badge_color(unit: Dictionary) -> Color: var class_id := str(unit.get("class_id", "")).to_lower() if class_id.contains("cavalry"): return Color(0.52, 0.24, 0.070, 0.99) if class_id.contains("archer") or class_id.contains("marksman"): return Color(0.18, 0.31, 0.14, 0.99) if class_id.contains("strategist") or class_id.contains("advisor"): return Color(0.15, 0.22, 0.38, 0.99) if class_id.contains("hero") or class_id.contains("commander"): return Color(0.44, 0.055, 0.032, 0.99) if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"): return Color(0.37, 0.15, 0.055, 0.99) return Color(0.24, 0.19, 0.10, 0.99) func _hud_unit_action_badges(unit: Dictionary, detail_text: String) -> Array: var result := [] var unit_id := str(unit.get("id", "")) if unit_id.is_empty(): result.append(_hud_unit_action_badge("봉", "", "봉인", "명령할 수 없는 표식입니다.", detail_text, Color(0.20, 0.16, 0.11, 0.98))) return result var team := str(unit.get("team", "")) if team != BattleState.TEAM_PLAYER: result.append(_hud_unit_action_badge("적", "threat", "적군", "직접 명령할 수 없습니다.", detail_text, Color(0.48, 0.045, 0.028, 0.99))) return result if not bool(unit.get("controllable", true)): result.append(_hud_unit_action_badge("호", "", "호위", "호위 대상은 직접 명령할 수 없습니다.", detail_text, Color(0.31, 0.23, 0.12, 0.98))) return result if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)) or bool(unit.get("acted", false)): result.append(_hud_unit_action_badge("봉", "", "봉인", "이 부대는 이미 행동했거나 출진하지 않았습니다.", detail_text, Color(0.20, 0.16, 0.11, 0.98))) return result var phase_block := _action_phase_block_reason() if not phase_block.is_empty(): result.append(_hud_unit_action_badge("대", "", str(phase_block.get("label", "대기")), str(phase_block.get("tooltip", "")), detail_text, Color(0.20, 0.16, 0.11, 0.98))) return result if not bool(unit.get("moved", false)) and not state.get_movement_range(unit_id).is_empty(): result.append(_hud_unit_action_badge("행", "move", "행군", "이동 가능한 칸이 있습니다.", detail_text, Color(0.34, 0.23, 0.070, 0.99))) if not state.get_attack_cells(unit_id).is_empty(): result.append(_hud_unit_action_badge("타", "attack", "타격", "무기로 공격할 수 있습니다.", detail_text, Color(0.46, 0.055, 0.032, 0.99))) var skill_id := state.get_first_usable_skill_id(unit_id) if not skill_id.is_empty() and _unit_has_usable_skill_target(unit_id): result.append(_hud_unit_action_badge("책", "tactic", "책략", "쓸 수 있는 책략 표식이 있습니다.", detail_text, Color(0.12, 0.24, 0.36, 0.99))) if _unit_has_usable_item_target(unit_id): result.append(_hud_unit_action_badge("도", "item", "도구", "사용 가능한 도구 대상이 있습니다.", detail_text, Color(0.18, 0.31, 0.16, 0.99))) if not bool(unit.get("moved", false)): result.append(_hud_unit_action_badge("병", "equip", "병장", "행군 전 병장을 바꿀 수 있습니다.", detail_text, Color(0.33, 0.18, 0.060, 0.99))) result.append(_hud_unit_action_badge("대", "wait", "대기", "이 부대의 행동을 마칠 수 있습니다.", detail_text, Color(0.22, 0.16, 0.09, 0.99))) return result func _hud_unit_action_badge( text: String, icon_kind: String, label: String, hint: String, detail_text: String, color: Color ) -> Dictionary: return { "text": text, "icon": icon_kind, "color": color, "tooltip": _hud_unit_badge_tooltip("%s\n%s" % [label, hint], detail_text) } func _format_local_command_tooltip(label: String, hint: String) -> String: var normalized_label := label.strip_edges() var normalized_hint := hint.strip_edges() if normalized_hint.is_empty(): return normalized_label if normalized_label.is_empty(): return normalized_hint return "%s\n%s" % [normalized_label, normalized_hint] func _make_top_hud_chip(node_name: String, icon_kind: String, chip_size: Vector2, variant: String) -> Dictionary: var chip := PanelContainer.new() chip.name = node_name chip.custom_minimum_size = chip_size chip.size_flags_vertical = Control.SIZE_SHRINK_CENTER _apply_panel_style(chip, variant) var row := HBoxContainer.new() row.add_theme_constant_override("separation", 6) chip.add_child(row) row.add_child(_make_top_hud_chip_icon(icon_kind)) return { "chip": chip, "row": row } func _make_top_hud_chip_icon(icon_kind: String) -> TextureRect: var icon := TextureRect.new() icon.name = "TopHudChipIcon" icon.custom_minimum_size = TOP_HUD_CHIP_ICON_SIZE icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture = _make_toolbar_icon(icon_kind) return icon func _make_side_info_chip_icon(icon_kind: String) -> TextureRect: var icon := TextureRect.new() icon.name = "SideInfoChipIcon" icon.custom_minimum_size = SIDE_INFO_CHIP_ICON_SIZE icon.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.texture = _make_toolbar_icon(icon_kind) return icon func _set_top_hud_chip_tooltip(chip: PanelContainer, tooltip: String) -> void: if chip == null: return _set_control_tree_tooltip(chip, tooltip) func _set_control_tree_tooltip(node: Node, tooltip: String) -> void: if node == null: return if node is Control: (node as Control).tooltip_text = tooltip for child in node.get_children(): _set_control_tree_tooltip(child, tooltip) func _make_toolbar_icon(kind: String) -> Texture2D: var image := Image.create(32, 32, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) var ink := Color(0.96, 0.82, 0.50, 1.0) var shadow := Color(0.07, 0.028, 0.012, 0.82) var red := Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 1.0) match kind: "status": _icon_circle_outline(image, Vector2i(16, 16), 10, shadow, 4) _icon_circle_outline(image, Vector2i(16, 16), 10, ink, 2) _icon_line(image, Vector2i(16, 8), Vector2i(16, 16), red, 3) _icon_line(image, Vector2i(16, 16), Vector2i(22, 20), red, 3) _icon_circle(image, Vector2i(16, 16), 2, ink) "objective": _icon_line(image, Vector2i(16, 6), Vector2i(26, 16), shadow, 4) _icon_line(image, Vector2i(26, 16), Vector2i(16, 26), shadow, 4) _icon_line(image, Vector2i(16, 26), Vector2i(6, 16), shadow, 4) _icon_line(image, Vector2i(6, 16), Vector2i(16, 6), shadow, 4) _icon_line(image, Vector2i(16, 6), Vector2i(26, 16), ink, 2) _icon_line(image, Vector2i(26, 16), Vector2i(16, 26), ink, 2) _icon_line(image, Vector2i(16, 26), Vector2i(6, 16), ink, 2) _icon_line(image, Vector2i(6, 16), Vector2i(16, 6), ink, 2) _icon_circle(image, Vector2i(16, 16), 5, red) _icon_circle(image, Vector2i(16, 16), 2, ink) "campaign": _icon_rect(image, Rect2i(8, 7, 17, 20), shadow) _icon_rect(image, Rect2i(7, 6, 17, 20), ink) _icon_rect(image, Rect2i(10, 10, 11, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_rect(image, Rect2i(10, 15, 10, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_rect(image, Rect2i(10, 20, 8, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_circle(image, Vector2i(24, 9), 3, red) "unit_list": _icon_circle(image, Vector2i(11, 10), 4, shadow) _icon_circle(image, Vector2i(21, 10), 4, shadow) _icon_circle(image, Vector2i(11, 9), 3, ink) _icon_circle(image, Vector2i(21, 9), 3, ink) _icon_rect(image, Rect2i(7, 15, 8, 9), shadow) _icon_rect(image, Rect2i(17, 15, 8, 9), shadow) _icon_rect(image, Rect2i(8, 15, 6, 8), ink) _icon_rect(image, Rect2i(18, 15, 6, 8), ink) _icon_line(image, Vector2i(5, 25), Vector2i(27, 25), red, 3) _icon_line(image, Vector2i(8, 18), Vector2i(14, 23), red, 2) _icon_line(image, Vector2i(24, 18), Vector2i(18, 23), red, 2) "settings": _icon_circle_outline(image, Vector2i(16, 16), 9, shadow, 5) _icon_circle_outline(image, Vector2i(16, 16), 9, ink, 3) _icon_circle(image, Vector2i(16, 16), 3, red) for point in [ Vector2i(16, 4), Vector2i(16, 28), Vector2i(4, 16), Vector2i(28, 16), Vector2i(8, 8), Vector2i(24, 8), Vector2i(8, 24), Vector2i(24, 24) ]: _icon_circle(image, point, 2, ink) "terrain": _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), shadow, 5) _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), shadow, 5) _icon_line(image, Vector2i(18, 18), Vector2i(25, 8), shadow, 5) _icon_line(image, Vector2i(5, 22), Vector2i(13, 12), ink, 3) _icon_line(image, Vector2i(13, 12), Vector2i(18, 18), ink, 3) _icon_line(image, Vector2i(18, 18), Vector2i(25, 8), ink, 3) _icon_line(image, Vector2i(6, 24), Vector2i(26, 24), red, 3) "forecast": _icon_circle_outline(image, Vector2i(16, 16), 10, shadow, 4) _icon_circle_outline(image, Vector2i(16, 16), 10, ink, 2) _icon_line(image, Vector2i(6, 16), Vector2i(26, 16), red, 2) _icon_line(image, Vector2i(16, 6), Vector2i(16, 26), red, 2) _icon_circle(image, Vector2i(16, 16), 4, red) _icon_circle(image, Vector2i(16, 16), 2, ink) "move": _icon_line(image, Vector2i(8, 22), Vector2i(16, 9), shadow, 5) _icon_line(image, Vector2i(16, 9), Vector2i(24, 22), shadow, 5) _icon_line(image, Vector2i(8, 22), Vector2i(16, 9), ink, 3) _icon_line(image, Vector2i(16, 9), Vector2i(24, 22), ink, 3) _icon_line(image, Vector2i(11, 22), Vector2i(21, 22), ink, 3) _icon_circle(image, Vector2i(16, 9), 3, red) "end_turn": _icon_line(image, Vector2i(7, 10), Vector2i(22, 10), shadow, 4) _icon_line(image, Vector2i(21, 10), Vector2i(21, 22), shadow, 4) _icon_line(image, Vector2i(7, 10), Vector2i(22, 10), ink, 2) _icon_line(image, Vector2i(21, 10), Vector2i(21, 22), ink, 2) _icon_line(image, Vector2i(18, 18), Vector2i(21, 22), ink, 2) _icon_line(image, Vector2i(24, 18), Vector2i(21, 22), ink, 2) "threat": _icon_line(image, Vector2i(6, 16), Vector2i(13, 10), shadow, 4) _icon_line(image, Vector2i(13, 10), Vector2i(20, 10), shadow, 4) _icon_line(image, Vector2i(20, 10), Vector2i(27, 16), shadow, 4) _icon_line(image, Vector2i(6, 16), Vector2i(13, 22), shadow, 4) _icon_line(image, Vector2i(13, 22), Vector2i(20, 22), shadow, 4) _icon_line(image, Vector2i(20, 22), Vector2i(27, 16), shadow, 4) _icon_line(image, Vector2i(6, 16), Vector2i(13, 10), ink, 2) _icon_line(image, Vector2i(13, 10), Vector2i(20, 10), ink, 2) _icon_line(image, Vector2i(20, 10), Vector2i(27, 16), ink, 2) _icon_line(image, Vector2i(6, 16), Vector2i(13, 22), ink, 2) _icon_line(image, Vector2i(13, 22), Vector2i(20, 22), ink, 2) _icon_line(image, Vector2i(20, 22), Vector2i(27, 16), ink, 2) _icon_circle(image, Vector2i(16, 16), 4, red) "save": _icon_rect(image, Rect2i(8, 6, 17, 22), shadow) _icon_rect(image, Rect2i(7, 5, 17, 22), ink) _icon_rect(image, Rect2i(10, 8, 11, 5), Color(0.18, 0.08, 0.03, 1.0)) _icon_rect(image, Rect2i(11, 17, 9, 6), Color(0.18, 0.08, 0.03, 1.0)) _icon_rect(image, Rect2i(19, 8, 2, 5), red) "load": _icon_rect(image, Rect2i(8, 8, 17, 18), shadow) _icon_rect(image, Rect2i(7, 7, 17, 18), ink) _icon_rect(image, Rect2i(10, 18, 11, 4), Color(0.18, 0.08, 0.03, 1.0)) _icon_line(image, Vector2i(16, 24), Vector2i(16, 10), red, 3) _icon_line(image, Vector2i(11, 15), Vector2i(16, 10), red, 3) _icon_line(image, Vector2i(21, 15), Vector2i(16, 10), red, 3) "restart": _icon_circle_outline(image, Vector2i(16, 16), 9, shadow, 4) _icon_circle_outline(image, Vector2i(16, 16), 9, ink, 2) _icon_line(image, Vector2i(8, 15), Vector2i(6, 8), red, 3) _icon_line(image, Vector2i(8, 15), Vector2i(14, 12), red, 3) "new": _icon_circle(image, Vector2i(16, 16), 10, red) _icon_line(image, Vector2i(16, 9), Vector2i(16, 23), ink, 3) _icon_line(image, Vector2i(9, 16), Vector2i(23, 16), ink, 3) "attack": _icon_line(image, Vector2i(8, 25), Vector2i(24, 9), shadow, 5) _icon_line(image, Vector2i(8, 25), Vector2i(24, 9), ink, 3) _icon_line(image, Vector2i(20, 7), Vector2i(26, 13), ink, 3) _icon_line(image, Vector2i(7, 13), Vector2i(13, 19), red, 3) _icon_line(image, Vector2i(6, 20), Vector2i(13, 27), red, 3) "wait": _icon_rect(image, Rect2i(9, 9, 15, 17), shadow) _icon_line(image, Vector2i(16, 7), Vector2i(24, 11), ink, 3) _icon_line(image, Vector2i(24, 11), Vector2i(21, 24), ink, 3) _icon_line(image, Vector2i(21, 24), Vector2i(11, 24), ink, 3) _icon_line(image, Vector2i(11, 24), Vector2i(8, 11), ink, 3) _icon_line(image, Vector2i(8, 11), Vector2i(16, 7), ink, 3) _icon_line(image, Vector2i(12, 15), Vector2i(20, 15), red, 2) "tactic": _icon_rect(image, Rect2i(9, 8, 16, 18), shadow) _icon_rect(image, Rect2i(8, 7, 16, 18), ink) _icon_rect(image, Rect2i(11, 10, 10, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_rect(image, Rect2i(11, 15, 10, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_rect(image, Rect2i(11, 20, 7, 2), Color(0.16, 0.07, 0.03, 1.0)) _icon_circle(image, Vector2i(24, 9), 3, red) "item": _icon_line(image, Vector2i(10, 13), Vector2i(22, 13), shadow, 5) _icon_rect(image, Rect2i(9, 12, 15, 13), ink) _icon_line(image, Vector2i(12, 12), Vector2i(16, 7), red, 3) _icon_line(image, Vector2i(21, 12), Vector2i(16, 7), red, 3) _icon_rect(image, Rect2i(12, 17, 9, 3), Color(0.16, 0.07, 0.03, 1.0)) "equip": _icon_line(image, Vector2i(9, 24), Vector2i(23, 10), shadow, 5) _icon_line(image, Vector2i(9, 24), Vector2i(23, 10), ink, 3) _icon_line(image, Vector2i(20, 7), Vector2i(25, 12), ink, 3) _icon_line(image, Vector2i(10, 18), Vector2i(16, 24), red, 3) _icon_line(image, Vector2i(7, 21), Vector2i(13, 27), red, 3) "cancel": _icon_line(image, Vector2i(22, 8), Vector2i(9, 16), shadow, 5) _icon_line(image, Vector2i(9, 16), Vector2i(22, 24), shadow, 5) _icon_line(image, Vector2i(22, 8), Vector2i(9, 16), ink, 3) _icon_line(image, Vector2i(9, 16), Vector2i(22, 24), ink, 3) _icon_line(image, Vector2i(10, 16), Vector2i(17, 9), red, 3) _icon_line(image, Vector2i(10, 16), Vector2i(17, 23), red, 3) _: _icon_circle(image, Vector2i(16, 16), 7, ink) return ImageTexture.create_from_image(image) func _icon_rect(image: Image, rect: Rect2i, color: Color) -> void: for y in range(maxi(0, rect.position.y), mini(image.get_height(), rect.position.y + rect.size.y)): for x in range(maxi(0, rect.position.x), mini(image.get_width(), rect.position.x + rect.size.x)): image.set_pixel(x, y, color) func _icon_circle(image: Image, center: Vector2i, radius: int, color: Color) -> void: var r2 := radius * radius for y in range(center.y - radius, center.y + radius + 1): for x in range(center.x - radius, center.x + radius + 1): if x < 0 or y < 0 or x >= image.get_width() or y >= image.get_height(): continue var dx := x - center.x var dy := y - center.y if dx * dx + dy * dy <= r2: image.set_pixel(x, y, color) func _icon_circle_outline(image: Image, center: Vector2i, radius: int, color: Color, width: int = 1) -> void: var outer := radius * radius var inner_radius := maxi(0, radius - width) var inner := inner_radius * inner_radius for y in range(center.y - radius, center.y + radius + 1): for x in range(center.x - radius, center.x + radius + 1): if x < 0 or y < 0 or x >= image.get_width() or y >= image.get_height(): continue var dx := x - center.x var dy := y - center.y var d2 := dx * dx + dy * dy if d2 <= outer and d2 >= inner: image.set_pixel(x, y, color) func _icon_line(image: Image, from_point: Vector2i, to_point: Vector2i, color: Color, width: int = 1) -> void: var delta := to_point - from_point var steps: int = maxi(abs(delta.x), abs(delta.y)) if steps <= 0: _icon_circle(image, from_point, maxi(1, width / 2), color) return var radius: int = maxi(0, int(floor(float(width) * 0.5))) for step in range(steps + 1): var t := float(step) / float(steps) var point := Vector2i(roundi(lerpf(from_point.x, to_point.x, t)), roundi(lerpf(from_point.y, to_point.y, t))) _icon_circle(image, point, radius, color) 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: texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR 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(1232, 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) var status_chip_data := _make_top_hud_chip("TopStatusChip", "status", Vector2(138, 54), "hud_info") top_status_chip = status_chip_data["chip"] as PanelContainer top_row.add_child(top_status_chip) status_label = Label.new() status_label.custom_minimum_size = Vector2(74, 36) status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(status_label, UI_PARCHMENT_TEXT) (status_chip_data["row"] as HBoxContainer).add_child(status_label) top_objective_chip = PanelContainer.new() top_objective_chip.name = "TopObjectiveChip" top_objective_chip.custom_minimum_size = Vector2(396, 54) _apply_panel_style(top_objective_chip, "edict_compact") top_row.add_child(top_objective_chip) var objective_top_row := HBoxContainer.new() objective_top_row.add_theme_constant_override("separation", 6) top_objective_chip.add_child(objective_top_row) objective_top_row.add_child(_make_bamboo_gutter(10, 36)) objective_top_row.add_child(_make_top_hud_chip_icon("objective")) objective_label = Label.new() objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART objective_label.custom_minimum_size = Vector2(274, 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(10, 36)) var campaign_chip_data := _make_top_hud_chip("TopCampaignChip", "campaign", Vector2(156, 54), "hud_info") top_campaign_chip = campaign_chip_data["chip"] as PanelContainer top_row.add_child(top_campaign_chip) campaign_status_label = Label.new() campaign_status_label.custom_minimum_size = Vector2(88, 36) campaign_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER campaign_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(campaign_status_label, UI_PARCHMENT_TEXT) (campaign_chip_data["row"] as HBoxContainer).add_child(campaign_status_label) var top_tool_row := HBoxContainer.new() top_tool_row.name = "TopToolBar" top_tool_row.custom_minimum_size = Vector2(378, 48) top_tool_row.add_theme_constant_override("separation", 5) top_row.add_child(top_tool_row) end_turn_button = Button.new() _configure_top_tool_button(end_turn_button, "TopEndTurnButton", "end_turn", "차례 종료\n아군 차례를 마치고 적군에게 넘깁니다.") end_turn_button.pressed.connect(_on_end_turn_pressed) top_tool_row.add_child(end_turn_button) threat_button = Button.new() _configure_top_tool_button(threat_button, "TopThreatButton", "threat", "적정\n적의 무기와 책략 사거리를 살핍니다.") threat_button.toggle_mode = true threat_button.pressed.connect(_on_threat_pressed) top_tool_row.add_child(threat_button) battle_unit_list_button = Button.new() _configure_top_tool_button(battle_unit_list_button, "TopBattleUnitListButton", "unit_list", "군세\n전장에 있는 아군과 적군 목록을 엽니다.") battle_unit_list_button.pressed.connect(_on_battle_unit_list_pressed) top_tool_row.add_child(battle_unit_list_button) top_settings_button = Button.new() _configure_top_tool_button(top_settings_button, "TopSettingsButton", "settings", "환경 설정\n배경음, 효과음, 가장자리 스크롤을 조절합니다.") top_settings_button.pressed.connect(_on_settings_pressed) top_tool_row.add_child(top_settings_button) top_save_button = Button.new() _configure_top_tool_button(top_save_button, "TopSaveButton", "save", "저장\n전투 전 군막에서 수동 전기를 봉인합니다.") top_save_button.pressed.connect(_on_top_manual_save_pressed) top_tool_row.add_child(top_save_button) top_load_button = Button.new() _configure_top_tool_button(top_load_button, "TopLoadButton", "load", "불러오기\n수동 봉인을 열어 군막으로 돌아갑니다.") top_load_button.pressed.connect(_on_top_manual_load_pressed) top_tool_row.add_child(top_load_button) restart_button = Button.new() _configure_top_tool_button(restart_button, "TopRestartButton", "restart", "재정비\n현재 전장을 처음부터 다시 펼칩니다.") restart_button.pressed.connect(_on_restart_pressed) top_tool_row.add_child(restart_button) new_campaign_button = Button.new() _configure_top_tool_button(new_campaign_button, "TopNewCampaignButton", "new", "새 전기 열기\n저장된 진행을 지우고 처음부터 시작합니다.") new_campaign_button.pressed.connect(_on_new_campaign_pressed) top_tool_row.add_child(new_campaign_button) battle_unit_list_panel = PanelContainer.new() battle_unit_list_panel.name = "BattleUnitListPanel" battle_unit_list_panel.visible = false battle_unit_list_panel.mouse_filter = Control.MOUSE_FILTER_STOP battle_unit_list_panel.position = Vector2(824, 88) battle_unit_list_panel.size = BATTLE_UNIT_LIST_PANEL_SIZE battle_unit_list_panel.custom_minimum_size = BATTLE_UNIT_LIST_PANEL_SIZE _apply_panel_style(battle_unit_list_panel, "compact") root.add_child(battle_unit_list_panel) var battle_unit_column := VBoxContainer.new() battle_unit_column.add_theme_constant_override("separation", 7) battle_unit_list_panel.add_child(battle_unit_column) var battle_unit_header_row := HBoxContainer.new() battle_unit_header_row.add_theme_constant_override("separation", 8) battle_unit_column.add_child(battle_unit_header_row) battle_unit_list_title_label = Label.new() battle_unit_list_title_label.text = "군세" battle_unit_list_title_label.custom_minimum_size = Vector2(232, 28) battle_unit_list_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(battle_unit_list_title_label, UI_OLD_BRONZE, 16) battle_unit_header_row.add_child(battle_unit_list_title_label) var battle_unit_close_button := Button.new() battle_unit_close_button.text = "닫기" battle_unit_close_button.tooltip_text = "군세 목록을 닫습니다." battle_unit_close_button.custom_minimum_size = Vector2(76, 28) _apply_button_style(battle_unit_close_button) battle_unit_close_button.pressed.connect(_hide_battle_unit_list_panel) battle_unit_header_row.add_child(battle_unit_close_button) var battle_unit_tab_row := HBoxContainer.new() battle_unit_tab_row.add_theme_constant_override("separation", 6) battle_unit_column.add_child(battle_unit_tab_row) battle_unit_list_ally_button = Button.new() battle_unit_list_ally_button.text = "아군" battle_unit_list_ally_button.tooltip_text = "아군 장수와 행동 상태를 봅니다." battle_unit_list_ally_button.custom_minimum_size = Vector2(94, 32) _apply_button_style(battle_unit_list_ally_button) battle_unit_list_ally_button.pressed.connect(_on_battle_unit_list_tab_pressed.bind("ally")) battle_unit_tab_row.add_child(battle_unit_list_ally_button) battle_unit_list_enemy_button = Button.new() battle_unit_list_enemy_button.text = "적군" battle_unit_list_enemy_button.tooltip_text = "전장에 남은 적 부대를 봅니다." battle_unit_list_enemy_button.custom_minimum_size = Vector2(94, 32) _apply_button_style(battle_unit_list_enemy_button) battle_unit_list_enemy_button.pressed.connect(_on_battle_unit_list_tab_pressed.bind("enemy")) battle_unit_tab_row.add_child(battle_unit_list_enemy_button) battle_unit_list_status_label = Label.new() battle_unit_list_status_label.custom_minimum_size = Vector2(164, 30) battle_unit_list_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL battle_unit_list_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _apply_label_style(battle_unit_list_status_label, UI_PARCHMENT_TEXT, 12) battle_unit_tab_row.add_child(battle_unit_list_status_label) var battle_unit_scroll := ScrollContainer.new() battle_unit_scroll.custom_minimum_size = Vector2(380, 250) battle_unit_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL battle_unit_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED battle_unit_column.add_child(battle_unit_scroll) battle_unit_list_rows = VBoxContainer.new() battle_unit_list_rows.custom_minimum_size = Vector2(372, 0) battle_unit_list_rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL battle_unit_list_rows.add_theme_constant_override("separation", 5) battle_unit_scroll.add_child(battle_unit_list_rows) settings_panel = PanelContainer.new() settings_panel.name = "SettingsPanel" settings_panel.visible = false settings_panel.mouse_filter = Control.MOUSE_FILTER_STOP settings_panel.position = Vector2(824, 88) settings_panel.size = SETTINGS_PANEL_SIZE settings_panel.custom_minimum_size = SETTINGS_PANEL_SIZE _apply_panel_style(settings_panel, "compact") root.add_child(settings_panel) var settings_column := VBoxContainer.new() settings_column.add_theme_constant_override("separation", 8) settings_panel.add_child(settings_column) var settings_header_row := HBoxContainer.new() settings_header_row.add_theme_constant_override("separation", 8) settings_column.add_child(settings_header_row) var settings_title_label := Label.new() settings_title_label.text = "환경 설정" settings_title_label.custom_minimum_size = Vector2(250, 28) settings_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(settings_title_label, UI_OLD_BRONZE, 16) settings_header_row.add_child(settings_title_label) var settings_close_button := Button.new() settings_close_button.text = "닫기" settings_close_button.tooltip_text = "환경 설정을 닫습니다." settings_close_button.custom_minimum_size = Vector2(76, 28) _apply_button_style(settings_close_button) settings_close_button.pressed.connect(_hide_settings_panel) settings_header_row.add_child(settings_close_button) settings_column.add_child(_make_seal_ribbon(374, 12)) settings_bgm_toggle = CheckButton.new() settings_bgm_toggle.text = "배경음" settings_bgm_toggle.custom_minimum_size = Vector2(374, 30) settings_bgm_toggle.tooltip_text = "배경 음악을 켜거나 끕니다." settings_bgm_toggle.toggled.connect(_on_settings_bgm_toggled) settings_column.add_child(settings_bgm_toggle) var settings_bgm_volume_row := HBoxContainer.new() settings_bgm_volume_row.custom_minimum_size = Vector2(374, 34) settings_bgm_volume_row.add_theme_constant_override("separation", 8) settings_column.add_child(settings_bgm_volume_row) var settings_bgm_volume_caption := Label.new() settings_bgm_volume_caption.text = "배경음 크기" settings_bgm_volume_caption.custom_minimum_size = Vector2(94, 30) settings_bgm_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(settings_bgm_volume_caption, UI_PARCHMENT_TEXT, 13) settings_bgm_volume_row.add_child(settings_bgm_volume_caption) settings_bgm_volume_slider = HSlider.new() settings_bgm_volume_slider.min_value = 0.0 settings_bgm_volume_slider.max_value = 100.0 settings_bgm_volume_slider.step = 5.0 settings_bgm_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL settings_bgm_volume_slider.custom_minimum_size = Vector2(194, 30) settings_bgm_volume_slider.tooltip_text = "배경 음악의 크기를 조절합니다." settings_bgm_volume_slider.value_changed.connect(_on_settings_bgm_volume_changed) settings_bgm_volume_row.add_child(settings_bgm_volume_slider) settings_bgm_volume_value_label = Label.new() settings_bgm_volume_value_label.custom_minimum_size = Vector2(48, 30) settings_bgm_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT settings_bgm_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(settings_bgm_volume_value_label, UI_PARCHMENT_TEXT, 13) settings_bgm_volume_row.add_child(settings_bgm_volume_value_label) settings_sfx_toggle = CheckButton.new() settings_sfx_toggle.text = "효과음" settings_sfx_toggle.custom_minimum_size = Vector2(374, 30) settings_sfx_toggle.tooltip_text = "버튼과 전투 효과음을 켜거나 끕니다." settings_sfx_toggle.toggled.connect(_on_settings_sfx_toggled) settings_column.add_child(settings_sfx_toggle) var settings_sfx_volume_row := HBoxContainer.new() settings_sfx_volume_row.custom_minimum_size = Vector2(374, 34) settings_sfx_volume_row.add_theme_constant_override("separation", 8) settings_column.add_child(settings_sfx_volume_row) var settings_sfx_volume_caption := Label.new() settings_sfx_volume_caption.text = "효과음 크기" settings_sfx_volume_caption.custom_minimum_size = Vector2(94, 30) settings_sfx_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(settings_sfx_volume_caption, UI_PARCHMENT_TEXT, 13) settings_sfx_volume_row.add_child(settings_sfx_volume_caption) settings_sfx_volume_slider = HSlider.new() settings_sfx_volume_slider.min_value = 0.0 settings_sfx_volume_slider.max_value = 100.0 settings_sfx_volume_slider.step = 5.0 settings_sfx_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL settings_sfx_volume_slider.custom_minimum_size = Vector2(194, 30) settings_sfx_volume_slider.tooltip_text = "버튼과 전투 효과음의 크기를 조절합니다." settings_sfx_volume_slider.value_changed.connect(_on_settings_sfx_volume_changed) settings_sfx_volume_row.add_child(settings_sfx_volume_slider) settings_sfx_volume_value_label = Label.new() settings_sfx_volume_value_label.custom_minimum_size = Vector2(48, 30) settings_sfx_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT settings_sfx_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(settings_sfx_volume_value_label, UI_PARCHMENT_TEXT, 13) settings_sfx_volume_row.add_child(settings_sfx_volume_value_label) settings_edge_scroll_toggle = CheckButton.new() settings_edge_scroll_toggle.text = "가장자리 스크롤" settings_edge_scroll_toggle.custom_minimum_size = Vector2(374, 30) settings_edge_scroll_toggle.tooltip_text = "마우스를 전장 가장자리에 두면 화면을 움직입니다." settings_edge_scroll_toggle.toggled.connect(_on_settings_edge_scroll_toggled) settings_column.add_child(settings_edge_scroll_toggle) 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)) auto_end_turn_prompt_panel = PanelContainer.new() auto_end_turn_prompt_panel.name = "AutoEndTurnPrompt" auto_end_turn_prompt_panel.visible = false auto_end_turn_prompt_panel.mouse_filter = Control.MOUSE_FILTER_STOP auto_end_turn_prompt_panel.position = Vector2(408, 112) auto_end_turn_prompt_panel.size = Vector2(464, 154) auto_end_turn_prompt_panel.custom_minimum_size = Vector2(464, 154) _apply_panel_style(auto_end_turn_prompt_panel, "command_seal") root.add_child(auto_end_turn_prompt_panel) var auto_end_column := VBoxContainer.new() auto_end_column.add_theme_constant_override("separation", 8) auto_end_turn_prompt_panel.add_child(auto_end_column) auto_end_column.add_child(_make_seal_ribbon(420, 10)) auto_end_turn_prompt_label = Label.new() auto_end_turn_prompt_label.text = "모든 아군이 군령을 마쳤습니다.\n차례를 넘기시겠습니까?" auto_end_turn_prompt_label.custom_minimum_size = Vector2(420, 56) auto_end_turn_prompt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER auto_end_turn_prompt_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER auto_end_turn_prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _apply_label_style(auto_end_turn_prompt_label, UI_PARCHMENT_TEXT, 17) auto_end_column.add_child(auto_end_turn_prompt_label) var auto_end_button_row := HBoxContainer.new() auto_end_button_row.alignment = BoxContainer.ALIGNMENT_CENTER auto_end_button_row.add_theme_constant_override("separation", 12) auto_end_column.add_child(auto_end_button_row) auto_end_turn_yes_button = Button.new() auto_end_turn_yes_button.text = "예" auto_end_turn_yes_button.tooltip_text = "아군 차례를 마치고 적군 차례로 넘깁니다." auto_end_turn_yes_button.custom_minimum_size = Vector2(122, 38) auto_end_turn_yes_button.pressed.connect(_on_auto_end_turn_yes_pressed) auto_end_button_row.add_child(auto_end_turn_yes_button) auto_end_turn_no_button = Button.new() auto_end_turn_no_button.text = "아니오" auto_end_turn_no_button.tooltip_text = "이번 차례에는 자동 확인을 닫습니다." auto_end_turn_no_button.custom_minimum_size = Vector2(122, 38) auto_end_turn_no_button.pressed.connect(_on_auto_end_turn_no_pressed) auto_end_button_row.add_child(auto_end_turn_no_button) 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) hud_unit_info_column = VBoxContainer.new() hud_unit_info_column.custom_minimum_size = HUD_UNIT_INFO_SIZE hud_unit_info_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL hud_unit_info_column.add_theme_constant_override("separation", 5) selected_row.add_child(hud_unit_info_column) selected_label = Label.new() selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART selected_label.custom_minimum_size = HUD_UNIT_SUMMARY_SIZE selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(selected_label, UI_PARCHMENT_TEXT, 13) hud_unit_info_column.add_child(selected_label) hud_unit_badge_row = HBoxContainer.new() hud_unit_badge_row.custom_minimum_size = HUD_UNIT_BADGE_ROW_SIZE hud_unit_badge_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL hud_unit_badge_row.add_theme_constant_override("separation", 3) hud_unit_info_column.add_child(hud_unit_badge_row) var hp_row := _make_hud_unit_resource_row("병력", Color(0.66, 0.12, 0.075, 1.0)) hud_unit_hp_bar = hp_row["bar"] as ProgressBar hud_unit_info_column.add_child(hp_row["row"] as Control) var mp_row := _make_hud_unit_resource_row("기력", Color(0.20, 0.42, 0.70, 1.0)) hud_unit_mp_bar = mp_row["bar"] as ProgressBar hud_unit_info_column.add_child(mp_row["row"] as Control) 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.visible = false 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_panel = PanelContainer.new() cell_info_panel.name = "CellInfoChip" cell_info_panel.custom_minimum_size = Vector2(420, 58) cell_info_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_panel_style(cell_info_panel, "hud_info") side_column.add_child(cell_info_panel) var cell_info_row := HBoxContainer.new() cell_info_row.add_theme_constant_override("separation", 8) cell_info_panel.add_child(cell_info_row) cell_info_icon = _make_side_info_chip_icon("terrain") cell_info_row.add_child(cell_info_icon) cell_info_label = Label.new() cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART cell_info_label.custom_minimum_size = Vector2(354, 38) cell_info_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(cell_info_label, Color(0.92, 0.78, 0.54, 1.0), 12) cell_info_row.add_child(cell_info_label) forecast_panel = PanelContainer.new() forecast_panel.name = "ForecastChip" forecast_panel.custom_minimum_size = Vector2(420, 66) forecast_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_panel_style(forecast_panel, "hud_info") side_column.add_child(forecast_panel) var forecast_row := HBoxContainer.new() forecast_row.add_theme_constant_override("separation", 8) forecast_panel.add_child(forecast_row) forecast_icon = _make_side_info_chip_icon("forecast") forecast_row.add_child(forecast_icon) forecast_label = Label.new() forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART forecast_label.custom_minimum_size = Vector2(354, 46) forecast_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_label_style(forecast_label, Color(0.95, 0.80, 0.56, 1.0), 13) forecast_row.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 := GridContainer.new() button_row.name = "HudCommandGrid" button_row.columns = HUD_COMMAND_GRID_COLUMNS button_row.custom_minimum_size = HUD_COMMAND_GRID_SIZE button_row.add_theme_constant_override("h_separation", 6) button_row.add_theme_constant_override("v_separation", 5) side_column.add_child(button_row) wait_button = Button.new() wait_button.text = "대기" _configure_hud_command_button(wait_button, "wait") wait_button.pressed.connect(_on_wait_pressed) button_row.add_child(wait_button) tactic_button = Button.new() tactic_button.text = "책략" _configure_hud_command_button(tactic_button, "tactic") tactic_button.pressed.connect(_on_tactic_pressed) button_row.add_child(tactic_button) item_button = Button.new() item_button.text = "도구" _configure_hud_command_button(item_button, "item") item_button.pressed.connect(_on_item_pressed) button_row.add_child(item_button) equip_button = Button.new() equip_button.text = "병장" _configure_hud_command_button(equip_button, "equip") equip_button.pressed.connect(_on_equip_pressed) button_row.add_child(equip_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() _configure_local_command_button(post_move_attack_button, "attack", "타격령", "무기 공격") 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() _configure_local_command_button(post_move_tactic_button, "tactic", "책략첩", "책략 표식") 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() _configure_local_command_button(post_move_item_button, "item", "보급첩", "보급 사용") 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() _configure_local_command_button(post_move_wait_button, "wait", "대기령", "행동 종료") 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() _configure_local_command_button(post_move_cancel_button, "cancel", "발걸음 거두기", "이동 취소", LOCAL_COMMAND_CANCEL_BUTTON_SIZE) 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 = "죽간 거두기" _suppress_local_command_tooltip(post_move_picker_back_button) 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 = "발걸음 거두기" _suppress_local_command_tooltip(post_move_picker_cancel_move_button) 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_IGNORE targeting_hint_panel.custom_minimum_size = TARGETING_HINT_PANEL_SIZE targeting_hint_panel.size = TARGETING_HINT_PANEL_SIZE _apply_panel_style(targeting_hint_panel, "target_hint") 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 = "죽간 거두기" _suppress_local_command_tooltip(targeting_hint_back_button) 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 = "발걸음 거두기" _suppress_local_command_tooltip(targeting_hint_cancel_move_button) 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) opening_prologue_root = Control.new() opening_prologue_root.name = "OpeningPrologue" opening_prologue_root.visible = false opening_prologue_root.mouse_filter = Control.MOUSE_FILTER_STOP opening_prologue_root.set_anchors_preset(Control.PRESET_FULL_RECT) root.add_child(opening_prologue_root) var opening_background := TextureRect.new() opening_background.name = "OpeningBattlefieldBackdrop" opening_background.mouse_filter = Control.MOUSE_FILTER_IGNORE opening_background.set_anchors_preset(Control.PRESET_FULL_RECT) opening_background.expand_mode = TextureRect.EXPAND_IGNORE_SIZE opening_background.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED opening_background.texture = _load_art_texture(TITLE_BACKGROUND_PATH) opening_background.modulate = Color(0.76, 0.62, 0.43, 0.98) opening_prologue_root.add_child(opening_background) var opening_dim := ColorRect.new() opening_dim.name = "OpeningInkWash" opening_dim.color = Color(0.030, 0.016, 0.008, 0.62) opening_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE opening_dim.set_anchors_preset(Control.PRESET_FULL_RECT) opening_prologue_root.add_child(opening_dim) var opening_panel := PanelContainer.new() opening_panel.name = "OpeningProloguePanel" opening_panel.position = Vector2(104, 58) opening_panel.size = Vector2(1072, 604) opening_panel.custom_minimum_size = Vector2(1072, 604) _apply_panel_style(opening_panel, "paper") opening_prologue_root.add_child(opening_panel) var opening_shell := HBoxContainer.new() opening_shell.custom_minimum_size = Vector2(1016, 548) opening_shell.add_theme_constant_override("separation", 18) opening_panel.add_child(opening_shell) opening_shell.add_child(_make_bamboo_gutter(22, 548)) var opening_portrait := TextureRect.new() opening_portrait.name = "OpeningCaoCaoPortrait" opening_portrait.custom_minimum_size = Vector2(356, 548) opening_portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE opening_portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED opening_portrait.texture = _load_art_texture(TITLE_PORTRAIT_PATH) opening_portrait.modulate = Color(1.0, 0.92, 0.80, 0.96) opening_portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE opening_shell.add_child(opening_portrait) var opening_story_column := VBoxContainer.new() opening_story_column.custom_minimum_size = Vector2(586, 548) opening_story_column.add_theme_constant_override("separation", 14) opening_shell.add_child(opening_story_column) opening_shell.add_child(_make_bamboo_gutter(22, 548)) opening_story_column.add_child(_make_scroll_rod(586, 6)) opening_story_column.add_child(_make_seal_ribbon(586, 18)) opening_prologue_title_label = Label.new() opening_prologue_title_label.custom_minimum_size = Vector2(586, 52) opening_prologue_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER opening_prologue_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(opening_prologue_title_label, UI_PARCHMENT_TEXT, 26) opening_story_column.add_child(opening_prologue_title_label) var opening_body_panel := PanelContainer.new() opening_body_panel.custom_minimum_size = Vector2(586, 250) _apply_panel_style(opening_body_panel, "edict") opening_story_column.add_child(opening_body_panel) var opening_body_row := HBoxContainer.new() opening_body_row.add_theme_constant_override("separation", 10) opening_body_panel.add_child(opening_body_row) opening_body_row.add_child(_make_edict_marker_stack(["", "", ""], 206)) opening_prologue_body_label = Label.new() opening_prologue_body_label.custom_minimum_size = Vector2(486, 206) opening_prologue_body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART opening_prologue_body_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(opening_prologue_body_label, UI_AGED_INK, 20) opening_body_row.add_child(opening_prologue_body_label) opening_body_row.add_child(_make_edict_marker_stack(["", "", ""], 206)) opening_prologue_step_label = Label.new() opening_prologue_step_label.custom_minimum_size = Vector2(586, 26) opening_prologue_step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER opening_prologue_step_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(opening_prologue_step_label, UI_OLD_BRONZE, 14) opening_story_column.add_child(opening_prologue_step_label) var opening_button_row := HBoxContainer.new() opening_button_row.custom_minimum_size = Vector2(586, 44) opening_button_row.add_theme_constant_override("separation", 10) opening_story_column.add_child(opening_button_row) opening_prologue_skip_button = Button.new() opening_prologue_skip_button.text = "건너뛰기" opening_prologue_skip_button.custom_minimum_size = Vector2(188, 42) opening_prologue_skip_button.tooltip_text = "오프닝을 닫고 군막으로 이동합니다." opening_prologue_skip_button.pressed.connect(_on_opening_prologue_skip_pressed) opening_button_row.add_child(opening_prologue_skip_button) opening_prologue_next_button = Button.new() opening_prologue_next_button.text = "다음" opening_prologue_next_button.custom_minimum_size = Vector2(388, 42) opening_prologue_next_button.tooltip_text = "다음 이야기로 넘깁니다." opening_prologue_next_button.pressed.connect(_on_opening_prologue_next_pressed) opening_button_row.add_child(opening_prologue_next_button) opening_story_column.add_child(_make_scroll_rod(586, 5)) 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", 6) dialogue_row.add_child(dialogue_column) dialogue_right_tassel = _make_hanging_tassel(DIALOGUE_PORTRAIT_SIZE.y) dialogue_row.add_child(dialogue_right_tassel) dialogue_seal_ribbon = _make_seal_ribbon(DIALOGUE_TEXT_SIZE.x, 18) dialogue_seal_ribbon.modulate = Color(1.0, 1.0, 1.0, 0.62) 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_label = Label.new() dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 24, 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_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", 6) dialogue_text_panel.add_child(dialogue_text_row) dialogue_text_row.add_child(_make_bamboo_gutter(10, 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 - 44, 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_bamboo_gutter(10, DIALOGUE_TEXT_SIZE.y - 20)) var dialogue_bottom_rule := _make_ink_wash_rule(DIALOGUE_TEXT_SIZE.x, 5) dialogue_bottom_rule.modulate = Color(1.0, 1.0, 1.0, 0.62) dialogue_column.add_child(dialogue_bottom_rule) 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)) var result_middle_column := VBoxContainer.new() result_middle_column.custom_minimum_size = Vector2(436, 198) result_middle_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL result_middle_column.size_flags_vertical = Control.SIZE_EXPAND_FILL result_middle_column.add_theme_constant_override("separation", 8) result_body_row.add_child(result_middle_column) result_reward_strip = HBoxContainer.new() result_reward_strip.visible = false result_reward_strip.custom_minimum_size = Vector2(436, 52) result_reward_strip.add_theme_constant_override("separation", 8) result_reward_strip.tooltip_text = "이번 전투에서 정리된 전리품과 명부 변화입니다." result_middle_column.add_child(result_reward_strip) result_text_scroll = ScrollContainer.new() result_text_scroll.custom_minimum_size = Vector2(436, 138) result_text_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL result_text_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL result_text_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED result_middle_column.add_child(result_text_scroll) 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(420, 138) 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_text_scroll.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) title_screen_root = Control.new() title_screen_root.name = "TitleScreen" title_screen_root.visible = false title_screen_root.mouse_filter = Control.MOUSE_FILTER_STOP title_screen_root.set_anchors_preset(Control.PRESET_FULL_RECT) root.add_child(title_screen_root) title_background_texture_rect = TextureRect.new() title_background_texture_rect.name = "TitleBattlefieldBackdrop" title_background_texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE title_background_texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT) title_background_texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE title_background_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED title_background_texture_rect.texture = _load_art_texture(TITLE_BACKGROUND_PATH) title_screen_root.add_child(title_background_texture_rect) var title_dim := ColorRect.new() title_dim.name = "TitleInkWash" title_dim.color = Color(0.025, 0.016, 0.010, 0.48) title_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE title_dim.set_anchors_preset(Control.PRESET_FULL_RECT) title_screen_root.add_child(title_dim) var title_menu_shadow := ColorRect.new() title_menu_shadow.name = "TitleMenuInkPanel" title_menu_shadow.position = Vector2(0, 0) title_menu_shadow.size = Vector2(790, 720) title_menu_shadow.color = Color(0.030, 0.018, 0.010, 0.58) title_menu_shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE title_screen_root.add_child(title_menu_shadow) var title_portrait_shadow := ColorRect.new() title_portrait_shadow.name = "TitlePortraitLacquerField" title_portrait_shadow.position = Vector2(770, 36) title_portrait_shadow.size = Vector2(430, 628) title_portrait_shadow.color = Color(0.045, 0.018, 0.010, 0.56) title_portrait_shadow.mouse_filter = Control.MOUSE_FILTER_IGNORE title_screen_root.add_child(title_portrait_shadow) title_portrait_texture_rect = TextureRect.new() title_portrait_texture_rect.name = "TitleCaoCaoPortrait" title_portrait_texture_rect.position = Vector2(804, 48) title_portrait_texture_rect.size = Vector2(388, 606) title_portrait_texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE title_portrait_texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE title_portrait_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED title_portrait_texture_rect.texture = _load_art_texture(TITLE_PORTRAIT_PATH) title_portrait_texture_rect.modulate = Color(1.0, 0.95, 0.86, 0.94) title_screen_root.add_child(title_portrait_texture_rect) var title_portrait_caption := Label.new() title_portrait_caption.name = "TitlePortraitCaption" title_portrait_caption.text = "조조" title_portrait_caption.position = Vector2(818, 604) title_portrait_caption.size = Vector2(350, 34) title_portrait_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT title_portrait_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title_portrait_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE _apply_label_style(title_portrait_caption, UI_OLD_BRONZE, 18) title_screen_root.add_child(title_portrait_caption) title_panel = PanelContainer.new() title_panel.name = "TitleMenuPanel" title_panel.position = Vector2(58, 76) title_panel.size = Vector2(708, 568) title_panel.custom_minimum_size = Vector2(708, 568) _apply_panel_style(title_panel, "paper") title_screen_root.add_child(title_panel) var title_column := VBoxContainer.new() title_column.alignment = BoxContainer.ALIGNMENT_CENTER title_column.add_theme_constant_override("separation", 10) title_panel.add_child(title_column) title_column.add_child(_make_scroll_rod(616, 5)) title_column.add_child(_make_seal_ribbon(616, 18)) title_caption_label = Label.new() title_caption_label.text = "조조전" title_caption_label.custom_minimum_size = Vector2(616, 58) title_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_caption_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_caption_label, UI_PARCHMENT_TEXT, 34) title_column.add_child(title_caption_label) title_subtitle_label = Label.new() title_subtitle_label.text = "난세를 여는 첫 군령" title_subtitle_label.custom_minimum_size = Vector2(616, 34) title_subtitle_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_subtitle_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_subtitle_label, UI_OLD_BRONZE, 17) title_column.add_child(title_subtitle_label) var title_body_panel := PanelContainer.new() title_body_panel.custom_minimum_size = Vector2(616, 314) _apply_panel_style(title_body_panel, "edict") title_column.add_child(title_body_panel) var title_body_row := HBoxContainer.new() title_body_row.add_theme_constant_override("separation", 10) title_body_panel.add_child(title_body_row) title_body_row.add_child(_make_edict_marker_stack(["", "", ""], 276)) var title_menu_column := VBoxContainer.new() title_menu_column.add_theme_constant_override("separation", 10) title_body_row.add_child(title_menu_column) title_status_label = Label.new() title_status_label.custom_minimum_size = Vector2(432, 60) title_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _apply_label_style(title_status_label, UI_AGED_INK, 14) title_menu_column.add_child(title_status_label) title_start_button = Button.new() title_start_button.text = "처음부터 시작" title_start_button.custom_minimum_size = Vector2(432, 48) title_start_button.tooltip_text = "새 전기를 열고 첫 전장부터 시작합니다." title_start_button.pressed.connect(_on_title_start_pressed) title_menu_column.add_child(title_start_button) title_load_button = Button.new() title_load_button.text = "게임 로드" title_load_button.custom_minimum_size = Vector2(432, 48) title_load_button.tooltip_text = "저장된 전기를 불러옵니다." title_load_button.pressed.connect(_on_title_load_pressed) title_menu_column.add_child(title_load_button) title_settings_button = Button.new() title_settings_button.text = "환경 설정" title_settings_button.custom_minimum_size = Vector2(432, 48) title_settings_button.tooltip_text = "소리와 전장 조작 옵션을 바꿉니다." title_settings_button.pressed.connect(_on_title_settings_pressed) title_menu_column.add_child(title_settings_button) title_load_panel = PanelContainer.new() title_load_panel.visible = false title_load_panel.custom_minimum_size = Vector2(432, 188) _apply_panel_style(title_load_panel, "hud_info") title_menu_column.add_child(title_load_panel) var title_load_column := VBoxContainer.new() title_load_column.add_theme_constant_override("separation", 8) title_load_panel.add_child(title_load_column) var title_load_caption := Label.new() title_load_caption.text = "불러올 기록" title_load_caption.custom_minimum_size = Vector2(392, 24) title_load_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_load_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_load_caption, UI_OLD_BRONZE, 15) title_load_column.add_child(title_load_caption) title_load_auto_button = Button.new() title_load_auto_button.custom_minimum_size = Vector2(392, 38) title_load_auto_button.tooltip_text = "자동으로 봉인된 진행 기록을 엽니다." title_load_auto_button.pressed.connect(_on_title_load_auto_pressed) title_load_column.add_child(title_load_auto_button) title_load_manual_button = Button.new() title_load_manual_button.custom_minimum_size = Vector2(392, 38) title_load_manual_button.tooltip_text = "군막에서 직접 남긴 수동 봉인을 엽니다." title_load_manual_button.pressed.connect(_on_title_load_manual_pressed) title_load_column.add_child(title_load_manual_button) title_load_back_button = Button.new() title_load_back_button.text = "뒤로" title_load_back_button.custom_minimum_size = Vector2(392, 34) title_load_back_button.tooltip_text = "처음 화면 선택으로 돌아갑니다." title_load_back_button.pressed.connect(_on_title_load_back_pressed) title_load_column.add_child(title_load_back_button) title_settings_panel = PanelContainer.new() title_settings_panel.visible = false title_settings_panel.custom_minimum_size = Vector2(432, 236) _apply_panel_style(title_settings_panel, "hud_info") title_menu_column.add_child(title_settings_panel) var title_settings_column := VBoxContainer.new() title_settings_column.add_theme_constant_override("separation", 6) title_settings_panel.add_child(title_settings_column) var title_settings_caption := Label.new() title_settings_caption.text = "환경 설정" title_settings_caption.custom_minimum_size = Vector2(392, 24) title_settings_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title_settings_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_settings_caption, UI_OLD_BRONZE, 15) title_settings_column.add_child(title_settings_caption) title_bgm_toggle = CheckButton.new() title_bgm_toggle.text = "배경음" title_bgm_toggle.custom_minimum_size = Vector2(392, 30) title_bgm_toggle.button_pressed = title_bgm_enabled title_bgm_toggle.tooltip_text = "배경 음악을 켜거나 끕니다." title_bgm_toggle.toggled.connect(_on_title_bgm_toggled) title_settings_column.add_child(title_bgm_toggle) var title_bgm_volume_row := HBoxContainer.new() title_bgm_volume_row.custom_minimum_size = Vector2(392, 34) title_bgm_volume_row.add_theme_constant_override("separation", 8) title_settings_column.add_child(title_bgm_volume_row) var title_bgm_volume_caption := Label.new() title_bgm_volume_caption.text = "배경음 크기" title_bgm_volume_caption.custom_minimum_size = Vector2(94, 30) title_bgm_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_bgm_volume_caption, UI_PARCHMENT_TEXT, 13) title_bgm_volume_row.add_child(title_bgm_volume_caption) title_bgm_volume_slider = HSlider.new() title_bgm_volume_slider.min_value = 0.0 title_bgm_volume_slider.max_value = 100.0 title_bgm_volume_slider.step = 5.0 title_bgm_volume_slider.value = float(title_bgm_volume_percent) title_bgm_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL title_bgm_volume_slider.custom_minimum_size = Vector2(216, 30) title_bgm_volume_slider.tooltip_text = "배경 음악의 크기를 조절합니다." title_bgm_volume_slider.value_changed.connect(_on_title_bgm_volume_changed) title_bgm_volume_row.add_child(title_bgm_volume_slider) title_bgm_volume_value_label = Label.new() title_bgm_volume_value_label.custom_minimum_size = Vector2(48, 30) title_bgm_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT title_bgm_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_bgm_volume_value_label, UI_PARCHMENT_TEXT, 13) title_bgm_volume_row.add_child(title_bgm_volume_value_label) title_sfx_toggle = CheckButton.new() title_sfx_toggle.text = "효과음" title_sfx_toggle.custom_minimum_size = Vector2(392, 30) title_sfx_toggle.button_pressed = title_sfx_enabled title_sfx_toggle.tooltip_text = "버튼과 전투 효과음을 켜거나 끕니다." title_sfx_toggle.toggled.connect(_on_title_sfx_toggled) title_settings_column.add_child(title_sfx_toggle) var title_sfx_volume_row := HBoxContainer.new() title_sfx_volume_row.custom_minimum_size = Vector2(392, 34) title_sfx_volume_row.add_theme_constant_override("separation", 8) title_settings_column.add_child(title_sfx_volume_row) var title_sfx_volume_caption := Label.new() title_sfx_volume_caption.text = "효과음 크기" title_sfx_volume_caption.custom_minimum_size = Vector2(94, 30) title_sfx_volume_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_sfx_volume_caption, UI_PARCHMENT_TEXT, 13) title_sfx_volume_row.add_child(title_sfx_volume_caption) title_sfx_volume_slider = HSlider.new() title_sfx_volume_slider.min_value = 0.0 title_sfx_volume_slider.max_value = 100.0 title_sfx_volume_slider.step = 5.0 title_sfx_volume_slider.value = float(title_sfx_volume_percent) title_sfx_volume_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL title_sfx_volume_slider.custom_minimum_size = Vector2(216, 30) title_sfx_volume_slider.tooltip_text = "버튼과 전투 효과음의 크기를 조절합니다." title_sfx_volume_slider.value_changed.connect(_on_title_sfx_volume_changed) title_sfx_volume_row.add_child(title_sfx_volume_slider) title_sfx_volume_value_label = Label.new() title_sfx_volume_value_label.custom_minimum_size = Vector2(48, 30) title_sfx_volume_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT title_sfx_volume_value_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(title_sfx_volume_value_label, UI_PARCHMENT_TEXT, 13) title_sfx_volume_row.add_child(title_sfx_volume_value_label) title_edge_scroll_toggle = CheckButton.new() title_edge_scroll_toggle.text = "가장자리 스크롤" title_edge_scroll_toggle.custom_minimum_size = Vector2(392, 30) title_edge_scroll_toggle.button_pressed = title_edge_scroll_enabled title_edge_scroll_toggle.tooltip_text = "마우스를 전장 가장자리에 두면 화면을 움직입니다." title_edge_scroll_toggle.toggled.connect(_on_title_edge_scroll_toggled) title_settings_column.add_child(title_edge_scroll_toggle) title_settings_back_button = Button.new() title_settings_back_button.text = "설정 닫기" title_settings_back_button.custom_minimum_size = Vector2(432, 34) title_settings_back_button.tooltip_text = "환경 설정을 접습니다." title_settings_back_button.pressed.connect(_on_title_settings_back_pressed) title_menu_column.add_child(title_settings_back_button) title_body_row.add_child(_make_edict_marker_stack(["", "", ""], 276)) title_column.add_child(_make_scroll_rod(616, 4)) 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, top_save_button, top_load_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, opening_prologue_skip_button, opening_prologue_next_button, top_settings_button, title_start_button, title_load_button, title_settings_button, title_load_auto_button, title_load_manual_button, title_load_back_button, title_settings_back_button ]: _apply_button_style(hud_button) _apply_button_style(title_bgm_toggle) _apply_button_style(title_sfx_toggle) _apply_button_style(title_edge_scroll_toggle) _apply_button_style(settings_bgm_toggle) _apply_button_style(settings_sfx_toggle) _apply_button_style(settings_edge_scroll_toggle) _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) _apply_audio_settings() func _load_title_settings() -> void: if not FileAccess.file_exists(SETTINGS_PATH): return var parsed = JSON.parse_string(FileAccess.get_file_as_string(SETTINGS_PATH)) if not parsed is Dictionary: return title_bgm_enabled = bool(parsed.get("bgm_enabled", title_bgm_enabled)) title_sfx_enabled = bool(parsed.get("sfx_enabled", title_sfx_enabled)) title_edge_scroll_enabled = bool(parsed.get("edge_scroll_enabled", title_edge_scroll_enabled)) title_bgm_volume_percent = _clamp_volume_percent(parsed.get("bgm_volume_percent", title_bgm_volume_percent)) title_sfx_volume_percent = _clamp_volume_percent(parsed.get("sfx_volume_percent", title_sfx_volume_percent)) func _save_title_settings() -> bool: var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) if file == null: push_warning("Unable to save title settings: %s" % SETTINGS_PATH) return false var payload := { "save_version": SETTINGS_SAVE_VERSION, "bgm_enabled": title_bgm_enabled, "bgm_volume_percent": title_bgm_volume_percent, "sfx_enabled": title_sfx_enabled, "sfx_volume_percent": title_sfx_volume_percent, "edge_scroll_enabled": title_edge_scroll_enabled } file.store_string(JSON.stringify(payload, "\t")) return true func _apply_audio_settings() -> void: if bgm_player != null: bgm_player.volume_db = _bgm_volume_db() if not title_bgm_enabled: bgm_player.stop() func _clamp_volume_percent(value) -> int: return mini(maxi(int(value), 0), 100) func _bgm_volume_db() -> float: return _volume_percent_to_db(BGM_VOLUME_DB, title_bgm_volume_percent) func _effective_sfx_volume_db(base_volume_db: float) -> float: return _volume_percent_to_db(base_volume_db, title_sfx_volume_percent) func _volume_percent_to_db(base_volume_db: float, percent: int) -> float: var clamped := _clamp_volume_percent(percent) if clamped <= 0: return AUDIO_MUTED_DB return base_volume_db + linear_to_db(float(clamped) / 100.0) func _refresh_title_volume_controls() -> void: if settings_bgm_toggle != null: settings_bgm_toggle.set_pressed_no_signal(title_bgm_enabled) if settings_sfx_toggle != null: settings_sfx_toggle.set_pressed_no_signal(title_sfx_enabled) if settings_edge_scroll_toggle != null: settings_edge_scroll_toggle.set_pressed_no_signal(title_edge_scroll_enabled) if title_bgm_volume_slider != null: title_bgm_volume_slider.set_value_no_signal(float(title_bgm_volume_percent)) title_bgm_volume_slider.editable = title_bgm_enabled if settings_bgm_volume_slider != null: settings_bgm_volume_slider.set_value_no_signal(float(title_bgm_volume_percent)) settings_bgm_volume_slider.editable = title_bgm_enabled if title_sfx_volume_slider != null: title_sfx_volume_slider.set_value_no_signal(float(title_sfx_volume_percent)) title_sfx_volume_slider.editable = title_sfx_enabled if settings_sfx_volume_slider != null: settings_sfx_volume_slider.set_value_no_signal(float(title_sfx_volume_percent)) settings_sfx_volume_slider.editable = title_sfx_enabled if title_bgm_volume_value_label != null: title_bgm_volume_value_label.text = "%d%%" % title_bgm_volume_percent if settings_bgm_volume_value_label != null: settings_bgm_volume_value_label.text = "%d%%" % title_bgm_volume_percent if title_sfx_volume_value_label != null: title_sfx_volume_value_label.text = "%d%%" % title_sfx_volume_percent if settings_sfx_volume_value_label != null: settings_sfx_volume_value_label.text = "%d%%" % title_sfx_volume_percent func _on_bgm_finished() -> void: if not title_bgm_enabled: return 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 not title_bgm_enabled: current_bgm_key = key bgm_player.stop() return bgm_player.volume_db = _bgm_volume_db() 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: if not title_sfx_enabled: return 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 = _effective_sfx_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 _has_pending_move() and not basic_attack_targeting and _try_auto_move_attack(selected_unit, clicked_unit): 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 _is_auto_end_turn_prompt_visible(): _on_auto_end_turn_no_pressed() return true 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 battle_unit_list_panel != null and battle_unit_list_panel.visible: _play_ui_cancel() _hide_battle_unit_list_panel() _update_hud() queue_redraw() return true if settings_panel != null and settings_panel.visible: _play_ui_cancel() _hide_settings_panel() _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 settings_panel != null and settings_panel.visible: _hide_settings_panel() _update_hud() queue_redraw() elif briefing_panel != null and briefing_panel.visible: if _set_briefing_objective_collapsed(true): _play_ui_cancel() return _on_begin_battle_pressed() elif battle_unit_list_panel != null and battle_unit_list_panel.visible: _hide_battle_unit_list_panel() _update_hud() queue_redraw() 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 var had_sequence_presentation := _has_active_battle_presentation_sequence() if _should_animate_unit_idle(): unit_idle_time += delta 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 _activate_presentation_focus_if_ready(next_popup) 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 _activate_presentation_focus_if_ready(next_motion) 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 _activate_presentation_focus_if_ready(next_motion) 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() if had_sequence_presentation and not _has_active_battle_presentation_sequence(): if status_label != null: _update_hud() queue_redraw() func _should_animate_unit_idle() -> bool: return battle_started and state.battle_status == BattleState.STATUS_ACTIVE and not _is_dialogue_visible() 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 view_rect := _map_view_rect() var visible_rect := board_rect.intersection(view_rect) var background := _current_battle_background_texture() var has_background := background != null draw_rect(view_rect, Color(0.025, 0.020, 0.014, 0.96)) if visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0: return if background != null: _draw_texture_cover_region(background, visible_rect, board_rect, Color(1.0, 1.0, 1.0, 0.96)) draw_rect(visible_rect, Color(0.02, 0.025, 0.03, 0.12)) draw_rect(visible_rect, Color(0.35, 0.22, 0.10, 0.10)) else: draw_rect(visible_rect, Color(0.18, 0.26, 0.18)) var bounds := _visible_cell_bounds() for y in range(bounds.position.y, bounds.position.y + bounds.size.y): for x in range(bounds.position.x, bounds.position.x + bounds.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(visible_rect, has_background) 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_recovery_terrain_markers(bounds: Rect2i) -> void: var font := _draw_ui_font(true) for y in range(bounds.position.y, bounds.position.y + bounds.size.y): for x in range(bounds.position.x, bounds.position.x + bounds.size.x): var cell := Vector2i(x, y) var marker_text := _recovery_marker_text(cell) if marker_text.is_empty(): continue var marker_rect := _recovery_marker_rect(cell) draw_rect(marker_rect, Color(0.035, 0.022, 0.012, 0.74)) draw_rect(marker_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88), false, 1.4) var plus_center := marker_rect.position + Vector2(8.0, marker_rect.size.y * 0.5) var plus_color := Color(0.54, 0.86, 0.48, 0.92) draw_line(plus_center + Vector2(-3.0, 0.0), plus_center + Vector2(3.0, 0.0), plus_color, 1.6) draw_line(plus_center + Vector2(0.0, -3.0), plus_center + Vector2(0.0, 3.0), plus_color, 1.6) _draw_ink_text(font, marker_rect.position + Vector2(9.0, 13.0), marker_text, HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x - 9.0, 11, Color(0.78, 0.98, 0.62, 0.96)) func _recovery_marker_text(cell: Vector2i) -> String: var heal := state.get_terrain_heal(cell) if heal <= 0: return "" return "+%d" % heal func _recovery_marker_rect(cell: Vector2i) -> Rect2: var cell_rect := _rect_for_cell(cell) var size := Vector2(34.0, 16.0) return Rect2(cell_rect.position + Vector2(TILE_SIZE - size.x - 4.0, 4.0), size) 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 _draw_map_view_chrome() -> void: var view_rect := _map_view_rect() var viewport_size := _viewport_size_for_layout() var mask := Color(0.018, 0.012, 0.008, 0.94) draw_rect(Rect2(Vector2.ZERO, Vector2(viewport_size.x, view_rect.position.y)), mask) draw_rect(Rect2(Vector2.ZERO, Vector2(view_rect.position.x, viewport_size.y)), mask) draw_rect(Rect2(Vector2(view_rect.end.x, 0.0), Vector2(maxf(0.0, viewport_size.x - view_rect.end.x), viewport_size.y)), mask) draw_rect(Rect2(Vector2(view_rect.position.x, view_rect.end.y), Vector2(view_rect.size.x, maxf(0.0, viewport_size.y - view_rect.end.y))), mask) _draw_edge_scroll_cues(view_rect) _draw_silk_map_frame(view_rect) func _draw_edge_scroll_cues(view_rect: Rect2) -> void: if not title_edge_scroll_enabled: return if not _is_minimap_visible(): return var min_offset := _clamped_board_scroll_offset(Vector2(-9999.0, -9999.0)) var mouse_position := view_rect.get_center() if is_inside_tree(): mouse_position = get_viewport().get_mouse_position() var active_sides := _edge_scroll_active_sides(mouse_position, view_rect) if board_scroll_offset.x < -0.5: var active_left := bool(active_sides.get("left", false)) var strip_left := _edge_scroll_cue_strip_width(active_left) var cue_color_left := _edge_scroll_cue_fill_color(active_left) var line_color_left := _edge_scroll_cue_line_color(active_left) draw_rect(Rect2(view_rect.position, Vector2(strip_left, view_rect.size.y)), cue_color_left) draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 - 8.0), line_color_left, 2.0) draw_line(view_rect.position + Vector2(5.0, view_rect.size.y * 0.5), view_rect.position + Vector2(11.0, view_rect.size.y * 0.5 + 8.0), line_color_left, 2.0) if board_scroll_offset.x > min_offset.x + 0.5: var active_right := bool(active_sides.get("right", false)) var strip_right := _edge_scroll_cue_strip_width(active_right) var cue_color_right := _edge_scroll_cue_fill_color(active_right) var line_color_right := _edge_scroll_cue_line_color(active_right) draw_rect(Rect2(Vector2(view_rect.end.x - strip_right, view_rect.position.y), Vector2(strip_right, view_rect.size.y)), cue_color_right) draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 - 8.0), line_color_right, 2.0) draw_line(Vector2(view_rect.end.x - 5.0, view_rect.position.y + view_rect.size.y * 0.5), Vector2(view_rect.end.x - 11.0, view_rect.position.y + view_rect.size.y * 0.5 + 8.0), line_color_right, 2.0) if board_scroll_offset.y < -0.5: var active_up := bool(active_sides.get("up", false)) var strip_up := _edge_scroll_cue_strip_width(active_up) var cue_color_up := _edge_scroll_cue_fill_color(active_up) var line_color_up := _edge_scroll_cue_line_color(active_up) draw_rect(Rect2(view_rect.position, Vector2(view_rect.size.x, strip_up)), cue_color_up) draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 - 8.0, 11.0), line_color_up, 2.0) draw_line(view_rect.position + Vector2(view_rect.size.x * 0.5, 5.0), view_rect.position + Vector2(view_rect.size.x * 0.5 + 8.0, 11.0), line_color_up, 2.0) if board_scroll_offset.y > min_offset.y + 0.5: var active_down := bool(active_sides.get("down", false)) var strip_down := _edge_scroll_cue_strip_width(active_down) var cue_color_down := _edge_scroll_cue_fill_color(active_down) var line_color_down := _edge_scroll_cue_line_color(active_down) draw_rect(Rect2(Vector2(view_rect.position.x, view_rect.end.y - strip_down), Vector2(view_rect.size.x, strip_down)), cue_color_down) draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 - 8.0, view_rect.end.y - 11.0), line_color_down, 2.0) draw_line(Vector2(view_rect.position.x + view_rect.size.x * 0.5, view_rect.end.y - 5.0), Vector2(view_rect.position.x + view_rect.size.x * 0.5 + 8.0, view_rect.end.y - 11.0), line_color_down, 2.0) func _edge_scroll_active_sides(mouse_position: Vector2, view_rect: Rect2) -> Dictionary: var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect) return { "left": velocity.x > 0.0, "right": velocity.x < 0.0, "up": velocity.y > 0.0, "down": velocity.y < 0.0 } func _edge_scroll_cue_strip_width(active: bool) -> float: return 20.0 if active else 14.0 func _edge_scroll_cue_fill_color(active: bool) -> Color: return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34 if active else 0.18) func _edge_scroll_cue_line_color(active: bool) -> Color: return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.62 if active else 0.36) func _draw_minimap() -> void: if not _is_minimap_visible(): return var rect := _minimap_rect() var map_rect := _minimap_map_rect() draw_rect(rect, Color(0.040, 0.024, 0.014, 0.94)) draw_rect(rect, Color(UI_TARNISHED_BRONZE.r, UI_TARNISHED_BRONZE.g, UI_TARNISHED_BRONZE.b, 0.92), false, 3.0) draw_rect(Rect2(rect.position + Vector2(4.0, 4.0), Vector2(rect.size.x - 8.0, MINIMAP_TITLE_HEIGHT + 2.0)), Color(0.12, 0.055, 0.026, 0.72)) var font := _draw_ui_font(true) _draw_ink_text(font, rect.position + Vector2(0.0, 19.0), "전장도", HORIZONTAL_ALIGNMENT_CENTER, rect.size.x, 13, UI_PARCHMENT_TEXT) draw_rect(map_rect, Color(0.075, 0.070, 0.050, 0.94)) for y in range(state.map_size.y): for x in range(state.map_size.x): var cell := Vector2i(x, y) var terrain_color: Color = state.get_terrain_color(cell) terrain_color.a = 0.76 draw_rect(_minimap_cell_rect(cell), terrain_color) _draw_minimap_event_markers() _draw_minimap_objective_markers() for unit in state.get_living_units(): var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) if not state.is_inside(cell): continue var dot_color := PLAYER_COLOR.lightened(0.36) if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR.lightened(0.30) var cell_rect := _minimap_cell_rect(cell) var radius := maxf(2.4, minf(cell_rect.size.x, cell_rect.size.y) * 0.70) draw_circle(cell_rect.get_center(), radius + 1.1, Color(0.020, 0.014, 0.010, 0.82)) draw_circle(cell_rect.get_center(), radius, dot_color) var current_view := _minimap_view_rect() if current_view.size.x > 0.0 and current_view.size.y > 0.0: draw_rect(current_view, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.22)) draw_rect(current_view, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.98), false, 2.0) draw_rect(map_rect, Color(UI_BRUSH_RULE.r, UI_BRUSH_RULE.g, UI_BRUSH_RULE.b, 0.90), false, 1.5) func _draw_minimap_objective_markers() -> void: for cell in state.get_objective_cells(): if not state.is_inside(cell): continue var marker_rect := _minimap_marker_rect(cell, 1.36, 5.8, 9.0) var center := marker_rect.get_center() var diamond := PackedVector2Array([ Vector2(center.x, marker_rect.position.y), Vector2(marker_rect.end.x, center.y), Vector2(center.x, marker_rect.end.y), Vector2(marker_rect.position.x, center.y) ]) var outline := PackedVector2Array([diamond[0], diamond[1], diamond[2], diamond[3], diamond[0]]) draw_colored_polygon(diamond, _minimap_objective_marker_fill_color()) draw_polyline(outline, _minimap_marker_outline_color(), 1.1) func _draw_minimap_event_markers() -> void: for marker in state.get_event_markers(): var cells = marker.get("cells", []) if typeof(cells) != TYPE_ARRAY: continue var kind := str(marker.get("kind", "event")) for cell in cells: if typeof(cell) != TYPE_VECTOR2I or not state.is_inside(cell): continue var marker_rect := _minimap_marker_rect(cell, 1.05, 4.8, 7.0) draw_rect(marker_rect, Color(0.020, 0.014, 0.010, 0.84)) draw_rect(marker_rect.grow(-1.0), _minimap_event_marker_fill_color(kind)) draw_rect(marker_rect, _minimap_marker_outline_color(), false, 1.0) func _minimap_marker_rect(cell: Vector2i, scale: float = 1.0, min_size: float = 4.0, max_size: float = 8.0) -> Rect2: var cell_rect := _minimap_cell_rect(cell) var size := clampf(minf(cell_rect.size.x, cell_rect.size.y) * scale, min_size, max_size) var map_rect := _minimap_map_rect() var position := cell_rect.get_center() - Vector2(size * 0.5, size * 0.5) position.x = clampf(position.x, map_rect.position.x, maxf(map_rect.position.x, map_rect.end.x - size)) position.y = clampf(position.y, map_rect.position.y, maxf(map_rect.position.y, map_rect.end.y - size)) return Rect2(position, Vector2(size, size)) func _minimap_objective_marker_fill_color() -> Color: return Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.92) func _minimap_event_marker_fill_color(kind: String) -> Color: var color := _event_marker_color(kind) return Color(color.r, color.g, color.b, 0.90) func _minimap_marker_outline_color() -> Color: return Color(UI_PARCHMENT_TEXT.r, UI_PARCHMENT_TEXT.g, UI_PARCHMENT_TEXT.b, 0.88) 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 source_rect := _texture_cover_source_rect(texture, rect) if source_rect.size.x <= 0.0 or source_rect.size.y <= 0.0: return draw_texture_rect_region(texture, rect, source_rect, modulate) func _draw_texture_cover_region(texture: Texture2D, visible_rect: Rect2, full_rect: Rect2, modulate: Color) -> void: var source_rect := _texture_cover_region_source_rect(texture, visible_rect, full_rect) if source_rect.size.x <= 0.0 or source_rect.size.y <= 0.0: return draw_texture_rect_region(texture, visible_rect, source_rect, modulate) func _texture_cover_source_rect(texture: Texture2D, rect: Rect2) -> Rect2: 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 Rect2() 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 return source_rect func _texture_cover_region_source_rect(texture: Texture2D, visible_rect: Rect2, full_rect: Rect2) -> Rect2: if full_rect.size.x <= 0.0 or full_rect.size.y <= 0.0 or visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0: return Rect2() var full_source := _texture_cover_source_rect(texture, full_rect) if full_source.size.x <= 0.0 or full_source.size.y <= 0.0: return Rect2() var relative_position := Vector2( (visible_rect.position.x - full_rect.position.x) / full_rect.size.x, (visible_rect.position.y - full_rect.position.y) / full_rect.size.y ) var relative_size := Vector2(visible_rect.size.x / full_rect.size.x, visible_rect.size.y / full_rect.size.y) return Rect2( full_source.position + Vector2(full_source.size.x * relative_position.x, full_source.size.y * relative_position.y), Vector2(full_source.size.x * relative_size.x, full_source.size.y * relative_size.y) ) 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) or not _cell_is_in_visible_bounds(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_event_foreground_markers() -> void: var font := _draw_ui_font(true) for marker in state.get_event_markers(): var cells = marker.get("cells", []) if typeof(cells) != TYPE_ARRAY: continue for cell in cells: if typeof(cell) != TYPE_VECTOR2I or not state.is_inside(cell) or not _cell_is_in_visible_bounds(cell): continue _draw_event_foreground_marker(_rect_for_cell(cell), str(marker.get("label", "보급")), str(marker.get("kind", "event")), font) func _draw_event_foreground_marker(rect: Rect2, label: String, kind: String, font: Font) -> void: var color := _event_marker_color(kind) var marker_rect := _event_marker_rect(rect) draw_rect(marker_rect, Color(0.035, 0.022, 0.012, 0.76)) draw_rect(marker_rect.grow(-2.0), Color(color.r, color.g, color.b, 0.72)) draw_rect(marker_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88), false, 1.2) var text := _event_marker_abbrev(label, kind) _draw_ink_text(font, marker_rect.position + Vector2(0, 12), text, HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 11, UI_PARCHMENT_TEXT) var knot := marker_rect.get_center() + Vector2(0.0, -8.0) draw_line(knot + Vector2(-5.0, 0.0), knot + Vector2(5.0, 0.0), Color(0.10, 0.05, 0.02, 0.70), 1.2) draw_line(knot + Vector2(0.0, -4.0), knot + Vector2(0.0, 4.0), Color(0.10, 0.05, 0.02, 0.56), 1.0) func _event_marker_rect(cell_rect: Rect2) -> Rect2: return Rect2(cell_rect.position + Vector2(4.0, 26.0), Vector2(23.0, 17.0)) func _event_marker_color(kind: String) -> Color: if kind == "gold": return Color(0.92, 0.62, 0.18, 1.0) if kind == "supply": return Color(0.38, 0.62, 0.34, 1.0) return Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 1.0) func _event_marker_abbrev(label: String, kind: String) -> String: if kind == "gold": return "금" var text := label.strip_edges() if text.is_empty(): return "보" if kind == "supply" else "표" if text.contains("마을"): return "마" if text.contains("숲"): return "숲" if text.contains("보급"): return "보" return text.substr(0, 1) 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) if not rect.intersects(_map_view_rect().grow(TILE_SIZE)): continue 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, 18), 22, Color(0.0, 0.0, 0.0, 0.28)) _draw_unit_class_emblem(rect, center, unit, team_color) if not _draw_high_res_unit_sprite(rect, unit, sprite_modulate, _unit_pixel_facing(unit)): _draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate, _unit_pixel_facing(unit)) _draw_unit_identity_marks(rect, center, unit, team_color) _draw_unit_class_badge(rect, unit, team_color) _draw_unit_facing_marker(rect, unit, team_color, sprite_modulate) 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: var markers := _target_selection_marker_entries() if markers.is_empty(): return var font := _draw_ui_font(true) for marker in markers: var cell: Vector2i = marker.get("cell", Vector2i(-1, -1)) if not state.is_inside(cell) or not _cell_is_in_visible_bounds(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"))) if bool(marker.get("requires_move", false)): _draw_auto_attack_origin_marker(marker, color, font) 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_auto_attack_origin_marker(marker: Dictionary, target_color: Color, font: Font) -> void: var origin: Vector2i = marker.get("origin", Vector2i(-1, -1)) var target_cell: Vector2i = marker.get("cell", Vector2i(-1, -1)) if not state.is_inside(origin) or not state.is_inside(target_cell): return if _cell_is_in_visible_bounds(origin) and _cell_is_in_visible_bounds(target_cell): var route_points := _auto_attack_route_points(origin, target_cell) if route_points.size() >= 2: var from_point := route_points[0] var to_point := route_points[1] var route_color := Color(target_color.r, target_color.g, target_color.b, 0.62) draw_line(from_point, to_point, Color(0.02, 0.014, 0.010, 0.82), 4.0) draw_line(from_point, to_point, route_color, 2.2) var direction := (to_point - from_point).normalized() if direction.length() > 0.0: var side := Vector2(-direction.y, direction.x) draw_line(to_point, to_point - direction * 8.5 + side * 4.2, route_color, 2.0) draw_line(to_point, to_point - direction * 8.5 - side * 4.2, route_color, 2.0) if not _cell_is_in_visible_bounds(origin): return var origin_rect := _rect_for_cell(origin) var move_color := _target_preview_badge_color("move") var marker_rect := _auto_attack_origin_marker_rect(origin) draw_rect(origin_rect.grow(-8.0), Color(move_color.r, move_color.g, move_color.b, 0.17)) draw_rect(origin_rect.grow(-10.0), move_color, false, 1.8) draw_rect(marker_rect, Color(0.025, 0.028, 0.034, 0.86)) draw_rect(marker_rect, move_color, false, 1.2) _draw_ink_text(font, marker_rect.position + Vector2(0, 13), "진", HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 12, move_color) func _auto_attack_origin_marker_rect(origin: Vector2i) -> Rect2: var rect := _rect_for_cell(origin) return Rect2(rect.position + Vector2(7.0, 5.0), Vector2(22.0, 18.0)) func _auto_attack_route_points(origin: Vector2i, target_cell: Vector2i) -> PackedVector2Array: if not state.is_inside(origin) or not state.is_inside(target_cell): return PackedVector2Array() var from_point := _rect_for_cell(origin).get_center() var to_point := _rect_for_cell(target_cell).get_center() var delta := to_point - from_point var distance := delta.length() if distance > 1.0: var direction := delta / distance from_point += direction * 19.0 to_point -= direction * 19.0 return PackedVector2Array([from_point, to_point]) 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() if not state.get_selected_unit().is_empty() and not _has_pending_move(): return _attack_target_marker_entries() return [] func _attack_target_marker_entries() -> Array[Dictionary]: var result: Array[Dictionary] = [] if not basic_attack_targeting and _has_pending_move(): 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)) var directional_text := _format_directional_badge_suffix(preview) var marker_text := "%s%s" % [result_text, directional_text] if not directional_text.is_empty() else "%s %d%%" % [result_text, hit_chance] result.append({ "cell": cell, "target_id": str(target.get("id", "")), "text": marker_text, "kind": _attack_preview_badge_kind(preview) }) if not basic_attack_targeting and not _has_pending_move(): _append_auto_attack_target_marker_entries(result, selected) return result func _append_auto_attack_target_marker_entries(result: Array[Dictionary], selected: Dictionary) -> void: if selected.is_empty() or bool(selected.get("moved", false)) or bool(selected.get("acted", false)): return var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) for target in state.units: if not bool(target.get("alive", false)) or not bool(target.get("deployed", true)): continue if target.get("team", "") == selected.get("team", ""): continue var target_id := str(target.get("id", "")) if target_id.is_empty() or _marker_entries_have_target(result, target_id): continue var attack_origin := _best_auto_attack_origin(selected, target) if attack_origin == Vector2i(-1, -1) or attack_origin == current_cell: continue var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) if not state.is_inside(target_cell): continue var preview := _auto_attack_preview_for_origin(selected, target, attack_origin) if preview.is_empty(): continue result.append({ "cell": target_cell, "target_id": target_id, "text": "행공", "kind": _auto_attack_preview_badge_kind(preview), "origin": attack_origin, "requires_move": true, "damage": int(preview.get("damage", 0)), "counter_damage": int(preview.get("counter_damage", 0)), "counter_in_range": bool(preview.get("counter_in_range", false)), "counter_would_defeat": bool(preview.get("counter_would_defeat", false)) }) func _marker_entries_have_target(entries: Array[Dictionary], target_id: String) -> bool: for entry in entries: if str(entry.get("target_id", "")) == target_id: return true return false 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 _try_auto_move_attack(selected: Dictionary, target: Dictionary) -> bool: if selected.is_empty() or target.is_empty(): return false if bool(selected.get("moved", false)) or bool(selected.get("acted", false)): return false if selected.get("team", "") == target.get("team", ""): return false var unit_id := str(selected.get("id", "")) var target_id := str(target.get("id", "")) if unit_id.is_empty() or target_id.is_empty(): return false var attack_origin := _best_auto_attack_origin(selected, target) if attack_origin == Vector2i(-1, -1): return false _play_ui_confirm() 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() _hide_targeting_hint_panel() if selected.get("pos", Vector2i(-1, -1)) != attack_origin: if not state.try_move_selected(attack_origin, true): _refresh_ranges() _update_hud() queue_redraw() return false if not state.commit_pending_move_events(unit_id): _refresh_ranges() _update_hud() queue_redraw() return true if state.try_attack_selected(target_id): _clear_pending_move_state() _refresh_ranges() _update_hud() queue_redraw() return true _refresh_ranges() _update_hud() queue_redraw() return false func _best_auto_attack_origin(selected: Dictionary, target: Dictionary) -> Vector2i: var unit_id := str(selected.get("id", "")) var target_id := str(target.get("id", "")) if unit_id.is_empty() or target_id.is_empty(): return Vector2i(-1, -1) var base_preview := state.get_damage_preview(unit_id, target_id) if base_preview.is_empty() or bool(base_preview.get("attack_locked", false)): return Vector2i(-1, -1) var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) if not state.is_inside(current_cell) or not state.is_inside(target_cell): return Vector2i(-1, -1) var best_cell := Vector2i(-1, -1) var best_score := -999999 var best_preview := {} var best_move_distance := 999999 for cell in state.get_movement_range(unit_id): if not state.is_inside(cell): continue if cell == target_cell: continue var blocker := state.get_unit_at(cell) if not blocker.is_empty() and str(blocker.get("id", "")) != unit_id: continue var preview := _auto_attack_preview_for_origin(selected, target, cell) if preview.is_empty(): continue var move_distance: int = _manhattan(current_cell, cell) var score: int = _auto_attack_origin_score(preview, move_distance) if best_cell == Vector2i(-1, -1) or _auto_attack_origin_is_better(score, preview, move_distance, best_score, best_preview, best_move_distance): best_score = score best_preview = preview best_move_distance = move_distance best_cell = cell return best_cell func _auto_attack_origin_score(preview: Dictionary, move_distance: int) -> int: var score := int(preview.get("damage", 0)) * 100 + int(preview.get("hit_chance", 0)) - move_distance if bool(preview.get("would_defeat", false)): score += 100000 if bool(preview.get("counter_in_range", false)): score -= 1500 score -= int(preview.get("counter_damage", 0)) * 80 score -= int(preview.get("counter_hit_chance", 0)) * 2 if bool(preview.get("counter_would_defeat", false)): score -= 100000 return score func _auto_attack_origin_is_better(score: int, preview: Dictionary, move_distance: int, best_score: int, best_preview: Dictionary, best_move_distance: int) -> bool: if score != best_score: return score > best_score var counter_defeat := bool(preview.get("counter_would_defeat", false)) var best_counter_defeat := bool(best_preview.get("counter_would_defeat", false)) if counter_defeat != best_counter_defeat: return not counter_defeat var counter_damage := int(preview.get("counter_damage", 0)) var best_counter_damage := int(best_preview.get("counter_damage", 0)) if counter_damage != best_counter_damage: return counter_damage < best_counter_damage var damage := int(preview.get("damage", 0)) var best_damage := int(best_preview.get("damage", 0)) if damage != best_damage: return damage > best_damage return move_distance < best_move_distance func _can_attack_target_from(unit: Dictionary, origin: Vector2i, target_cell: Vector2i) -> bool: var attack_range: int = maxi(1, int(unit.get("range", 1))) var min_range: int = clampi(int(unit.get("min_range", mini(1, attack_range))), 1, attack_range) var distance: int = _manhattan(origin, target_cell) return distance >= min_range and distance <= attack_range func _manhattan(a: Vector2i, b: Vector2i) -> int: return absi(a.x - b.x) + absi(a.y - b.y) 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_high_res_unit_sprite(rect: Rect2, unit: Dictionary, modulate: Color, facing: int = 1) -> bool: var texture := _load_art_texture(str(unit.get("sprite", ""))) if texture == null: return false var sprite_rect := _unit_map_sprite_rect(texture, rect, unit) if sprite_rect.size.x <= 0.0 or sprite_rect.size.y <= 0.0: return false var safe_facing := -1 if facing < 0 else 1 if safe_facing < 0: draw_set_transform(Vector2(sprite_rect.position.x * 2.0 + sprite_rect.size.x, 0.0), 0.0, Vector2(-1.0, 1.0)) draw_texture_rect(texture, sprite_rect, false, modulate) if safe_facing < 0: draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) return true func _unit_map_sprite_rect(texture: Texture2D, rect: Rect2, unit: Dictionary) -> Rect2: if texture == null: return Rect2() var family := _unit_class_family(unit) var phase := _unit_idle_phase(unit) var intensity := _unit_idle_intensity(unit) var bob := _unit_idle_bob(family, phase, intensity) * 0.42 var target := Rect2( rect.position + Vector2((TILE_SIZE - UNIT_MAP_SPRITE_TARGET_SIZE.x) * 0.5, 1.0 + bob), UNIT_MAP_SPRITE_TARGET_SIZE ) return _fit_texture_rect(texture, target) func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color, facing: int = 1) -> 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 idle_intensity := _unit_idle_intensity(unit) var bob := _unit_idle_bob(family, phase, idle_intensity) var step := _unit_idle_step(family, phase, idle_intensity) var pixel := float(profile.get("pixel", 3.0)) var safe_facing := -1 if facing < 0 else 1 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) if safe_facing < 0: draw_set_transform(Vector2(rect.position.x * 2.0 + TILE_SIZE, 0.0), 0.0, Vector2(-1.0, 1.0)) _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) if safe_facing < 0: draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) func _unit_pixel_facing(unit: Dictionary) -> int: var unit_id := str(unit.get("id", "")) if not unit_id.is_empty() and unit_action_motion_by_unit.has(unit_id): var action_motion: Dictionary = unit_action_motion_by_unit[unit_id] if float(action_motion.get("age", 0.0)) >= 0.0: var from_cell: Vector2i = action_motion.get("from", unit.get("pos", Vector2i.ZERO)) var to_cell: Vector2i = action_motion.get("to", from_cell) if to_cell.x < from_cell.x: return -1 if to_cell.x > from_cell.x: return 1 if unit.has("facing_x"): return state.unit_facing_x(unit) if unit.get("team", "") == BattleState.TEAM_ENEMY: return -1 return 1 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 unit_idle_time * UNIT_IDLE_PHASE_SPEED + float(seed % 360) * 0.01745 func _unit_idle_intensity(unit: Dictionary) -> float: if unit.is_empty(): return 1.0 var intensity := 1.0 if bool(unit.get("acted", false)): intensity *= 0.34 var max_hp: int = max(1, int(unit.get("max_hp", 1))) var hp_ratio := float(unit.get("hp", max_hp)) / float(max_hp) if hp_ratio <= LOW_HP_WARNING_RATIO: intensity *= 0.58 if str(unit.get("id", "")) == state.selected_unit_id and not bool(unit.get("acted", false)): intensity *= 1.18 return clampf(intensity, 0.18, 1.22) func _unit_idle_bob(family: String, phase: float, intensity := 1.0) -> float: if family == "cavalry": return roundf(sin(phase * 1.55) * 0.75 * intensity) if family == "ranged": return roundf(sin(phase * 1.10) * 0.75 * intensity) if family == "tactic": return roundf(sin(phase * 0.82) * 0.65 * intensity) if family == "heavy": return roundf(sin(phase * 0.72) * 0.45 * intensity) return roundf(sin(phase) * 1.15 * intensity) func _unit_idle_step(family: String, phase: float, intensity := 1.0) -> float: if intensity < 0.40: return 0.0 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_facing_marker(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color = Color.WHITE) -> void: var points := _unit_facing_marker_points(rect, unit) if points.size() < 3: return var fill := _unit_facing_marker_fill_color(unit, team_color) fill.a *= modulate.a var shadow_points := _offset_points(points, Vector2(1.2, 1.2)) draw_colored_polygon(shadow_points, Color(0.020, 0.012, 0.006, 0.64 * modulate.a)) draw_colored_polygon(points, fill) draw_polyline(_closed_polyline_points(points), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88 * modulate.a), 1.3) func _unit_facing_marker_points(rect: Rect2, unit: Dictionary) -> PackedVector2Array: var facing := _unit_pixel_facing(unit) var center_y := rect.position.y + rect.size.y * 0.48 if facing < 0: return PackedVector2Array([ Vector2(rect.position.x + 5.0, center_y), Vector2(rect.position.x + 18.0, center_y - 7.0), Vector2(rect.position.x + 18.0, center_y + 7.0) ]) return PackedVector2Array([ Vector2(rect.end.x - 5.0, center_y), Vector2(rect.end.x - 18.0, center_y - 7.0), Vector2(rect.end.x - 18.0, center_y + 7.0) ]) func _unit_facing_marker_fill_color(unit: Dictionary, team_color: Color) -> Color: if _is_generic_enemy_unit(unit): return Color(ENEMY_COLOR.r, ENEMY_COLOR.g, ENEMY_COLOR.b, 0.84) var facing_color := team_color.lightened(0.24) return Color(facing_color.r, facing_color.g, facing_color.b, 0.84) func _offset_points(points: PackedVector2Array, offset: Vector2) -> PackedVector2Array: var result := PackedVector2Array() for point in points: result.append(point + offset) return result func _closed_polyline_points(points: PackedVector2Array) -> PackedVector2Array: var result := PackedVector2Array() for point in points: result.append(point) if not points.is_empty(): result.append(points[0]) return result 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)) if age < 0.0: continue 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)) if age < 0.0: continue 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 _draw_hover_info_badge() -> void: var badge := _hover_info_badge() if badge.is_empty(): return var lines: Array[String] = badge.get("lines", []) if lines.is_empty(): return var cell: Vector2i = badge.get("cell", hover_cell) var badge_rect := _hover_info_badge_rect(cell, lines) var color := _hover_info_badge_color(str(badge.get("kind", "terrain"))) var background := Color(0.035, 0.022, 0.014, 0.90) var font := _draw_ui_font(true) draw_rect(badge_rect, background) draw_rect(badge_rect.grow(-2.0), Color(color.r, color.g, color.b, 0.10)) draw_rect(badge_rect, color, false, 2.0) for index in range(lines.size()): var line_color := color if index == 0 else UI_PARCHMENT_TEXT _draw_ink_text( font, badge_rect.position + Vector2(8.0, 16.0 + float(index) * 15.0), lines[index], HORIZONTAL_ALIGNMENT_LEFT, badge_rect.size.x - 16.0, 12, line_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 visible_board := _board_rect().intersection(_map_view_rect()) if visible_board.size.x <= 0.0 or visible_board.size.y <= 0.0: visible_board = _board_rect() var min_x := visible_board.position.x + 2.0 var min_y := visible_board.position.y + 2.0 var max_x := maxf(min_x, visible_board.end.x - size.x - 2.0) var max_y := maxf(min_y, visible_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 _hover_info_badge() -> Dictionary: if _is_input_locked() or not state.is_inside(hover_cell): return {} if not _target_preview_badge().is_empty(): return {} var lines := _hover_info_badge_lines(hover_cell) if lines.is_empty(): return {} return { "cell": hover_cell, "kind": _hover_info_badge_kind(hover_cell), "lines": lines } func _hover_info_badge_lines(cell: Vector2i) -> Array[String]: if not state.is_inside(cell): return [] var summary := state.get_cell_summary(cell) var lines: Array[String] = [] var unit: Dictionary = summary.get("unit", {}) if not unit.is_empty(): lines.append("%s · %s %s" % [ str(unit.get("name", "부대")), _unit_team_text(str(unit.get("team", ""))), _unit_class_display_name(unit) ]) lines.append("병력 %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)) ]) var status_effects := state.get_status_effect_summary(str(unit.get("id", ""))) if not status_effects.is_empty(): lines.append("상태: %s" % status_effects) else: lines.append("%d,%d · %s" % [ cell.x + 1, cell.y + 1, _terrain_display_name(str(summary.get("terrain", ""))) ]) var terrain_line := "지형 %s · 방비 +%d · 회피 +%d%%" % [ _terrain_display_name(str(summary.get("terrain", ""))), int(summary.get("defense", 0)), int(summary.get("avoid", 0)) ] var heal := int(summary.get("heal", 0)) if heal > 0: terrain_line += " · 회복 +%d" % heal lines.append(terrain_line) var markers: Array[String] = [] var objective_label := state.get_objective_cell_label(cell) if not objective_label.is_empty(): markers.append("목표 %s" % objective_label) var event_label := state.get_event_marker_label(cell) if not event_label.is_empty(): markers.append("표식 %s" % event_label) if show_threat_overlay: var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY) if not threat_names.is_empty(): markers.append("감시 %s" % _compact_name_list(threat_names, 2)) if not markers.is_empty(): lines.append(_join_strings(markers, " · ")) var capped_lines: Array[String] = [] for index in range(mini(lines.size(), 4)): capped_lines.append(lines[index]) return capped_lines func _hover_info_badge_kind(cell: Vector2i) -> String: var unit := state.get_unit_at(cell) if not unit.is_empty(): return "player" if str(unit.get("team", "")) == BattleState.TEAM_PLAYER else "enemy" if not state.get_objective_cell_label(cell).is_empty(): return "objective" if not state.get_event_marker_label(cell).is_empty(): return "event" return "terrain" func _hover_info_badge_rect(cell: Vector2i, lines: Array[String]) -> Rect2: var size := _hover_info_badge_size(lines) var cell_rect := _rect_for_cell(cell) var position := cell_rect.position + Vector2(TILE_SIZE + 6.0, 4.0) var visible_board := _board_rect().intersection(_map_view_rect()) if visible_board.size.x <= 0.0 or visible_board.size.y <= 0.0: visible_board = _board_rect() var min_x := visible_board.position.x + 2.0 var min_y := visible_board.position.y + 2.0 var max_x := maxf(min_x, visible_board.end.x - size.x - 2.0) var max_y := maxf(min_y, visible_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 _hover_info_badge_size(lines: Array[String]) -> Vector2: var max_chars := 8 for line in lines: max_chars = maxi(max_chars, str(line).length()) var width := clampf(float(max_chars) * 8.2 + 18.0, HOVER_INFO_BADGE_MIN_WIDTH, HOVER_INFO_BADGE_MAX_WIDTH) var height := 13.0 + float(maxi(1, lines.size())) * 15.0 return Vector2(width, height) func _hover_info_badge_color(kind: String) -> Color: if kind == "player": return PLAYER_COLOR.lightened(0.45) if kind == "enemy": return ENEMY_COLOR.lightened(0.42) if kind == "objective": return Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.96) if kind == "event": return Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.94) return Color(UI_PARCHMENT_TEXT.r, UI_PARCHMENT_TEXT.g, UI_PARCHMENT_TEXT.b, 0.88) 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() and (str(attack_badge.get("kind", "")) != "invalid" or basic_attack_targeting or _has_pending_move()): return attack_badge var auto_attack_badge := _auto_attack_target_preview_badge(selected, target) if not auto_attack_badge.is_empty(): return auto_attack_badge 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 directional_text := _format_directional_badge_suffix(preview) var counter_text := _format_attack_counter_badge_suffix(preview) return _make_target_preview_badge("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _attack_preview_badge_kind(preview)) func _format_directional_badge_suffix(preview: Dictionary) -> String: var bonus := int(preview.get("directional_bonus", 0)) if bonus <= 0: return "" var label := _compact_directional_badge_label(str(preview.get("directional_attack", ""))) if label.is_empty(): label = _compact_directional_badge_label(str(preview.get("directional_label", ""))) if label.is_empty(): return "" return " %s+%d" % [label, bonus] func _compact_directional_badge_label(value: String) -> String: var normalized := value.strip_edges().to_lower() if normalized == BattleState.DIRECTIONAL_BACK_ATTACK or value.contains("후"): return "후" if normalized == BattleState.DIRECTIONAL_SIDE_ATTACK or value.contains("측"): return "측" if normalized == BattleState.DIRECTIONAL_FRONT_ATTACK or value.contains("정"): return "정" return "" 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 _auto_attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary: if selected.is_empty() or target.is_empty(): return {} if selected.get("team", "") == target.get("team", ""): return {} if bool(selected.get("moved", false)) or bool(selected.get("acted", false)): return {} var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) var attack_origin := _best_auto_attack_origin(selected, target) if attack_origin == Vector2i(-1, -1) or attack_origin == current_cell: return {} return _auto_attack_target_preview_badge_for_origin(selected, target, attack_origin) func _auto_attack_target_preview_badge_for_origin(selected: Dictionary, target: Dictionary, attack_origin: Vector2i) -> Dictionary: var preview := _auto_attack_preview_for_origin(selected, target, attack_origin) if preview.is_empty(): return {} var target_cell: Vector2i = preview.get("target_cell", Vector2i(-1, -1)) var damage := int(preview.get("damage", 0)) var hit_chance := int(preview.get("hit_chance", 0)) var result_text := "행공 퇴각" if bool(preview.get("would_defeat", false)) else "행공 -%d" % damage var directional_text := _format_directional_badge_suffix(preview) var counter_text := _format_auto_attack_counter_badge_suffix(preview) return _make_target_preview_badge_at("%s%s %d%%%s" % [result_text, directional_text, hit_chance, counter_text], _auto_attack_preview_badge_kind(preview), target_cell) func _auto_attack_preview_for_origin(selected: Dictionary, target: Dictionary, attack_origin: Vector2i) -> Dictionary: var target_cell: Vector2i = target.get("pos", Vector2i(-1, -1)) if selected.is_empty() or target.is_empty() or not state.is_inside(attack_origin) or not state.is_inside(target_cell): return {} if not _can_attack_target_from(selected, attack_origin, target_cell): return {} var candidate_attacker := _auto_attack_candidate_after_move(selected, target_cell, attack_origin) var damage := state.calculate_damage(candidate_attacker, target) var hit_chance := state.calculate_hit_chance(candidate_attacker, target) var target_hp_after := maxi(0, int(target.get("hp", 0)) - damage) var directional_info := state.get_directional_attack_info(candidate_attacker, target) var counter_in_range := false var counter_damage := 0 var counter_hit_chance := 0 var counter_would_defeat := false if bool(target.get("controllable", true)) and (target_hp_after > 0 or hit_chance < 100): counter_in_range = _can_attack_target_from(target, target_cell, attack_origin) if counter_in_range: var counter_attacker := target.duplicate(true) _face_unit_snapshot_toward_cell(counter_attacker, attack_origin) counter_damage = state.calculate_damage(counter_attacker, candidate_attacker) counter_hit_chance = state.calculate_hit_chance(counter_attacker, candidate_attacker) counter_would_defeat = int(candidate_attacker.get("hp", 0)) - counter_damage <= 0 return { "origin": attack_origin, "target_cell": target_cell, "damage": damage, "hit_chance": hit_chance, "target_hp_after": target_hp_after, "would_defeat": target_hp_after <= 0, "directional_attack": str(directional_info.get("kind", BattleState.DIRECTIONAL_FRONT_ATTACK)), "directional_bonus": int(directional_info.get("bonus", 0)), "directional_label": str(directional_info.get("label", "")), "counter_in_range": counter_in_range, "counter_damage": counter_damage, "counter_hit_chance": counter_hit_chance, "counter_would_defeat": counter_would_defeat } func _auto_attack_candidate_after_move(selected: Dictionary, target_cell: Vector2i, attack_origin: Vector2i) -> Dictionary: var candidate_attacker := selected.duplicate(true) candidate_attacker["pos"] = attack_origin _face_unit_snapshot_toward_cell(candidate_attacker, target_cell) return candidate_attacker func _face_unit_snapshot_toward_cell(unit: Dictionary, target_cell: Vector2i) -> void: if unit.is_empty() or not unit.has("pos"): return var from_cell: Vector2i = unit.get("pos", target_cell) if from_cell.x == target_cell.x: return unit["facing_x"] = -1 if target_cell.x < from_cell.x else 1 func _format_auto_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 _auto_attack_preview_badge_kind(preview: Dictionary) -> String: if bool(preview.get("would_defeat", false)) and not bool(preview.get("counter_in_range", false)): return "advance" if bool(preview.get("counter_would_defeat", false)): return "danger" if bool(preview.get("counter_in_range", false)): return "counter" return "advance" 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 == "advance": return Color(0.44, 0.82, 0.76) 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: if not _map_view_rect().has_point(screen_position): return Vector2i(-1, -1) var local := screen_position - _board_origin() if local.x < 0 or local.y < 0: return Vector2i(-1, -1) var cell := Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE))) if not state.is_inside(cell): return Vector2i(-1, -1) return cell 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 _visible_board_local_rect() -> Rect2: var visible_rect := _board_rect().intersection(_map_view_rect()) if visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0: return Rect2() return Rect2(visible_rect.position - _board_origin(), visible_rect.size) func _visible_cell_bounds(extra_margin: int = 1) -> Rect2i: if state.map_size.x <= 0 or state.map_size.y <= 0: return Rect2i() var local_rect := _visible_board_local_rect() if local_rect.size.x <= 0.0 or local_rect.size.y <= 0.0: return Rect2i() var start_x := clampi(int(floor(local_rect.position.x / TILE_SIZE)) - extra_margin, 0, state.map_size.x) var start_y := clampi(int(floor(local_rect.position.y / TILE_SIZE)) - extra_margin, 0, state.map_size.y) var end_x := clampi(int(ceil((local_rect.position.x + local_rect.size.x) / TILE_SIZE)) + extra_margin, 0, state.map_size.x) var end_y := clampi(int(ceil((local_rect.position.y + local_rect.size.y) / TILE_SIZE)) + extra_margin, 0, state.map_size.y) return Rect2i(Vector2i(start_x, start_y), Vector2i(maxi(0, end_x - start_x), maxi(0, end_y - start_y))) func _cell_is_in_visible_bounds(cell: Vector2i, extra_margin: int = 1) -> bool: var bounds := _visible_cell_bounds(extra_margin) return ( cell.x >= bounds.position.x and cell.y >= bounds.position.y and cell.x < bounds.position.x + bounds.size.x and cell.y < bounds.position.y + bounds.size.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 _minimap_rect() -> Rect2: var view_rect := _map_view_rect() var position := view_rect.end - MINIMAP_SIZE - MINIMAP_MARGIN position.x = maxf(view_rect.position.x + MINIMAP_MARGIN.x, position.x) position.y = maxf(view_rect.position.y + MINIMAP_MARGIN.y, position.y) return Rect2(position, MINIMAP_SIZE) func _minimap_map_rect() -> Rect2: var rect := _minimap_rect() return Rect2( rect.position + Vector2(MINIMAP_PADDING, MINIMAP_PADDING + MINIMAP_TITLE_HEIGHT), Vector2( maxf(1.0, rect.size.x - MINIMAP_PADDING * 2.0), maxf(1.0, rect.size.y - MINIMAP_PADDING * 2.0 - MINIMAP_TITLE_HEIGHT) ) ) func _minimap_cell_rect(cell: Vector2i) -> Rect2: var map_rect := _minimap_map_rect() if state.map_size.x <= 0 or state.map_size.y <= 0: return Rect2(map_rect.position, Vector2.ZERO) var cell_size := Vector2(map_rect.size.x / float(state.map_size.x), map_rect.size.y / float(state.map_size.y)) return Rect2(map_rect.position + Vector2(cell.x * cell_size.x, cell.y * cell_size.y), cell_size) func _minimap_view_rect() -> Rect2: var board_size := _board_size() if board_size.x <= 0.0 or board_size.y <= 0.0: return Rect2() var local_rect := _visible_board_local_rect() if local_rect.size.x <= 0.0 or local_rect.size.y <= 0.0: return Rect2() var map_rect := _minimap_map_rect() return Rect2( map_rect.position + Vector2( (local_rect.position.x / board_size.x) * map_rect.size.x, (local_rect.position.y / board_size.y) * map_rect.size.y ), Vector2( (local_rect.size.x / board_size.x) * map_rect.size.x, (local_rect.size.y / board_size.y) * map_rect.size.y ) ) func _board_scroll_offset_for_minimap_position(screen_position: Vector2) -> Vector2: var map_rect := _minimap_map_rect() var board_size := _board_size() if board_size.x <= 0.0 or board_size.y <= 0.0: return _clamped_board_scroll_offset(board_scroll_offset) var local_fraction := Vector2( clampf((screen_position.x - map_rect.position.x) / map_rect.size.x, 0.0, 1.0), clampf((screen_position.y - map_rect.position.y) / map_rect.size.y, 0.0, 1.0) ) var target_local := Vector2(board_size.x * local_fraction.x, board_size.y * local_fraction.y) var view_rect := _map_view_rect() var desired_offset := view_rect.get_center() - BOARD_OFFSET - target_local return _clamped_board_scroll_offset(desired_offset) func _scroll_board_to_minimap_position(screen_position: Vector2) -> bool: var previous_offset := board_scroll_offset board_scroll_offset = _board_scroll_offset_for_minimap_position(screen_position) var next_hover := _cell_from_screen(_map_view_rect().get_center()) if next_hover != hover_cell: hover_cell = next_hover if status_label != null: _update_hud() _refresh_board_attached_panels() queue_redraw() return previous_offset.distance_squared_to(board_scroll_offset) > 0.01 func _board_scroll_offset_for_cell_focus(cell: Vector2i, force_center := false) -> Vector2: if not state.is_inside(cell): return _clamped_board_scroll_offset(board_scroll_offset) var view_rect := _map_view_rect() var cell_center := BOARD_OFFSET + board_scroll_offset + Vector2(cell.x + 0.5, cell.y + 0.5) * TILE_SIZE if force_center: var target_local := Vector2(cell.x + 0.5, cell.y + 0.5) * TILE_SIZE return _clamped_board_scroll_offset(view_rect.get_center() - BOARD_OFFSET - target_local) var margin := minf( MAP_FOCUS_MARGIN, maxf(12.0, minf(view_rect.size.x, view_rect.size.y) * 0.22) ) var next_offset := board_scroll_offset var min_x := view_rect.position.x + margin var max_x := view_rect.end.x - margin var min_y := view_rect.position.y + margin var max_y := view_rect.end.y - margin if min_x > max_x: min_x = view_rect.position.x + TILE_SIZE * 0.5 max_x = view_rect.end.x - TILE_SIZE * 0.5 if min_y > max_y: min_y = view_rect.position.y + TILE_SIZE * 0.5 max_y = view_rect.end.y - TILE_SIZE * 0.5 if cell_center.x < min_x: next_offset.x += min_x - cell_center.x elif cell_center.x > max_x: next_offset.x -= cell_center.x - max_x if cell_center.y < min_y: next_offset.y += min_y - cell_center.y elif cell_center.y > max_y: next_offset.y -= cell_center.y - max_y return _clamped_board_scroll_offset(next_offset) func _focus_board_on_cell(cell: Vector2i, force_center := false, allow_prebattle := false) -> bool: if (not battle_started and not allow_prebattle) or not state.is_inside(cell): return false var previous_offset := board_scroll_offset board_scroll_offset = _board_scroll_offset_for_cell_focus(cell, force_center) if previous_offset.distance_squared_to(board_scroll_offset) <= 0.01: return false _refresh_board_attached_panels() return true func _refresh_board_attached_panels() -> void: if post_move_menu != null and post_move_menu.visible: _position_post_move_menu() if post_move_picker_panel != null and post_move_picker_panel.visible: _position_post_move_picker() if targeting_hint_panel != null and targeting_hint_panel.visible: _update_targeting_hint_panel() func _player_deployment_focus_cell() -> Vector2i: var units := state.get_controllable_player_units() if units.is_empty(): units = state.get_living_units(BattleState.TEAM_PLAYER) if units.is_empty(): return Vector2i(-1, -1) var min_x := state.map_size.x var min_y := state.map_size.y var max_x := 0 var max_y := 0 for unit in units: var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) if not state.is_inside(cell): continue min_x = mini(min_x, cell.x) min_y = mini(min_y, cell.y) max_x = maxi(max_x, cell.x) max_y = maxi(max_y, cell.y) if min_x > max_x or min_y > max_y: return Vector2i(-1, -1) return Vector2i(clampi(int(round(float(min_x + max_x) * 0.5)), 0, state.map_size.x - 1), clampi(int(round(float(min_y + max_y) * 0.5)), 0, state.map_size.y - 1)) func _focus_board_on_player_deployment(force_center := false) -> bool: return _focus_board_on_cell(_player_deployment_focus_cell(), force_center) func _focus_board_on_formation_unit(force_center := false) -> bool: var unit := state.get_unit(formation_unit_id) if unit.is_empty(): return false return _focus_board_on_cell(unit.get("pos", Vector2i(-1, -1)), force_center, true) func _activate_presentation_focus_if_ready(entry: Dictionary) -> void: if bool(entry.get("focused", false)): return if float(entry.get("age", 0.0)) < 0.0: return var focus_cell: Vector2i = entry.get("focus_cell", Vector2i(-1, -1)) if state.is_inside(focus_cell): _focus_board_on_cell(focus_cell, bool(entry.get("force_focus", false))) var sfx_key := str(entry.get("sfx", "")) if not sfx_key.is_empty() and not bool(entry.get("sfx_played", false)) and is_inside_tree(): _play_sfx(sfx_key) entry["sfx_played"] = true entry["focused"] = true func _begin_battle_presentation_sequence() -> void: battle_presentation_sequence_active = true battle_presentation_delay_cursor = 0.0 battle_presentation_feedback_delay_by_unit.clear() func _end_battle_presentation_sequence() -> void: battle_presentation_sequence_active = false battle_presentation_delay_cursor = 0.0 func _presentation_delay_for_step(duration: float) -> float: if not battle_presentation_sequence_active: return 0.0 var delay := battle_presentation_delay_cursor battle_presentation_delay_cursor += maxf(0.01, duration) + BATTLE_PRESENTATION_STEP_GAP return delay func _presentation_feedback_delay_for_unit(unit_id: String) -> float: if battle_presentation_feedback_delay_by_unit.has(unit_id): return maxf(0.0, float(battle_presentation_feedback_delay_by_unit[unit_id])) if battle_presentation_sequence_active: return maxf(0.0, battle_presentation_delay_cursor) return 0.0 func _clear_presentation_state() -> void: battle_presentation_sequence_active = false battle_presentation_delay_cursor = 0.0 battle_presentation_feedback_delay_by_unit.clear() func _has_active_battle_presentation_sequence() -> bool: for motion in unit_motion_by_unit.values(): if typeof(motion) == TYPE_DICTIONARY and bool((motion as Dictionary).get("sequence", false)): return true for motion in unit_action_motion_by_unit.values(): if typeof(motion) == TYPE_DICTIONARY and bool((motion as Dictionary).get("sequence", false)): return true for popup in floating_texts: if typeof(popup) == TYPE_DICTIONARY and bool((popup as Dictionary).get("sequence", false)): return true return false func _is_minimap_visible() -> bool: return ( battle_started and state.map_size.x > 0 and state.map_size.y > 0 and state.battle_status == BattleState.STATUS_ACTIVE and _map_has_scrollable_area() ) func _map_has_scrollable_area() -> bool: var board_size := _board_size() var view_size := _map_view_rect().size return board_size.x > view_size.x + 0.5 or board_size.y > view_size.y + 0.5 func _handle_minimap_mouse_button(event: InputEventMouseButton) -> bool: if not _is_minimap_visible(): minimap_dragging = false return false if event.button_index != MOUSE_BUTTON_LEFT: return false var inside_minimap := _minimap_rect().has_point(event.position) var inside_map_body := _minimap_map_rect().has_point(event.position) if event.pressed: if not inside_minimap: return false if inside_map_body: minimap_dragging = true _scroll_board_to_minimap_position(event.position) return true if minimap_dragging: minimap_dragging = false return true return inside_minimap func _handle_minimap_mouse_motion(event: InputEventMouseMotion) -> bool: if not _is_minimap_visible(): minimap_dragging = false return false if minimap_dragging: _scroll_board_to_minimap_position(event.position) return true return _minimap_rect().has_point(event.position) 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 not title_edge_scroll_enabled: return false if _is_input_locked(): return false var viewport := get_viewport() if viewport == null: return false var view_rect := _map_view_rect() var mouse_position := viewport.get_mouse_position() if minimap_dragging or (_is_minimap_visible() and _minimap_rect().has_point(mouse_position)): return false 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() _refresh_board_attached_panels() 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: var status_detail := _format_status_hud_detail_text() status_label.text = _format_status_hud_text() status_label.tooltip_text = status_detail _set_top_hud_chip_tooltip(top_status_chip, status_detail) var objective_detail := _format_objective_hud_detail_text() objective_label.text = _format_objective_hud_text() objective_label.tooltip_text = objective_detail _set_top_hud_chip_tooltip(top_objective_chip, objective_detail) _fit_label_font_size_to_text( objective_label, objective_label.text, OBJECTIVE_HUD_FONT_SIZE, OBJECTIVE_HUD_MIN_FONT_SIZE ) var campaign_detail := campaign_state.get_progress_text() campaign_status_label.text = _format_campaign_hud_summary_text() campaign_status_label.tooltip_text = campaign_detail _set_top_hud_chip_tooltip(top_campaign_chip, campaign_detail) _update_mission_panel() _update_cell_info() _update_forecast() _update_forecast_panel_visibility() _update_result_panel() if inventory_label != null: inventory_label.text = _format_inventory_status_summary_text() inventory_label.tooltip_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 = "전장 관망" selected_label.tooltip_text = "마우스를 부대 위에 올리면 상세 군세가 표시됩니다." _update_hud_unit_badges({}, selected_label.tooltip_text) _update_hud_unit_resource_bars({}, selected_label.tooltip_text) else: var hover_detail := _format_unit_focus_text(hovered_unit, "Hover") selected_label.text = _format_unit_focus_summary_text(hovered_unit, "Hover") selected_label.tooltip_text = hover_detail _update_hud_unit_badges(hovered_unit, hover_detail) _update_hud_unit_resource_bars(hovered_unit, hover_detail) _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) var selected_detail := _format_unit_focus_text(selected, "Selected") selected_label.text = _format_unit_focus_summary_text(selected, "Selected") selected_label.tooltip_text = selected_detail _update_hud_unit_badges(selected, selected_detail) _update_hud_unit_resource_bars(selected, selected_detail) _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_battle_unit_list_button() _update_settings_button() _update_top_archive_buttons() _update_post_move_menu() _update_post_move_picker() _update_targeting_hint_panel() _update_battle_unit_list_panel() _update_settings_panel() _update_auto_end_turn_prompt() func _update_auto_end_turn_prompt() -> void: if auto_end_turn_prompt_panel == null: return if _should_show_auto_end_turn_prompt(): auto_end_turn_prompt_panel.visible = true return auto_end_turn_prompt_panel.visible = false func _should_show_auto_end_turn_prompt() -> bool: if campaign_complete_screen or not battle_started: return false if state.battle_status != BattleState.STATUS_ACTIVE: return false if state.current_team != BattleState.TEAM_PLAYER: return false if auto_end_turn_prompt_declined_turn == state.turn_number: return false if _is_dialogue_visible() or _has_active_battle_presentation_sequence(): return false if briefing_panel != null and briefing_panel.visible: return false if result_panel != null and result_panel.visible: return false if _has_pending_move(): return false if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting: return false if post_move_menu != null and post_move_menu.visible: return false if post_move_picker_panel != null and post_move_picker_panel.visible: return false if targeting_hint_panel != null and targeting_hint_panel.visible: return false return _all_controllable_player_units_acted() func _all_controllable_player_units_acted() -> bool: var has_active_unit := false for unit in state.units: if str(unit.get("team", "")) != BattleState.TEAM_PLAYER: continue if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)): continue if not bool(unit.get("controllable", true)): continue has_active_unit = true if not bool(unit.get("acted", false)): return false return has_active_unit func _is_auto_end_turn_prompt_visible() -> bool: return auto_end_turn_prompt_panel != null and auto_end_turn_prompt_panel.visible func _format_status_hud_text() -> String: if state.battle_status == BattleState.STATUS_VICTORY: return "승전" if state.battle_status == BattleState.STATUS_DEFEAT: return "패전" var turn_limit := state.get_turn_limit() var team_text := _team_hud_short_text(state.current_team) if turn_limit > 0: return "령 %d/%d\n%s" % [state.turn_number, turn_limit, team_text] return "령 %d\n%s" % [state.turn_number, team_text] func _format_status_hud_detail_text() -> String: var status_text := state.get_status_text() if status_text.strip_edges().is_empty(): return "현재 군령 상태입니다." return "%s\n현재 차례와 제한 턴을 표시합니다." % status_text func _team_hud_short_text(team: String) -> String: if team == BattleState.TEAM_PLAYER: return "아군" if team == BattleState.TEAM_ENEMY: return "적군" return team.capitalize() func _format_objective_hud_text() -> String: var progress_lines := state.get_objective_progress_lines(false, false) if not progress_lines.is_empty(): return _join_strings(progress_lines, " · ") var victory := String(state.objectives.get("victory", "적군을 모두 격파하라.")).strip_edges() if victory.is_empty(): return "대기" return _truncate_hud_text(victory, 34) func _format_objective_hud_detail_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 _truncate_hud_text(text: String, max_length: int) -> String: var normalized := text.strip_edges() if normalized.length() <= max_length: return normalized return "%s..." % normalized.substr(0, maxi(0, max_length - 3)).strip_edges() func _format_campaign_hud_summary_text() -> String: return "봉 %d/%d\n%d냥" % [ campaign_state.completed_scenarios.size(), campaign_state.get_scenario_count(), campaign_state.gold ] 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 시선 %s" % [ 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)), _unit_facing_text(unit) ]) lines.append(_unit_action_status_text(unit)) var ai_text := _unit_ai_behavior_text(unit) if not ai_text.is_empty(): lines.append(ai_text) 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 _format_unit_focus_summary_text(unit: Dictionary, prefix: String) -> String: if unit.is_empty(): return "표식 없는 빈 전장입니다." var name := str(unit.get("name", "부대")) var control_label := " 호위" if not bool(unit.get("controllable", true)) else "" return "%s: %s%s\n%s 품계 %d · %s" % [ _unit_focus_prefix_text(prefix), name, control_label, _unit_team_text(str(unit.get("team", ""))), int(unit.get("level", 1)), _unit_class_display_name(unit) ] func _update_hud_unit_resource_bars(unit: Dictionary, detail_text: String) -> void: if unit.is_empty(): _set_hud_unit_resource_bar(hud_unit_hp_bar, 0, 1, "병력", "", false) _set_hud_unit_resource_bar(hud_unit_mp_bar, 0, 1, "기력", "", false) return var hp_max := maxi(1, int(unit.get("max_hp", 1))) var hp_value := clampi(int(unit.get("hp", 0)), 0, hp_max) _set_hud_unit_resource_bar(hud_unit_hp_bar, hp_value, hp_max, "병력", detail_text, true) var mp_max := int(unit.get("max_mp", 0)) var mp_value := clampi(int(unit.get("mp", 0)), 0, maxi(0, mp_max)) _set_hud_unit_resource_bar(hud_unit_mp_bar, mp_value, maxi(1, mp_max), "기력", detail_text, mp_max > 0) func _set_hud_unit_resource_bar(bar: ProgressBar, value: int, max_value: int, label_text: String, detail_text: String, visible: bool) -> void: if bar == null: return var row := bar.get_parent() as Control if row != null: row.visible = visible bar.max_value = float(maxi(1, max_value)) bar.value = float(clampi(value, 0, maxi(1, max_value))) var tooltip := "" if visible: tooltip = "%s %d/%d" % [label_text, value, max_value] if not detail_text.strip_edges().is_empty(): tooltip = "%s\n%s" % [tooltip, detail_text] bar.tooltip_text = tooltip if row != null: row.tooltip_text = tooltip 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_facing_text(unit: Dictionary) -> String: return "서쪽" if _unit_pixel_facing(unit) < 0 else "동쪽" 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_ai_behavior_text(unit: Dictionary) -> String: if str(unit.get("team", "")) != BattleState.TEAM_ENEMY: return "" if str(unit.get("ai_behavior", "")).strip_edges().to_lower() != "guard": return "" var anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1))) var radius := int(unit.get("ai_guard_radius", 0)) if not state.is_inside(anchor): return "수비: 거점 경계" return "수비: 거점 %d,%d 반경 %d" % [anchor.x + 1, anchor.y + 1, radius] 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_panel.tooltip_text = "" hud_unit_portrait_texture.texture = null hud_unit_portrait_texture.visible = false hud_unit_portrait_texture.tooltip_text = "" hud_unit_portrait_label.text = "" hud_unit_portrait_label.visible = false hud_unit_portrait_label.tooltip_text = "" return hud_unit_portrait_panel.visible = true var focus_detail := _format_unit_focus_text(unit, "Hover") hud_unit_portrait_panel.tooltip_text = focus_detail 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_texture.tooltip_text = focus_detail hud_unit_portrait_label.text = _dialogue_portrait_initials(str(unit.get("name", ""))) hud_unit_portrait_label.visible = not has_portrait hud_unit_portrait_label.tooltip_text = focus_detail func _update_cell_info() -> void: if cell_info_label == null: return if cell_info_panel != null: cell_info_panel.visible = state.is_inside(hover_cell) if not state.is_inside(hover_cell): cell_info_label.text = "" cell_info_label.tooltip_text = "전장 칸 위에 올리면 지형과 부대 정보를 볼 수 있습니다." if cell_info_panel != null: _set_control_tree_tooltip(cell_info_panel, cell_info_label.tooltip_text) return var detail_text := _format_cell_info_detail_text(hover_cell) cell_info_label.text = _format_cell_info_summary_text(hover_cell) cell_info_label.tooltip_text = detail_text if cell_info_panel != null: _set_control_tree_tooltip(cell_info_panel, detail_text) func _format_cell_info_summary_text(cell: Vector2i) -> String: if not state.is_inside(cell): return "" var summary := state.get_cell_summary(cell) var text := "%s 행%d 방+%d 회+%d%%" % [ _terrain_display_name(str(summary["terrain"])), int(summary["move_cost"]), int(summary["defense"]), int(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 var event_marker_label := state.get_event_marker_label(cell) if not event_marker_label.is_empty(): text += "\n표식 %s" % event_marker_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) return text func _format_cell_info_detail_text(cell: Vector2i) -> String: if not state.is_inside(cell): return "마우스를 전장 칸 위에 올리면 지형과 부대 정보를 볼 수 있습니다." var summary := state.get_cell_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 var event_marker_label := state.get_event_marker_label(cell) if not event_marker_label.is_empty(): text += "\n표식: %s" % event_marker_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 시선 %s\n%s %s 병력 %d/%d 기력 %d/%d\n병장: %s" % [ unit["name"], control_label, _unit_team_text(str(unit["team"])), unit["level"], _unit_facing_text(unit), _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"]) var ai_text := _unit_ai_behavior_text(unit) if not ai_text.is_empty(): text += "\n%s" % ai_text 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) return 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] var directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label") parts.append("%s 피해 %d/명중 %d%%%s%s%s" % [ str(preview.get("source_name", "부대")), int(preview.get("damage", 0)), int(preview.get("hit_chance", 0)), effective_text, directional_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): _clear_forecast() return if not selected_skill_id.is_empty(): _update_skill_forecast(selected) return if not selected_item_id.is_empty(): _update_item_forecast(selected) return var target := state.get_unit_at(hover_cell) if target.is_empty() or target.get("team", "") == selected.get("team", ""): _clear_forecast() return var preview := state.get_damage_preview(selected["id"], target["id"]) if preview.is_empty(): _clear_forecast() return if bool(preview.get("attack_locked", false)): _set_forecast_text("전황: 병장 봉인\n무기로 공격할 수 없습니다.") return if not bool(preview.get("in_range", false)): var auto_attack_detail := _format_auto_attack_forecast_text(selected, target) if not auto_attack_detail.is_empty(): _set_forecast_text(auto_attack_detail) 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 directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label") 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") var counter_directional_text := _format_directional_forecast_text(preview, "counter_directional_bonus", "counter_directional_label") counter_text = "\n반격 %d%s%s, 명중 %d%%, 아군 병력 %d/%d.%s" % [ preview["counter_damage"], counter_effective_text, counter_directional_text, preview.get("counter_hit_chance", 0), preview["attacker_hp_after"], selected["max_hp"], counter_defeat ] var detail_text := "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [ range_text, preview["damage"], effective_text + directional_text, preview.get("hit_chance", 100), preview["target_hp_after"], target["max_hp"], defeat_text, counter_text ] _set_forecast_text(detail_text) func _clear_forecast() -> void: if forecast_label == null: return forecast_label.text = "전황: -" forecast_label.tooltip_text = "공격, 책략, 도구 대상을 가리키면 전황 예측이 표시됩니다." if forecast_panel != null: _set_control_tree_tooltip(forecast_panel, forecast_label.tooltip_text) func _set_forecast_text(detail_text: String) -> void: if forecast_label == null: return var compact_text := _format_forecast_summary_text(detail_text) forecast_label.text = compact_text forecast_label.tooltip_text = detail_text if forecast_panel != null: _set_control_tree_tooltip(forecast_panel, detail_text) func _format_forecast_summary_text(detail_text: String) -> String: var lines := detail_text.split("\n", false) if lines.is_empty(): return "전황: -" var first_line := str(lines[0]).strip_edges() var second_line := "" if lines.size() > 1: second_line = str(lines[1]).strip_edges() if first_line == "전황: -": return first_line if second_line.is_empty(): return first_line var compact_second := second_line compact_second = compact_second.replace("피해 ", "피") compact_second = compact_second.replace("명중 ", "명") compact_second = compact_second.replace("기력 ", "기") compact_second = compact_second.replace("적 병력 ", "적") compact_second = compact_second.replace("아군 병력 ", "아") compact_second = compact_second.replace("지목 병력 ", "지목") compact_second = compact_second.replace("군기 병력 ", "군") compact_second = compact_second.replace("병력 ", "병") compact_second = compact_second.replace("회복", "회") compact_second = compact_second.replace(", ", " ") compact_second = compact_second.replace("; ", " ") compact_second = compact_second.replace(".", "") return "%s\n%s" % [first_line, _truncate_hud_text(compact_second, 34)] func _format_auto_attack_forecast_text(selected: Dictionary, target: Dictionary) -> String: if selected.is_empty() or target.is_empty(): return "" if basic_attack_targeting or _has_pending_move() or bool(selected.get("moved", false)) or bool(selected.get("acted", false)): return "" var origin := _best_auto_attack_origin(selected, target) if origin == Vector2i(-1, -1): return "" var current_cell: Vector2i = selected.get("pos", Vector2i(-1, -1)) if origin == current_cell: return "" var preview := _auto_attack_preview_for_origin(selected, target, origin) if preview.is_empty(): return "" var move_distance := _manhattan(current_cell, origin) var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else "" var directional_text := _format_directional_forecast_text(preview, "directional_bonus", "directional_label") var counter_text := "" if bool(preview.get("counter_in_range", false)): var counter_defeat := " 퇴각" if bool(preview.get("counter_would_defeat", false)) else "" counter_text = "\n반격 %d, 명중 %d%%.%s" % [ int(preview.get("counter_damage", 0)), int(preview.get("counter_hit_chance", 0)), counter_defeat ] return "전황: 행공 진 %d,%d 이동 %d칸\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [ origin.x + 1, origin.y + 1, move_distance, int(preview.get("damage", 0)), directional_text, int(preview.get("hit_chance", 0)), int(preview.get("target_hp_after", 0)), int(target.get("max_hp", 1)), 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 _format_directional_forecast_text(preview: Dictionary, bonus_key: String, label_key: String) -> String: var bonus := int(preview.get(bonus_key, 0)) if bonus <= 0: return "" var label := str(preview.get(label_key, "방향")).strip_edges() if label.is_empty(): label = "방향" return " (%s +%d)" % [label, bonus] func _update_skill_forecast(selected: Dictionary) -> void: var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell) if preview.is_empty(): _clear_forecast() return var range_text := "사거리 안" if preview["in_range"] else "사거리 밖" var mp_text := "" if preview["has_mp"] else " 기력이 부족합니다." if bool(preview.get("skill_locked", false)): _set_forecast_text("%s: 봉인, 기력 %d.\n책략을 펼칠 수 없습니다." % [ preview["skill_name"], preview["mp_cost"] ]) return if not preview["valid_target"]: _set_forecast_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: _set_forecast_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 _set_forecast_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 "" _set_forecast_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 "" _set_forecast_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 _set_forecast_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(): _clear_forecast() 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"]: _set_forecast_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(): _set_forecast_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, ", ")) _set_forecast_text("%s: %s, %s\n%s." % [ preview["item_name"], range_text, count_text, _join_strings(recovery_parts, "; ") ]) func _update_forecast_panel_visibility() -> void: if forecast_panel == null or forecast_label == null: return var forecast_text := forecast_label.text.strip_edges() forecast_panel.visible = not forecast_text.is_empty() and forecast_text != "전황: -" func _update_result_panel() -> void: if result_panel == null: return _announce_battle_status_audio() if campaign_complete_screen: _clear_dialogue() _clear_result_choices() _clear_result_reward_strip() _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() _rebuild_result_reward_strip() 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() _clear_result_reward_strip() _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() _clear_result_reward_strip() 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 _clear_result_reward_strip() -> void: if result_reward_strip == null: return for child in result_reward_strip.get_children(): result_reward_strip.remove_child(child) child.queue_free() result_reward_strip.visible = false func _rebuild_result_reward_strip() -> void: _clear_result_reward_strip() if result_reward_strip == null or battle_result_summary.is_empty(): return if bool(battle_result_summary.get("pending_choice", false)): return if bool(battle_result_summary.get("already_completed", false)): result_reward_strip.add_child(_make_result_reward_text_chip("수령", "이미 수령한 전리품입니다.")) elif bool(battle_result_summary.get("rewards_applied", false)): var gold_reward := int(battle_result_summary.get("gold", 0)) if gold_reward > 0: result_reward_strip.add_child(_make_result_reward_text_chip("냥", "군자금 +%d냥" % gold_reward)) var item_counts := _result_item_counts(battle_result_summary.get("items", [])) var item_ids := item_counts.keys() item_ids.sort() for item_id in item_ids: var key := str(item_id) result_reward_strip.add_child(_make_result_reward_item_chip(key, int(item_counts.get(key, 0)))) else: result_reward_strip.add_child(_make_result_reward_text_chip("보류", "전리품이 아직 기록되지 않았습니다.")) var joined_officers: Array = battle_result_summary.get("joined_officers", []) for officer_id in joined_officers: result_reward_strip.add_child(_make_result_reward_officer_chip(str(officer_id), true)) var left_officers: Array = battle_result_summary.get("left_officers", []) for officer_id in left_officers: result_reward_strip.add_child(_make_result_reward_officer_chip(str(officer_id), false)) result_reward_strip.visible = result_reward_strip.get_child_count() > 0 func _result_item_counts(values) -> Dictionary: var counts := {} if typeof(values) != TYPE_ARRAY: return counts for value in values: var key := str(value) if key.is_empty(): continue counts[key] = int(counts.get(key, 0)) + 1 return counts func _make_result_reward_text_chip(label_text: String, tooltip_text: String) -> PanelContainer: var panel := _make_result_reward_chip_shell(tooltip_text) 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.text = label_text label.tooltip_text = tooltip_text _apply_label_style(label, UI_PARCHMENT_TEXT, 14) panel.add_child(label) return panel func _make_result_reward_item_chip(item_id: String, count: int) -> PanelContainer: var item := state.get_item_def(item_id) var display_name := _item_inventory_display_name(item_id) var detail := _format_shop_item_effect_text(item) var tooltip := "물자: %s %d점" % [display_name, count] if not detail.is_empty(): tooltip += "\n%s" % detail var panel := _make_result_reward_chip_shell(tooltip) var stack := Control.new() stack.set_anchors_preset(Control.PRESET_FULL_RECT) stack.tooltip_text = tooltip 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 icon_rect.texture = _load_art_texture(str(item.get("icon", ""))) icon_rect.visible = icon_rect.texture != null icon_rect.tooltip_text = tooltip stack.add_child(icon_rect) var fallback_label := Label.new() fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT) fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER fallback_label.text = _dialogue_portrait_initials(display_name) fallback_label.visible = icon_rect.texture == null fallback_label.tooltip_text = tooltip _apply_label_style(fallback_label, UI_PARCHMENT_TEXT, 16) stack.add_child(fallback_label) if count > 1: stack.add_child(_make_result_reward_count_badge(count, tooltip)) return panel func _make_result_reward_officer_chip(officer_id: String, joined: bool) -> PanelContainer: var officer_name := _officer_display_name_from_id(officer_id) var action_text := "합류" if joined else "이탈" var tooltip := "%s: %s" % [action_text, officer_name] var panel := _make_result_reward_chip_shell(tooltip) var stack := Control.new() stack.set_anchors_preset(Control.PRESET_FULL_RECT) stack.tooltip_text = tooltip panel.add_child(stack) var portrait := TextureRect.new() portrait.set_anchors_preset(Control.PRESET_FULL_RECT) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED portrait.texture = _load_art_texture(state.data_catalog.get_portrait_for_officer(officer_id)) portrait.visible = portrait.texture != null portrait.tooltip_text = tooltip stack.add_child(portrait) var fallback_label := Label.new() fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT) fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER fallback_label.text = _dialogue_portrait_initials(officer_name) fallback_label.visible = portrait.texture == null fallback_label.tooltip_text = tooltip _apply_label_style(fallback_label, UI_PARCHMENT_TEXT, 16) stack.add_child(fallback_label) var badge_text := "+" if joined else "-" stack.add_child(_make_result_reward_corner_badge(badge_text, tooltip)) return panel func _make_result_reward_chip_shell(tooltip_text: String) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = RESULT_REWARD_ICON_SIZE panel.mouse_filter = Control.MOUSE_FILTER_STOP panel.tooltip_text = tooltip_text _apply_panel_style(panel, "portrait") return panel func _make_result_reward_count_badge(count: int, tooltip_text: String) -> Label: return _make_result_reward_corner_badge("x%d" % count, tooltip_text) func _make_result_reward_corner_badge(text: String, tooltip_text: String) -> Label: var badge := Label.new() badge.text = text badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER badge.position = Vector2(25, 28) badge.size = Vector2(23, 18) badge.add_theme_color_override("font_color", UI_PARCHMENT_TEXT) badge.add_theme_color_override("font_shadow_color", UI_AGED_INK) badge.add_theme_constant_override("shadow_offset_x", 1) badge.add_theme_constant_override("shadow_offset_y", 1) badge.add_theme_font_size_override("font_size", 12) badge.tooltip_text = tooltip_text return badge 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() else: var selected := state.get_selected_unit() if not selected.is_empty(): _focus_board_on_cell(selected.get("pos", Vector2i(-1, -1))) _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 delay := _presentation_feedback_delay_for_unit(unit_id) if delay <= 0.0: _focus_board_on_cell(cell, true) 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": -delay, "duration": FLOATING_TEXT_LIFETIME, "focus_cell": cell, "force_focus": true, "focused": delay <= 0.0, "sequence": delay > 0.0 }) 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 var duration := UNIT_MOVE_ANIMATION_DURATION var delay := _presentation_delay_for_step(duration) battle_presentation_feedback_delay_by_unit[unit_id] = delay + duration + BATTLE_PRESENTATION_FEEDBACK_LAG if delay <= 0.0: _focus_board_on_cell(to_cell) unit_motion_by_unit[unit_id] = { "from": from_cell, "to": to_cell, "age": -delay, "duration": duration, "focus_cell": to_cell, "force_focus": false, "focused": delay <= 0.0, "sequence": battle_presentation_sequence_active or delay > 0.0 } 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 var focus_cell := to_cell if state.is_inside(to_cell) else from_cell var duration := _action_motion_duration(profile) var delay := _presentation_delay_for_step(duration) battle_presentation_feedback_delay_by_unit[unit_id] = delay + duration * 0.58 if delay <= 0.0: _focus_board_on_cell(focus_cell, true) var sfx_key := _action_motion_sfx(profile) 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": -delay, "duration": duration, "focus_cell": focus_cell, "force_focus": true, "focused": delay <= 0.0, "sfx": sfx_key, "sfx_played": delay <= 0.0, "sequence": battle_presentation_sequence_active or delay > 0.0 } if delay <= 0.0 and is_inside_tree(): _play_sfx(sfx_key) 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 = "다음" func _apply_dialogue_side(side: String) -> void: if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null: return 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_auto_end_turn_yes_pressed() -> void: if auto_end_turn_prompt_panel != null: auto_end_turn_prompt_panel.visible = false _on_end_turn_pressed() func _on_auto_end_turn_no_pressed() -> void: auto_end_turn_prompt_declined_turn = state.turn_number if auto_end_turn_prompt_panel != null: auto_end_turn_prompt_panel.visible = false _play_ui_cancel() _update_hud() queue_redraw() 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() _begin_battle_presentation_sequence() state.end_player_turn() _end_battle_presentation_sequence() 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() unit_action_motion_by_unit.clear() _clear_presentation_state() 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() unit_action_motion_by_unit.clear() _clear_presentation_state() 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() _focus_board_on_player_deployment(true) _show_next_dialogue() _update_hud() queue_redraw() 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 tooltip_text := _format_camp_conversation_tooltip_text(conversation) var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 62) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text talk_list.add_child(row) row.add_child(_make_camp_conversation_portrait(conversation, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 4) column.tooltip_text = tooltip_text 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, 30) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL button.tooltip_text = tooltip_text _apply_button_style(button) button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id)) column.add_child(button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 22) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in _format_camp_conversation_visible_badges(conversation): var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_armory_info_badge(text, tooltip_text)) func _make_camp_conversation_portrait(conversation: Dictionary, tooltip_text: String = "") -> PanelContainer: var portrait_panel := PanelContainer.new() portrait_panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE portrait_panel.tooltip_text = tooltip_text _apply_panel_style(portrait_panel, "portrait") var stack := Control.new() stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE stack.tooltip_text = tooltip_text 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 texture_rect.tooltip_text = tooltip_text 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) initials_label.tooltip_text = tooltip_text _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 parts := [label] if not speaker.is_empty(): parts.append(speaker) return _join_strings(parts, " · ") func _format_camp_conversation_visible_badges(conversation: Dictionary) -> Array: var badges := [ _camp_conversation_group_text(str(conversation.get("group", "topic"))) ] var speaker := _camp_conversation_preview_speaker(conversation) if not speaker.is_empty(): badges.append(speaker) if _camp_conversation_has_grant_item_effect(conversation): badges.append(_format_camp_conversation_supply_state_text(conversation)) else: badges.append("대화") return badges func _format_camp_conversation_tooltip_text(conversation: Dictionary) -> String: var lines := [ _format_camp_conversation_button_text(conversation) ] var preview := _format_camp_conversation_preview_text(conversation) if not preview.is_empty(): lines.append(preview) lines.append("클릭하면 군막 대화를 엽니다.") return _join_strings(lines, "\n") 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 _camp_conversation_has_grant_item_effect(conversation: Dictionary) -> bool: for effect in conversation.get("effects", []): if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == "grant_item": return true return false func _format_camp_conversation_supply_state_text(conversation: Dictionary) -> String: var conversation_id := str(conversation.get("id", "")) if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id): return "보급 완료" 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 _focus_board_on_formation_unit(true) _update_hud() queue_redraw() func _hide_formation_menu() -> void: if formation_menu == null: return formation_menu.visible = false func _on_top_manual_save_pressed() -> void: _on_manual_save_pressed() func _on_top_manual_load_pressed() -> void: _on_manual_load_pressed() func _on_settings_pressed() -> void: if top_settings_button == null or top_settings_button.disabled: return _play_ui_click() if _is_settings_panel_visible(): _hide_settings_panel() else: _show_settings_panel() _update_hud() func _show_settings_panel() -> void: if settings_panel == null: return _hide_battle_unit_list_panel() _refresh_title_volume_controls() settings_panel.visible = true settings_panel.move_to_front() func _hide_settings_panel() -> void: if settings_panel == null: return settings_panel.visible = false func _is_settings_panel_visible() -> bool: return settings_panel != null and settings_panel.visible func _update_settings_button() -> void: if top_settings_button == null: return var can_show := not _is_title_screen_visible() and not _is_opening_prologue_visible() _set_action_button_state( top_settings_button, "환경 설정", not can_show, "배경음, 효과음, 가장자리 스크롤을 조절합니다." if can_show else "타이틀 화면에서는 왼쪽 메뉴의 환경 설정을 사용합니다." ) if not can_show: _hide_settings_panel() func _update_settings_panel() -> void: if not _is_settings_panel_visible(): return _refresh_title_volume_controls() func _on_settings_bgm_toggled(enabled: bool) -> void: _on_title_bgm_toggled(enabled) func _on_settings_sfx_toggled(enabled: bool) -> void: _on_title_sfx_toggled(enabled) func _on_settings_edge_scroll_toggled(enabled: bool) -> void: _on_title_edge_scroll_toggled(enabled) func _on_settings_bgm_volume_changed(value: float) -> void: _on_title_bgm_volume_changed(value) func _on_settings_sfx_volume_changed(value: float) -> void: _on_title_sfx_volume_changed(value) func _on_battle_unit_list_pressed() -> void: if battle_unit_list_button == null or battle_unit_list_button.disabled: return _play_ui_click() if battle_unit_list_panel != null and battle_unit_list_panel.visible: _hide_battle_unit_list_panel() else: _hide_settings_panel() _rebuild_battle_unit_list_rows() if battle_unit_list_panel != null: battle_unit_list_panel.visible = true _update_hud() func _hide_battle_unit_list_panel() -> void: if battle_unit_list_panel == null: return battle_unit_list_panel.visible = false func _on_battle_unit_list_tab_pressed(mode: String) -> void: var normalized_mode := "enemy" if mode == "enemy" else "ally" if battle_unit_list_mode == normalized_mode and battle_unit_list_panel != null and battle_unit_list_panel.visible: return battle_unit_list_mode = normalized_mode _play_ui_click() _rebuild_battle_unit_list_rows() func _update_battle_unit_list_button() -> void: if battle_unit_list_button == null: return var can_show := ( battle_started and not campaign_complete_screen and state.battle_status == BattleState.STATUS_ACTIVE ) _set_action_button_state( battle_unit_list_button, "군세", not can_show, "전장에 있는 아군과 적군 목록을 엽니다." if can_show else "전투 중에만 군세를 볼 수 있습니다." ) if not can_show: _hide_battle_unit_list_panel() func _update_battle_unit_list_panel() -> void: if battle_unit_list_panel == null or not battle_unit_list_panel.visible: return if not battle_started or campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE: _hide_battle_unit_list_panel() return _rebuild_battle_unit_list_rows() func _rebuild_battle_unit_list_rows() -> void: if battle_unit_list_rows == null: return _clear_battle_unit_list_rows() var units := _battle_unit_list_units() if battle_unit_list_title_label != null: battle_unit_list_title_label.text = "군세 · %s" % _battle_unit_list_mode_label() if battle_unit_list_ally_button != null: battle_unit_list_ally_button.disabled = battle_unit_list_mode == "ally" if battle_unit_list_enemy_button != null: battle_unit_list_enemy_button.disabled = battle_unit_list_mode == "enemy" if battle_unit_list_status_label != null: battle_unit_list_status_label.text = _format_battle_unit_list_status(units) if units.is_empty(): var empty_label := Label.new() empty_label.text = "남은 부대 없음" empty_label.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE empty_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _apply_label_style(empty_label, UI_AGED_INK, 13) battle_unit_list_rows.add_child(empty_label) return for unit in units: battle_unit_list_rows.add_child(_make_battle_unit_list_row_button(unit)) func _clear_battle_unit_list_rows() -> void: for child in battle_unit_list_rows.get_children(): battle_unit_list_rows.remove_child(child) child.queue_free() func _battle_unit_list_units() -> Array[Dictionary]: if battle_unit_list_mode == "enemy": return state.get_living_units(BattleState.TEAM_ENEMY) return state.get_player_units(false) func _battle_unit_list_mode_label() -> String: return "적군" if battle_unit_list_mode == "enemy" else "아군" func _format_battle_unit_list_status(units: Array[Dictionary]) -> String: if battle_unit_list_mode == "enemy": return "잔적 %d" % units.size() var ready_count := 0 var done_count := 0 for unit in units: if not bool(unit.get("alive", true)) or not bool(unit.get("controllable", true)): continue if bool(unit.get("acted", false)): done_count += 1 else: ready_count += 1 return "행동 %d · 완료 %d" % [ready_count, done_count] func _make_battle_unit_list_row_button(unit: Dictionary) -> Button: var unit_id := str(unit.get("id", "")) var row_button := Button.new() row_button.text = _format_battle_unit_list_row_text(unit) row_button.tooltip_text = _format_battle_unit_list_row_tooltip(unit) row_button.custom_minimum_size = BATTLE_UNIT_LIST_ROW_SIZE row_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL _apply_button_style(row_button) row_button.add_theme_font_size_override("font_size", 12) row_button.pressed.connect(_on_battle_unit_list_row_pressed.bind(unit_id)) return row_button func _format_battle_unit_list_row_text(unit: Dictionary) -> String: var hp := int(unit.get("hp", 0)) var max_hp := maxi(1, int(unit.get("max_hp", 1))) var hp_percent := int(round(float(hp) * 100.0 / float(max_hp))) return "%s %s 병%d%% %s %s" % [ str(unit.get("name", "부대")), _unit_class_display_name(unit), hp_percent, _battle_unit_action_state_text(unit), _format_cell_label(unit.get("pos", Vector2i(-1, -1))) ] func _format_battle_unit_list_row_tooltip(unit: Dictionary) -> String: var hint := "클릭하면 위치로 이동합니다." if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list(): hint = "클릭하면 이 장수를 선택하고 위치로 이동합니다." return "%s\n%s" % [_format_unit_focus_text(unit, "군세"), hint] func _battle_unit_action_state_text(unit: Dictionary) -> String: if not bool(unit.get("alive", true)): return "퇴각" if str(unit.get("team", "")) == BattleState.TEAM_ENEMY: return "적" if not bool(unit.get("controllable", true)): return "호위" if bool(unit.get("acted", false)): return "완료" return "행동" func _can_select_from_battle_unit_list() -> bool: return ( battle_started and state.can_player_act() and not _is_input_locked() and not _has_pending_move() and selected_skill_id.is_empty() and selected_item_id.is_empty() and not basic_attack_targeting ) func _on_battle_unit_list_row_pressed(unit_id: String) -> void: var unit := state.get_unit(unit_id) if unit.is_empty(): return var unit_cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) if state.is_inside(unit_cell): _focus_board_on_cell(unit_cell, true, true) if str(unit.get("team", "")) == BattleState.TEAM_PLAYER and _can_select(unit) and _can_select_from_battle_unit_list(): selected_skill_id = "" selected_item_id = "" basic_attack_targeting = false _hide_tactic_menu() _hide_item_menu() _hide_equip_menu() _hide_post_move_menu() state.select_unit(unit_id) _play_ui_confirm() else: _play_ui_click() _rebuild_battle_unit_list_rows() _update_hud() queue_redraw() func _show_title_menu() -> void: title_screen_visible = true _play_bgm("menu") _hide_opening_prologue() _clear_dialogue() _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() _hide_battle_unit_list_panel() _hide_settings_panel() _clear_pending_move_state() if briefing_panel != null: briefing_panel.visible = false if result_panel != null: result_panel.visible = false if title_screen_root != null: title_screen_root.visible = true title_screen_root.move_to_front() _set_title_load_panel_visible(false) _set_title_settings_visible(false) _update_title_menu_state() _refresh_screen_backdrop_visibility() queue_redraw() func _hide_title_menu() -> void: title_screen_visible = false if title_screen_root != null: title_screen_root.visible = false _refresh_screen_backdrop_visibility() func _is_title_screen_visible() -> bool: return title_screen_visible and title_screen_root != null and title_screen_root.visible func _show_opening_prologue() -> void: _hide_title_menu() if briefing_panel != null: briefing_panel.visible = false if result_panel != null: result_panel.visible = false opening_prologue_index = 0 _update_opening_prologue_page() if opening_prologue_root != null: opening_prologue_root.visible = true opening_prologue_root.move_to_front() _play_bgm("menu") _refresh_screen_backdrop_visibility() queue_redraw() func _hide_opening_prologue() -> void: if opening_prologue_root != null: opening_prologue_root.visible = false func _is_opening_prologue_visible() -> bool: return opening_prologue_root != null and opening_prologue_root.visible func _should_show_opening_prologue() -> bool: return ( campaign_state.current_scenario_id == campaign_state.get_start_scenario_id() and campaign_state.completed_scenarios.is_empty() ) func _update_opening_prologue_page() -> void: var page_count := OPENING_PROLOGUE_PAGES.size() if page_count <= 0: return opening_prologue_index = mini(maxi(opening_prologue_index, 0), page_count - 1) var page: Dictionary = OPENING_PROLOGUE_PAGES[opening_prologue_index] if opening_prologue_title_label != null: opening_prologue_title_label.text = str(page.get("title", "")) if opening_prologue_body_label != null: opening_prologue_body_label.text = str(page.get("body", "")) if opening_prologue_step_label != null: opening_prologue_step_label.text = "이야기 %d/%d" % [opening_prologue_index + 1, page_count] if opening_prologue_next_button != null: opening_prologue_next_button.text = "군막으로" if opening_prologue_index >= page_count - 1 else "다음" opening_prologue_next_button.tooltip_text = "군막으로 이동합니다." if opening_prologue_index >= page_count - 1 else "다음 이야기로 넘깁니다." func _on_opening_prologue_next_pressed() -> void: if not _is_opening_prologue_visible(): return _play_ui_confirm() if opening_prologue_index >= OPENING_PROLOGUE_PAGES.size() - 1: _finish_opening_prologue() return opening_prologue_index += 1 _update_opening_prologue_page() func _on_opening_prologue_skip_pressed() -> void: if not _is_opening_prologue_visible(): return _play_ui_cancel() _finish_opening_prologue() func _finish_opening_prologue() -> void: _hide_opening_prologue() _enter_campaign_from_title() func _update_title_menu_state() -> void: var has_load := campaign_state.has_save() or campaign_state.has_manual_save() if title_load_button != null: title_load_button.disabled = not has_load title_load_button.tooltip_text = "불러올 기록을 선택합니다." if has_load else "저장된 전기가 없습니다." if title_status_label != null: title_status_label.text = _format_title_status_text(has_load) _update_title_load_panel_state() if title_settings_back_button != null: title_settings_back_button.visible = title_settings_panel != null and title_settings_panel.visible if title_bgm_toggle != null: title_bgm_toggle.set_pressed_no_signal(title_bgm_enabled) if title_sfx_toggle != null: title_sfx_toggle.set_pressed_no_signal(title_sfx_enabled) if title_edge_scroll_toggle != null: title_edge_scroll_toggle.set_pressed_no_signal(title_edge_scroll_enabled) _refresh_title_volume_controls() func _update_title_load_panel_state() -> void: if title_load_auto_button != null: var auto_summary := campaign_state.get_save_summary() var has_auto := not auto_summary.is_empty() title_load_auto_button.disabled = not has_auto title_load_auto_button.text = _format_title_load_button_text(auto_summary, "자동 기록", "자동 기록 없음") title_load_auto_button.tooltip_text = _format_title_load_button_tooltip(auto_summary, "자동으로 봉인된 진행 기록입니다.") if title_load_manual_button != null: var manual_summary := campaign_state.get_manual_save_summary() var has_manual := not manual_summary.is_empty() title_load_manual_button.disabled = not has_manual title_load_manual_button.text = _format_title_load_button_text(manual_summary, "수동 봉인", "수동 봉인 없음") title_load_manual_button.tooltip_text = _format_title_load_button_tooltip(manual_summary, "군막에서 직접 남긴 기록입니다.") func _format_title_load_button_text(summary: Dictionary, label: String, empty_text: String) -> String: if summary.is_empty(): return empty_text var title := str(summary.get("current_scenario_title", "전기")) if title.is_empty(): title = "전기" return "%s: %s" % [label, title] func _format_title_load_button_tooltip(summary: Dictionary, header: String) -> String: if summary.is_empty(): return "%s\n저장된 기록이 없습니다." % header var state_label := "전후 선택 대기" if bool(summary.get("pending_choice", false)) else "군막 또는 전장 진입 전" return "%s\n진행 %d/%d · 군자금 %d냥 · %s" % [ header, int(summary.get("completed_count", 0)), int(summary.get("total_count", 0)), int(summary.get("gold", 0)), state_label ] func _format_title_status_text(has_load: bool) -> String: if not has_load: return "새 전기를 열어 조조와 하후돈의 첫 군령을 시작합니다." var auto_summary := campaign_state.get_save_summary() if not auto_summary.is_empty(): return "이어 할 전기: %s · %d/%d 봉인 · %d냥" % [ str(auto_summary.get("current_scenario_title", "전기")), int(auto_summary.get("completed_count", 0)), int(auto_summary.get("total_count", 0)), int(auto_summary.get("gold", 0)) ] var summary := campaign_state.get_manual_save_summary() if not summary.is_empty(): return "수동 봉인: %s · %d/%d 봉인 · %d냥" % [ str(summary.get("current_scenario_title", "전기")), int(summary.get("completed_count", 0)), int(summary.get("total_count", 0)), int(summary.get("gold", 0)) ] return "저장된 전기가 있습니다." func _on_title_start_pressed() -> void: _play_ui_confirm() if not campaign_state.reset_save(campaign_state.get_start_scenario_id()): if title_status_label != null: title_status_label.text = "새 전기를 열지 못했습니다. 저장 권한을 확인해 주세요." return if _should_show_opening_prologue(): _show_opening_prologue() return _enter_campaign_from_title() func _on_title_load_pressed() -> void: if title_load_button != null and title_load_button.disabled: return _play_ui_confirm() _set_title_load_panel_visible(true) _update_title_menu_state() func _on_title_load_auto_pressed() -> void: _load_title_slot(false) func _on_title_load_manual_pressed() -> void: _load_title_slot(true) func _on_title_load_back_pressed() -> void: _play_ui_cancel() _set_title_load_panel_visible(false) _update_title_menu_state() func _load_title_slot(manual: bool) -> void: if manual and title_load_manual_button != null and title_load_manual_button.disabled: return if not manual and title_load_auto_button != null and title_load_auto_button.disabled: return _play_ui_confirm() var loaded := campaign_state.load_manual_game() if manual else campaign_state.load_game() if not loaded: if title_status_label != null: title_status_label.text = "저장된 전기를 열지 못했습니다." _set_title_load_panel_visible(false) _update_title_load_panel_state() return _enter_campaign_from_title() func _enter_campaign_from_title() -> void: _hide_opening_prologue() _hide_title_menu() if log_box != null: log_box.clear() 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() _update_result_panel() else: _load_current_battle() _show_briefing() _update_hud() queue_redraw() func _on_title_settings_pressed() -> void: _play_ui_click() _set_title_settings_visible(true) _update_title_menu_state() func _on_title_settings_back_pressed() -> void: _play_ui_cancel() _set_title_settings_visible(false) _update_title_menu_state() func _set_title_settings_visible(visible: bool) -> void: if visible and title_load_panel != null: title_load_panel.visible = false if title_status_label != null: title_status_label.visible = not visible if title_start_button != null: title_start_button.visible = not visible if title_load_button != null: title_load_button.visible = not visible if title_settings_button != null: title_settings_button.visible = not visible if title_settings_panel != null: title_settings_panel.visible = visible if title_settings_back_button != null: title_settings_back_button.visible = visible func _set_title_load_panel_visible(visible: bool) -> void: if visible and title_settings_panel != null: title_settings_panel.visible = false if title_status_label != null: title_status_label.visible = not visible if title_start_button != null: title_start_button.visible = not visible if title_load_button != null: title_load_button.visible = not visible if title_settings_button != null: title_settings_button.visible = not visible if title_load_panel != null: title_load_panel.visible = visible if title_settings_back_button != null: title_settings_back_button.visible = false func _is_title_load_panel_visible() -> bool: return title_load_panel != null and title_load_panel.visible func _is_title_settings_panel_visible() -> bool: return title_settings_panel != null and title_settings_panel.visible func _on_title_bgm_toggled(enabled: bool) -> void: title_bgm_enabled = enabled if title_bgm_enabled: _play_bgm(current_bgm_key if not current_bgm_key.is_empty() else "menu") elif bgm_player != null: bgm_player.stop() _apply_audio_settings() _save_title_settings() _update_title_menu_state() func _on_title_sfx_toggled(enabled: bool) -> void: title_sfx_enabled = enabled if title_sfx_enabled: _play_sfx("ui_click", UI_SFX_VOLUME_DB) _save_title_settings() _update_title_menu_state() func _on_title_edge_scroll_toggled(enabled: bool) -> void: title_edge_scroll_enabled = enabled _save_title_settings() _update_title_menu_state() queue_redraw() func _on_title_bgm_volume_changed(value: float) -> void: title_bgm_volume_percent = _clamp_volume_percent(value) _apply_audio_settings() _save_title_settings() _refresh_title_volume_controls() func _on_title_sfx_volume_changed(value: float) -> void: title_sfx_volume_percent = _clamp_volume_percent(value) _save_title_settings() _refresh_title_volume_controls() if title_sfx_enabled: _play_sfx("ui_click", UI_SFX_VOLUME_DB) func _update_top_archive_buttons() -> void: var prep_available := ( not battle_started and not _is_prebattle_prep_locked() and briefing_panel != null and briefing_panel.visible and not campaign_complete_screen ) if top_save_button != null: top_save_button.disabled = not prep_available if prep_available: top_save_button.tooltip_text = "저장\n현재 군막 준비와 전기 진행을 수동 봉인합니다." else: top_save_button.tooltip_text = "저장\n전투 중에는 저장할 수 없습니다. 군막 화면에서 사용합니다." if top_load_button != null: var has_manual := campaign_state.has_manual_save() top_load_button.disabled = (not prep_available) or (not has_manual) if not prep_available: top_load_button.tooltip_text = "불러오기\n전투 중에는 불러올 수 없습니다. 군막 화면에서 사용합니다." elif not has_manual: top_load_button.tooltip_text = "불러오기\n열 수 있는 수동 봉인이 없습니다." else: top_load_button.tooltip_text = "불러오기\n수동 봉인을 열어 군막으로 돌아갑니다." 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() _update_top_archive_buttons() 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() _update_top_archive_buttons() 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() unit_action_motion_by_unit.clear() _clear_presentation_state() 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() _hide_battle_unit_list_panel() 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 deployed := bool(unit.get("deployed", true)) var disabled := ( bool(unit.get("required_deployment", false)) or (not deployed and deployed_count >= max_units) ) _add_roster_unit_row(unit, disabled) 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: return str(unit.get("name", "장수")) func _add_roster_unit_row(unit: Dictionary, disabled: bool) -> void: var unit_id := str(unit.get("id", "")) var tooltip_text := _format_roster_unit_tooltip(unit, disabled) var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 62) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text roster_list.add_child(row) row.add_child(_make_roster_unit_portrait(unit, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 4) column.tooltip_text = tooltip_text row.add_child(column) var roster_unit_button := Button.new() roster_unit_button.text = _format_roster_unit_button_text(unit) roster_unit_button.custom_minimum_size = Vector2(500, 30) roster_unit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL roster_unit_button.disabled = disabled roster_unit_button.tooltip_text = tooltip_text _apply_button_style(roster_unit_button) roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id)) column.add_child(roster_unit_button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 22) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in _format_roster_unit_badges(unit): var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_roster_unit_badge(text, tooltip_text)) func _make_roster_unit_portrait(unit: Dictionary, tooltip_text: String) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE panel.tooltip_text = tooltip_text _apply_panel_style(panel, "portrait") var stack := Control.new() stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE stack.tooltip_text = tooltip_text panel.add_child(stack) var portrait := TextureRect.new() portrait.set_anchors_preset(Control.PRESET_FULL_RECT) portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED portrait.texture = _load_art_texture(str(unit.get("portrait", ""))) portrait.visible = portrait.texture != null portrait.tooltip_text = tooltip_text stack.add_child(portrait) 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.text = _dialogue_portrait_initials(str(unit.get("name", ""))) initials_label.visible = portrait.texture == null initials_label.tooltip_text = tooltip_text _apply_label_style(initials_label, UI_PARCHMENT_TEXT, 16) stack.add_child(initials_label) return panel func _format_roster_unit_badges(unit: Dictionary) -> Array: var state_text := "출진" if bool(unit.get("deployed", true)) else "예비" var badges := [ state_text, "품계 %d" % int(unit.get("level", 1)), _unit_class_display_name(unit) ] if bool(unit.get("required_deployment", false)): badges.append("필수") if not bool(unit.get("controllable", true)): badges.append("호위") return badges func _format_roster_unit_tooltip(unit: Dictionary, disabled: bool) -> String: var lines := [ "%s · 품계 %d · %s" % [ str(unit.get("name", "장수")), int(unit.get("level", 1)), _unit_class_display_name(unit) ], "상태: %s" % ("출진" if bool(unit.get("deployed", true)) else "예비"), "병장: %s" % _format_unit_equipment(unit) ] if bool(unit.get("required_deployment", false)): lines.append("필수 출진 장수입니다.") if not bool(unit.get("controllable", true)): lines.append("호위 대상이라 직접 명령할 수 없습니다.") if not disabled: lines.append("클릭하면 출진과 예비를 바꿉니다.") elif not bool(unit.get("required_deployment", false)) and not bool(unit.get("deployed", true)): lines.append("출진 명부가 가득 찼습니다.") return _join_strings(lines, "\n") func _make_roster_unit_badge(text: String, tooltip_text: String) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(82, 22) panel.tooltip_text = tooltip_text _apply_panel_style(panel, "caption") 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.text = text label.tooltip_text = tooltip_text _apply_label_style(label, UI_PARCHMENT_TEXT, 11) panel.add_child(label) return panel func _on_roster_unit_pressed(unit_id: String) -> void: var unit := state.get_unit(unit_id) if unit.is_empty(): return var next_deployed := not bool(unit.get("deployed", true)) if state.try_set_unit_deployed(unit_id, next_deployed): _play_ui_confirm() if not state.get_unit(formation_unit_id).get("deployed", false): formation_unit_id = "" if not state.get_unit(armory_unit_id).get("deployed", false): armory_unit_id = "" _rebuild_roster_menu() _update_hud() queue_redraw() func _rebuild_formation_menu() -> void: if formation_list == null: return _clear_formation_list() var units := state.get_controllable_player_units() if formation_unit_id.is_empty() or _find_unit_in_list(units, formation_unit_id).is_empty(): formation_unit_id = str(units[0].get("id", "")) if not units.is_empty() else "" if formation_status_label != null: var selected := state.get_unit(formation_unit_id) if selected.is_empty(): formation_status_label.text = "진형" formation_status_label.tooltip_text = "출진 장수를 고른 뒤 전장도의 금빛 진형 칸을 클릭해 배치합니다." else: formation_status_label.text = "진형: %s" % str(selected.get("name", "장수")) formation_status_label.tooltip_text = _format_formation_unit_tooltip(selected, true) 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", "")) _add_formation_unit_row(unit, unit_id == formation_unit_id) 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: return str(unit.get("name", "장수")) func _add_formation_unit_row(unit: Dictionary, selected: bool) -> void: var unit_id := str(unit.get("id", "")) var tooltip_text := _format_formation_unit_tooltip(unit, selected) var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 62) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text formation_list.add_child(row) row.add_child(_make_roster_unit_portrait(unit, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 4) column.tooltip_text = tooltip_text row.add_child(column) var unit_button := Button.new() unit_button.text = _format_formation_unit_button_text(unit) unit_button.custom_minimum_size = Vector2(500, 30) unit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL unit_button.disabled = selected unit_button.tooltip_text = tooltip_text _apply_button_style(unit_button) unit_button.pressed.connect(_on_formation_unit_pressed.bind(unit_id)) column.add_child(unit_button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 22) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in _format_formation_unit_badges(unit, selected): var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_armory_info_badge(text, tooltip_text)) func _format_formation_unit_badges(unit: Dictionary, selected: bool) -> Array: return [ "선택" if selected else "대상", "좌표 %s" % _format_cell_label(unit.get("pos", Vector2i.ZERO)), _unit_class_display_name(unit) ] func _format_formation_unit_tooltip(unit: Dictionary, selected: bool) -> String: var lines := [ "%s · 품계 %d · %s" % [ str(unit.get("name", "장수")), int(unit.get("level", 1)), _unit_class_display_name(unit) ], "현재 위치: %s" % _format_cell_label(unit.get("pos", Vector2i.ZERO)) ] if selected: lines.append("전장도의 금빛 진형 칸을 클릭하면 이 장수를 옮깁니다.") else: lines.append("클릭하면 배치할 장수로 선택합니다.") return _join_strings(lines, "\n") 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() _focus_board_on_formation_unit(true) _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() _focus_board_on_formation_unit() _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", "")) _add_armory_unit_row(unit, unit_id == armory_unit_id) 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" % str(selected.get("name", "장수")) equipment_label.tooltip_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() var tooltip_text := "%s\n클릭하면 군수고로 돌려보냅니다." % _format_equipment_unequip_text(selected, normalized_slot, item_id, item) unequip_button.text = _format_armory_unequip_button_text(item_id) unequip_button.tooltip_text = tooltip_text _apply_button_style(unequip_button) unequip_button.pressed.connect(_on_armory_unequip_pressed.bind(normalized_slot)) _add_armory_equipment_row( item_id, item, unequip_button, tooltip_text, _format_armory_unequip_badges(normalized_slot, item) ) 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() var owned := int(inventory.get(normalized_id, 0)) var tooltip_text := "%s\n클릭하면 선택 장수에게 장착합니다." % _format_equipment_option_text( selected, normalized_id, item, owned ) armory_equip_button.text = _format_armory_equip_button_text(normalized_id) armory_equip_button.tooltip_text = tooltip_text _apply_button_style(armory_equip_button) armory_equip_button.pressed.connect(_on_armory_equip_pressed.bind(normalized_id)) _add_armory_equipment_row( normalized_id, item, armory_equip_button, tooltip_text, _format_armory_equip_badges(item, owned) ) 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: return str(unit.get("name", "장수")) func _add_armory_unit_row(unit: Dictionary, selected: bool) -> void: var unit_id := str(unit.get("id", "")) var tooltip_text := _format_armory_unit_tooltip(unit, selected) var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 62) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text armory_list.add_child(row) row.add_child(_make_roster_unit_portrait(unit, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 4) column.tooltip_text = tooltip_text row.add_child(column) var unit_button := Button.new() unit_button.text = _format_armory_unit_button_text(unit) unit_button.custom_minimum_size = Vector2(500, 30) unit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL unit_button.disabled = selected unit_button.tooltip_text = tooltip_text _apply_button_style(unit_button) unit_button.pressed.connect(_on_armory_unit_pressed.bind(unit_id)) column.add_child(unit_button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 22) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in _format_armory_unit_badges(unit, selected): var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_armory_info_badge(text, tooltip_text)) func _format_armory_unit_badges(unit: Dictionary, selected: bool) -> Array: var badges := [ "선택" if selected else "대상", "품계 %d" % int(unit.get("level", 1)), _unit_class_display_name(unit) ] return badges func _format_armory_unit_tooltip(unit: Dictionary, selected: bool) -> String: var lines := [ "%s · 품계 %d · %s" % [ str(unit.get("name", "장수")), int(unit.get("level", 1)), _unit_class_display_name(unit) ], "병장: %s" % _format_unit_equipment(unit) ] if selected: lines.append("현재 병장을 바꿀 장수입니다.") else: lines.append("클릭하면 이 장수의 병장 장부를 엽니다.") return _join_strings(lines, "\n") func _add_armory_equipment_row(item_id: String, item: Dictionary, action_button: Button, tooltip_text: String, visible_badges: Array) -> void: var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 62) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text armory_list.add_child(row) row.add_child(_make_shop_item_icon_panel(item_id, item, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 4) column.tooltip_text = tooltip_text row.add_child(column) action_button.custom_minimum_size = Vector2(500, 30) action_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_child(action_button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 22) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in visible_badges: var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_armory_info_badge(text, tooltip_text)) func _format_armory_equip_button_text(item_id: String) -> String: return _item_display_name(item_id) func _format_armory_unequip_button_text(item_id: String) -> String: return "해제 %s" % _item_display_name(item_id) func _format_armory_equip_badges(item: Dictionary, owned: int) -> Array: var slot := _equipment_slot_for_display(item) var badges := [ _equipment_slot_display_name(slot), "보유 %d" % owned ] var stat_text := _format_equipment_primary_badge(item) if not stat_text.is_empty(): badges.append(stat_text) return badges func _format_armory_unequip_badges(slot: String, item: Dictionary) -> Array: var badges := [ "해제", _equipment_slot_display_name(slot) ] var stat_text := _format_equipment_primary_badge(item) if not stat_text.is_empty(): badges.append(stat_text) return badges func _format_equipment_primary_badge(item: Dictionary) -> String: if item.is_empty(): return "" var rarity_text := _format_equipment_rarity_text(item) if not rarity_text.is_empty(): return rarity_text var bonuses: Dictionary = item.get("bonuses", {}) for stat in ["atk", "def", "int", "agi", "hp", "mp"]: var amount := int(bonuses.get(stat, 0)) if amount == 0: continue var sign := "+" if amount > 0 else "" return "%s %s%d" % [_battle_stat_display_name(stat), sign, amount] return _item_kind_text(str(item.get("kind", "item"))) func _make_armory_info_badge(text: String, tooltip_text: String) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(86, 22) panel.tooltip_text = tooltip_text _apply_panel_style(panel, "caption") 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.text = text label.tooltip_text = tooltip_text _apply_label_style(label, UI_PARCHMENT_TEXT, 11) panel.add_child(label) return panel 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) var visible_badges := _format_shop_buy_visible_badges(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, visible_badges) 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) var visible_badges := _format_shop_sell_visible_badges(item, 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, visible_badges) 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, visible_badges: Array) -> void: var tooltip_text := action_button.tooltip_text if tooltip_text.is_empty(): tooltip_text = detail_text var row := HBoxContainer.new() row.custom_minimum_size = Vector2(580, 64) row.add_theme_constant_override("separation", 8) row.tooltip_text = tooltip_text shop_list.add_child(row) row.add_child(_make_shop_item_icon_panel(item_id, item, tooltip_text)) var column := VBoxContainer.new() column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 3) column.tooltip_text = tooltip_text row.add_child(column) column.add_child(action_button) var badge_row := HBoxContainer.new() badge_row.custom_minimum_size = Vector2(500, 24) badge_row.add_theme_constant_override("separation", 5) badge_row.tooltip_text = tooltip_text column.add_child(badge_row) for badge_text in visible_badges: var text := str(badge_text).strip_edges() if text.is_empty(): continue badge_row.add_child(_make_shop_item_info_badge(text, tooltip_text)) func _make_shop_item_icon_panel(item_id: String, item: Dictionary, tooltip_text: String = "") -> PanelContainer: var icon_panel := PanelContainer.new() icon_panel.custom_minimum_size = SHOP_ITEM_ICON_SIZE icon_panel.tooltip_text = tooltip_text _apply_panel_style(icon_panel, "portrait") var stack := Control.new() stack.custom_minimum_size = SHOP_ITEM_ICON_SIZE stack.tooltip_text = tooltip_text 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 icon_rect.tooltip_text = tooltip_text 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) initials_label.tooltip_text = tooltip_text _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_visible_badges(item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> Array: var badges := _shop_item_common_visible_badges(item, owned) if stock_limit >= 0: badges.append("잔량 %d/%d" % [max(0, remaining), stock_limit]) if stock_limit >= 0 and remaining <= 0: badges.append("매진") elif campaign_state.gold < price: badges.append("부족 %d냥" % (price - campaign_state.gold)) return badges func _format_shop_sell_visible_badges(item: Dictionary, owned: int) -> Array: var badges := _shop_item_common_visible_badges(item, owned) badges.append("반값") return badges func _shop_item_common_visible_badges(item: Dictionary, owned: int) -> Array: return [ _item_kind_text(str(item.get("kind", "item"))), "보유 %d" % owned ] func _make_shop_item_info_badge(text: String, tooltip_text: String) -> PanelContainer: var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(92, 24) panel.tooltip_text = tooltip_text _apply_panel_style(panel, "caption") 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.text = text label.tooltip_text = tooltip_text _apply_label_style(label, UI_PARCHMENT_TEXT, 11) panel.add_child(label) return panel 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_title_screen_visible() or _is_opening_prologue_visible() or _is_dialogue_visible() or _is_auto_end_turn_prompt_visible() or _has_active_battle_presentation_sequence() 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_inventory_status_summary_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() var consumable_count := 0 var equipment_count := 0 var other_count := 0 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": consumable_count += count elif kind == "weapon" or kind == "armor" or kind == "accessory": equipment_count += count else: other_count += count var parts: Array[String] = [] if consumable_count > 0: parts.append("소 %d" % consumable_count) if equipment_count > 0: parts.append("병 %d" % equipment_count) if other_count > 0: parts.append("잡 %d" % other_count) if parts.is_empty(): return "군수 없음" return _join_strings(parts, " · ") 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() unit_action_motion_by_unit.clear() _clear_presentation_state() 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() _hide_settings_panel() _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() if _should_show_opening_prologue(): _show_opening_prologue() _update_hud() return _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: _refresh_post_move_title(selected) if post_move_attack_button != null: var attack_blocked := not _has_basic_attack_target(pending_move_unit_id) _set_local_command_button_state( post_move_attack_button, "타격령", "무기 공격", attack_blocked, "타격 없음" ) if post_move_tactic_button != null: var skill_blocked := not _unit_has_usable_skill_target(pending_move_unit_id) _set_local_command_button_state( post_move_tactic_button, "책략첩", "책략 표식", skill_blocked, "책략 없음" ) if post_move_item_button != null: var item_blocked := not _unit_has_usable_item_target(pending_move_unit_id) _set_local_command_button_state( post_move_item_button, "보급첩", "보급 사용", item_blocked, "보급 없음" ) if post_move_wait_button != null: _set_local_command_button_state(post_move_wait_button, "대기령", "행동 종료", false, "") if post_move_cancel_button != null: _set_local_command_button_state(post_move_cancel_button, "발걸음 거두기", "이동 취소", false, "") func _set_local_command_button_state(button: Button, label: String, hint: String, disabled: bool, disabled_hint: String) -> void: if button == null: return button.text = "" button.disabled = disabled button.set_meta("command_label", label) button.set_meta("command_hint", hint) button.set_meta("disabled_hint", disabled_hint) var tooltip_hint := disabled_hint if disabled and not disabled_hint.strip_edges().is_empty() else hint button.tooltip_text = _format_local_command_tooltip(label, tooltip_hint) func _refresh_post_move_title(selected: Dictionary = {}) -> void: if post_move_title_label == null: return var focus := selected if focus.is_empty(): focus = state.get_selected_unit() var unit_name := str(focus.get("name", "부대")) if not focus.is_empty() else "부대" _set_fitted_label_text( post_move_title_label, "%s: 군령 선택" % unit_name, LOCAL_COMMAND_TITLE_FONT_SIZE, LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, POST_MOVE_TITLE_SIZE ) func _on_local_command_button_mouse_entered(button: Button) -> void: if button == null or post_move_title_label == null: return var label := str(button.get_meta("command_label", "군령")) var hint := str(button.get_meta("command_hint", "")) if button.disabled: var disabled_hint := str(button.get_meta("disabled_hint", "")) if not disabled_hint.is_empty(): hint = disabled_hint var text := label if not hint.is_empty(): text = "%s · %s" % [label, hint] _set_fitted_label_text( post_move_title_label, text, LOCAL_COMMAND_TITLE_FONT_SIZE, LOCAL_COMMAND_TITLE_MIN_FONT_SIZE, POST_MOVE_TITLE_SIZE ) func _on_local_command_button_mouse_exited() -> void: _refresh_post_move_title() 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() _suppress_local_command_tooltip(skill_button) _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() _suppress_local_command_tooltip(item_button) _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 _hide_targeting_hint_panel() 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 _hide_targeting_hint_panel() 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 if bool(button.get_meta("icon_only", false)): button.text = "" button.tooltip_text = label if tooltip.strip_edges().is_empty() else "%s\n%s" % [label, tooltip] else: button.text = label button.tooltip_text = tooltip button.disabled = disabled func _set_action_button_blocked(button: Button, base_label: String, reason_label: String, tooltip: String) -> void: var reason := reason_label.strip_edges() var blocked_tooltip := tooltip if not reason.is_empty(): blocked_tooltip = "%s: %s" % [reason, tooltip] if not tooltip.strip_edges().is_empty() else reason _set_action_button_state(button, base_label, true, blocked_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, "책략 선택", false, "%s 대상 지목." % str(skill.get("name", selected_skill_id))) 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, "도구 선택", false, "%s 대상 지목." % str(item.get("name", selected_item_id))) 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()