9685 lines
344 KiB
GDScript
9685 lines
344 KiB
GDScript
extends Node2D
|
||
|
||
const BattleStateScript := preload("res://scripts/core/battle_state.gd")
|
||
const CampaignStateScript := preload("res://scripts/core/campaign_state.gd")
|
||
|
||
const CAMPAIGN_PATH := "res://data/campaign/campaign.json"
|
||
const TILE_SIZE := 54
|
||
const BOARD_OFFSET := Vector2(48, 104)
|
||
const SIDE_PANEL_POSITION := Vector2(824, 104)
|
||
const SIDE_PANEL_SIZE := Vector2(424, 610)
|
||
const DEFAULT_BATTLE_BACKGROUND_PATH := "res://art/backgrounds/battlefield_frontier.png"
|
||
const PLAYER_COLOR := Color(0.17, 0.38, 0.29)
|
||
const ENEMY_COLOR := Color(0.64, 0.085, 0.045)
|
||
const GRID_COLOR := Color(0.12, 0.070, 0.032, 0.36)
|
||
const MOVE_OVERLAY_COLOR := Color(0.72, 0.54, 0.24, 0.26)
|
||
const MOVE_BORDER_COLOR := Color(0.94, 0.73, 0.36, 0.48)
|
||
const ATTACK_OVERLAY_COLOR := Color(0.72, 0.055, 0.032, 0.24)
|
||
const ATTACK_BORDER_COLOR := Color(0.92, 0.34, 0.18, 0.50)
|
||
const SKILL_OVERLAY_COLOR := Color(0.10, 0.25, 0.20, 0.28)
|
||
const SKILL_AREA_OVERLAY_COLOR := Color(0.26, 0.34, 0.23, 0.30)
|
||
const ITEM_OVERLAY_COLOR := Color(0.24, 0.42, 0.25, 0.28)
|
||
const ITEM_BORDER_COLOR := Color(0.58, 0.74, 0.42, 0.44)
|
||
const FORMATION_OVERLAY_COLOR := Color(0.78, 0.54, 0.18, 0.32)
|
||
const OBJECTIVE_OVERLAY_COLOR := Color(0.88, 0.52, 0.14, 0.30)
|
||
const THREAT_OVERLAY_COLOR := Color(0.76, 0.060, 0.035, 0.20)
|
||
const THREAT_BORDER_COLOR := Color(0.86, 0.18, 0.10, 0.48)
|
||
const BGM_MENU_PATH := "res://audio/bgm/menu_theme_placeholder.wav"
|
||
const BGM_BATTLE_PATH := "res://audio/bgm/battle_loop_placeholder.wav"
|
||
const SFX_BOW_RELEASE_PATH := "res://audio/sfx/bow_release_01.wav"
|
||
const SFX_DEFEAT_STING_PATH := "res://audio/sfx/defeat_sting_01.wav"
|
||
const SFX_FIRE_SKILL_PATH := "res://audio/sfx/fire_skill_01.wav"
|
||
const SFX_FOOTSTEP_PATH := "res://audio/sfx/footstep_armor_01.wav"
|
||
const SFX_GUARD_BLOCK_PATH := "res://audio/sfx/guard_block_01.wav"
|
||
const SFX_HIT_HEAVY_PATH := "res://audio/sfx/hit_heavy_01.wav"
|
||
const SFX_ITEM_PICKUP_PATH := "res://audio/sfx/item_pickup_01.wav"
|
||
const SFX_MENU_CANCEL_PATH := "res://audio/sfx/menu_cancel_01.wav"
|
||
const SFX_MENU_CONFIRM_PATH := "res://audio/sfx/menu_confirm_01.wav"
|
||
const SFX_SKILL_CAST_PATH := "res://audio/sfx/skill_cast_01.wav"
|
||
const SFX_SLASH_PATH := "res://audio/sfx/slash_01.wav"
|
||
const SFX_UI_CLICK_PATH := "res://audio/sfx/ui_click_01.wav"
|
||
const SFX_VICTORY_STING_PATH := "res://audio/sfx/victory_sting_01.wav"
|
||
const UI_FONT_REGULAR_PATH := "res://art/ui/fonts/GowunBatang-Regular.ttf"
|
||
const UI_FONT_BOLD_PATH := "res://art/ui/fonts/GowunBatang-Bold.ttf"
|
||
const BGM_VOLUME_DB := -14.0
|
||
const SFX_VOLUME_DB := -4.0
|
||
const UI_SFX_VOLUME_DB := -8.0
|
||
const FLOATING_TEXT_LIFETIME := 1.0
|
||
const FLOATING_TEXT_RISE := 42.0
|
||
const OBJECTIVE_NOTICE_DURATION := 2.6
|
||
const EDGE_SCROLL_MARGIN := 48.0
|
||
const EDGE_SCROLL_OUTER_MARGIN := 28.0
|
||
const EDGE_SCROLL_MIN_SPEED := 80.0
|
||
const EDGE_SCROLL_MAX_SPEED := 430.0
|
||
const UNIT_MOVE_ANIMATION_DURATION := 0.18
|
||
const TARGET_PREVIEW_BADGE_SIZE := Vector2(104, 22)
|
||
const LOW_HP_WARNING_RATIO := 0.35
|
||
const UNIT_STATUS_MARKER_RADIUS := 6.0
|
||
const UNIT_STATUS_MARKER_GAP := 14.0
|
||
const UNIT_STATUS_MARKER_OFFSET_Y := 13.0
|
||
const MAX_UNIT_STATUS_MARKERS := 4
|
||
const UNIT_ATTACK_ANIMATION_DURATION := 0.30
|
||
const UNIT_ATTACK_LUNGE_DISTANCE := 15.0
|
||
const UNIT_INFANTRY_ATTACK_DURATION := 0.32
|
||
const UNIT_CAVALRY_ATTACK_DURATION := 0.42
|
||
const UNIT_ARROW_ATTACK_DURATION := 0.36
|
||
const UNIT_COMMAND_ATTACK_DURATION := 0.38
|
||
const UNIT_HEAVY_ATTACK_DURATION := 0.34
|
||
const UNIT_TACTIC_CAST_DURATION := 0.42
|
||
const DIALOGUE_PANEL_POSITION := Vector2(40, 324)
|
||
const DIALOGUE_PANEL_SIZE := Vector2(1200, 356)
|
||
const DIALOGUE_PORTRAIT_SIZE := Vector2(220, 310)
|
||
const DIALOGUE_COLUMN_SIZE := Vector2(850, 318)
|
||
const DIALOGUE_TEXT_SIZE := Vector2(830, 164)
|
||
const OBJECTIVE_HUD_FONT_SIZE := 13
|
||
const OBJECTIVE_HUD_MIN_FONT_SIZE := 11
|
||
const OBJECTIVE_NOTICE_FONT_SIZE := 15
|
||
const OBJECTIVE_NOTICE_MIN_FONT_SIZE := 12
|
||
const BRIEFING_OBJECTIVE_FONT_SIZE := 15
|
||
const BRIEFING_OBJECTIVE_MIN_FONT_SIZE := 12
|
||
const DIALOGUE_SPEAKER_FONT_SIZE := 18
|
||
const DIALOGUE_SPEAKER_MIN_FONT_SIZE := 15
|
||
const DIALOGUE_TEXT_FONT_SIZE := 17
|
||
const DIALOGUE_TEXT_MIN_FONT_SIZE := 14
|
||
const LOCAL_COMMAND_TITLE_FONT_SIZE := 14
|
||
const LOCAL_COMMAND_TITLE_MIN_FONT_SIZE := 10
|
||
const LOCAL_COMMAND_DETAIL_FONT_SIZE := 12
|
||
const LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE := 10
|
||
const BRIEFING_CAMP_THUMBNAIL_SIZE := Vector2(188, 86)
|
||
const CAMP_TALK_PORTRAIT_SIZE := Vector2(58, 58)
|
||
const SHOP_MERCHANT_BADGE_SIZE := Vector2(50, 50)
|
||
const SHOP_ITEM_ICON_SIZE := Vector2(48, 48)
|
||
const HUD_UNIT_PORTRAIT_SIZE := Vector2(92, 104)
|
||
const HUD_UNIT_PORTRAIT_STACK_SIZE := Vector2(88, 100)
|
||
const POST_MOVE_MENU_SIZE := Vector2(240, 146)
|
||
const POST_MOVE_TITLE_SIZE := Vector2(196, 22)
|
||
const POST_MOVE_PICKER_PANEL_SIZE := Vector2(336, 236)
|
||
const POST_MOVE_PICKER_TITLE_SIZE := Vector2(296, 22)
|
||
const POST_MOVE_PICKER_DETAIL_SIZE := Vector2(296, 32)
|
||
const TARGETING_HINT_PANEL_SIZE := Vector2(254, 118)
|
||
const TARGETING_HINT_TITLE_SIZE := Vector2(220, 22)
|
||
const TARGETING_HINT_DETAIL_SIZE := Vector2(220, 28)
|
||
const LOCAL_COMMAND_PANEL_GAP := 8.0
|
||
const LOCAL_COMMAND_PANEL_BOARD_PADDING := 4.0
|
||
const UI_RICE_PAPER := Color(0.58, 0.45, 0.25, 0.98)
|
||
const UI_RICE_PAPER_LIGHT := Color(0.71, 0.56, 0.31, 0.99)
|
||
const UI_RICE_PAPER_DARK := Color(0.29, 0.17, 0.075, 0.98)
|
||
const UI_AGED_INK := Color(0.09, 0.055, 0.032, 1.0)
|
||
const UI_DARK_LACQUER := Color(0.085, 0.025, 0.012, 0.988)
|
||
const UI_LACQUER_EDGE := Color(0.035, 0.014, 0.008, 1.0)
|
||
const UI_CINNABAR := Color(0.52, 0.045, 0.025, 1.0)
|
||
const UI_CINNABAR_DARK := Color(0.32, 0.025, 0.016, 1.0)
|
||
const UI_OLD_BRONZE := Color(0.78, 0.58, 0.27, 1.0)
|
||
const UI_TARNISHED_BRONZE := Color(0.50, 0.36, 0.16, 1.0)
|
||
const UI_BAMBOO := Color(0.43, 0.27, 0.10, 1.0)
|
||
const UI_BAMBOO_DARK := Color(0.20, 0.11, 0.045, 1.0)
|
||
const UI_MUTED_JADE := Color(0.16, 0.30, 0.23, 1.0)
|
||
const UI_PARCHMENT_TEXT := Color(0.95, 0.82, 0.55, 1.0)
|
||
const UI_DISABLED_TEXT := Color(0.48, 0.40, 0.29, 1.0)
|
||
const UI_SILK_WASH := Color(0.80, 0.64, 0.37, 1.0)
|
||
const UI_SILK_FIBER := Color(0.18, 0.10, 0.045, 1.0)
|
||
const UI_SOOT_EDGE := Color(0.035, 0.018, 0.012, 1.0)
|
||
const UI_BONE_PAPER := Color(0.66, 0.52, 0.31, 0.995)
|
||
const UI_INK_WASH := Color(0.10, 0.065, 0.040, 1.0)
|
||
const UI_DARK_JADE := Color(0.055, 0.18, 0.13, 1.0)
|
||
const UI_JADE_EDGE := Color(0.12, 0.36, 0.25, 1.0)
|
||
const UI_SEAL_RED := Color(0.64, 0.055, 0.032, 1.0)
|
||
const UI_SEAL_RED_DARK := Color(0.28, 0.020, 0.014, 1.0)
|
||
const UI_CHARCOAL_WASH := Color(0.045, 0.026, 0.016, 1.0)
|
||
const UI_SCROLL_CENTER := Color(0.74, 0.58, 0.32, 0.996)
|
||
const UI_SCROLL_EDGE := Color(0.43, 0.25, 0.095, 0.996)
|
||
const UI_OLD_SILK := Color(0.61, 0.43, 0.20, 0.997)
|
||
const UI_BURNT_PAPER := Color(0.33, 0.19, 0.075, 0.996)
|
||
const UI_BRUSH_RULE := Color(0.12, 0.060, 0.026, 0.98)
|
||
const MAP_SCROLL_FRAME_PADDING := 12.0
|
||
const SPEAKER_DISPLAY_NAMES := {
|
||
"Cao Cao": "조조",
|
||
"Xiahou Dun": "하후돈",
|
||
"Xiahou Yuan": "하후연",
|
||
"Cao Ren": "조인",
|
||
"Dian Wei": "전위",
|
||
"Guo Jia": "곽가",
|
||
"Zhang He": "장합",
|
||
"Zhang Liao": "장료",
|
||
"Zhang Xiu": "장수",
|
||
"Jia Xu": "가후",
|
||
"Cao Ang": "조앙",
|
||
"Xu Rong": "서영",
|
||
"Li Jue": "이각",
|
||
"Hua Xiong": "화웅",
|
||
"Zhang Mancheng": "장만성",
|
||
"Chen Gong": "진궁",
|
||
"Gao Shun": "고순",
|
||
"Lu Bu": "여포",
|
||
"Qingzhou Chief": "청주 두령",
|
||
"Yan Liang": "안량",
|
||
"Wen Chou": "문추",
|
||
"Yuan Shao": "원소",
|
||
"Ju Shou": "저수",
|
||
"Chunyu Qiong": "순우경",
|
||
"Yuan Guard": "원소군 호위병",
|
||
"Yuan Field Advisor": "원소군 군사",
|
||
"Yuan Rally Advisor": "원소 수습군사",
|
||
"Yuan Tan Guard": "원담 호위병",
|
||
"Yuan Shang Guard": "원상 호위병",
|
||
"Ye Guard": "업성 수비병",
|
||
"Yuan Shang Inner Guard": "원상 내성위",
|
||
"Inner City Advisor": "내성 군사",
|
||
"Ye Last Loyalist": "업성 결사항전병",
|
||
"Palace Advisor": "궁도 군사",
|
||
"Puyang Night Trader": "복양 야상",
|
||
"Dingtao Field Sutler": "정도 야상",
|
||
"Escort Road Sutler": "호송 야상",
|
||
"Wan Castle Sutler": "완성 야상",
|
||
"Xiapi Flood-Gate Sutler": "하비 수문 야상",
|
||
"White Horse Road Sutler": "백마 야상",
|
||
"Imperial Envoy": "천자 사자",
|
||
"Imperial Guard": "천자 금위",
|
||
"Wan Scout": "완성 척후",
|
||
"Yuan Scout": "원소 척후",
|
||
"Coalition Merchant": "군막 상인",
|
||
"Coalition Quartermaster": "연합 군수관",
|
||
"Camp Merchant": "군막 상인",
|
||
"Scout": "척후"
|
||
}
|
||
const SPEAKER_CANONICAL_NAMES := {
|
||
"조조": "Cao Cao",
|
||
"하후돈": "Xiahou Dun",
|
||
"하후연": "Xiahou Yuan",
|
||
"조인": "Cao Ren",
|
||
"전위": "Dian Wei",
|
||
"곽가": "Guo Jia",
|
||
"장합": "Zhang He",
|
||
"장료": "Zhang Liao",
|
||
"장수": "Zhang Xiu",
|
||
"가후": "Jia Xu",
|
||
"조앙": "Cao Ang",
|
||
"서영": "Xu Rong",
|
||
"이각": "Li Jue",
|
||
"화웅": "Hua Xiong",
|
||
"장만성": "Zhang Mancheng",
|
||
"진궁": "Chen Gong",
|
||
"고순": "Gao Shun",
|
||
"여포": "Lu Bu",
|
||
"청주 두령": "Qingzhou Chief",
|
||
"안량": "Yan Liang",
|
||
"문추": "Wen Chou",
|
||
"원소": "Yuan Shao",
|
||
"저수": "Ju Shou",
|
||
"순우경": "Chunyu Qiong",
|
||
"원소군 호위병": "Yuan Guard",
|
||
"원소군 군사": "Yuan Field Advisor",
|
||
"원소 수습군사": "Yuan Rally Advisor",
|
||
"원담 호위병": "Yuan Tan Guard",
|
||
"원상 호위병": "Yuan Shang Guard",
|
||
"업성 수비병": "Ye Guard",
|
||
"원상 내성위": "Yuan Shang Inner Guard",
|
||
"내성 군사": "Inner City Advisor",
|
||
"업성 결사항전병": "Ye Last Loyalist",
|
||
"궁도 군사": "Palace Advisor",
|
||
"원소 척후": "Yuan Scout",
|
||
"천자 금위": "Imperial Guard",
|
||
"완성 척후": "Wan Scout"
|
||
}
|
||
const DIALOGUE_RIGHT_SIDE_SPEAKERS := {
|
||
"Camp Merchant": true,
|
||
"Coalition Merchant": true,
|
||
"Coalition Quartermaster": true,
|
||
"Dingtao Field Sutler": true,
|
||
"Escort Road Sutler": true,
|
||
"Puyang Night Trader": true,
|
||
"Wan Castle Sutler": true,
|
||
"Xiapi Flood-Gate Sutler": true,
|
||
"White Horse Road Sutler": true,
|
||
"Scout": true,
|
||
"Wan Scout": true,
|
||
"Yuan Scout": true,
|
||
"Imperial Envoy": true,
|
||
"Imperial Guard": true,
|
||
"Hua Xiong": true,
|
||
"Zhang Mancheng": true,
|
||
"Xu Rong": true,
|
||
"Li Jue": true,
|
||
"Zhang Xiu": true,
|
||
"Jia Xu": true,
|
||
"Zhang Liao": true,
|
||
"Chen Gong": true,
|
||
"Gao Shun": true,
|
||
"Lu Bu": true,
|
||
"Qingzhou Chief": true,
|
||
"Yuan Shao": true,
|
||
"Yan Liang": true,
|
||
"Wen Chou": true,
|
||
"Ju Shou": true,
|
||
"Chunyu Qiong": true,
|
||
"Yuan Guard": true,
|
||
"Yuan Field Advisor": true,
|
||
"Yuan Rally Advisor": true,
|
||
"Yuan Tan Guard": true,
|
||
"Yuan Shang Guard": true,
|
||
"Ye Guard": true,
|
||
"Yuan Shang Inner Guard": true,
|
||
"Inner City Advisor": true,
|
||
"Ye Last Loyalist": true,
|
||
"Palace Advisor": true
|
||
}
|
||
|
||
var state: BattleState = BattleStateScript.new()
|
||
var campaign_state: CampaignState = CampaignStateScript.new()
|
||
var hover_cell := Vector2i(-1, -1)
|
||
var move_cells: Array[Vector2i] = []
|
||
var attack_cells: Array[Vector2i] = []
|
||
var skill_cells: Array[Vector2i] = []
|
||
var item_cells: Array[Vector2i] = []
|
||
var threat_cells: Array[Vector2i] = []
|
||
var selected_skill_id := ""
|
||
var selected_item_id := ""
|
||
var basic_attack_targeting := false
|
||
var pending_move_unit_id := ""
|
||
var pending_move_from_cell := Vector2i(-1, -1)
|
||
var pending_move_to_cell := Vector2i(-1, -1)
|
||
var show_threat_overlay := false
|
||
var mission_detail_collapsed := false
|
||
var briefing_objective_collapsed := false
|
||
var active_scenario_id := ""
|
||
var battle_started := false
|
||
var battle_result_applied := false
|
||
var battle_result_summary := {}
|
||
var post_battle_dialogue_started := false
|
||
var campaign_complete_screen := false
|
||
|
||
var status_label: Label
|
||
var objective_label: Label
|
||
var campaign_status_label: Label
|
||
var mission_title_label: Label
|
||
var mission_toggle_button: Button
|
||
var mission_detail_panel: PanelContainer
|
||
var mission_detail_label: Label
|
||
var objective_notice_panel: PanelContainer
|
||
var objective_notice_label: Label
|
||
var ui_root_control: Control
|
||
var screen_backdrop: ColorRect
|
||
var hud_unit_portrait_panel: PanelContainer
|
||
var hud_unit_portrait_texture: TextureRect
|
||
var hud_unit_portrait_label: Label
|
||
var selected_label: Label
|
||
var cell_info_label: Label
|
||
var forecast_label: Label
|
||
var inventory_label: Label
|
||
var briefing_panel: PanelContainer
|
||
var briefing_title_label: Label
|
||
var briefing_location_label: Label
|
||
var briefing_seal_ribbon: HBoxContainer
|
||
var briefing_objective_panel: PanelContainer
|
||
var briefing_objective_label: Label
|
||
var briefing_objective_toggle_button: Button
|
||
var briefing_camp_overview_panel: PanelContainer
|
||
var briefing_camp_overview_row: HBoxContainer
|
||
var briefing_camp_overview_texture: TextureRect
|
||
var briefing_camp_overview_fallback_label: Label
|
||
var briefing_camp_overview_label: Label
|
||
var briefing_label: Label
|
||
var briefing_start_button: Button
|
||
var chapter_button: Button
|
||
var chapter_menu: VBoxContainer
|
||
var chapter_list: VBoxContainer
|
||
var chapter_status_label: Label
|
||
var shop_button: Button
|
||
var talk_button: Button
|
||
var talk_menu: VBoxContainer
|
||
var talk_list: VBoxContainer
|
||
var talk_status_label: Label
|
||
var shop_menu: VBoxContainer
|
||
var shop_buy_button: Button
|
||
var shop_sell_button: Button
|
||
var shop_list: VBoxContainer
|
||
var shop_status_label: Label
|
||
var shop_sell_mode := false
|
||
var armory_button: Button
|
||
var armory_menu: VBoxContainer
|
||
var armory_list: VBoxContainer
|
||
var armory_status_label: Label
|
||
var armory_unit_id := ""
|
||
var roster_button: Button
|
||
var roster_menu: VBoxContainer
|
||
var roster_list: VBoxContainer
|
||
var roster_status_label: Label
|
||
var formation_button: Button
|
||
var formation_menu: VBoxContainer
|
||
var formation_list: VBoxContainer
|
||
var formation_status_label: Label
|
||
var formation_unit_id := ""
|
||
var save_button: Button
|
||
var save_menu: VBoxContainer
|
||
var save_status_label: Label
|
||
var manual_save_button: Button
|
||
var manual_load_button: Button
|
||
var dialogue_row: HBoxContainer
|
||
var dialogue_panel: PanelContainer
|
||
var dialogue_left_tassel: VBoxContainer
|
||
var dialogue_right_tassel: VBoxContainer
|
||
var dialogue_portrait_panel: PanelContainer
|
||
var dialogue_portrait_texture: TextureRect
|
||
var dialogue_portrait_label: Label
|
||
var dialogue_column: VBoxContainer
|
||
var dialogue_speaker_panel: PanelContainer
|
||
var dialogue_speaker_label: Label
|
||
var dialogue_text_panel: PanelContainer
|
||
var dialogue_text_label: Label
|
||
var dialogue_seal_ribbon: HBoxContainer
|
||
var dialogue_progress_label: Label
|
||
var dialogue_previous_button: Button
|
||
var dialogue_continue_button: Button
|
||
var result_panel: PanelContainer
|
||
var result_label: Label
|
||
var result_seal_ribbon: HBoxContainer
|
||
var result_choice_list: VBoxContainer
|
||
var result_restart_button: Button
|
||
var next_battle_button: Button
|
||
var end_turn_button: Button
|
||
var wait_button: Button
|
||
var tactic_button: Button
|
||
var tactic_menu: VBoxContainer
|
||
var tactic_list: VBoxContainer
|
||
var item_button: Button
|
||
var item_menu: VBoxContainer
|
||
var item_list: VBoxContainer
|
||
var equip_button: Button
|
||
var equip_menu: VBoxContainer
|
||
var equip_list: VBoxContainer
|
||
var post_move_menu: PanelContainer
|
||
var post_move_title_label: Label
|
||
var post_move_attack_button: Button
|
||
var post_move_tactic_button: Button
|
||
var post_move_item_button: Button
|
||
var post_move_wait_button: Button
|
||
var post_move_cancel_button: Button
|
||
var post_move_picker_panel: PanelContainer
|
||
var post_move_picker_title_label: Label
|
||
var post_move_picker_detail_label: Label
|
||
var post_move_picker_list: VBoxContainer
|
||
var post_move_picker_back_button: Button
|
||
var post_move_picker_cancel_move_button: Button
|
||
var post_move_picker_mode := ""
|
||
var targeting_hint_panel: PanelContainer
|
||
var targeting_hint_title_label: Label
|
||
var targeting_hint_detail_label: Label
|
||
var targeting_hint_back_button: Button
|
||
var targeting_hint_cancel_move_button: Button
|
||
var threat_button: Button
|
||
var restart_button: Button
|
||
var new_campaign_button: Button
|
||
var log_box: RichTextLabel
|
||
var dialogue_queue: Array = []
|
||
var active_dialogue_lines: Array = []
|
||
var active_dialogue_index := 0
|
||
var portrait_texture_cache: Dictionary = {}
|
||
var missing_portrait_paths := {}
|
||
var bgm_player: AudioStreamPlayer
|
||
var current_bgm_key := ""
|
||
var audio_stream_cache := {}
|
||
var last_announced_battle_status := ""
|
||
var floating_texts: Array[Dictionary] = []
|
||
var objective_notice_timer := 0.0
|
||
var unit_motion_by_unit: Dictionary = {}
|
||
var unit_action_motion_by_unit: Dictionary = {}
|
||
var battle_background_path := ""
|
||
var battle_background_texture: Texture2D
|
||
var ui_regular_font: Font
|
||
var ui_bold_font: Font
|
||
var board_scroll_offset := Vector2.ZERO
|
||
|
||
|
||
func _ready() -> void:
|
||
_create_hud()
|
||
_create_audio()
|
||
state.changed.connect(_on_state_changed)
|
||
state.log_added.connect(_on_log_added)
|
||
state.dialogue_requested.connect(_on_dialogue_requested)
|
||
state.combat_feedback_requested.connect(_on_combat_feedback_requested)
|
||
state.unit_motion_requested.connect(_on_unit_motion_requested)
|
||
state.unit_action_motion_requested.connect(_on_unit_action_motion_requested)
|
||
state.objective_updated.connect(_on_objective_updated)
|
||
campaign_state.load_campaign(CAMPAIGN_PATH)
|
||
campaign_state.load_or_start(campaign_state.get_start_scenario_id())
|
||
if campaign_state.has_pending_post_battle_choice():
|
||
_load_pending_post_battle_choice()
|
||
elif campaign_state.is_campaign_complete():
|
||
_show_campaign_complete()
|
||
else:
|
||
_load_current_battle()
|
||
_show_briefing()
|
||
_update_hud()
|
||
|
||
|
||
func _unhandled_input(event: InputEvent) -> void:
|
||
if _is_dialogue_visible():
|
||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||
_advance_dialogue()
|
||
elif event is InputEventKey and event.pressed and not event.echo:
|
||
if event.keycode == KEY_LEFT or event.keycode == KEY_BACKSPACE:
|
||
_retreat_dialogue()
|
||
elif event.keycode == KEY_ENTER or event.keycode == KEY_SPACE or event.keycode == KEY_ESCAPE or event.keycode == KEY_RIGHT:
|
||
_advance_dialogue()
|
||
return
|
||
|
||
if event is InputEventMouseMotion:
|
||
var next_hover := _cell_from_screen(event.position)
|
||
if next_hover != hover_cell:
|
||
hover_cell = next_hover
|
||
_update_hud()
|
||
queue_redraw()
|
||
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||
_handle_board_click(event.position)
|
||
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
|
||
_handle_cancel_input()
|
||
elif event is InputEventKey and event.pressed and not event.echo:
|
||
_handle_key(event)
|
||
|
||
|
||
func _draw() -> void:
|
||
_draw_map()
|
||
_draw_overlays()
|
||
_draw_units()
|
||
_draw_objective_foreground_markers()
|
||
_draw_target_selection_markers()
|
||
_draw_attack_effects()
|
||
_draw_target_preview_badge()
|
||
_draw_floating_texts()
|
||
|
||
|
||
func _make_panel_style(fill: Color, border: Color, border_width: int, radius: int, margin: int, shadow_size: int) -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = fill
|
||
style.border_color = border
|
||
style.set_border_width_all(border_width)
|
||
style.set_corner_radius_all(radius)
|
||
style.content_margin_left = margin
|
||
style.content_margin_right = margin
|
||
style.content_margin_top = margin
|
||
style.content_margin_bottom = margin
|
||
style.anti_aliasing = false
|
||
if shadow_size > 0:
|
||
style.shadow_color = Color(0.0, 0.0, 0.0, 0.48)
|
||
style.shadow_offset = Vector2(3, 4)
|
||
style.shadow_size = shadow_size
|
||
return style
|
||
|
||
|
||
func _age_panel_style(style: StyleBoxFlat, variant: String, border_width: int) -> void:
|
||
if style == null:
|
||
return
|
||
match variant:
|
||
"paper", "dialogue", "result_tablet", "result_victory_tablet", "result_defeat_tablet":
|
||
style.set_border_width(SIDE_TOP, border_width + 4)
|
||
style.set_border_width(SIDE_BOTTOM, border_width + 4)
|
||
style.set_border_width(SIDE_LEFT, border_width + 1)
|
||
style.set_border_width(SIDE_RIGHT, border_width + 1)
|
||
style.content_margin_top += 2
|
||
style.content_margin_bottom += 2
|
||
"edict", "notice_edict", "speech_scroll", "bamboo_slips", "silk_map", "command_seal":
|
||
style.set_border_width(SIDE_TOP, border_width + 2)
|
||
style.set_border_width(SIDE_BOTTOM, border_width + 2)
|
||
"edict_compact":
|
||
style.set_border_width(SIDE_TOP, border_width + 1)
|
||
style.set_border_width(SIDE_BOTTOM, border_width + 1)
|
||
"seal", "speaker_seal", "caption":
|
||
style.set_border_width(SIDE_LEFT, border_width + 2)
|
||
style.set_border_width(SIDE_RIGHT, border_width + 2)
|
||
_:
|
||
pass
|
||
|
||
|
||
func _apply_panel_style(panel: PanelContainer, variant: String) -> void:
|
||
if panel == null:
|
||
return
|
||
var fill := UI_DARK_LACQUER
|
||
var border := UI_OLD_BRONZE
|
||
var border_width := 3
|
||
var radius := 0
|
||
var margin := 10
|
||
var shadow_size := 6
|
||
match variant:
|
||
"paper":
|
||
fill = Color(0.54, 0.36, 0.14, 0.997)
|
||
border = UI_LACQUER_EDGE
|
||
border_width = 15
|
||
margin = 24
|
||
shadow_size = 24
|
||
"edict":
|
||
fill = Color(0.67, 0.49, 0.24, 0.998)
|
||
border = UI_BRUSH_RULE
|
||
border_width = 10
|
||
margin = 18
|
||
shadow_size = 12
|
||
"edict_compact":
|
||
fill = Color(0.55, 0.36, 0.15, 0.996)
|
||
border = UI_BRUSH_RULE
|
||
border_width = 4
|
||
margin = 6
|
||
shadow_size = 4
|
||
"notice_edict":
|
||
fill = Color(0.62, 0.42, 0.17, 0.998)
|
||
border = UI_LACQUER_EDGE
|
||
border_width = 9
|
||
margin = 14
|
||
shadow_size = 14
|
||
"seal":
|
||
fill = Color(0.12, 0.014, 0.008, 1.0)
|
||
border = UI_OLD_BRONZE
|
||
border_width = 5
|
||
margin = 10
|
||
"speaker_seal":
|
||
fill = Color(0.36, 0.025, 0.016, 0.995)
|
||
border = UI_OLD_BRONZE
|
||
border_width = 4
|
||
margin = 9
|
||
"portrait":
|
||
fill = UI_LACQUER_EDGE
|
||
border = UI_OLD_BRONZE
|
||
border_width = 5
|
||
margin = 3
|
||
"dialogue":
|
||
fill = Color(0.070, 0.024, 0.012, 0.986)
|
||
border = Color(0.37, 0.20, 0.070, 1.0)
|
||
border_width = 13
|
||
margin = 22
|
||
shadow_size = 22
|
||
"speech_scroll":
|
||
fill = Color(0.72, 0.53, 0.25, 0.998)
|
||
border = UI_BRUSH_RULE
|
||
border_width = 9
|
||
margin = 17
|
||
shadow_size = 9
|
||
"notice":
|
||
fill = Color(0.070, 0.020, 0.014, 0.982)
|
||
border = UI_SEAL_RED
|
||
border_width = 3
|
||
margin = 9
|
||
"map":
|
||
fill = Color(0.11, 0.060, 0.024, 1.0)
|
||
border = UI_INK_WASH
|
||
border_width = 6
|
||
margin = 4
|
||
"silk_map":
|
||
fill = Color(0.42, 0.27, 0.095, 0.995)
|
||
border = UI_BRUSH_RULE
|
||
border_width = 7
|
||
margin = 10
|
||
shadow_size = 10
|
||
"compact":
|
||
fill = Color(0.10, 0.055, 0.030, 0.93)
|
||
border = UI_TARNISHED_BRONZE
|
||
border_width = 4
|
||
margin = 8
|
||
shadow_size = 8
|
||
"bamboo_slips":
|
||
fill = Color(0.43, 0.28, 0.11, 0.992)
|
||
border = UI_BRUSH_RULE
|
||
border_width = 5
|
||
margin = 10
|
||
shadow_size = 6
|
||
"caption":
|
||
fill = Color(0.17, 0.030, 0.017, 0.988)
|
||
border = UI_OLD_BRONZE
|
||
border_width = 3
|
||
margin = 5
|
||
shadow_size = 2
|
||
"result_tablet":
|
||
fill = Color(0.47, 0.29, 0.10, 0.997)
|
||
border = UI_LACQUER_EDGE
|
||
border_width = 15
|
||
margin = 24
|
||
shadow_size = 24
|
||
"result_victory_tablet":
|
||
fill = Color(0.54, 0.36, 0.15, 0.997)
|
||
border = UI_OLD_BRONZE
|
||
border_width = 14
|
||
margin = 24
|
||
shadow_size = 22
|
||
"result_defeat_tablet":
|
||
fill = Color(0.13, 0.032, 0.014, 0.997)
|
||
border = UI_SEAL_RED_DARK
|
||
border_width = 14
|
||
margin = 24
|
||
shadow_size = 24
|
||
"command_seal":
|
||
fill = Color(0.065, 0.020, 0.012, 0.992)
|
||
border = UI_TARNISHED_BRONZE
|
||
border_width = 5
|
||
margin = 10
|
||
shadow_size = 9
|
||
_:
|
||
pass
|
||
var style := _make_panel_style(fill, border, border_width, radius, margin, shadow_size)
|
||
_age_panel_style(style, variant, border_width)
|
||
panel.add_theme_stylebox_override("panel", style)
|
||
|
||
|
||
func _make_button_style(fill: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||
var style := _make_panel_style(fill, border, border_width, 0, 10, 0)
|
||
style.set_border_width(SIDE_TOP, border_width + 2)
|
||
style.set_border_width(SIDE_BOTTOM, border_width + 2)
|
||
style.set_border_width(SIDE_LEFT, border_width + 1)
|
||
style.set_border_width(SIDE_RIGHT, border_width + 1)
|
||
return style
|
||
|
||
|
||
func _ui_font(bold: bool = false) -> Font:
|
||
if bold:
|
||
if ui_bold_font == null:
|
||
ui_bold_font = _load_ui_font(UI_FONT_BOLD_PATH)
|
||
return ui_bold_font
|
||
if ui_regular_font == null:
|
||
ui_regular_font = _load_ui_font(UI_FONT_REGULAR_PATH)
|
||
return ui_regular_font
|
||
|
||
|
||
func _load_ui_font(font_path: String) -> Font:
|
||
if font_path.is_empty():
|
||
return null
|
||
if ResourceLoader.exists(font_path):
|
||
var resource := ResourceLoader.load(font_path)
|
||
if resource is Font:
|
||
return resource as Font
|
||
var font := FontFile.new()
|
||
if font.load_dynamic_font(font_path) != OK:
|
||
return null
|
||
return font
|
||
|
||
|
||
func _make_ui_theme() -> Theme:
|
||
var theme := Theme.new()
|
||
var regular := _ui_font(false)
|
||
if regular != null:
|
||
theme.default_font = regular
|
||
theme.set_font("normal_font", "RichTextLabel", regular)
|
||
theme.set_font("italics_font", "RichTextLabel", regular)
|
||
theme.set_font("mono_font", "RichTextLabel", regular)
|
||
var bold := _ui_font(true)
|
||
if bold != null:
|
||
theme.set_font("bold_font", "RichTextLabel", bold)
|
||
theme.set_font("bold_italics_font", "RichTextLabel", bold)
|
||
return theme
|
||
|
||
|
||
func _draw_ui_font(bold: bool = false) -> Font:
|
||
var font := _ui_font(bold)
|
||
if font != null:
|
||
return font
|
||
return ThemeDB.fallback_font
|
||
|
||
|
||
func _draw_ink_text(font: Font, position: Vector2, text: String, alignment: HorizontalAlignment, width: float, font_size: int, color: Color, outline_color := Color(0.02, 0.012, 0.006, 0.95)) -> void:
|
||
if text.is_empty():
|
||
return
|
||
for offset in [Vector2(-1, 0), Vector2(1, 0), Vector2(0, -1), Vector2(0, 1), Vector2(1, 1)]:
|
||
draw_string(font, position + offset, text, alignment, width, font_size, outline_color)
|
||
draw_string(font, position, text, alignment, width, font_size, color)
|
||
|
||
|
||
func _apply_control_font(control: Control, bold: bool = false) -> void:
|
||
if control == null:
|
||
return
|
||
var font := _ui_font(bold)
|
||
if font == null:
|
||
return
|
||
control.add_theme_font_override("font", font)
|
||
|
||
|
||
func _apply_button_style(button: Button, important: bool = false) -> void:
|
||
if button == null:
|
||
return
|
||
var normal_fill := Color(0.36, 0.035, 0.020, 0.99) if important else Color(0.20, 0.095, 0.035, 0.98)
|
||
var hover_fill := Color(0.46, 0.075, 0.035, 1.0) if important else Color(0.30, 0.16, 0.060, 1.0)
|
||
var pressed_fill := Color(0.070, 0.024, 0.014, 1.0)
|
||
button.add_theme_stylebox_override("normal", _make_button_style(normal_fill, UI_TARNISHED_BRONZE, 2))
|
||
button.add_theme_stylebox_override("hover", _make_button_style(hover_fill, UI_OLD_BRONZE, 2))
|
||
button.add_theme_stylebox_override("pressed", _make_button_style(pressed_fill, UI_OLD_BRONZE, 2))
|
||
button.add_theme_stylebox_override("focus", _make_button_style(Color(0.31, 0.16, 0.060, 0.98), UI_OLD_BRONZE, 2))
|
||
button.add_theme_stylebox_override("disabled", _make_button_style(Color(0.10, 0.065, 0.040, 0.88), UI_RICE_PAPER_DARK, 1))
|
||
button.add_theme_color_override("font_color", UI_PARCHMENT_TEXT)
|
||
button.add_theme_color_override("font_hover_color", UI_PARCHMENT_TEXT)
|
||
button.add_theme_color_override("font_pressed_color", UI_PARCHMENT_TEXT)
|
||
button.add_theme_color_override("font_focus_color", UI_PARCHMENT_TEXT)
|
||
button.add_theme_color_override("font_disabled_color", UI_DISABLED_TEXT)
|
||
_apply_control_font(button, important)
|
||
|
||
|
||
func _apply_label_style(label: Label, font_color: Color, font_size: int = 0) -> void:
|
||
if label == null:
|
||
return
|
||
label.add_theme_color_override("font_color", font_color)
|
||
if font_size > 0:
|
||
label.add_theme_font_size_override("font_size", font_size)
|
||
_apply_control_font(label, font_size >= 16)
|
||
|
||
|
||
func _fit_label_font_size_to_text(label: Label, text: String, base_size: int, min_size: int, available_size: Vector2 = Vector2.ZERO) -> int:
|
||
if label == null:
|
||
return base_size
|
||
var fit_size := available_size
|
||
if fit_size.x <= 0.0:
|
||
fit_size.x = label.custom_minimum_size.x
|
||
if fit_size.y <= 0.0:
|
||
fit_size.y = label.custom_minimum_size.y
|
||
var font_size := _fit_font_size_for_text(text, base_size, min_size, fit_size)
|
||
label.add_theme_font_size_override("font_size", font_size)
|
||
return font_size
|
||
|
||
|
||
func _set_fitted_label_text(label: Label, text: String, base_size: int, min_size: int, available_size: Vector2 = Vector2.ZERO) -> void:
|
||
if label == null:
|
||
return
|
||
label.text = text
|
||
_fit_label_font_size_to_text(label, text, base_size, min_size, available_size)
|
||
|
||
|
||
func _fit_font_size_for_text(text: String, base_size: int, min_size: int, available_size: Vector2) -> int:
|
||
if text.strip_edges().is_empty():
|
||
return base_size
|
||
var safe_base: int = maxi(base_size, min_size)
|
||
var safe_min: int = maxi(1, mini(base_size, min_size))
|
||
for font_size in range(safe_base, safe_min - 1, -1):
|
||
if _estimated_wrapped_text_height(text, available_size.x, font_size) <= available_size.y:
|
||
return font_size
|
||
return safe_min
|
||
|
||
|
||
func _estimated_wrapped_text_height(text: String, width: float, font_size: int) -> float:
|
||
var line_count := _estimated_wrapped_line_count(text, width, font_size)
|
||
return float(line_count) * _estimated_line_height(font_size)
|
||
|
||
|
||
func _estimated_wrapped_line_count(text: String, width: float, font_size: int) -> int:
|
||
var capacity := maxf(1.0, floorf(width / maxf(1.0, float(font_size) * 0.92)))
|
||
var count := 0
|
||
for paragraph in text.split("\n"):
|
||
var weighted_length := _weighted_text_length(str(paragraph))
|
||
count += maxi(1, int(ceil(weighted_length / capacity)))
|
||
return maxi(1, count)
|
||
|
||
|
||
func _estimated_line_height(font_size: int) -> float:
|
||
return ceilf(float(font_size) * 1.36)
|
||
|
||
|
||
func _weighted_text_length(text: String) -> float:
|
||
var total := 0.0
|
||
for index in range(text.length()):
|
||
var code := text.unicode_at(index)
|
||
if code == 10:
|
||
continue
|
||
if code == 32:
|
||
total += 0.35
|
||
elif code < 128:
|
||
total += 0.56
|
||
else:
|
||
total += 1.0
|
||
return total
|
||
|
||
|
||
func _make_separator(width: float = 0.0, height: float = 2.0) -> ColorRect:
|
||
var separator := ColorRect.new()
|
||
separator.color = UI_OLD_BRONZE
|
||
separator.custom_minimum_size = Vector2(width, height)
|
||
separator.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
separator.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return separator
|
||
|
||
|
||
func _make_bamboo_divider(width: float = 0.0, height: float = 4.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_theme_constant_override("separation", 2)
|
||
for index in range(3):
|
||
var strip := ColorRect.new()
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
strip.custom_minimum_size = Vector2(8 if index != 1 else width, height)
|
||
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 1 else Control.SIZE_SHRINK_CENTER
|
||
strip.color = UI_BAMBOO if index == 1 else UI_BAMBOO_DARK
|
||
row.add_child(strip)
|
||
return row
|
||
|
||
|
||
func _make_scroll_rod(width: float = 0.0, height: float = 4.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_theme_constant_override("separation", 2)
|
||
var cap_width: float = maxf(8.0, height * 3.0)
|
||
for index in range(5):
|
||
var strip := ColorRect.new()
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
strip.custom_minimum_size = Vector2(cap_width if index != 2 else width, height)
|
||
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 2 else Control.SIZE_SHRINK_CENTER
|
||
if index == 0 or index == 4:
|
||
strip.color = UI_CINNABAR_DARK
|
||
elif index == 1 or index == 3:
|
||
strip.color = UI_OLD_BRONZE
|
||
else:
|
||
strip.color = UI_BAMBOO
|
||
row.add_child(strip)
|
||
return row
|
||
|
||
|
||
func _make_tablet_binding(width: float = 0.0, height: float = 18.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_theme_constant_override("separation", 3)
|
||
row.add_child(_make_binding_end_cap(height))
|
||
row.add_child(_make_binding_strip(height, UI_BAMBOO_DARK, 14.0))
|
||
row.add_child(_make_binding_strip(height, UI_TARNISHED_BRONZE, 0.0))
|
||
row.add_child(_make_seal_knot(height))
|
||
row.add_child(_make_binding_strip(height, UI_TARNISHED_BRONZE, 0.0))
|
||
row.add_child(_make_binding_strip(height, UI_BAMBOO_DARK, 14.0))
|
||
row.add_child(_make_binding_end_cap(height))
|
||
return row
|
||
|
||
|
||
func _make_binding_end_cap(size: float) -> ColorRect:
|
||
var cap := ColorRect.new()
|
||
cap.color = UI_LACQUER_EDGE
|
||
cap.custom_minimum_size = Vector2(size * 1.15, size)
|
||
cap.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return cap
|
||
|
||
|
||
func _make_binding_strip(height: float, color: Color, width: float) -> ColorRect:
|
||
var strip := ColorRect.new()
|
||
strip.color = color
|
||
strip.custom_minimum_size = Vector2(width, height)
|
||
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width <= 0.0 else Control.SIZE_SHRINK_CENTER
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return strip
|
||
|
||
|
||
func _make_seal_knot(height: float) -> HBoxContainer:
|
||
var knot := HBoxContainer.new()
|
||
knot.custom_minimum_size = Vector2(height * 2.0 + 3.0, height)
|
||
knot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
knot.add_theme_constant_override("separation", 3)
|
||
for index in range(2):
|
||
var tile := ColorRect.new()
|
||
tile.color = UI_SEAL_RED if index == 0 else UI_SEAL_RED_DARK
|
||
tile.custom_minimum_size = Vector2(height, height)
|
||
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
knot.add_child(tile)
|
||
return knot
|
||
|
||
|
||
func _make_ink_wash_rule(width: float = 0.0, height: float = 7.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_theme_constant_override("separation", 2)
|
||
for index in range(5):
|
||
var strip := ColorRect.new()
|
||
strip.custom_minimum_size = Vector2(10 if index != 2 else width, height)
|
||
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL if index == 2 else Control.SIZE_SHRINK_CENTER
|
||
strip.color = UI_INK_WASH if index == 2 else Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.96)
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_child(strip)
|
||
return row
|
||
|
||
|
||
func _make_edict_title_strip(labels: Array, width: float = 0.0, height: float = 24.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_theme_constant_override("separation", 4)
|
||
row.add_child(_make_lacquer_clasp(height))
|
||
row.add_child(_make_binding_strip(5.0, UI_TARNISHED_BRONZE, 0.0))
|
||
for label_text in labels:
|
||
row.add_child(_make_seal_tile(str(label_text), height))
|
||
row.add_child(_make_binding_strip(5.0, UI_TARNISHED_BRONZE, 0.0))
|
||
row.add_child(_make_lacquer_clasp(height))
|
||
return row
|
||
|
||
|
||
func _make_seal_tile(text: String, size: float = 24.0) -> PanelContainer:
|
||
var panel := PanelContainer.new()
|
||
panel.custom_minimum_size = Vector2(size, size)
|
||
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
panel.add_theme_stylebox_override("panel", _make_panel_style(UI_SEAL_RED_DARK, UI_OLD_BRONZE, 2, 0, 3, 0))
|
||
var label := Label.new()
|
||
label.text = text
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.custom_minimum_size = Vector2(size - 6.0, size - 6.0)
|
||
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
|
||
panel.add_child(label)
|
||
return panel
|
||
|
||
|
||
func _make_seal_ribbon(width: float = 0.0, height: float = 18.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_theme_constant_override("separation", 4)
|
||
row.add_child(_make_lacquer_clasp(height))
|
||
row.add_child(_make_bronze_ribbon_strip(height))
|
||
row.add_child(_make_seal_cluster(height))
|
||
row.add_child(_make_bronze_ribbon_strip(height))
|
||
row.add_child(_make_lacquer_clasp(height))
|
||
return row
|
||
|
||
|
||
func _make_lacquer_clasp(size: float) -> ColorRect:
|
||
var clasp := ColorRect.new()
|
||
clasp.color = UI_CINNABAR_DARK
|
||
clasp.custom_minimum_size = Vector2(size, size)
|
||
clasp.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return clasp
|
||
|
||
|
||
func _make_bronze_ribbon_strip(height: float) -> ColorRect:
|
||
var strip := ColorRect.new()
|
||
strip.color = Color(UI_TARNISHED_BRONZE.r, UI_TARNISHED_BRONZE.g, UI_TARNISHED_BRONZE.b, 0.92)
|
||
strip.custom_minimum_size = Vector2(12, height)
|
||
strip.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return strip
|
||
|
||
|
||
func _make_seal_cluster(height: float) -> HBoxContainer:
|
||
var cluster := HBoxContainer.new()
|
||
cluster.custom_minimum_size = Vector2(height * 3.0 + 4.0, height)
|
||
cluster.add_theme_constant_override("separation", 2)
|
||
for index in range(3):
|
||
var tile := ColorRect.new()
|
||
tile.color = UI_CINNABAR if index == 1 else UI_CINNABAR_DARK
|
||
tile.custom_minimum_size = Vector2(height, height)
|
||
tile.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
cluster.add_child(tile)
|
||
return cluster
|
||
|
||
|
||
func _make_map_lattice_overlay(size: Vector2) -> Control:
|
||
var overlay := Control.new()
|
||
overlay.name = "MapLatticeOverlay"
|
||
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
overlay.custom_minimum_size = size
|
||
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
|
||
var silk_wash := ColorRect.new()
|
||
silk_wash.name = "SilkMapWash"
|
||
silk_wash.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
silk_wash.color = Color(UI_SILK_WASH.r, UI_SILK_WASH.g, UI_SILK_WASH.b, 0.10)
|
||
silk_wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(silk_wash)
|
||
|
||
for fraction in [0.25, 0.50, 0.75]:
|
||
var vertical := _make_map_lattice_line(Vector2(size.x * fraction, 0.0), Vector2(1.0, size.y), 0.22)
|
||
overlay.add_child(vertical)
|
||
for fraction in [0.33, 0.66]:
|
||
var horizontal := _make_map_lattice_line(Vector2(0.0, size.y * fraction), Vector2(size.x, 1.0), 0.18)
|
||
overlay.add_child(horizontal)
|
||
|
||
var corner_specs := [
|
||
{"text": "", "pos": Vector2(4, 4)},
|
||
{"text": "", "pos": Vector2(size.x - 24, 4)},
|
||
{"text": "", "pos": Vector2(4, size.y - 24)},
|
||
{"text": "", "pos": Vector2(size.x - 24, size.y - 24)}
|
||
]
|
||
for spec in corner_specs:
|
||
var seal := _make_seal_tile(str(spec["text"]), 20)
|
||
seal.name = "MapCornerSeal"
|
||
seal.position = spec["pos"]
|
||
seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
overlay.add_child(seal)
|
||
return overlay
|
||
|
||
|
||
func _make_map_lattice_line(position: Vector2, size: Vector2, alpha: float) -> ColorRect:
|
||
var line := ColorRect.new()
|
||
line.position = position
|
||
line.size = size
|
||
line.custom_minimum_size = size
|
||
line.color = Color(UI_INK_WASH.r, UI_INK_WASH.g, UI_INK_WASH.b, alpha)
|
||
line.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return line
|
||
|
||
|
||
func _make_section_caption(text: String, width: float = 0.0) -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.custom_minimum_size = Vector2(width, 24)
|
||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_panel_style(panel, "caption")
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 8)
|
||
panel.add_child(row)
|
||
row.add_child(_make_seal_tick())
|
||
var label := Label.new()
|
||
label.text = text
|
||
label.custom_minimum_size = Vector2(maxf(0.0, width - 68.0), 18)
|
||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
|
||
row.add_child(label)
|
||
row.add_child(_make_seal_tick())
|
||
return panel
|
||
|
||
|
||
func _make_seal_tick() -> ColorRect:
|
||
var tick := ColorRect.new()
|
||
tick.color = UI_CINNABAR
|
||
tick.custom_minimum_size = Vector2(18, 18)
|
||
tick.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return tick
|
||
|
||
|
||
func _make_bamboo_gutter(width: float = 18.0, height: float = 0.0) -> HBoxContainer:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(width, height)
|
||
row.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_theme_constant_override("separation", 2)
|
||
var strip_width := maxf(3.0, floorf((width - 4.0) / 3.0))
|
||
for index in range(3):
|
||
var strip := ColorRect.new()
|
||
strip.color = UI_BAMBOO if index == 1 else UI_BAMBOO_DARK
|
||
strip.custom_minimum_size = Vector2(strip_width, height)
|
||
strip.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
strip.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
row.add_child(strip)
|
||
return row
|
||
|
||
|
||
func _make_ornament_bar(width: float = 4.0, height: float = 0.0) -> ColorRect:
|
||
var bar := ColorRect.new()
|
||
bar.color = UI_CINNABAR
|
||
bar.custom_minimum_size = Vector2(width, height)
|
||
bar.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return bar
|
||
|
||
|
||
func _make_edict_marker_stack(labels: Array, height: float = 84.0) -> VBoxContainer:
|
||
var stack := VBoxContainer.new()
|
||
stack.custom_minimum_size = Vector2(30, height)
|
||
stack.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
stack.add_theme_constant_override("separation", 3)
|
||
for index in range(labels.size()):
|
||
var panel := PanelContainer.new()
|
||
panel.custom_minimum_size = Vector2(30, 22)
|
||
panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
_apply_panel_style(panel, "speaker_seal" if index == 0 else "caption")
|
||
stack.add_child(panel)
|
||
|
||
var label := Label.new()
|
||
label.text = str(labels[index])
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.custom_minimum_size = Vector2(18, 16)
|
||
_apply_label_style(label, UI_PARCHMENT_TEXT, 13)
|
||
panel.add_child(label)
|
||
return stack
|
||
|
||
|
||
func _make_hanging_tassel(height: float = 262.0) -> VBoxContainer:
|
||
var tassel := VBoxContainer.new()
|
||
tassel.custom_minimum_size = Vector2(18, height)
|
||
tassel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
tassel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
tassel.add_theme_constant_override("separation", 4)
|
||
|
||
var top_seal := ColorRect.new()
|
||
top_seal.color = UI_SEAL_RED
|
||
top_seal.custom_minimum_size = Vector2(18, 18)
|
||
top_seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
tassel.add_child(top_seal)
|
||
|
||
var cords := HBoxContainer.new()
|
||
cords.custom_minimum_size = Vector2(18, maxf(0.0, height - 44.0))
|
||
cords.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
cords.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
cords.add_theme_constant_override("separation", 4)
|
||
for index in range(2):
|
||
var cord := ColorRect.new()
|
||
cord.color = UI_TARNISHED_BRONZE if index == 0 else UI_CINNABAR_DARK
|
||
cord.custom_minimum_size = Vector2(6, height - 44.0)
|
||
cord.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
cord.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
cords.add_child(cord)
|
||
tassel.add_child(cords)
|
||
|
||
var bottom_seal := ColorRect.new()
|
||
bottom_seal.color = UI_SEAL_RED_DARK
|
||
bottom_seal.custom_minimum_size = Vector2(18, 18)
|
||
bottom_seal.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
tassel.add_child(bottom_seal)
|
||
return tassel
|
||
|
||
|
||
func _make_screen_backdrop() -> ColorRect:
|
||
var backdrop := ColorRect.new()
|
||
backdrop.visible = false
|
||
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
backdrop.color = Color(0.018, 0.009, 0.004, 0.74)
|
||
backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return backdrop
|
||
|
||
|
||
func _refresh_screen_backdrop_visibility() -> void:
|
||
if screen_backdrop == null:
|
||
return
|
||
screen_backdrop.visible = (
|
||
(briefing_panel != null and briefing_panel.visible)
|
||
or (result_panel != null and result_panel.visible)
|
||
)
|
||
|
||
|
||
func _create_hud() -> void:
|
||
var layer := CanvasLayer.new()
|
||
add_child(layer)
|
||
|
||
var root := Control.new()
|
||
ui_root_control = root
|
||
root.theme = _make_ui_theme()
|
||
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
layer.add_child(root)
|
||
|
||
var top_bar := PanelContainer.new()
|
||
top_bar.position = Vector2(24, 14)
|
||
top_bar.size = Vector2(1192, 72)
|
||
_apply_panel_style(top_bar, "compact")
|
||
root.add_child(top_bar)
|
||
|
||
var top_row := HBoxContainer.new()
|
||
top_row.add_theme_constant_override("separation", 8)
|
||
top_bar.add_child(top_row)
|
||
|
||
status_label = Label.new()
|
||
status_label.custom_minimum_size = Vector2(154, 48)
|
||
_apply_label_style(status_label, UI_PARCHMENT_TEXT)
|
||
top_row.add_child(status_label)
|
||
|
||
var objective_top_panel := PanelContainer.new()
|
||
objective_top_panel.custom_minimum_size = Vector2(460, 54)
|
||
_apply_panel_style(objective_top_panel, "edict_compact")
|
||
top_row.add_child(objective_top_panel)
|
||
|
||
var objective_top_row := HBoxContainer.new()
|
||
objective_top_row.add_theme_constant_override("separation", 7)
|
||
objective_top_panel.add_child(objective_top_row)
|
||
objective_top_row.add_child(_make_bamboo_gutter(14, 36))
|
||
|
||
objective_label = Label.new()
|
||
objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
objective_label.custom_minimum_size = Vector2(382, 36)
|
||
objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(objective_label, UI_AGED_INK, OBJECTIVE_HUD_FONT_SIZE)
|
||
objective_top_row.add_child(objective_label)
|
||
objective_top_row.add_child(_make_bamboo_gutter(14, 36))
|
||
|
||
campaign_status_label = Label.new()
|
||
campaign_status_label.custom_minimum_size = Vector2(260, 48)
|
||
_apply_label_style(campaign_status_label, UI_PARCHMENT_TEXT)
|
||
top_row.add_child(campaign_status_label)
|
||
|
||
restart_button = Button.new()
|
||
restart_button.text = "재정비"
|
||
restart_button.pressed.connect(_on_restart_pressed)
|
||
top_row.add_child(restart_button)
|
||
|
||
new_campaign_button = Button.new()
|
||
new_campaign_button.text = "새 전기 열기"
|
||
new_campaign_button.pressed.connect(_on_new_campaign_pressed)
|
||
top_row.add_child(new_campaign_button)
|
||
|
||
objective_notice_panel = PanelContainer.new()
|
||
objective_notice_panel.visible = false
|
||
objective_notice_panel.position = Vector2(338, 88)
|
||
objective_notice_panel.size = Vector2(604, 154)
|
||
objective_notice_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
_apply_panel_style(objective_notice_panel, "notice_edict")
|
||
root.add_child(objective_notice_panel)
|
||
|
||
var objective_notice_column := VBoxContainer.new()
|
||
objective_notice_column.add_theme_constant_override("separation", 5)
|
||
objective_notice_panel.add_child(objective_notice_column)
|
||
objective_notice_column.add_child(_make_scroll_rod(548, 4))
|
||
objective_notice_column.add_child(_make_seal_ribbon(548, 14))
|
||
|
||
objective_notice_label = Label.new()
|
||
objective_notice_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
objective_notice_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
objective_notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
objective_notice_label.custom_minimum_size = Vector2(548, 80)
|
||
_apply_label_style(objective_notice_label, UI_AGED_INK, OBJECTIVE_NOTICE_FONT_SIZE)
|
||
objective_notice_column.add_child(objective_notice_label)
|
||
objective_notice_column.add_child(_make_scroll_rod(548, 3))
|
||
|
||
var side_panel := PanelContainer.new()
|
||
side_panel.position = SIDE_PANEL_POSITION
|
||
side_panel.size = SIDE_PANEL_SIZE
|
||
_apply_panel_style(side_panel, "compact")
|
||
root.add_child(side_panel)
|
||
|
||
var side_column := VBoxContainer.new()
|
||
side_column.add_theme_constant_override("separation", 6)
|
||
side_panel.add_child(side_column)
|
||
|
||
var selected_row := HBoxContainer.new()
|
||
selected_row.custom_minimum_size = Vector2(420, 126)
|
||
selected_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
selected_row.add_theme_constant_override("separation", 8)
|
||
side_column.add_child(selected_row)
|
||
|
||
hud_unit_portrait_panel = PanelContainer.new()
|
||
hud_unit_portrait_panel.custom_minimum_size = HUD_UNIT_PORTRAIT_SIZE
|
||
_apply_panel_style(hud_unit_portrait_panel, "portrait")
|
||
selected_row.add_child(hud_unit_portrait_panel)
|
||
|
||
var hud_unit_portrait_stack := Control.new()
|
||
hud_unit_portrait_stack.custom_minimum_size = HUD_UNIT_PORTRAIT_STACK_SIZE
|
||
hud_unit_portrait_panel.add_child(hud_unit_portrait_stack)
|
||
|
||
hud_unit_portrait_texture = TextureRect.new()
|
||
hud_unit_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
hud_unit_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
hud_unit_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
hud_unit_portrait_stack.add_child(hud_unit_portrait_texture)
|
||
|
||
hud_unit_portrait_label = Label.new()
|
||
hud_unit_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
hud_unit_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
hud_unit_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
hud_unit_portrait_label.add_theme_font_size_override("font_size", 24)
|
||
_apply_label_style(hud_unit_portrait_label, UI_PARCHMENT_TEXT)
|
||
hud_unit_portrait_stack.add_child(hud_unit_portrait_label)
|
||
|
||
selected_label = Label.new()
|
||
selected_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
selected_label.custom_minimum_size = Vector2(320, 126)
|
||
selected_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(selected_label, UI_PARCHMENT_TEXT)
|
||
selected_row.add_child(selected_label)
|
||
|
||
var mission_title_row := HBoxContainer.new()
|
||
mission_title_row.add_theme_constant_override("separation", 8)
|
||
side_column.add_child(mission_title_row)
|
||
|
||
mission_title_label = Label.new()
|
||
mission_title_label.text = "전투 목표"
|
||
mission_title_label.custom_minimum_size = Vector2(296, 28)
|
||
mission_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
mission_title_label.add_theme_font_size_override("font_size", 14)
|
||
_apply_label_style(mission_title_label, UI_OLD_BRONZE)
|
||
mission_title_row.add_child(mission_title_label)
|
||
|
||
mission_toggle_button = Button.new()
|
||
mission_toggle_button.text = "접기"
|
||
mission_toggle_button.tooltip_text = "전투 목표 판을 접거나 펼칩니다."
|
||
mission_toggle_button.custom_minimum_size = Vector2(78, 28)
|
||
mission_toggle_button.pressed.connect(_on_mission_toggle_pressed)
|
||
mission_title_row.add_child(mission_toggle_button)
|
||
|
||
mission_detail_panel = PanelContainer.new()
|
||
mission_detail_panel.custom_minimum_size = Vector2(420, 134)
|
||
mission_detail_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_panel_style(mission_detail_panel, "bamboo_slips")
|
||
side_column.add_child(mission_detail_panel)
|
||
|
||
var mission_detail_row := HBoxContainer.new()
|
||
mission_detail_row.add_theme_constant_override("separation", 8)
|
||
mission_detail_panel.add_child(mission_detail_row)
|
||
|
||
mission_detail_row.add_child(_make_bamboo_gutter(12, 112))
|
||
mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112))
|
||
|
||
mission_detail_label = Label.new()
|
||
mission_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
mission_detail_label.custom_minimum_size = Vector2(292, 112)
|
||
mission_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(mission_detail_label, UI_PARCHMENT_TEXT)
|
||
mission_detail_row.add_child(mission_detail_label)
|
||
mission_detail_row.add_child(_make_edict_marker_stack(["", "", ""], 112))
|
||
mission_detail_row.add_child(_make_bamboo_gutter(12, 112))
|
||
|
||
cell_info_label = Label.new()
|
||
cell_info_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
cell_info_label.custom_minimum_size = Vector2(420, 78)
|
||
_apply_label_style(cell_info_label, UI_PARCHMENT_TEXT)
|
||
side_column.add_child(cell_info_label)
|
||
|
||
forecast_label = Label.new()
|
||
forecast_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
forecast_label.custom_minimum_size = Vector2(420, 60)
|
||
_apply_label_style(forecast_label, UI_PARCHMENT_TEXT)
|
||
side_column.add_child(forecast_label)
|
||
|
||
inventory_label = Label.new()
|
||
inventory_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
inventory_label.custom_minimum_size = Vector2(420, 34)
|
||
_apply_label_style(inventory_label, UI_PARCHMENT_TEXT)
|
||
side_column.add_child(inventory_label)
|
||
|
||
var button_row := HBoxContainer.new()
|
||
button_row.add_theme_constant_override("separation", 8)
|
||
side_column.add_child(button_row)
|
||
|
||
wait_button = Button.new()
|
||
wait_button.text = "대기령"
|
||
wait_button.pressed.connect(_on_wait_pressed)
|
||
button_row.add_child(wait_button)
|
||
|
||
tactic_button = Button.new()
|
||
tactic_button.text = "책략첩"
|
||
tactic_button.pressed.connect(_on_tactic_pressed)
|
||
button_row.add_child(tactic_button)
|
||
|
||
item_button = Button.new()
|
||
item_button.text = "보급첩"
|
||
item_button.pressed.connect(_on_item_pressed)
|
||
button_row.add_child(item_button)
|
||
|
||
equip_button = Button.new()
|
||
equip_button.text = "병장"
|
||
equip_button.pressed.connect(_on_equip_pressed)
|
||
button_row.add_child(equip_button)
|
||
|
||
threat_button = Button.new()
|
||
threat_button.text = "적진도"
|
||
threat_button.toggle_mode = true
|
||
threat_button.pressed.connect(_on_threat_pressed)
|
||
button_row.add_child(threat_button)
|
||
|
||
end_turn_button = Button.new()
|
||
end_turn_button.text = "군령 봉함"
|
||
end_turn_button.pressed.connect(_on_end_turn_pressed)
|
||
button_row.add_child(end_turn_button)
|
||
|
||
tactic_menu = VBoxContainer.new()
|
||
tactic_menu.visible = false
|
||
tactic_menu.add_theme_constant_override("separation", 6)
|
||
side_column.add_child(tactic_menu)
|
||
|
||
var tactic_title := Label.new()
|
||
tactic_title.text = "책략첩"
|
||
_apply_label_style(tactic_title, UI_OLD_BRONZE, 14)
|
||
tactic_menu.add_child(tactic_title)
|
||
|
||
tactic_list = VBoxContainer.new()
|
||
tactic_list.add_theme_constant_override("separation", 4)
|
||
tactic_menu.add_child(tactic_list)
|
||
|
||
item_menu = VBoxContainer.new()
|
||
item_menu.visible = false
|
||
item_menu.add_theme_constant_override("separation", 6)
|
||
side_column.add_child(item_menu)
|
||
|
||
var item_title := Label.new()
|
||
item_title.text = "보급첩"
|
||
_apply_label_style(item_title, UI_OLD_BRONZE, 14)
|
||
item_menu.add_child(item_title)
|
||
|
||
item_list = VBoxContainer.new()
|
||
item_list.add_theme_constant_override("separation", 4)
|
||
item_menu.add_child(item_list)
|
||
|
||
equip_menu = VBoxContainer.new()
|
||
equip_menu.visible = false
|
||
equip_menu.add_theme_constant_override("separation", 6)
|
||
side_column.add_child(equip_menu)
|
||
|
||
var equip_title := Label.new()
|
||
equip_title.text = "병장 장부"
|
||
_apply_label_style(equip_title, UI_OLD_BRONZE, 14)
|
||
equip_menu.add_child(equip_title)
|
||
|
||
equip_list = VBoxContainer.new()
|
||
equip_list.add_theme_constant_override("separation", 4)
|
||
equip_menu.add_child(equip_list)
|
||
|
||
post_move_menu = PanelContainer.new()
|
||
post_move_menu.visible = false
|
||
post_move_menu.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
post_move_menu.custom_minimum_size = POST_MOVE_MENU_SIZE
|
||
post_move_menu.size = POST_MOVE_MENU_SIZE
|
||
_apply_panel_style(post_move_menu, "command_seal")
|
||
root.add_child(post_move_menu)
|
||
|
||
var post_move_column := VBoxContainer.new()
|
||
post_move_column.add_theme_constant_override("separation", 5)
|
||
post_move_menu.add_child(post_move_column)
|
||
post_move_column.add_child(_make_seal_ribbon(196, 10))
|
||
|
||
post_move_title_label = Label.new()
|
||
post_move_title_label.text = "군령 선택"
|
||
post_move_title_label.custom_minimum_size = POST_MOVE_TITLE_SIZE
|
||
post_move_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_apply_label_style(post_move_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
|
||
post_move_column.add_child(post_move_title_label)
|
||
|
||
var post_move_grid := GridContainer.new()
|
||
post_move_grid.columns = 2
|
||
post_move_grid.add_theme_constant_override("h_separation", 6)
|
||
post_move_grid.add_theme_constant_override("v_separation", 4)
|
||
post_move_column.add_child(post_move_grid)
|
||
|
||
post_move_attack_button = Button.new()
|
||
post_move_attack_button.text = "타격령"
|
||
post_move_attack_button.tooltip_text = "이 자리에서 칠 적군을 지목합니다."
|
||
post_move_attack_button.pressed.connect(_on_post_move_attack_pressed)
|
||
post_move_grid.add_child(post_move_attack_button)
|
||
|
||
post_move_tactic_button = Button.new()
|
||
post_move_tactic_button.text = "책략첩"
|
||
post_move_tactic_button.tooltip_text = "군령에 올릴 책략 죽간을 고릅니다."
|
||
post_move_tactic_button.pressed.connect(_on_post_move_tactic_pressed)
|
||
post_move_grid.add_child(post_move_tactic_button)
|
||
|
||
post_move_item_button = Button.new()
|
||
post_move_item_button.text = "보급첩"
|
||
post_move_item_button.tooltip_text = "군령에 올릴 보급 물자를 고릅니다."
|
||
post_move_item_button.pressed.connect(_on_post_move_item_pressed)
|
||
post_move_grid.add_child(post_move_item_button)
|
||
|
||
post_move_wait_button = Button.new()
|
||
post_move_wait_button.text = "대기령"
|
||
post_move_wait_button.tooltip_text = "행군을 확정하고 군령을 봉인합니다."
|
||
post_move_wait_button.pressed.connect(_on_post_move_wait_pressed)
|
||
post_move_grid.add_child(post_move_wait_button)
|
||
|
||
post_move_cancel_button = Button.new()
|
||
post_move_cancel_button.text = "발걸음 거두기"
|
||
post_move_cancel_button.tooltip_text = "처음 있던 칸으로 돌아갑니다."
|
||
post_move_cancel_button.pressed.connect(_on_post_move_cancel_pressed)
|
||
post_move_column.add_child(post_move_cancel_button)
|
||
|
||
post_move_picker_panel = PanelContainer.new()
|
||
post_move_picker_panel.visible = false
|
||
post_move_picker_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
post_move_picker_panel.custom_minimum_size = POST_MOVE_PICKER_PANEL_SIZE
|
||
post_move_picker_panel.size = POST_MOVE_PICKER_PANEL_SIZE
|
||
_apply_panel_style(post_move_picker_panel, "command_seal")
|
||
root.add_child(post_move_picker_panel)
|
||
|
||
var picker_column := VBoxContainer.new()
|
||
picker_column.add_theme_constant_override("separation", 5)
|
||
post_move_picker_panel.add_child(picker_column)
|
||
picker_column.add_child(_make_seal_ribbon(296, 10))
|
||
|
||
post_move_picker_title_label = Label.new()
|
||
post_move_picker_title_label.text = "군령 선택"
|
||
post_move_picker_title_label.custom_minimum_size = POST_MOVE_PICKER_TITLE_SIZE
|
||
post_move_picker_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_apply_label_style(post_move_picker_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
|
||
picker_column.add_child(post_move_picker_title_label)
|
||
|
||
post_move_picker_detail_label = Label.new()
|
||
post_move_picker_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
post_move_picker_detail_label.custom_minimum_size = POST_MOVE_PICKER_DETAIL_SIZE
|
||
_apply_label_style(post_move_picker_detail_label, UI_PARCHMENT_TEXT, LOCAL_COMMAND_DETAIL_FONT_SIZE)
|
||
picker_column.add_child(post_move_picker_detail_label)
|
||
|
||
var picker_scroll := ScrollContainer.new()
|
||
picker_scroll.custom_minimum_size = Vector2(296, 98)
|
||
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
picker_column.add_child(picker_scroll)
|
||
|
||
post_move_picker_list = VBoxContainer.new()
|
||
post_move_picker_list.add_theme_constant_override("separation", 4)
|
||
post_move_picker_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
picker_scroll.add_child(post_move_picker_list)
|
||
|
||
var picker_button_row := HBoxContainer.new()
|
||
picker_button_row.add_theme_constant_override("separation", 6)
|
||
picker_column.add_child(picker_button_row)
|
||
|
||
post_move_picker_back_button = Button.new()
|
||
post_move_picker_back_button.text = "죽간 거두기"
|
||
post_move_picker_back_button.tooltip_text = "군령 선택으로 되돌립니다."
|
||
post_move_picker_back_button.pressed.connect(_on_post_move_picker_back_pressed)
|
||
picker_button_row.add_child(post_move_picker_back_button)
|
||
|
||
post_move_picker_cancel_move_button = Button.new()
|
||
post_move_picker_cancel_move_button.text = "발걸음 거두기"
|
||
post_move_picker_cancel_move_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
|
||
post_move_picker_cancel_move_button.pressed.connect(_on_post_move_picker_cancel_move_pressed)
|
||
picker_button_row.add_child(post_move_picker_cancel_move_button)
|
||
|
||
targeting_hint_panel = PanelContainer.new()
|
||
targeting_hint_panel.visible = false
|
||
targeting_hint_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
targeting_hint_panel.custom_minimum_size = TARGETING_HINT_PANEL_SIZE
|
||
targeting_hint_panel.size = TARGETING_HINT_PANEL_SIZE
|
||
_apply_panel_style(targeting_hint_panel, "command_seal")
|
||
root.add_child(targeting_hint_panel)
|
||
|
||
var targeting_column := VBoxContainer.new()
|
||
targeting_column.add_theme_constant_override("separation", 4)
|
||
targeting_hint_panel.add_child(targeting_column)
|
||
targeting_column.add_child(_make_seal_ribbon(218, 9))
|
||
|
||
targeting_hint_title_label = Label.new()
|
||
targeting_hint_title_label.text = "적장 지목"
|
||
targeting_hint_title_label.custom_minimum_size = TARGETING_HINT_TITLE_SIZE
|
||
targeting_hint_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_apply_label_style(targeting_hint_title_label, UI_OLD_BRONZE, LOCAL_COMMAND_TITLE_FONT_SIZE)
|
||
targeting_column.add_child(targeting_hint_title_label)
|
||
|
||
targeting_hint_detail_label = Label.new()
|
||
targeting_hint_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
targeting_hint_detail_label.custom_minimum_size = TARGETING_HINT_DETAIL_SIZE
|
||
_apply_label_style(targeting_hint_detail_label, UI_PARCHMENT_TEXT, LOCAL_COMMAND_DETAIL_FONT_SIZE)
|
||
targeting_column.add_child(targeting_hint_detail_label)
|
||
|
||
var targeting_button_row := HBoxContainer.new()
|
||
targeting_button_row.add_theme_constant_override("separation", 6)
|
||
targeting_column.add_child(targeting_button_row)
|
||
|
||
targeting_hint_back_button = Button.new()
|
||
targeting_hint_back_button.text = "죽간 거두기"
|
||
targeting_hint_back_button.tooltip_text = "군령 선택으로 되돌립니다."
|
||
targeting_hint_back_button.pressed.connect(_on_targeting_back_pressed)
|
||
targeting_button_row.add_child(targeting_hint_back_button)
|
||
|
||
targeting_hint_cancel_move_button = Button.new()
|
||
targeting_hint_cancel_move_button.text = "발걸음 거두기"
|
||
targeting_hint_cancel_move_button.tooltip_text = "부대를 원래 자리로 되돌립니다."
|
||
targeting_hint_cancel_move_button.pressed.connect(_on_targeting_cancel_move_pressed)
|
||
targeting_button_row.add_child(targeting_hint_cancel_move_button)
|
||
|
||
log_box = RichTextLabel.new()
|
||
log_box.custom_minimum_size = Vector2(420, 70)
|
||
log_box.fit_content = false
|
||
log_box.add_theme_color_override("default_color", UI_PARCHMENT_TEXT)
|
||
log_box.add_theme_stylebox_override("normal", _make_panel_style(Color(0.11, 0.07, 0.045, 0.78), UI_RICE_PAPER_DARK, 1, 1, 6, 0))
|
||
side_column.add_child(log_box)
|
||
|
||
screen_backdrop = _make_screen_backdrop()
|
||
root.add_child(screen_backdrop)
|
||
|
||
briefing_panel = PanelContainer.new()
|
||
briefing_panel.visible = false
|
||
briefing_panel.position = Vector2(212, 16)
|
||
briefing_panel.size = Vector2(856, 688)
|
||
_apply_panel_style(briefing_panel, "paper")
|
||
root.add_child(briefing_panel)
|
||
|
||
var briefing_shell := HBoxContainer.new()
|
||
briefing_shell.custom_minimum_size = Vector2(800, 632)
|
||
briefing_shell.add_theme_constant_override("separation", 10)
|
||
briefing_panel.add_child(briefing_shell)
|
||
briefing_shell.add_child(_make_bamboo_gutter(22, 632))
|
||
|
||
var briefing_column := VBoxContainer.new()
|
||
briefing_column.add_theme_constant_override("separation", 5)
|
||
briefing_shell.add_child(briefing_column)
|
||
briefing_shell.add_child(_make_bamboo_gutter(22, 632))
|
||
briefing_column.add_child(_make_scroll_rod(680, 6))
|
||
briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 24))
|
||
briefing_column.add_child(_make_tablet_binding(680, 16))
|
||
|
||
var briefing_title_panel := PanelContainer.new()
|
||
briefing_title_panel.custom_minimum_size = Vector2(680, 46)
|
||
_apply_panel_style(briefing_title_panel, "seal")
|
||
briefing_column.add_child(briefing_title_panel)
|
||
|
||
briefing_title_label = Label.new()
|
||
briefing_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
briefing_title_label.custom_minimum_size = Vector2(656, 34)
|
||
briefing_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
briefing_title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_apply_label_style(briefing_title_label, UI_PARCHMENT_TEXT, 22)
|
||
briefing_title_panel.add_child(briefing_title_label)
|
||
|
||
briefing_seal_ribbon = _make_seal_ribbon(680, 18)
|
||
briefing_column.add_child(briefing_seal_ribbon)
|
||
|
||
briefing_location_label = Label.new()
|
||
briefing_location_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
briefing_location_label.custom_minimum_size = Vector2(680, 22)
|
||
briefing_location_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
_apply_label_style(briefing_location_label, UI_INK_WASH, 14)
|
||
briefing_column.add_child(briefing_location_label)
|
||
|
||
briefing_start_button = Button.new()
|
||
briefing_start_button.text = "전투 시작"
|
||
briefing_start_button.tooltip_text = "브리핑을 닫고 전투를 시작합니다."
|
||
briefing_start_button.pressed.connect(_on_begin_battle_pressed)
|
||
briefing_column.add_child(briefing_start_button)
|
||
|
||
briefing_column.add_child(_make_scroll_rod(680, 5))
|
||
var briefing_objective_header := HBoxContainer.new()
|
||
briefing_objective_header.add_theme_constant_override("separation", 8)
|
||
briefing_column.add_child(briefing_objective_header)
|
||
briefing_objective_header.add_child(_make_section_caption("승리와 패배", 548))
|
||
|
||
briefing_objective_toggle_button = Button.new()
|
||
briefing_objective_toggle_button.text = "닫기"
|
||
briefing_objective_toggle_button.tooltip_text = "승리와 패배 조건을 닫거나 펼칩니다."
|
||
briefing_objective_toggle_button.custom_minimum_size = Vector2(124, 28)
|
||
briefing_objective_toggle_button.pressed.connect(_on_briefing_objective_toggle_pressed)
|
||
briefing_objective_header.add_child(briefing_objective_toggle_button)
|
||
|
||
briefing_objective_panel = PanelContainer.new()
|
||
briefing_objective_panel.custom_minimum_size = Vector2(680, 124)
|
||
_apply_panel_style(briefing_objective_panel, "edict")
|
||
briefing_column.add_child(briefing_objective_panel)
|
||
|
||
var briefing_objective_row := HBoxContainer.new()
|
||
briefing_objective_row.add_theme_constant_override("separation", 8)
|
||
briefing_objective_panel.add_child(briefing_objective_row)
|
||
|
||
briefing_objective_row.add_child(_make_bamboo_gutter(18, 104))
|
||
briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104))
|
||
|
||
briefing_objective_label = Label.new()
|
||
briefing_objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
briefing_objective_label.custom_minimum_size = Vector2(500, 104)
|
||
briefing_objective_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(briefing_objective_label, UI_AGED_INK, BRIEFING_OBJECTIVE_FONT_SIZE)
|
||
briefing_objective_row.add_child(briefing_objective_label)
|
||
briefing_objective_row.add_child(_make_edict_marker_stack(["", "", ""], 104))
|
||
briefing_objective_row.add_child(_make_bamboo_gutter(18, 104))
|
||
|
||
briefing_column.add_child(_make_ink_wash_rule(680, 8))
|
||
briefing_column.add_child(_make_section_caption("전장도와 군막 보고", 680))
|
||
|
||
briefing_camp_overview_panel = PanelContainer.new()
|
||
briefing_camp_overview_panel.custom_minimum_size = Vector2(680, 108)
|
||
_apply_panel_style(briefing_camp_overview_panel, "silk_map")
|
||
briefing_column.add_child(briefing_camp_overview_panel)
|
||
|
||
var briefing_camp_overview_shell := HBoxContainer.new()
|
||
briefing_camp_overview_shell.add_theme_constant_override("separation", 10)
|
||
briefing_camp_overview_panel.add_child(briefing_camp_overview_shell)
|
||
briefing_camp_overview_shell.add_child(_make_edict_marker_stack(["", ""], 86))
|
||
|
||
briefing_camp_overview_row = HBoxContainer.new()
|
||
briefing_camp_overview_row.custom_minimum_size = Vector2(580, 92)
|
||
briefing_camp_overview_row.add_theme_constant_override("separation", 10)
|
||
briefing_camp_overview_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
briefing_camp_overview_shell.add_child(briefing_camp_overview_row)
|
||
|
||
var camp_thumbnail_panel := PanelContainer.new()
|
||
camp_thumbnail_panel.custom_minimum_size = BRIEFING_CAMP_THUMBNAIL_SIZE
|
||
_apply_panel_style(camp_thumbnail_panel, "map")
|
||
briefing_camp_overview_row.add_child(camp_thumbnail_panel)
|
||
|
||
var camp_thumbnail_stack := Control.new()
|
||
camp_thumbnail_stack.custom_minimum_size = BRIEFING_CAMP_THUMBNAIL_SIZE
|
||
camp_thumbnail_panel.add_child(camp_thumbnail_stack)
|
||
|
||
briefing_camp_overview_texture = TextureRect.new()
|
||
briefing_camp_overview_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
briefing_camp_overview_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
briefing_camp_overview_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
briefing_camp_overview_texture.modulate = Color(0.90, 0.78, 0.56, 0.95)
|
||
camp_thumbnail_stack.add_child(briefing_camp_overview_texture)
|
||
|
||
briefing_camp_overview_fallback_label = Label.new()
|
||
briefing_camp_overview_fallback_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
briefing_camp_overview_fallback_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
briefing_camp_overview_fallback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
briefing_camp_overview_fallback_label.add_theme_font_size_override("font_size", 18)
|
||
_apply_label_style(briefing_camp_overview_fallback_label, UI_OLD_BRONZE)
|
||
camp_thumbnail_stack.add_child(briefing_camp_overview_fallback_label)
|
||
camp_thumbnail_stack.add_child(_make_map_lattice_overlay(BRIEFING_CAMP_THUMBNAIL_SIZE))
|
||
|
||
briefing_camp_overview_label = Label.new()
|
||
briefing_camp_overview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
briefing_camp_overview_label.custom_minimum_size = Vector2(360, 86)
|
||
briefing_camp_overview_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(briefing_camp_overview_label, UI_AGED_INK, 14)
|
||
briefing_camp_overview_row.add_child(briefing_camp_overview_label)
|
||
briefing_camp_overview_shell.add_child(_make_bamboo_gutter(14, 86))
|
||
|
||
briefing_column.add_child(_make_scroll_rod(680, 4))
|
||
|
||
var briefing_scroll := ScrollContainer.new()
|
||
briefing_scroll.custom_minimum_size = Vector2(680, 66)
|
||
briefing_column.add_child(briefing_scroll)
|
||
|
||
briefing_label = Label.new()
|
||
briefing_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
briefing_label.custom_minimum_size = Vector2(656, 0)
|
||
_apply_label_style(briefing_label, UI_AGED_INK, 14)
|
||
briefing_scroll.add_child(briefing_label)
|
||
|
||
briefing_column.add_child(_make_tablet_binding(680, 14))
|
||
briefing_column.add_child(_make_edict_title_strip(["", "", ""], 680, 22))
|
||
|
||
var prep_button_row := HBoxContainer.new()
|
||
prep_button_row.add_theme_constant_override("separation", 8)
|
||
briefing_column.add_child(prep_button_row)
|
||
|
||
chapter_button = Button.new()
|
||
chapter_button.text = "군기록"
|
||
chapter_button.pressed.connect(_on_chapter_pressed)
|
||
prep_button_row.add_child(chapter_button)
|
||
|
||
shop_button = Button.new()
|
||
shop_button.text = "군상"
|
||
shop_button.pressed.connect(_on_shop_pressed)
|
||
prep_button_row.add_child(shop_button)
|
||
|
||
talk_button = Button.new()
|
||
talk_button.text = "군막 회의"
|
||
talk_button.pressed.connect(_on_talk_pressed)
|
||
prep_button_row.add_child(talk_button)
|
||
|
||
armory_button = Button.new()
|
||
armory_button.text = "병장고"
|
||
armory_button.pressed.connect(_on_armory_pressed)
|
||
prep_button_row.add_child(armory_button)
|
||
|
||
roster_button = Button.new()
|
||
roster_button.text = "출진 명부"
|
||
roster_button.pressed.connect(_on_roster_pressed)
|
||
prep_button_row.add_child(roster_button)
|
||
|
||
formation_button = Button.new()
|
||
formation_button.text = "진형 배치"
|
||
formation_button.pressed.connect(_on_formation_pressed)
|
||
prep_button_row.add_child(formation_button)
|
||
|
||
save_button = Button.new()
|
||
save_button.text = "전기록"
|
||
save_button.pressed.connect(_on_save_pressed)
|
||
prep_button_row.add_child(save_button)
|
||
|
||
chapter_menu = VBoxContainer.new()
|
||
chapter_menu.visible = false
|
||
chapter_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(chapter_menu)
|
||
|
||
chapter_status_label = Label.new()
|
||
chapter_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
chapter_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(chapter_status_label, UI_AGED_INK, 14)
|
||
chapter_menu.add_child(chapter_status_label)
|
||
|
||
var chapter_scroll := ScrollContainer.new()
|
||
chapter_scroll.custom_minimum_size = Vector2(600, 190)
|
||
chapter_menu.add_child(chapter_scroll)
|
||
|
||
chapter_list = VBoxContainer.new()
|
||
chapter_list.custom_minimum_size = Vector2(580, 0)
|
||
chapter_list.add_theme_constant_override("separation", 4)
|
||
chapter_scroll.add_child(chapter_list)
|
||
|
||
shop_menu = VBoxContainer.new()
|
||
shop_menu.visible = false
|
||
shop_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(shop_menu)
|
||
|
||
shop_status_label = Label.new()
|
||
shop_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
shop_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(shop_status_label, UI_AGED_INK, 14)
|
||
shop_menu.add_child(shop_status_label)
|
||
|
||
var shop_mode_row := HBoxContainer.new()
|
||
shop_mode_row.add_theme_constant_override("separation", 8)
|
||
shop_menu.add_child(shop_mode_row)
|
||
|
||
shop_buy_button = Button.new()
|
||
shop_buy_button.text = "사들이기"
|
||
shop_buy_button.pressed.connect(_on_shop_buy_mode_pressed)
|
||
shop_mode_row.add_child(shop_buy_button)
|
||
|
||
shop_sell_button = Button.new()
|
||
shop_sell_button.text = "내다팔기"
|
||
shop_sell_button.pressed.connect(_on_shop_sell_mode_pressed)
|
||
shop_mode_row.add_child(shop_sell_button)
|
||
|
||
var shop_scroll := ScrollContainer.new()
|
||
shop_scroll.custom_minimum_size = Vector2(600, 190)
|
||
shop_menu.add_child(shop_scroll)
|
||
|
||
shop_list = VBoxContainer.new()
|
||
shop_list.custom_minimum_size = Vector2(580, 0)
|
||
shop_list.add_theme_constant_override("separation", 4)
|
||
shop_scroll.add_child(shop_list)
|
||
|
||
talk_menu = VBoxContainer.new()
|
||
talk_menu.visible = false
|
||
talk_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(talk_menu)
|
||
|
||
talk_status_label = Label.new()
|
||
talk_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
talk_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(talk_status_label, UI_AGED_INK, 14)
|
||
talk_menu.add_child(talk_status_label)
|
||
|
||
var talk_scroll := ScrollContainer.new()
|
||
talk_scroll.custom_minimum_size = Vector2(600, 150)
|
||
talk_menu.add_child(talk_scroll)
|
||
|
||
talk_list = VBoxContainer.new()
|
||
talk_list.custom_minimum_size = Vector2(580, 0)
|
||
talk_list.add_theme_constant_override("separation", 4)
|
||
talk_scroll.add_child(talk_list)
|
||
|
||
armory_menu = VBoxContainer.new()
|
||
armory_menu.visible = false
|
||
armory_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(armory_menu)
|
||
|
||
armory_status_label = Label.new()
|
||
armory_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
armory_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(armory_status_label, UI_AGED_INK, 14)
|
||
armory_menu.add_child(armory_status_label)
|
||
|
||
var armory_scroll := ScrollContainer.new()
|
||
armory_scroll.custom_minimum_size = Vector2(600, 190)
|
||
armory_menu.add_child(armory_scroll)
|
||
|
||
armory_list = VBoxContainer.new()
|
||
armory_list.custom_minimum_size = Vector2(580, 0)
|
||
armory_list.add_theme_constant_override("separation", 4)
|
||
armory_scroll.add_child(armory_list)
|
||
|
||
roster_menu = VBoxContainer.new()
|
||
roster_menu.visible = false
|
||
roster_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(roster_menu)
|
||
|
||
roster_status_label = Label.new()
|
||
roster_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
roster_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(roster_status_label, UI_AGED_INK, 14)
|
||
roster_menu.add_child(roster_status_label)
|
||
|
||
var roster_scroll := ScrollContainer.new()
|
||
roster_scroll.custom_minimum_size = Vector2(600, 190)
|
||
roster_menu.add_child(roster_scroll)
|
||
|
||
roster_list = VBoxContainer.new()
|
||
roster_list.custom_minimum_size = Vector2(580, 0)
|
||
roster_list.add_theme_constant_override("separation", 4)
|
||
roster_scroll.add_child(roster_list)
|
||
|
||
formation_menu = VBoxContainer.new()
|
||
formation_menu.visible = false
|
||
formation_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(formation_menu)
|
||
|
||
formation_status_label = Label.new()
|
||
formation_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
formation_status_label.custom_minimum_size = Vector2(600, 22)
|
||
_apply_label_style(formation_status_label, UI_AGED_INK, 14)
|
||
formation_menu.add_child(formation_status_label)
|
||
|
||
var formation_scroll := ScrollContainer.new()
|
||
formation_scroll.custom_minimum_size = Vector2(600, 190)
|
||
formation_menu.add_child(formation_scroll)
|
||
|
||
formation_list = VBoxContainer.new()
|
||
formation_list.custom_minimum_size = Vector2(580, 0)
|
||
formation_list.add_theme_constant_override("separation", 4)
|
||
formation_scroll.add_child(formation_list)
|
||
|
||
save_menu = VBoxContainer.new()
|
||
save_menu.visible = false
|
||
save_menu.add_theme_constant_override("separation", 6)
|
||
briefing_column.add_child(save_menu)
|
||
|
||
save_status_label = Label.new()
|
||
save_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
save_status_label.custom_minimum_size = Vector2(600, 64)
|
||
_apply_label_style(save_status_label, UI_AGED_INK, 14)
|
||
save_menu.add_child(save_status_label)
|
||
|
||
var save_action_row := HBoxContainer.new()
|
||
save_action_row.add_theme_constant_override("separation", 8)
|
||
save_menu.add_child(save_action_row)
|
||
|
||
manual_save_button = Button.new()
|
||
manual_save_button.text = "기록 봉인"
|
||
manual_save_button.pressed.connect(_on_manual_save_pressed)
|
||
save_action_row.add_child(manual_save_button)
|
||
|
||
manual_load_button = Button.new()
|
||
manual_load_button.text = "봉인 해제"
|
||
manual_load_button.pressed.connect(_on_manual_load_pressed)
|
||
save_action_row.add_child(manual_load_button)
|
||
|
||
dialogue_panel = PanelContainer.new()
|
||
dialogue_panel.visible = false
|
||
dialogue_panel.position = DIALOGUE_PANEL_POSITION
|
||
dialogue_panel.size = DIALOGUE_PANEL_SIZE
|
||
dialogue_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
_apply_panel_style(dialogue_panel, "dialogue")
|
||
root.add_child(dialogue_panel)
|
||
|
||
dialogue_row = HBoxContainer.new()
|
||
dialogue_row.add_theme_constant_override("separation", 10)
|
||
dialogue_panel.add_child(dialogue_row)
|
||
|
||
dialogue_left_tassel = _make_hanging_tassel(DIALOGUE_PORTRAIT_SIZE.y)
|
||
dialogue_row.add_child(dialogue_left_tassel)
|
||
|
||
dialogue_portrait_panel = PanelContainer.new()
|
||
dialogue_portrait_panel.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
|
||
_apply_panel_style(dialogue_portrait_panel, "portrait")
|
||
dialogue_row.add_child(dialogue_portrait_panel)
|
||
|
||
var dialogue_portrait_stack := Control.new()
|
||
dialogue_portrait_stack.custom_minimum_size = DIALOGUE_PORTRAIT_SIZE
|
||
dialogue_portrait_panel.add_child(dialogue_portrait_stack)
|
||
|
||
dialogue_portrait_texture = TextureRect.new()
|
||
dialogue_portrait_texture.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
dialogue_portrait_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
dialogue_portrait_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
dialogue_portrait_stack.add_child(dialogue_portrait_texture)
|
||
|
||
dialogue_portrait_label = Label.new()
|
||
dialogue_portrait_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
dialogue_portrait_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
dialogue_portrait_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
dialogue_portrait_label.add_theme_font_size_override("font_size", 30)
|
||
_apply_label_style(dialogue_portrait_label, UI_PARCHMENT_TEXT)
|
||
dialogue_portrait_stack.add_child(dialogue_portrait_label)
|
||
|
||
dialogue_column = VBoxContainer.new()
|
||
dialogue_column.custom_minimum_size = DIALOGUE_COLUMN_SIZE
|
||
dialogue_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
dialogue_column.add_theme_constant_override("separation", 4)
|
||
dialogue_row.add_child(dialogue_column)
|
||
|
||
dialogue_right_tassel = _make_hanging_tassel(DIALOGUE_PORTRAIT_SIZE.y)
|
||
dialogue_row.add_child(dialogue_right_tassel)
|
||
|
||
dialogue_column.add_child(_make_tablet_binding(DIALOGUE_TEXT_SIZE.x, 13))
|
||
dialogue_column.add_child(_make_edict_title_strip(["", "", ""], DIALOGUE_TEXT_SIZE.x, 22))
|
||
|
||
dialogue_seal_ribbon = _make_seal_ribbon(DIALOGUE_TEXT_SIZE.x, 18)
|
||
dialogue_column.add_child(dialogue_seal_ribbon)
|
||
|
||
dialogue_speaker_panel = PanelContainer.new()
|
||
dialogue_speaker_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, 34)
|
||
_apply_panel_style(dialogue_speaker_panel, "speaker_seal")
|
||
dialogue_column.add_child(dialogue_speaker_panel)
|
||
|
||
var dialogue_speaker_row := HBoxContainer.new()
|
||
dialogue_speaker_row.add_theme_constant_override("separation", 8)
|
||
dialogue_speaker_panel.add_child(dialogue_speaker_row)
|
||
dialogue_speaker_row.add_child(_make_seal_tile("", 24))
|
||
|
||
dialogue_speaker_label = Label.new()
|
||
dialogue_speaker_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 88, 24)
|
||
dialogue_speaker_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
dialogue_speaker_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
_apply_label_style(dialogue_speaker_label, UI_PARCHMENT_TEXT, DIALOGUE_SPEAKER_FONT_SIZE)
|
||
dialogue_speaker_row.add_child(dialogue_speaker_label)
|
||
dialogue_speaker_row.add_child(_make_seal_tile("", 24))
|
||
|
||
dialogue_text_panel = PanelContainer.new()
|
||
dialogue_text_panel.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x, DIALOGUE_TEXT_SIZE.y)
|
||
_apply_panel_style(dialogue_text_panel, "speech_scroll")
|
||
dialogue_column.add_child(dialogue_text_panel)
|
||
|
||
var dialogue_text_row := HBoxContainer.new()
|
||
dialogue_text_row.add_theme_constant_override("separation", 8)
|
||
dialogue_text_panel.add_child(dialogue_text_row)
|
||
|
||
dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20))
|
||
dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20))
|
||
|
||
dialogue_text_label = Label.new()
|
||
dialogue_text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
dialogue_text_label.custom_minimum_size = Vector2(DIALOGUE_TEXT_SIZE.x - 164, DIALOGUE_TEXT_SIZE.y - 20)
|
||
dialogue_text_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
dialogue_text_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(dialogue_text_label, UI_AGED_INK, DIALOGUE_TEXT_FONT_SIZE)
|
||
dialogue_text_row.add_child(dialogue_text_label)
|
||
dialogue_text_row.add_child(_make_edict_marker_stack(["", "", ""], DIALOGUE_TEXT_SIZE.y - 20))
|
||
dialogue_text_row.add_child(_make_bamboo_gutter(18, DIALOGUE_TEXT_SIZE.y - 20))
|
||
|
||
dialogue_column.add_child(_make_scroll_rod(DIALOGUE_TEXT_SIZE.x, 3))
|
||
dialogue_column.add_child(_make_ink_wash_rule(DIALOGUE_TEXT_SIZE.x, 6))
|
||
|
||
var dialogue_control_row := HBoxContainer.new()
|
||
dialogue_control_row.add_theme_constant_override("separation", 8)
|
||
dialogue_column.add_child(dialogue_control_row)
|
||
|
||
dialogue_progress_label = Label.new()
|
||
dialogue_progress_label.custom_minimum_size = Vector2(520, 32)
|
||
dialogue_progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
dialogue_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
_apply_label_style(dialogue_progress_label, UI_OLD_BRONZE)
|
||
dialogue_control_row.add_child(dialogue_progress_label)
|
||
|
||
dialogue_previous_button = Button.new()
|
||
dialogue_previous_button.text = "이전"
|
||
dialogue_previous_button.disabled = true
|
||
dialogue_previous_button.custom_minimum_size = Vector2(88, 32)
|
||
dialogue_previous_button.pressed.connect(_retreat_dialogue)
|
||
dialogue_control_row.add_child(dialogue_previous_button)
|
||
|
||
dialogue_continue_button = Button.new()
|
||
dialogue_continue_button.text = "다음"
|
||
dialogue_continue_button.custom_minimum_size = Vector2(120, 32)
|
||
dialogue_continue_button.pressed.connect(_advance_dialogue)
|
||
dialogue_control_row.add_child(dialogue_continue_button)
|
||
|
||
result_panel = PanelContainer.new()
|
||
result_panel.visible = false
|
||
result_panel.position = Vector2(260, 132)
|
||
result_panel.size = Vector2(600, 452)
|
||
_apply_panel_style(result_panel, "result_tablet")
|
||
root.add_child(result_panel)
|
||
|
||
var result_column := VBoxContainer.new()
|
||
result_column.alignment = BoxContainer.ALIGNMENT_CENTER
|
||
result_panel.add_child(result_column)
|
||
|
||
result_column.add_child(_make_tablet_binding(520, 16))
|
||
result_column.add_child(_make_scroll_rod(520, 4))
|
||
|
||
result_seal_ribbon = _make_seal_ribbon(520, 18)
|
||
result_column.add_child(result_seal_ribbon)
|
||
result_column.add_child(_make_section_caption("전공과 승패 기록", 520))
|
||
|
||
var result_body_panel := PanelContainer.new()
|
||
result_body_panel.custom_minimum_size = Vector2(520, 236)
|
||
_apply_panel_style(result_body_panel, "edict")
|
||
result_column.add_child(result_body_panel)
|
||
|
||
var result_body_row := HBoxContainer.new()
|
||
result_body_row.add_theme_constant_override("separation", 8)
|
||
result_body_panel.add_child(result_body_row)
|
||
result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198))
|
||
|
||
result_label = Label.new()
|
||
result_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||
result_label.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||
result_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
result_label.custom_minimum_size = Vector2(436, 198)
|
||
result_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
result_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
_apply_label_style(result_label, UI_AGED_INK, 16)
|
||
result_body_row.add_child(result_label)
|
||
result_body_row.add_child(_make_edict_marker_stack(["", "", ""], 198))
|
||
|
||
result_column.add_child(_make_scroll_rod(520, 3))
|
||
|
||
result_choice_list = VBoxContainer.new()
|
||
result_choice_list.add_theme_constant_override("separation", 6)
|
||
result_column.add_child(result_choice_list)
|
||
|
||
result_restart_button = Button.new()
|
||
result_restart_button.text = "군세 재정비"
|
||
result_restart_button.pressed.connect(_on_restart_pressed)
|
||
result_column.add_child(result_restart_button)
|
||
|
||
next_battle_button = Button.new()
|
||
next_battle_button.text = "다음 전장도 개봉"
|
||
next_battle_button.pressed.connect(_on_next_battle_pressed)
|
||
result_column.add_child(next_battle_button)
|
||
|
||
for hud_button in [
|
||
restart_button,
|
||
new_campaign_button,
|
||
wait_button,
|
||
tactic_button,
|
||
item_button,
|
||
equip_button,
|
||
mission_toggle_button,
|
||
threat_button,
|
||
end_turn_button,
|
||
post_move_attack_button,
|
||
post_move_tactic_button,
|
||
post_move_item_button,
|
||
post_move_wait_button,
|
||
post_move_cancel_button,
|
||
post_move_picker_back_button,
|
||
post_move_picker_cancel_move_button,
|
||
targeting_hint_back_button,
|
||
targeting_hint_cancel_move_button,
|
||
chapter_button,
|
||
shop_button,
|
||
talk_button,
|
||
armory_button,
|
||
roster_button,
|
||
formation_button,
|
||
save_button,
|
||
briefing_start_button,
|
||
briefing_objective_toggle_button,
|
||
shop_buy_button,
|
||
shop_sell_button,
|
||
manual_save_button,
|
||
manual_load_button,
|
||
dialogue_previous_button,
|
||
dialogue_continue_button,
|
||
result_restart_button,
|
||
next_battle_button
|
||
]:
|
||
_apply_button_style(hud_button)
|
||
_apply_button_style(briefing_start_button, true)
|
||
|
||
|
||
func _create_audio() -> void:
|
||
bgm_player = AudioStreamPlayer.new()
|
||
bgm_player.name = "BgmPlayer"
|
||
bgm_player.volume_db = BGM_VOLUME_DB
|
||
bgm_player.finished.connect(_on_bgm_finished)
|
||
add_child(bgm_player)
|
||
|
||
|
||
func _on_bgm_finished() -> void:
|
||
if current_bgm_key.is_empty() or bgm_player == null or bgm_player.stream == null:
|
||
return
|
||
bgm_player.play()
|
||
|
||
|
||
func _play_bgm(key: String) -> void:
|
||
if bgm_player == null:
|
||
return
|
||
if current_bgm_key == key and bgm_player.playing:
|
||
return
|
||
var stream := _bgm_stream_for(key)
|
||
if stream == null:
|
||
return
|
||
current_bgm_key = key
|
||
bgm_player.stop()
|
||
bgm_player.stream = stream
|
||
bgm_player.play()
|
||
|
||
|
||
func _bgm_stream_for(key: String) -> AudioStream:
|
||
if key == "battle":
|
||
return _audio_stream_from_path(BGM_BATTLE_PATH)
|
||
if key == "menu":
|
||
return _audio_stream_from_path(BGM_MENU_PATH)
|
||
return null
|
||
|
||
|
||
func _play_sfx(key: String, volume_db := SFX_VOLUME_DB) -> void:
|
||
var stream := _sfx_stream_for(key)
|
||
if stream == null:
|
||
return
|
||
var player := AudioStreamPlayer.new()
|
||
player.name = "Sfx_%s" % key
|
||
player.stream = stream
|
||
player.volume_db = volume_db
|
||
player.finished.connect(player.queue_free)
|
||
add_child(player)
|
||
player.play()
|
||
|
||
|
||
func _sfx_stream_for(key: String) -> AudioStream:
|
||
if key == "bow_release":
|
||
return _audio_stream_from_path(SFX_BOW_RELEASE_PATH)
|
||
if key == "defeat_sting":
|
||
return _audio_stream_from_path(SFX_DEFEAT_STING_PATH)
|
||
if key == "fire_skill":
|
||
return _audio_stream_from_path(SFX_FIRE_SKILL_PATH)
|
||
if key == "footstep":
|
||
return _audio_stream_from_path(SFX_FOOTSTEP_PATH)
|
||
if key == "guard_block":
|
||
return _audio_stream_from_path(SFX_GUARD_BLOCK_PATH)
|
||
if key == "hit_heavy":
|
||
return _audio_stream_from_path(SFX_HIT_HEAVY_PATH)
|
||
if key == "item_pickup":
|
||
return _audio_stream_from_path(SFX_ITEM_PICKUP_PATH)
|
||
if key == "menu_cancel":
|
||
return _audio_stream_from_path(SFX_MENU_CANCEL_PATH)
|
||
if key == "menu_confirm":
|
||
return _audio_stream_from_path(SFX_MENU_CONFIRM_PATH)
|
||
if key == "skill_cast":
|
||
return _audio_stream_from_path(SFX_SKILL_CAST_PATH)
|
||
if key == "slash":
|
||
return _audio_stream_from_path(SFX_SLASH_PATH)
|
||
if key == "ui_click":
|
||
return _audio_stream_from_path(SFX_UI_CLICK_PATH)
|
||
if key == "victory_sting":
|
||
return _audio_stream_from_path(SFX_VICTORY_STING_PATH)
|
||
return null
|
||
|
||
|
||
func _audio_stream_from_path(path: String) -> AudioStream:
|
||
if path.is_empty():
|
||
return null
|
||
if not ResourceLoader.exists(path, "AudioStream"):
|
||
return null
|
||
if not audio_stream_cache.has(path):
|
||
audio_stream_cache[path] = load(path)
|
||
return audio_stream_cache[path] as AudioStream
|
||
|
||
|
||
func _play_ui_click() -> void:
|
||
_play_sfx("ui_click", UI_SFX_VOLUME_DB)
|
||
|
||
|
||
func _play_ui_confirm() -> void:
|
||
_play_sfx("menu_confirm", UI_SFX_VOLUME_DB)
|
||
|
||
|
||
func _play_ui_cancel() -> void:
|
||
_play_sfx("menu_cancel", UI_SFX_VOLUME_DB)
|
||
|
||
|
||
func _play_log_sfx(message: String) -> void:
|
||
if message.contains(" but misses.") or message.contains("헛쳤다"):
|
||
_play_sfx("guard_block")
|
||
elif message.begins_with("Objective updated:") or message.begins_with("Defeat condition updated:") or message.begins_with("군령 갱신:") or message.begins_with("패배 조건 갱신:"):
|
||
_play_ui_confirm()
|
||
elif message.ends_with(" withdraws from the battlefield.") or message.ends_with("전장을 물러났다."):
|
||
_play_ui_confirm()
|
||
elif message.contains(" attacks ") or message.contains(" counterattacks ") or message.contains(" 공격 ") or message.contains(" 반격 "):
|
||
if message.contains(" for ") or message.contains("피해"):
|
||
_play_sfx("hit_heavy")
|
||
elif message.contains(" casts ") or message.contains(" 시전 ") or message.contains("펼쳐"):
|
||
if message.contains("restoring") or message.contains(" until ") or message.contains("회복") or message.contains("지원"):
|
||
_play_sfx("skill_cast")
|
||
else:
|
||
_play_sfx("fire_skill")
|
||
elif message.contains(" moved to ") or message.contains(" advances to ") or message.contains(" takes formation at ") or message.contains(" arrives at ") or message.contains("진군") or message.contains("진형을 잡았다") or message.contains("압박") or message.contains("당도"):
|
||
_play_sfx("footstep")
|
||
elif message.contains(" uses ") or message.contains(" equips ") or message.contains(" 사용:") or message.contains("장착") or message.contains("해제") or message.contains(" 매입:") or message.contains(" 매각:") or message.contains("군자금") or message.begins_with("Received ") or message.begins_with("전리품:"):
|
||
_play_sfx("item_pickup")
|
||
elif message.contains("공적") or message.contains("품계") or message.contains("승급"):
|
||
_play_sfx("menu_confirm")
|
||
|
||
|
||
func _announce_battle_status_audio() -> void:
|
||
if state.battle_status == BattleState.STATUS_ACTIVE:
|
||
last_announced_battle_status = ""
|
||
return
|
||
if last_announced_battle_status == state.battle_status:
|
||
return
|
||
last_announced_battle_status = state.battle_status
|
||
_play_bgm("menu")
|
||
if state.battle_status == BattleState.STATUS_VICTORY:
|
||
_play_sfx("victory_sting")
|
||
elif state.battle_status == BattleState.STATUS_DEFEAT:
|
||
_play_sfx("defeat_sting")
|
||
|
||
|
||
func _handle_board_click(screen_position: Vector2) -> void:
|
||
if formation_menu != null and formation_menu.visible:
|
||
_handle_formation_board_click(screen_position)
|
||
return
|
||
if _is_input_locked():
|
||
return
|
||
|
||
var cell := _cell_from_screen(screen_position)
|
||
if tactic_menu != null and tactic_menu.visible:
|
||
_hide_tactic_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
if item_menu != null and item_menu.visible:
|
||
_hide_item_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
if equip_menu != null and equip_menu.visible:
|
||
_hide_equip_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
if not state.is_inside(cell) or not state.can_player_act():
|
||
return
|
||
|
||
var clicked_unit := state.get_unit_at(cell)
|
||
var selected_unit := state.get_selected_unit()
|
||
|
||
if selected_unit.is_empty():
|
||
if _can_select(clicked_unit):
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
state.select_unit(clicked_unit["id"])
|
||
return
|
||
|
||
if not selected_item_id.is_empty():
|
||
if item_cells.has(cell) and _commit_pending_move_before_action() and state.try_use_selected_item_on_cell(selected_item_id, cell):
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
return
|
||
if _has_pending_move():
|
||
return
|
||
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit):
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
state.select_unit(clicked_unit["id"])
|
||
return
|
||
|
||
if not selected_skill_id.is_empty():
|
||
if skill_cells.has(cell) and _commit_pending_move_before_action() and state.try_cast_selected_skill(selected_skill_id, cell):
|
||
selected_skill_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
return
|
||
if _has_pending_move():
|
||
return
|
||
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", "") and _can_select(clicked_unit):
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
state.select_unit(clicked_unit["id"])
|
||
return
|
||
|
||
if not clicked_unit.is_empty() and clicked_unit.get("team", "") != selected_unit.get("team", ""):
|
||
if _has_pending_move() and not basic_attack_targeting:
|
||
return
|
||
var target_cell: Vector2i = clicked_unit.get("pos", cell)
|
||
if attack_cells.has(target_cell) and _commit_pending_move_before_action() and state.try_attack_selected(clicked_unit["id"]):
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
return
|
||
|
||
if not clicked_unit.is_empty() and clicked_unit.get("team", "") == selected_unit.get("team", ""):
|
||
if _has_pending_move():
|
||
return
|
||
if _can_select(clicked_unit):
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
state.select_unit(clicked_unit["id"])
|
||
return
|
||
|
||
var selected_start_cell: Vector2i = selected_unit.get("pos", Vector2i(-1, -1))
|
||
if not _has_pending_move() and state.try_move_selected(cell, true):
|
||
pending_move_unit_id = str(selected_unit.get("id", ""))
|
||
pending_move_from_cell = selected_start_cell
|
||
pending_move_to_cell = cell
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_refresh_ranges()
|
||
_show_post_move_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return
|
||
|
||
if _can_select(clicked_unit):
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_clear_pending_move_state()
|
||
state.select_unit(clicked_unit["id"])
|
||
|
||
|
||
func _has_pending_move() -> bool:
|
||
return (
|
||
not pending_move_unit_id.is_empty()
|
||
and pending_move_from_cell != Vector2i(-1, -1)
|
||
and pending_move_to_cell != Vector2i(-1, -1)
|
||
)
|
||
|
||
|
||
func _clear_pending_move_state(hide_menu := true) -> void:
|
||
pending_move_unit_id = ""
|
||
pending_move_from_cell = Vector2i(-1, -1)
|
||
pending_move_to_cell = Vector2i(-1, -1)
|
||
basic_attack_targeting = false
|
||
if hide_menu:
|
||
_hide_post_move_menu()
|
||
_hide_post_move_picker()
|
||
_hide_targeting_hint_panel()
|
||
|
||
|
||
func _commit_pending_move_before_action() -> bool:
|
||
if not _has_pending_move():
|
||
return true
|
||
var unit_id := pending_move_unit_id
|
||
_clear_pending_move_state()
|
||
var still_active := state.commit_pending_move_events(unit_id)
|
||
return still_active and state.can_player_act() and not state.get_selected_unit().is_empty() and not _is_input_locked()
|
||
|
||
|
||
func _cancel_pending_move() -> bool:
|
||
if not _has_pending_move():
|
||
return false
|
||
var unit_id := pending_move_unit_id
|
||
var from_cell := pending_move_from_cell
|
||
var to_cell := pending_move_to_cell
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_menu()
|
||
_hide_post_move_picker()
|
||
if state.cancel_pending_move(unit_id, from_cell, to_cell):
|
||
_clear_pending_move_state()
|
||
_play_ui_cancel()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return true
|
||
return false
|
||
|
||
|
||
func _handle_cancel_input() -> bool:
|
||
if _has_pending_move():
|
||
return _cancel_pending_move()
|
||
if post_move_picker_panel != null and post_move_picker_panel.visible:
|
||
_on_post_move_picker_back_pressed()
|
||
return true
|
||
if tactic_menu != null and tactic_menu.visible:
|
||
_play_ui_cancel()
|
||
_hide_tactic_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return true
|
||
if item_menu != null and item_menu.visible:
|
||
_play_ui_cancel()
|
||
_hide_item_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return true
|
||
if equip_menu != null and equip_menu.visible:
|
||
_play_ui_cancel()
|
||
_hide_equip_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return true
|
||
if not selected_skill_id.is_empty() or not selected_item_id.is_empty() or basic_attack_targeting:
|
||
_play_ui_cancel()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_targeting_hint_panel()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return true
|
||
if not state.get_selected_unit().is_empty():
|
||
_play_ui_cancel()
|
||
state.clear_selection()
|
||
return true
|
||
return false
|
||
|
||
|
||
func _handle_key(event: InputEventKey) -> void:
|
||
if event.keycode == KEY_ENTER and briefing_panel != null and briefing_panel.visible:
|
||
if _is_prep_menu_visible():
|
||
return
|
||
_on_begin_battle_pressed()
|
||
elif event.keycode == KEY_ESCAPE:
|
||
if chapter_menu != null and chapter_menu.visible:
|
||
_hide_chapter_menu()
|
||
elif shop_menu != null and shop_menu.visible:
|
||
_hide_shop_menu()
|
||
elif talk_menu != null and talk_menu.visible:
|
||
_hide_talk_menu()
|
||
elif armory_menu != null and armory_menu.visible:
|
||
_hide_armory_menu()
|
||
elif roster_menu != null and roster_menu.visible:
|
||
_hide_roster_menu()
|
||
elif formation_menu != null and formation_menu.visible:
|
||
_hide_formation_menu()
|
||
elif save_menu != null and save_menu.visible:
|
||
_hide_save_menu()
|
||
elif briefing_panel != null and briefing_panel.visible:
|
||
if _set_briefing_objective_collapsed(true):
|
||
_play_ui_cancel()
|
||
return
|
||
_on_begin_battle_pressed()
|
||
elif post_move_picker_panel != null and post_move_picker_panel.visible:
|
||
_on_post_move_picker_back_pressed()
|
||
elif tactic_menu != null and tactic_menu.visible:
|
||
_hide_tactic_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
elif item_menu != null and item_menu.visible:
|
||
_hide_item_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
elif equip_menu != null and equip_menu.visible:
|
||
_hide_equip_menu()
|
||
else:
|
||
_handle_cancel_input()
|
||
elif event.keycode == KEY_SPACE and not _is_input_locked():
|
||
_on_end_turn_pressed()
|
||
elif event.keycode == KEY_R and not _is_prep_menu_visible():
|
||
_on_restart_pressed()
|
||
elif event.keycode == KEY_T and not _is_input_locked():
|
||
_on_tactic_pressed()
|
||
elif event.keycode == KEY_I and not _is_input_locked():
|
||
_on_item_pressed()
|
||
elif event.keycode == KEY_E and not _is_input_locked():
|
||
_on_equip_pressed()
|
||
elif event.keycode == KEY_Y and not _is_prep_menu_visible():
|
||
_on_threat_pressed()
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
var needs_redraw := false
|
||
if battle_started and state.battle_status == BattleState.STATUS_ACTIVE and not _is_dialogue_visible():
|
||
needs_redraw = true
|
||
if _update_edge_scroll(delta):
|
||
needs_redraw = true
|
||
if objective_notice_timer > 0.0:
|
||
objective_notice_timer = maxf(0.0, objective_notice_timer - delta)
|
||
if objective_notice_timer <= 0.0 and objective_notice_panel != null:
|
||
objective_notice_panel.visible = false
|
||
|
||
if not floating_texts.is_empty():
|
||
var active_texts: Array[Dictionary] = []
|
||
for popup in floating_texts:
|
||
var next_popup := popup.duplicate(true)
|
||
next_popup["age"] = float(next_popup.get("age", 0.0)) + delta
|
||
if float(next_popup["age"]) < float(next_popup.get("duration", FLOATING_TEXT_LIFETIME)):
|
||
active_texts.append(next_popup)
|
||
floating_texts = active_texts
|
||
needs_redraw = true
|
||
|
||
if not unit_motion_by_unit.is_empty():
|
||
var active_motion := {}
|
||
for unit_id in unit_motion_by_unit.keys():
|
||
var motion: Dictionary = unit_motion_by_unit[unit_id]
|
||
var next_motion := motion.duplicate(true)
|
||
next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
|
||
if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)):
|
||
active_motion[unit_id] = next_motion
|
||
unit_motion_by_unit = active_motion
|
||
needs_redraw = true
|
||
|
||
if not unit_action_motion_by_unit.is_empty():
|
||
var active_actions := {}
|
||
for unit_id in unit_action_motion_by_unit.keys():
|
||
var motion: Dictionary = unit_action_motion_by_unit[unit_id]
|
||
var next_motion := motion.duplicate(true)
|
||
next_motion["age"] = float(next_motion.get("age", 0.0)) + delta
|
||
if float(next_motion["age"]) < float(next_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)):
|
||
active_actions[unit_id] = next_motion
|
||
unit_action_motion_by_unit = active_actions
|
||
needs_redraw = true
|
||
|
||
if needs_redraw:
|
||
queue_redraw()
|
||
|
||
|
||
func _can_select(unit: Dictionary) -> bool:
|
||
if unit.is_empty():
|
||
return false
|
||
return (
|
||
unit.get("team", "") == BattleState.TEAM_PLAYER
|
||
and bool(unit.get("controllable", true))
|
||
and not unit.get("acted", false)
|
||
)
|
||
|
||
|
||
func _draw_map() -> void:
|
||
var board_rect := _board_rect()
|
||
var background := _current_battle_background_texture()
|
||
var has_background := background != null
|
||
if background != null:
|
||
_draw_texture_cover(background, board_rect, Color(1.0, 1.0, 1.0, 0.96))
|
||
draw_rect(board_rect, Color(0.02, 0.025, 0.03, 0.12))
|
||
draw_rect(board_rect, Color(0.35, 0.22, 0.10, 0.10))
|
||
else:
|
||
draw_rect(board_rect, Color(0.18, 0.26, 0.18))
|
||
|
||
for y in range(state.map_size.y):
|
||
for x in range(state.map_size.x):
|
||
var cell := Vector2i(x, y)
|
||
var rect := _rect_for_cell(cell)
|
||
var terrain_key := state.get_terrain_key(cell)
|
||
draw_rect(rect, _terrain_fill_color(cell, terrain_key, has_background))
|
||
_draw_terrain_detail(cell, rect, terrain_key)
|
||
_draw_terrain_edge_blend(cell, rect, terrain_key)
|
||
draw_rect(rect, _map_grid_color(has_background), false, 1.0)
|
||
|
||
_draw_silk_map_patina(board_rect, has_background)
|
||
_draw_silk_map_frame(board_rect)
|
||
|
||
|
||
func _draw_silk_map_patina(board_rect: Rect2, has_background: bool) -> void:
|
||
var wash := UI_SILK_WASH
|
||
wash.a = 0.08 if has_background else 0.14
|
||
draw_rect(board_rect, wash)
|
||
var fiber := UI_SILK_FIBER
|
||
fiber.a = 0.12 if has_background else 0.18
|
||
for index in range(7):
|
||
var y := board_rect.position.y + 18.0 + float(index) * (board_rect.size.y - 36.0) / 6.0
|
||
var drift := 4.0 + _cell_noise(Vector2i(index, state.map_size.y), 211) * 10.0
|
||
draw_line(
|
||
Vector2(board_rect.position.x + 12.0, y),
|
||
Vector2(board_rect.end.x - 12.0, y + drift),
|
||
fiber,
|
||
1.0
|
||
)
|
||
for index in range(5):
|
||
var x := board_rect.position.x + 20.0 + float(index) * (board_rect.size.x - 40.0) / 4.0
|
||
var drift := 5.0 + _cell_noise(Vector2i(state.map_size.x, index), 227) * 9.0
|
||
draw_line(
|
||
Vector2(x, board_rect.position.y + 12.0),
|
||
Vector2(x + drift, board_rect.end.y - 12.0),
|
||
Color(fiber.r, fiber.g, fiber.b, fiber.a * 0.65),
|
||
1.0
|
||
)
|
||
var corner_stain := Color(0.12, 0.06, 0.025, 0.12 if has_background else 0.18)
|
||
draw_circle(board_rect.position + Vector2(20, 18), 26.0, corner_stain)
|
||
draw_circle(board_rect.end - Vector2(24, 22), 30.0, corner_stain)
|
||
|
||
|
||
func _draw_silk_map_frame(board_rect: Rect2) -> void:
|
||
var padding := MAP_SCROLL_FRAME_PADDING
|
||
var top_rod := Rect2(board_rect.position + Vector2(-padding, -padding), Vector2(board_rect.size.x + padding * 2.0, 7.0))
|
||
var bottom_rod := Rect2(Vector2(board_rect.position.x - padding, board_rect.end.y + padding - 7.0), top_rod.size)
|
||
var left_rod := Rect2(board_rect.position + Vector2(-padding, -padding), Vector2(7.0, board_rect.size.y + padding * 2.0))
|
||
var right_rod := Rect2(Vector2(board_rect.end.x + padding - 7.0, board_rect.position.y - padding), left_rod.size)
|
||
for rod in [top_rod, bottom_rod, left_rod, right_rod]:
|
||
draw_rect(rod, UI_BAMBOO_DARK)
|
||
draw_rect(rod, Color(0.62, 0.43, 0.18, 0.80), false, 1.0)
|
||
var hanger_y := top_rod.position.y - 8.0
|
||
draw_line(
|
||
Vector2(top_rod.position.x + 32.0, hanger_y),
|
||
Vector2(top_rod.end.x - 32.0, hanger_y),
|
||
Color(UI_TARNISHED_BRONZE.r, UI_TARNISHED_BRONZE.g, UI_TARNISHED_BRONZE.b, 0.82),
|
||
2.0
|
||
)
|
||
for x in [top_rod.position.x + 54.0, top_rod.end.x - 54.0]:
|
||
draw_line(Vector2(x, hanger_y), Vector2(x, top_rod.position.y + 7.0), Color(UI_BAMBOO_DARK.r, UI_BAMBOO_DARK.g, UI_BAMBOO_DARK.b, 0.88), 2.0)
|
||
draw_circle(Vector2(x, hanger_y), 4.0, UI_SEAL_RED_DARK)
|
||
for corner in [
|
||
top_rod.position - Vector2(3, 3),
|
||
Vector2(top_rod.end.x - 11.0, top_rod.position.y - 3.0),
|
||
Vector2(bottom_rod.position.x - 3.0, bottom_rod.end.y - 11.0),
|
||
bottom_rod.end - Vector2(11, 11)
|
||
]:
|
||
draw_rect(Rect2(corner, Vector2(14, 14)), UI_CINNABAR_DARK)
|
||
draw_rect(Rect2(corner + Vector2(2, 2), Vector2(10, 10)), UI_OLD_BRONZE, false, 1.0)
|
||
draw_rect(board_rect.grow(4.0), UI_LACQUER_EDGE, false, 5.0)
|
||
draw_rect(board_rect.grow(1.0), UI_OLD_BRONZE, false, 2.0)
|
||
var seal_rect := Rect2(board_rect.end - Vector2(58, 54), Vector2(34, 34))
|
||
draw_rect(seal_rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.34))
|
||
draw_rect(seal_rect.grow(-5.0), Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.68), false, 2.0)
|
||
draw_line(seal_rect.position + Vector2(8, seal_rect.size.y * 0.5), seal_rect.end - Vector2(8, seal_rect.size.y * 0.5), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42), 1.2)
|
||
draw_line(seal_rect.position + Vector2(seal_rect.size.x * 0.5, 8), seal_rect.end - Vector2(seal_rect.size.x * 0.5, 8), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34), 1.2)
|
||
|
||
|
||
func _current_battle_background_texture() -> Texture2D:
|
||
var next_path := state.get_map_background_path()
|
||
if next_path.is_empty():
|
||
next_path = DEFAULT_BATTLE_BACKGROUND_PATH
|
||
if next_path == battle_background_path:
|
||
return battle_background_texture
|
||
battle_background_path = next_path
|
||
battle_background_texture = _load_art_texture(next_path)
|
||
return battle_background_texture
|
||
|
||
|
||
func _draw_texture_cover(texture: Texture2D, rect: Rect2, modulate: Color) -> void:
|
||
var texture_size := texture.get_size()
|
||
if texture_size.x <= 0.0 or texture_size.y <= 0.0 or rect.size.x <= 0.0 or rect.size.y <= 0.0:
|
||
return
|
||
var target_ratio := rect.size.x / rect.size.y
|
||
var source_ratio := texture_size.x / texture_size.y
|
||
var source_rect := Rect2(Vector2.ZERO, texture_size)
|
||
if source_ratio > target_ratio:
|
||
var source_width := texture_size.y * target_ratio
|
||
source_rect.position.x = (texture_size.x - source_width) * 0.5
|
||
source_rect.size.x = source_width
|
||
else:
|
||
var source_height := texture_size.x / target_ratio
|
||
source_rect.position.y = (texture_size.y - source_height) * 0.5
|
||
source_rect.size.y = source_height
|
||
draw_texture_rect_region(texture, rect, source_rect, modulate)
|
||
|
||
|
||
func _terrain_fill_color(cell: Vector2i, terrain_key: String, has_background: bool) -> Color:
|
||
var color := state.get_terrain_color(cell)
|
||
var alpha := 0.13 if has_background else 0.34
|
||
if terrain_key == "W":
|
||
alpha += 0.04
|
||
elif terrain_key == "C" or terrain_key == "H" or terrain_key == "T":
|
||
alpha += 0.02
|
||
elif terrain_key == "D":
|
||
alpha += 0.015
|
||
color.a = alpha
|
||
return color
|
||
|
||
|
||
func _map_grid_color(has_background: bool) -> Color:
|
||
var color := GRID_COLOR
|
||
color.a = 0.09 if has_background else 0.34
|
||
return color
|
||
|
||
|
||
func _draw_terrain_detail(cell: Vector2i, rect: Rect2, terrain_key: String) -> void:
|
||
if terrain_key == "G":
|
||
for index in range(3):
|
||
var point := _terrain_detail_point(cell, rect, index + 15)
|
||
var tint := Color(0.74, 0.88, 0.42, 0.24 + _cell_noise(cell, index + 101) * 0.10)
|
||
draw_line(point, point + Vector2(3, -7), tint, 1.2)
|
||
draw_line(point + Vector2(5, 3), point + Vector2(9, -3), Color(0.46, 0.66, 0.28, 0.18), 1.0)
|
||
elif terrain_key == "F":
|
||
for index in range(4):
|
||
var point := _terrain_detail_point(cell, rect, index)
|
||
draw_circle(point, 4.5 + _cell_noise(cell, index + 11) * 2.0, Color(0.05, 0.20, 0.09, 0.42))
|
||
draw_line(point + Vector2(0, 4), point + Vector2(0, 9), Color(0.08, 0.11, 0.06, 0.34), 1.3)
|
||
elif terrain_key == "H":
|
||
for index in range(3):
|
||
var point := _terrain_detail_point(cell, rect, index + 8)
|
||
var peak := point + Vector2(0, -9)
|
||
draw_line(point + Vector2(-12, 9), peak, Color(0.92, 0.86, 0.68, 0.46), 2.2)
|
||
draw_line(peak, point + Vector2(12, 9), Color(0.20, 0.18, 0.14, 0.42), 2.2)
|
||
draw_line(point + Vector2(-5, 5), point + Vector2(5, 6), Color(0.10, 0.09, 0.07, 0.20), 1.1)
|
||
elif terrain_key == "D":
|
||
_draw_wasteland_detail(cell, rect)
|
||
elif terrain_key == "R":
|
||
_draw_road_detail(cell, rect)
|
||
elif terrain_key == "W":
|
||
for index in range(3):
|
||
var y := rect.position.y + 15.0 + float(index) * 14.0
|
||
draw_line(rect.position + Vector2(10, y - rect.position.y), rect.position + Vector2(rect.size.x - 10, y - rect.position.y + 4), Color(0.70, 0.88, 1.0, 0.32), 1.6)
|
||
_draw_water_shoreline(cell, rect)
|
||
elif terrain_key == "C":
|
||
_draw_castle_detail(rect)
|
||
elif terrain_key == "T":
|
||
_draw_town_detail(rect)
|
||
else:
|
||
for index in range(2):
|
||
var point := _terrain_detail_point(cell, rect, index + 15)
|
||
draw_line(point, point + Vector2(3, -7), Color(0.78, 0.88, 0.50, 0.26), 1.2)
|
||
|
||
|
||
func _draw_road_detail(cell: Vector2i, rect: Rect2) -> void:
|
||
var road_color := Color(0.82, 0.69, 0.48, 0.34)
|
||
var rut_color := Color(0.36, 0.28, 0.18, 0.24)
|
||
var center := rect.position + rect.size * 0.5
|
||
var flags := _terrain_connection_flags(cell, "R")
|
||
var north := bool(flags.get("north", false))
|
||
var south := bool(flags.get("south", false))
|
||
var west := bool(flags.get("west", false))
|
||
var east := bool(flags.get("east", false))
|
||
if north or south or not (west or east):
|
||
var from_y := rect.position.y if north else center.y
|
||
var to_y := rect.end.y if south else center.y
|
||
draw_rect(Rect2(Vector2(center.x - rect.size.x * 0.14, from_y), Vector2(rect.size.x * 0.28, to_y - from_y)), road_color)
|
||
draw_line(Vector2(center.x - 5.0, from_y + 5.0), Vector2(center.x - 2.0, to_y - 5.0), rut_color, 1.2)
|
||
draw_line(Vector2(center.x + 5.0, from_y + 5.0), Vector2(center.x + 2.0, to_y - 5.0), rut_color, 1.2)
|
||
if west or east:
|
||
var from_x := rect.position.x if west else center.x
|
||
var to_x := rect.end.x if east else center.x
|
||
draw_rect(Rect2(Vector2(from_x, center.y - rect.size.y * 0.14), Vector2(to_x - from_x, rect.size.y * 0.28)), road_color)
|
||
draw_line(Vector2(from_x + 5.0, center.y - 5.0), Vector2(to_x - 5.0, center.y - 2.0), rut_color, 1.2)
|
||
draw_line(Vector2(from_x + 5.0, center.y + 5.0), Vector2(to_x - 5.0, center.y + 2.0), rut_color, 1.2)
|
||
draw_circle(center, 5.0, Color(0.84, 0.70, 0.48, 0.24))
|
||
|
||
|
||
func _draw_water_shoreline(cell: Vector2i, rect: Rect2) -> void:
|
||
var shore := Color(0.82, 0.88, 0.72, 0.24)
|
||
if _terrain_key_at(cell + Vector2i(0, -1)) != "W":
|
||
draw_line(rect.position + Vector2(7, 4), rect.position + Vector2(rect.size.x - 7, 6), shore, 2.0)
|
||
if _terrain_key_at(cell + Vector2i(0, 1)) != "W":
|
||
draw_line(rect.position + Vector2(7, rect.size.y - 6), rect.position + Vector2(rect.size.x - 7, rect.size.y - 4), shore, 2.0)
|
||
if _terrain_key_at(cell + Vector2i(-1, 0)) != "W":
|
||
draw_line(rect.position + Vector2(4, 7), rect.position + Vector2(6, rect.size.y - 7), shore, 2.0)
|
||
if _terrain_key_at(cell + Vector2i(1, 0)) != "W":
|
||
draw_line(rect.position + Vector2(rect.size.x - 6, 7), rect.position + Vector2(rect.size.x - 4, rect.size.y - 7), shore, 2.0)
|
||
|
||
|
||
func _draw_wasteland_detail(cell: Vector2i, rect: Rect2) -> void:
|
||
var crack := Color(0.16, 0.10, 0.055, 0.32)
|
||
var dust := Color(0.86, 0.68, 0.42, 0.20)
|
||
for index in range(3):
|
||
var point := _terrain_detail_point(cell, rect, index + 31)
|
||
var end := point + Vector2(10.0 + _cell_noise(cell, index + 41) * 8.0, -2.0 + _cell_noise(cell, index + 43) * 5.0)
|
||
draw_line(point, end, crack, 1.2)
|
||
draw_line(point + Vector2(2, 5), point + Vector2(9, 7), dust, 1.0)
|
||
for index in range(2):
|
||
var pebble := _terrain_detail_point(cell, rect, index + 61)
|
||
draw_circle(pebble, 1.6 + _cell_noise(cell, index + 63) * 1.2, Color(0.24, 0.18, 0.12, 0.24))
|
||
|
||
|
||
func _draw_castle_detail(rect: Rect2) -> void:
|
||
draw_rect(rect.grow(-6.0), Color(0.12, 0.115, 0.105, 0.32), false, 2.2)
|
||
draw_rect(rect.grow(-12.0), Color(0.86, 0.80, 0.68, 0.12))
|
||
for y in [15.0, 30.0, 45.0]:
|
||
draw_line(rect.position + Vector2(9, y), rect.position + Vector2(rect.size.x - 9, y + 1.5), Color(0.86, 0.82, 0.74, 0.34), 1.2)
|
||
for x in [18.0, 34.0, 50.0]:
|
||
draw_line(rect.position + Vector2(x, 10), rect.position + Vector2(x + 1.5, rect.size.y - 10), Color(0.04, 0.04, 0.04, 0.26), 1.1)
|
||
for index in range(3):
|
||
var notch := Rect2(rect.position + Vector2(11.0 + float(index) * 16.0, 7.0), Vector2(8.0, 5.0))
|
||
draw_rect(notch, Color(0.84, 0.80, 0.72, 0.28))
|
||
|
||
|
||
func _draw_town_detail(rect: Rect2) -> void:
|
||
var wall := Color(0.20, 0.12, 0.055, 0.30)
|
||
var roof := Color(0.58, 0.22, 0.10, 0.34)
|
||
var straw := Color(0.88, 0.70, 0.34, 0.28)
|
||
for index in range(2):
|
||
var base := rect.position + Vector2(12.0 + float(index) * 24.0, 31.0)
|
||
var body := Rect2(base, Vector2(18.0, 13.0))
|
||
var roof_points := PackedVector2Array([
|
||
base + Vector2(-3.0, 1.0),
|
||
base + Vector2(9.0, -9.0),
|
||
base + Vector2(21.0, 1.0)
|
||
])
|
||
draw_colored_polygon(roof_points, roof)
|
||
draw_rect(body, straw)
|
||
draw_rect(body, wall, false, 1.1)
|
||
draw_line(rect.position + Vector2(8, 50), rect.position + Vector2(rect.size.x - 8, 48), Color(0.26, 0.16, 0.08, 0.28), 1.4)
|
||
|
||
|
||
func _draw_terrain_edge_blend(cell: Vector2i, rect: Rect2, terrain_key: String) -> void:
|
||
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, -1))
|
||
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(1, 0))
|
||
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(0, 1))
|
||
_draw_terrain_edge(cell, rect, terrain_key, Vector2i(-1, 0))
|
||
|
||
|
||
func _draw_terrain_edge(cell: Vector2i, rect: Rect2, terrain_key: String, offset: Vector2i) -> void:
|
||
var neighbor_key := _terrain_key_at(cell + offset)
|
||
if not _should_draw_terrain_edge(terrain_key, neighbor_key, offset):
|
||
return
|
||
var color := _terrain_edge_color(terrain_key, neighbor_key)
|
||
if color.a <= 0.0:
|
||
return
|
||
var start := Vector2.ZERO
|
||
var end := Vector2.ZERO
|
||
if offset == Vector2i(0, -1):
|
||
start = rect.position + Vector2(3, 3)
|
||
end = rect.position + Vector2(rect.size.x - 3, 4 + _cell_noise(cell, 73) * 2.0)
|
||
elif offset == Vector2i(1, 0):
|
||
start = rect.position + Vector2(rect.size.x - 4, 3)
|
||
end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3)
|
||
elif offset == Vector2i(0, 1):
|
||
start = rect.position + Vector2(3, rect.size.y - 4)
|
||
end = rect.position + Vector2(rect.size.x - 3, rect.size.y - 3)
|
||
else:
|
||
start = rect.position + Vector2(3, 3)
|
||
end = rect.position + Vector2(4 + _cell_noise(cell, 91) * 2.0, rect.size.y - 3)
|
||
draw_line(start, end, color, _terrain_edge_width(terrain_key, neighbor_key))
|
||
|
||
|
||
func _should_draw_terrain_edge(terrain_key: String, neighbor_key: String, offset: Vector2i) -> bool:
|
||
if neighbor_key.is_empty() or neighbor_key == terrain_key:
|
||
return false
|
||
var priority := _terrain_edge_priority(terrain_key)
|
||
var neighbor_priority := _terrain_edge_priority(neighbor_key)
|
||
if priority != neighbor_priority:
|
||
return priority > neighbor_priority
|
||
return offset.x > 0 or offset.y > 0
|
||
|
||
|
||
func _terrain_edge_priority(terrain_key: String) -> int:
|
||
if terrain_key == "W":
|
||
return 60
|
||
if terrain_key == "C":
|
||
return 50
|
||
if terrain_key == "T":
|
||
return 45
|
||
if terrain_key == "F":
|
||
return 40
|
||
if terrain_key == "H":
|
||
return 30
|
||
if terrain_key == "D":
|
||
return 25
|
||
if terrain_key == "R":
|
||
return 20
|
||
return 10
|
||
|
||
|
||
func _terrain_edge_color(terrain_key: String, neighbor_key: String) -> Color:
|
||
if terrain_key == "F":
|
||
return Color(0.03, 0.16, 0.06, 0.34)
|
||
if terrain_key == "H":
|
||
return Color(0.88, 0.78, 0.48, 0.26)
|
||
if terrain_key == "D":
|
||
return Color(0.82, 0.58, 0.32, 0.28)
|
||
if terrain_key == "W":
|
||
return Color(0.72, 0.90, 1.0, 0.28)
|
||
if terrain_key == "C":
|
||
return Color(0.92, 0.86, 0.72, 0.24)
|
||
if terrain_key == "T":
|
||
return Color(0.92, 0.70, 0.40, 0.26)
|
||
if neighbor_key == "W":
|
||
return Color(0.08, 0.18, 0.20, 0.18)
|
||
return Color(0.96, 0.92, 0.62, 0.12)
|
||
|
||
|
||
func _terrain_edge_width(terrain_key: String, neighbor_key: String) -> float:
|
||
if terrain_key == "W" or neighbor_key == "W":
|
||
return 2.4
|
||
if terrain_key == "C":
|
||
return 2.0
|
||
if terrain_key == "T":
|
||
return 1.8
|
||
return 1.4
|
||
|
||
|
||
func _terrain_key_at(cell: Vector2i) -> String:
|
||
if not state.is_inside(cell):
|
||
return ""
|
||
return state.get_terrain_key(cell)
|
||
|
||
|
||
func _terrain_connection_flags(cell: Vector2i, terrain_key: String) -> Dictionary:
|
||
return {
|
||
"north": _terrain_key_at(cell + Vector2i(0, -1)) == terrain_key,
|
||
"south": _terrain_key_at(cell + Vector2i(0, 1)) == terrain_key,
|
||
"west": _terrain_key_at(cell + Vector2i(-1, 0)) == terrain_key,
|
||
"east": _terrain_key_at(cell + Vector2i(1, 0)) == terrain_key
|
||
}
|
||
|
||
|
||
func _terrain_detail_point(cell: Vector2i, rect: Rect2, salt: int) -> Vector2:
|
||
return rect.position + Vector2(
|
||
10.0 + _cell_noise(cell, salt) * (rect.size.x - 20.0),
|
||
10.0 + _cell_noise(cell, salt + 37) * (rect.size.y - 20.0)
|
||
)
|
||
|
||
|
||
func _cell_noise(cell: Vector2i, salt: int) -> float:
|
||
return fposmod(sin(float(cell.x * 91 + cell.y * 193 + salt * 53)) * 43758.5453, 1.0)
|
||
|
||
|
||
func _draw_objective_seal_marker(rect: Rect2) -> void:
|
||
var seal_rect := rect.grow(-9.0)
|
||
var pole_x := rect.position.x + rect.size.x * 0.24
|
||
var top_y := rect.position.y + 9.0
|
||
var bottom_y := rect.end.y - 8.0
|
||
draw_rect(rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.16))
|
||
draw_line(Vector2(pole_x, top_y), Vector2(pole_x, bottom_y), Color(0.12, 0.065, 0.030, 0.78), 2.2)
|
||
var banner_points := PackedVector2Array([
|
||
Vector2(pole_x + 1.0, top_y + 2.0),
|
||
Vector2(rect.end.x - 10.0, top_y + 8.0),
|
||
Vector2(rect.end.x - 16.0, top_y + 26.0),
|
||
Vector2(pole_x + 1.0, top_y + 24.0)
|
||
])
|
||
var banner_outline := PackedVector2Array([
|
||
banner_points[0],
|
||
banner_points[1],
|
||
banner_points[2],
|
||
banner_points[3],
|
||
banner_points[0]
|
||
])
|
||
draw_colored_polygon(banner_points, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.54))
|
||
draw_polyline(banner_outline, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.64), 1.6)
|
||
draw_rect(seal_rect, Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.24))
|
||
draw_rect(seal_rect.grow(-5.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.76), false, 1.8)
|
||
draw_line(seal_rect.position + Vector2(8, seal_rect.size.y * 0.5), seal_rect.end - Vector2(8, seal_rect.size.y * 0.5), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42), 1.2)
|
||
draw_line(seal_rect.position + Vector2(seal_rect.size.x * 0.5, 8), seal_rect.end - Vector2(seal_rect.size.x * 0.5, 8), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34), 1.2)
|
||
|
||
|
||
func _draw_objective_foreground_markers() -> void:
|
||
var font := _draw_ui_font(true)
|
||
for cell in state.get_objective_cells():
|
||
if not state.is_inside(cell):
|
||
continue
|
||
var rect := _rect_for_cell(cell)
|
||
_draw_objective_corner_brackets(rect)
|
||
var label_rect := Rect2(rect.position + Vector2(rect.size.x - 20.0, 3.0), Vector2(17.0, 16.0))
|
||
draw_rect(label_rect, Color(UI_SEAL_RED_DARK.r, UI_SEAL_RED_DARK.g, UI_SEAL_RED_DARK.b, 0.86))
|
||
draw_rect(label_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.88), false, 1.2)
|
||
_draw_ink_text(font, label_rect.position + Vector2(0, 12), "목", HORIZONTAL_ALIGNMENT_CENTER, label_rect.size.x, 11, UI_PARCHMENT_TEXT)
|
||
|
||
|
||
func _draw_objective_corner_brackets(rect: Rect2) -> void:
|
||
var bracket := 12.0
|
||
var inset := 4.0
|
||
var line_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.95)
|
||
var shadow := Color(0.02, 0.012, 0.006, 0.88)
|
||
var corners := [
|
||
rect.position + Vector2(inset, inset),
|
||
Vector2(rect.end.x - inset, rect.position.y + inset),
|
||
rect.end - Vector2(inset, inset),
|
||
Vector2(rect.position.x + inset, rect.end.y - inset)
|
||
]
|
||
_draw_objective_corner(corners[0], Vector2.RIGHT, Vector2.DOWN, bracket, shadow, 4.0)
|
||
_draw_objective_corner(corners[1], Vector2.LEFT, Vector2.DOWN, bracket, shadow, 4.0)
|
||
_draw_objective_corner(corners[2], Vector2.LEFT, Vector2.UP, bracket, shadow, 4.0)
|
||
_draw_objective_corner(corners[3], Vector2.RIGHT, Vector2.UP, bracket, shadow, 4.0)
|
||
_draw_objective_corner(corners[0], Vector2.RIGHT, Vector2.DOWN, bracket, line_color, 2.0)
|
||
_draw_objective_corner(corners[1], Vector2.LEFT, Vector2.DOWN, bracket, line_color, 2.0)
|
||
_draw_objective_corner(corners[2], Vector2.LEFT, Vector2.UP, bracket, line_color, 2.0)
|
||
_draw_objective_corner(corners[3], Vector2.RIGHT, Vector2.UP, bracket, line_color, 2.0)
|
||
|
||
|
||
func _draw_objective_corner(origin: Vector2, x_dir: Vector2, y_dir: Vector2, length: float, color: Color, width: float) -> void:
|
||
draw_line(origin, origin + x_dir * length, color, width)
|
||
draw_line(origin, origin + y_dir * length, color, width)
|
||
|
||
|
||
func _draw_overlays() -> void:
|
||
for cell in state.get_objective_cells():
|
||
_draw_objective_seal_marker(_rect_for_cell(cell))
|
||
|
||
for cell in threat_cells:
|
||
var threat_rect := _rect_for_cell(cell)
|
||
draw_rect(threat_rect, THREAT_OVERLAY_COLOR)
|
||
draw_rect(threat_rect.grow(-6.0), THREAT_BORDER_COLOR, false, 1.5)
|
||
|
||
for cell in move_cells:
|
||
var move_rect := _rect_for_cell(cell)
|
||
draw_rect(move_rect, MOVE_OVERLAY_COLOR)
|
||
draw_rect(move_rect.grow(-8.0), MOVE_BORDER_COLOR, false, 1.2)
|
||
|
||
if _has_pending_move() and state.is_inside(pending_move_to_cell):
|
||
var pending_rect := _rect_for_cell(pending_move_to_cell)
|
||
draw_rect(pending_rect.grow(-4.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.34))
|
||
draw_rect(pending_rect.grow(-7.0), Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.72), false, 2.0)
|
||
|
||
for cell in attack_cells:
|
||
var attack_rect := _rect_for_cell(cell)
|
||
draw_rect(attack_rect, ATTACK_OVERLAY_COLOR)
|
||
draw_rect(attack_rect.grow(-7.0), ATTACK_BORDER_COLOR, false, 1.4)
|
||
|
||
for cell in skill_cells:
|
||
var skill_rect := _rect_for_cell(cell)
|
||
draw_rect(skill_rect, SKILL_OVERLAY_COLOR)
|
||
draw_rect(skill_rect.grow(-8.0), Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.54), false, 1.2)
|
||
|
||
if not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
|
||
var area_selected := state.get_selected_unit()
|
||
if not area_selected.is_empty():
|
||
for cell in state.get_skill_area_cells(area_selected["id"], selected_skill_id, hover_cell):
|
||
var area_rect := _rect_for_cell(cell)
|
||
draw_rect(area_rect, SKILL_AREA_OVERLAY_COLOR)
|
||
draw_rect(area_rect.grow(-7.0), Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.62), false, 1.5)
|
||
|
||
for cell in item_cells:
|
||
var item_rect := _rect_for_cell(cell)
|
||
draw_rect(item_rect, ITEM_OVERLAY_COLOR)
|
||
draw_rect(item_rect.grow(-8.0), ITEM_BORDER_COLOR, false, 1.2)
|
||
|
||
if formation_menu != null and formation_menu.visible:
|
||
for cell in state.get_formation_cells():
|
||
var formation_rect := _rect_for_cell(cell)
|
||
draw_rect(formation_rect, FORMATION_OVERLAY_COLOR)
|
||
draw_rect(formation_rect.grow(-7.0), Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.58), false, 1.4)
|
||
|
||
if state.is_inside(hover_cell):
|
||
var hover_color := Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.42)
|
||
var hover_unit := state.get_unit_at(hover_cell)
|
||
var selected := state.get_selected_unit()
|
||
if formation_menu != null and formation_menu.visible and state.is_formation_cell(hover_cell):
|
||
hover_color = Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.66)
|
||
elif not selected_skill_id.is_empty() and skill_cells.has(hover_cell):
|
||
hover_color = Color(UI_MUTED_JADE.r, UI_MUTED_JADE.g, UI_MUTED_JADE.b, 0.58)
|
||
elif not selected_item_id.is_empty() and item_cells.has(hover_cell):
|
||
hover_color = Color(0.52, 0.70, 0.30, 0.54)
|
||
elif not hover_unit.is_empty() and not selected.is_empty() and hover_unit.get("team", "") != selected.get("team", ""):
|
||
hover_color = Color(UI_SEAL_RED.r, UI_SEAL_RED.g, UI_SEAL_RED.b, 0.52)
|
||
draw_rect(_rect_for_cell(hover_cell), hover_color, false, 3.0)
|
||
|
||
|
||
func _draw_units() -> void:
|
||
for unit in state.units:
|
||
if not unit.get("alive", false) or not unit.get("deployed", true):
|
||
continue
|
||
|
||
var rect := _visual_rect_for_unit(unit)
|
||
var center := rect.position + rect.size * 0.5
|
||
var team_color := PLAYER_COLOR if unit.get("team", "") == BattleState.TEAM_PLAYER else ENEMY_COLOR
|
||
var acted := bool(unit.get("acted", false))
|
||
var sprite_modulate := _unit_sprite_modulate(unit, acted)
|
||
|
||
draw_circle(center + Vector2(0, 17), 20, Color(0.0, 0.0, 0.0, 0.30))
|
||
_draw_unit_class_emblem(rect, center, unit, team_color)
|
||
_draw_pixel_unit_sprite(rect, unit, team_color, sprite_modulate)
|
||
_draw_unit_identity_marks(rect, center, unit, team_color)
|
||
_draw_unit_class_badge(rect, unit, team_color)
|
||
|
||
var hp_ratio := float(unit["hp"]) / float(max(1, int(unit["max_hp"])))
|
||
var hp_back := Rect2(rect.position + Vector2(8, TILE_SIZE - 8), Vector2(TILE_SIZE - 16, 5))
|
||
var hp_front := Rect2(hp_back.position, Vector2(hp_back.size.x * hp_ratio, hp_back.size.y))
|
||
draw_rect(hp_back, Color(0.08, 0.08, 0.09))
|
||
draw_rect(hp_front, _unit_hp_color(hp_ratio))
|
||
_draw_unit_status_markers(center, hp_ratio, unit)
|
||
|
||
if unit.get("id", "") == state.selected_unit_id:
|
||
draw_arc(center, 29, 0.0, TAU, 48, Color(1.0, 0.92, 0.25), 3.0)
|
||
_draw_unit_nameplate(rect, _short_name(String(unit["name"])))
|
||
|
||
|
||
func _draw_target_selection_markers() -> void:
|
||
if not _is_targeting_mode():
|
||
return
|
||
var font := _draw_ui_font(true)
|
||
for marker in _target_selection_marker_entries():
|
||
var cell: Vector2i = marker.get("cell", Vector2i(-1, -1))
|
||
if not state.is_inside(cell):
|
||
continue
|
||
var rect := _rect_for_cell(cell)
|
||
var marker_rect := Rect2(rect.position + Vector2(7.0, TILE_SIZE - 24.0), Vector2(TILE_SIZE - 14.0, 18.0))
|
||
var color := _target_preview_badge_color(str(marker.get("kind", "damage")))
|
||
draw_rect(rect.grow(-3.0), Color(color.r, color.g, color.b, 0.18))
|
||
draw_rect(rect.grow(-6.0), color, false, 2.2)
|
||
draw_rect(marker_rect, Color(0.025, 0.028, 0.034, 0.88))
|
||
draw_rect(marker_rect, color, false, 1.4)
|
||
_draw_ink_text(font, marker_rect.position + Vector2(0, 13), str(marker.get("text", "표")), HORIZONTAL_ALIGNMENT_CENTER, marker_rect.size.x, 12, color)
|
||
|
||
|
||
func _draw_unit_nameplate(rect: Rect2, label: String) -> void:
|
||
if label.is_empty():
|
||
return
|
||
var font := _draw_ui_font(true)
|
||
var label_rect := Rect2(rect.position + Vector2(4.0, TILE_SIZE - 21.0), Vector2(TILE_SIZE - 8.0, 13.0))
|
||
draw_rect(label_rect, Color(0.025, 0.018, 0.012, 0.76))
|
||
draw_rect(label_rect, Color(UI_OLD_BRONZE.r, UI_OLD_BRONZE.g, UI_OLD_BRONZE.b, 0.48), false, 1.0)
|
||
_draw_ink_text(font, label_rect.position + Vector2(0, 10), label, HORIZONTAL_ALIGNMENT_CENTER, label_rect.size.x, 9, UI_PARCHMENT_TEXT)
|
||
|
||
|
||
func _target_selection_marker_entries() -> Array[Dictionary]:
|
||
if basic_attack_targeting:
|
||
return _attack_target_marker_entries()
|
||
if not selected_skill_id.is_empty():
|
||
return _skill_target_marker_entries()
|
||
if not selected_item_id.is_empty():
|
||
return _item_target_marker_entries()
|
||
return []
|
||
|
||
|
||
func _attack_target_marker_entries() -> Array[Dictionary]:
|
||
var result: Array[Dictionary] = []
|
||
if not basic_attack_targeting:
|
||
return result
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return result
|
||
for cell in attack_cells:
|
||
var target := state.get_unit_at(cell)
|
||
if target.is_empty() or target.get("team", "") == selected.get("team", ""):
|
||
continue
|
||
var preview := state.get_damage_preview(str(selected.get("id", "")), str(target.get("id", "")))
|
||
if preview.is_empty() or not bool(preview.get("in_range", false)) or bool(preview.get("attack_locked", false)):
|
||
continue
|
||
var hit_chance := int(preview.get("hit_chance", 100))
|
||
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
|
||
result.append({
|
||
"cell": cell,
|
||
"target_id": str(target.get("id", "")),
|
||
"text": "%s %d%%" % [result_text, hit_chance],
|
||
"kind": _attack_preview_badge_kind(preview)
|
||
})
|
||
return result
|
||
|
||
|
||
func _skill_target_marker_entries() -> Array[Dictionary]:
|
||
var result: Array[Dictionary] = []
|
||
if selected_skill_id.is_empty():
|
||
return result
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return result
|
||
for cell in skill_cells:
|
||
var target := state.get_unit_at(cell)
|
||
var badge := _skill_target_preview_badge_for_cell(selected, target, cell)
|
||
if badge.is_empty() or str(badge.get("kind", "")) == "invalid":
|
||
continue
|
||
result.append({
|
||
"cell": cell,
|
||
"target_id": str(target.get("id", "")) if not target.is_empty() else "",
|
||
"text": str(badge.get("text", "표")),
|
||
"kind": str(badge.get("kind", "support"))
|
||
})
|
||
return result
|
||
|
||
|
||
func _item_target_marker_entries() -> Array[Dictionary]:
|
||
var result: Array[Dictionary] = []
|
||
if selected_item_id.is_empty():
|
||
return result
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return result
|
||
for cell in item_cells:
|
||
var target := state.get_unit_at(cell)
|
||
var badge := _item_target_preview_badge_for_cell(selected, target, cell)
|
||
if badge.is_empty() or str(badge.get("kind", "")) == "invalid":
|
||
continue
|
||
result.append({
|
||
"cell": cell,
|
||
"target_id": str(target.get("id", "")) if not target.is_empty() else "",
|
||
"text": str(badge.get("text", "표")),
|
||
"kind": str(badge.get("kind", "support"))
|
||
})
|
||
return result
|
||
|
||
|
||
func _has_basic_attack_target(unit_id: String) -> bool:
|
||
var unit := state.get_unit(unit_id)
|
||
if unit.is_empty():
|
||
return false
|
||
for cell in state.get_attack_cells(unit_id):
|
||
var target := state.get_unit_at(cell)
|
||
if target.is_empty() or target.get("team", "") == unit.get("team", ""):
|
||
continue
|
||
var preview := state.get_damage_preview(unit_id, str(target.get("id", "")))
|
||
if not preview.is_empty() and bool(preview.get("in_range", false)) and not bool(preview.get("attack_locked", false)):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _fit_texture_rect(texture: Texture2D, target: Rect2) -> Rect2:
|
||
var texture_size := texture.get_size()
|
||
if texture_size.x <= 0.0 or texture_size.y <= 0.0:
|
||
return target
|
||
var scale := minf(target.size.x / texture_size.x, target.size.y / texture_size.y)
|
||
var fit_size := texture_size * scale
|
||
return Rect2(target.position + (target.size - fit_size) * 0.5, fit_size)
|
||
|
||
|
||
func _unit_sprite_modulate(unit: Dictionary, acted: bool) -> Color:
|
||
if acted:
|
||
return Color(0.60, 0.62, 0.66, 0.82)
|
||
if bool(unit.get("uses_enemy_sprite", false)):
|
||
return Color(1.0, 1.0, 1.0, 0.98)
|
||
if _is_generic_enemy_unit(unit):
|
||
return Color(1.0, 0.82, 0.74, 0.94)
|
||
return Color.WHITE
|
||
|
||
|
||
func _draw_pixel_unit_sprite(rect: Rect2, unit: Dictionary, team_color: Color, modulate: Color) -> void:
|
||
var profile := _pixel_unit_sprite_profile(unit)
|
||
var family := str(profile.get("family", _unit_class_family(unit)))
|
||
var phase := _unit_idle_phase(unit)
|
||
var bob := _unit_idle_bob(family, phase)
|
||
var step := _unit_idle_step(family, phase)
|
||
var pixel := float(profile.get("pixel", 3.0))
|
||
var origin := Vector2(roundf(rect.position.x + 12.0), roundf(rect.position.y + 5.0 + bob))
|
||
var outline := _unit_pixel_color(Color(0.035, 0.026, 0.018, 1.0), modulate)
|
||
var skin := _unit_pixel_color(Color(0.86, 0.62, 0.38, 1.0), modulate)
|
||
var cloth := _unit_pixel_color(team_color.lightened(0.05), modulate)
|
||
var cloth_dark := _unit_pixel_color(team_color.darkened(0.34), modulate)
|
||
var class_color := _unit_pixel_color(_unit_class_badge_color(unit, team_color), modulate)
|
||
var accent := _unit_pixel_color(Color(0.98, 0.76, 0.28, 1.0) if not str(unit.get("officer_id", "")).is_empty() else class_color.lightened(0.25), modulate)
|
||
|
||
_draw_pixel_rect(origin, 2, 11, 8, 2, pixel, Color(0.0, 0.0, 0.0, 0.22 * modulate.a))
|
||
if family == "cavalry":
|
||
_draw_pixel_cavalry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||
elif family == "ranged":
|
||
_draw_pixel_archer(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||
elif family == "tactic":
|
||
_draw_pixel_strategist(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||
elif family == "heavy":
|
||
_draw_pixel_heavy(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||
else:
|
||
_draw_pixel_infantry(origin, pixel, outline, skin, cloth, cloth_dark, class_color, accent, step)
|
||
|
||
|
||
func _unit_idle_phase(unit: Dictionary) -> float:
|
||
var seed := 0
|
||
var unit_id := str(unit.get("id", "unit"))
|
||
for index in range(unit_id.length()):
|
||
seed += unit_id.unicode_at(index) * (index + 1)
|
||
return float(Time.get_ticks_msec()) * 0.0024 + float(seed % 360) * 0.01745
|
||
|
||
|
||
func _unit_idle_bob(family: String, phase: float) -> float:
|
||
if family == "cavalry":
|
||
return roundf(sin(phase * 1.55) * 0.75)
|
||
if family == "ranged":
|
||
return roundf(sin(phase * 1.10) * 0.75)
|
||
if family == "tactic":
|
||
return roundf(sin(phase * 0.82) * 0.65)
|
||
if family == "heavy":
|
||
return roundf(sin(phase * 0.72) * 0.45)
|
||
return roundf(sin(phase) * 1.15)
|
||
|
||
|
||
func _unit_idle_step(family: String, phase: float) -> float:
|
||
var rate := 1.65
|
||
if family == "cavalry":
|
||
rate = 2.15
|
||
elif family == "ranged":
|
||
rate = 1.25
|
||
elif family == "tactic":
|
||
rate = 0.95
|
||
elif family == "heavy":
|
||
rate = 0.78
|
||
return 1.0 if sin(phase * rate) >= 0.0 else -1.0
|
||
|
||
|
||
func _pixel_unit_sprite_profile(unit: Dictionary) -> Dictionary:
|
||
var family := _unit_class_family(unit)
|
||
if family == "cavalry":
|
||
return {
|
||
"family": family,
|
||
"pixel": 3.0,
|
||
"min_x": 0.0,
|
||
"max_x": 15.0,
|
||
"max_y": 14.0,
|
||
"weapon": "banner",
|
||
"idle": "hoof_cycle"
|
||
}
|
||
if family == "ranged":
|
||
return {
|
||
"family": family,
|
||
"pixel": 3.0,
|
||
"min_x": 1.0,
|
||
"max_x": 14.0,
|
||
"max_y": 13.0,
|
||
"weapon": "bow",
|
||
"idle": "bow_sway"
|
||
}
|
||
if family == "tactic":
|
||
return {
|
||
"family": family,
|
||
"pixel": 3.0,
|
||
"min_x": 1.0,
|
||
"max_x": 13.0,
|
||
"max_y": 14.0,
|
||
"weapon": "staff",
|
||
"idle": "robe_pulse"
|
||
}
|
||
if family == "heavy":
|
||
return {
|
||
"family": family,
|
||
"pixel": 3.0,
|
||
"min_x": 1.0,
|
||
"max_x": 14.0,
|
||
"max_y": 14.0,
|
||
"weapon": "heavy_blade",
|
||
"idle": "weight_shift"
|
||
}
|
||
return {
|
||
"family": family,
|
||
"pixel": 3.0,
|
||
"min_x": 1.0,
|
||
"max_x": 12.0,
|
||
"max_y": 13.0,
|
||
"weapon": "shield_spear",
|
||
"idle": "march_step"
|
||
}
|
||
|
||
|
||
func _unit_pixel_color(color: Color, modulate: Color) -> Color:
|
||
return Color(
|
||
color.r * modulate.r,
|
||
color.g * modulate.g,
|
||
color.b * modulate.b,
|
||
color.a * modulate.a
|
||
)
|
||
|
||
|
||
func _draw_pixel_rect(origin: Vector2, x: float, y: float, width: float, height: float, pixel: float, color: Color) -> void:
|
||
draw_rect(Rect2(origin + Vector2(x, y) * pixel, Vector2(width, height) * pixel), color)
|
||
|
||
|
||
func _draw_pixel_shield(origin: Vector2, x: float, y: float, pixel: float, outline: Color, face: Color, trim: Color) -> void:
|
||
_draw_pixel_rect(origin, x + 1, y, 2, 1, pixel, outline)
|
||
_draw_pixel_rect(origin, x, y + 1, 4, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, x + 1, y + 4, 2, 1, pixel, outline)
|
||
_draw_pixel_rect(origin, x + 1, y + 1, 2, 3, pixel, face)
|
||
_draw_pixel_rect(origin, x + 2, y + 1, 1, 3, pixel, trim)
|
||
|
||
|
||
func _draw_pixel_spear(origin: Vector2, x: float, y: float, pixel: float, shaft: Color, blade: Color) -> void:
|
||
_draw_pixel_rect(origin, x, y, 1, 9, pixel, shaft)
|
||
_draw_pixel_rect(origin, x - 1, y - 1, 3, 1, pixel, blade)
|
||
_draw_pixel_rect(origin, x, y - 2, 1, 1, pixel, blade.lightened(0.20))
|
||
|
||
|
||
func _draw_pixel_bow(origin: Vector2, x: float, y: float, pixel: float, wood: Color, string_color: Color, arrow: Color) -> void:
|
||
_draw_pixel_rect(origin, x + 1, y, 2, 1, pixel, wood)
|
||
_draw_pixel_rect(origin, x + 3, y + 1, 1, 2, pixel, wood)
|
||
_draw_pixel_rect(origin, x + 4, y + 3, 1, 3, pixel, wood)
|
||
_draw_pixel_rect(origin, x + 3, y + 6, 1, 2, pixel, wood)
|
||
_draw_pixel_rect(origin, x + 1, y + 8, 2, 1, pixel, wood)
|
||
_draw_pixel_rect(origin, x + 1, y + 1, 1, 7, pixel, string_color)
|
||
_draw_pixel_rect(origin, x - 3, y + 4, 6, 1, pixel, arrow)
|
||
_draw_pixel_rect(origin, x + 3, y + 3, 1, 3, pixel, arrow.lightened(0.10))
|
||
|
||
|
||
func _draw_pixel_horse_tack(origin: Vector2, pixel: float, cloth: Color, trim: Color) -> void:
|
||
_draw_pixel_rect(origin, 4, 6, 4, 1, pixel, trim)
|
||
_draw_pixel_rect(origin, 4, 7, 4, 2, pixel, cloth)
|
||
_draw_pixel_rect(origin, 8, 7, 1, 2, pixel, trim)
|
||
|
||
|
||
func _draw_pixel_banner(origin: Vector2, x: float, y: float, pixel: float, pole: Color, cloth: Color, trim: Color) -> void:
|
||
_draw_pixel_rect(origin, x, y, 1, 9, pixel, pole)
|
||
_draw_pixel_rect(origin, x + 1, y, 3, 2, pixel, cloth)
|
||
_draw_pixel_rect(origin, x + 1, y + 2, 2, 1, pixel, cloth.darkened(0.12))
|
||
_draw_pixel_rect(origin, x + 3, y + 2, 1, 1, pixel, trim)
|
||
|
||
|
||
func _draw_pixel_staff(origin: Vector2, x: float, y: float, pixel: float, staff: Color, tassel: Color) -> void:
|
||
_draw_pixel_rect(origin, x, y, 1, 9, pixel, staff)
|
||
_draw_pixel_rect(origin, x - 1, y, 3, 1, pixel, tassel)
|
||
_draw_pixel_rect(origin, x, y - 1, 1, 1, pixel, tassel.lightened(0.18))
|
||
_draw_pixel_rect(origin, x + 1, y + 2, 1, 2, pixel, tassel.darkened(0.18))
|
||
|
||
|
||
func _draw_pixel_heavy_blade(origin: Vector2, x: float, y: float, pixel: float, handle: Color, blade: Color) -> void:
|
||
_draw_pixel_rect(origin, x, y + 1, 1, 7, pixel, handle)
|
||
_draw_pixel_rect(origin, x - 1, y, 3, 1, pixel, handle.darkened(0.10))
|
||
_draw_pixel_rect(origin, x - 2, y - 1, 3, 2, pixel, blade)
|
||
_draw_pixel_rect(origin, x - 3, y, 1, 2, pixel, blade.darkened(0.10))
|
||
_draw_pixel_rect(origin, x - 1, y + 1, 2, 1, pixel, blade.lightened(0.16))
|
||
|
||
|
||
func _draw_pixel_infantry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
|
||
_draw_pixel_rect(origin, 4, 1, 4, 1, pixel, accent)
|
||
_draw_pixel_rect(origin, 4, 2, 4, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
|
||
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, outline)
|
||
_draw_pixel_rect(origin, 4, 5, 4, 4, pixel, cloth)
|
||
_draw_pixel_rect(origin, 5, 6, 2, 3, pixel, class_color)
|
||
_draw_pixel_shield(origin, 1, 5, pixel, outline, class_color.darkened(0.12), accent)
|
||
_draw_pixel_spear(origin, 10 + step, 2, pixel, accent.darkened(0.12), accent.lightened(0.18))
|
||
_draw_pixel_rect(origin, 4 + step, 10, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 7 - step, 10, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 4 + step, 10, 2, 1, pixel, cloth_dark)
|
||
_draw_pixel_rect(origin, 7 - step, 10, 2, 1, pixel, cloth_dark)
|
||
|
||
|
||
func _draw_pixel_archer(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
|
||
_draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 4, 2, 4, 1, pixel, cloth_dark)
|
||
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
|
||
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, outline)
|
||
_draw_pixel_rect(origin, 4, 5, 4, 4, pixel, cloth)
|
||
_draw_pixel_rect(origin, 2, 6, 2, 2, pixel, cloth_dark)
|
||
_draw_pixel_rect(origin, 5, 6, 2, 3, pixel, class_color)
|
||
_draw_pixel_rect(origin, 4 + step, 10, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 7 - step, 10, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 1, 4, 1, 5, pixel, accent.darkened(0.22))
|
||
_draw_pixel_rect(origin, 1, 5, 2, 1, pixel, accent)
|
||
_draw_pixel_bow(origin, 8 + step, 2, pixel, accent, outline.lightened(0.28), accent.lightened(0.16))
|
||
|
||
|
||
func _draw_pixel_cavalry(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
|
||
var horse := _unit_pixel_color(Color(0.33, 0.20, 0.11, 1.0), Color(1.0, 1.0, 1.0, outline.a))
|
||
var mane := _unit_pixel_color(Color(0.12, 0.075, 0.04, 1.0), Color(1.0, 1.0, 1.0, outline.a))
|
||
_draw_pixel_rect(origin, 0, 8, 10, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 1, 8, 8, 2, pixel, horse)
|
||
_draw_pixel_rect(origin, 8, 6, 4, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 8, 7, 3, 2, pixel, horse)
|
||
_draw_pixel_rect(origin, 7, 6, 1, 3, pixel, mane)
|
||
_draw_pixel_rect(origin, 0, 7, 2, 1, pixel, mane)
|
||
_draw_pixel_rect(origin, 1 + step, 11, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 4 - step, 11, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 7 + step, 11, 2, 3, pixel, outline)
|
||
_draw_pixel_horse_tack(origin, pixel, class_color, accent)
|
||
_draw_pixel_rect(origin, 4, 2, 4, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
|
||
_draw_pixel_rect(origin, 4, 5, 4, 3, pixel, cloth)
|
||
_draw_pixel_rect(origin, 5, 5, 2, 2, pixel, class_color)
|
||
_draw_pixel_banner(origin, 11, 0, pixel, accent.darkened(0.22), class_color, accent)
|
||
|
||
|
||
func _draw_pixel_strategist(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
|
||
_draw_pixel_rect(origin, 4, 0, 4, 1, pixel, class_color.lightened(0.20))
|
||
_draw_pixel_rect(origin, 3, 1, 6, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
|
||
_draw_pixel_rect(origin, 3, 5, 6, 7, pixel, outline)
|
||
_draw_pixel_rect(origin, 4, 5, 4, 6, pixel, cloth)
|
||
_draw_pixel_rect(origin, 3, 7, 2, 3, pixel, class_color)
|
||
_draw_pixel_rect(origin, 7, 7, 2, 3, pixel, class_color.darkened(0.08))
|
||
_draw_pixel_rect(origin, 4 + step, 12, 2, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 7 - step, 12, 2, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 1, 6, 2, 4, pixel, cloth_dark)
|
||
_draw_pixel_rect(origin, 2, 10, 3, 1, pixel, accent.lightened(0.10))
|
||
_draw_pixel_staff(origin, 10 + step, 3, pixel, accent.darkened(0.22), accent)
|
||
|
||
|
||
func _draw_pixel_heavy(origin: Vector2, pixel: float, outline: Color, skin: Color, cloth: Color, cloth_dark: Color, class_color: Color, accent: Color, step: float) -> void:
|
||
_draw_pixel_rect(origin, 4, 1, 4, 2, pixel, outline)
|
||
_draw_pixel_rect(origin, 3, 2, 6, 1, pixel, accent.darkened(0.08))
|
||
_draw_pixel_rect(origin, 5, 3, 2, 2, pixel, skin)
|
||
_draw_pixel_rect(origin, 2, 5, 8, 6, pixel, outline)
|
||
_draw_pixel_rect(origin, 3, 5, 6, 5, pixel, cloth)
|
||
_draw_pixel_rect(origin, 4, 6, 4, 4, pixel, class_color)
|
||
_draw_pixel_rect(origin, 1, 6, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 9, 6, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 3 + step, 11, 2, 3, pixel, outline)
|
||
_draw_pixel_rect(origin, 8 - step, 11, 2, 3, pixel, outline)
|
||
_draw_pixel_heavy_blade(origin, 11 + step, 2, pixel, accent.darkened(0.22), accent.lightened(0.12))
|
||
|
||
|
||
func _draw_unit_identity_marks(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void:
|
||
if _is_generic_enemy_unit(unit):
|
||
var sash := ENEMY_COLOR.lightened(0.22)
|
||
sash.a = 0.48
|
||
draw_line(rect.position + Vector2(12, 48), rect.position + Vector2(50, 14), sash, 4.0)
|
||
draw_circle(center + Vector2(18, -17), 5.0, Color(0.05, 0.02, 0.02, 0.78))
|
||
draw_circle(center + Vector2(18, -17), 3.2, ENEMY_COLOR.lightened(0.18))
|
||
return
|
||
var officer_id := str(unit.get("officer_id", ""))
|
||
if not officer_id.is_empty():
|
||
draw_circle(center + Vector2(18, -17), 5.0, Color(0.06, 0.045, 0.02, 0.82))
|
||
draw_circle(center + Vector2(18, -17), 3.0, Color(1.0, 0.84, 0.34, 0.88))
|
||
draw_arc(center, 25.0, -PI * 0.18, PI * 0.32, 18, team_color.lightened(0.34), 1.8)
|
||
|
||
|
||
func _is_generic_enemy_unit(unit: Dictionary) -> bool:
|
||
return unit.get("team", "") == BattleState.TEAM_ENEMY and str(unit.get("officer_id", "")).is_empty()
|
||
|
||
|
||
func _draw_unit_class_badge(rect: Rect2, unit: Dictionary, team_color: Color) -> void:
|
||
var badge_rect := Rect2(rect.position + Vector2(4, 4), Vector2(20, 20))
|
||
var class_color := _unit_class_badge_color(unit, team_color)
|
||
draw_rect(badge_rect, Color(0.035, 0.018, 0.012, 0.88))
|
||
draw_rect(badge_rect.grow(-2.0), Color(class_color.r, class_color.g, class_color.b, 0.78))
|
||
draw_rect(badge_rect, UI_OLD_BRONZE, false, 1.3)
|
||
var font := _draw_ui_font(true)
|
||
draw_string(font, badge_rect.position + Vector2(0, 15), _unit_class_abbrev(unit), HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 13, UI_PARCHMENT_TEXT)
|
||
|
||
|
||
func _draw_unit_class_emblem(rect: Rect2, center: Vector2, unit: Dictionary, team_color: Color) -> void:
|
||
var family := _unit_class_family(unit)
|
||
var class_color := _unit_class_badge_color(unit, team_color)
|
||
var emblem := class_color.lightened(0.36)
|
||
emblem.a = 0.72
|
||
var shadow := Color(0.02, 0.016, 0.012, 0.72)
|
||
if family == "cavalry":
|
||
var pole_start := center + Vector2(16, -24)
|
||
var pole_end := center + Vector2(16, 16)
|
||
draw_line(pole_start + Vector2(1, 1), pole_end + Vector2(1, 1), shadow, 2.2)
|
||
draw_line(pole_start, pole_end, emblem, 2.0)
|
||
var pennant := PackedVector2Array([
|
||
pole_start,
|
||
pole_start + Vector2(22, 7),
|
||
pole_start + Vector2(5, 16)
|
||
])
|
||
draw_colored_polygon(pennant, Color(class_color.r, class_color.g, class_color.b, 0.50))
|
||
draw_polyline(PackedVector2Array([pennant[0], pennant[1], pennant[2], pennant[0]]), emblem, 1.2)
|
||
elif family == "ranged":
|
||
var bow_center := center + Vector2(-17, -5)
|
||
draw_arc(bow_center + Vector2(1, 1), 17.0, -PI * 0.55, PI * 0.55, 24, shadow, 2.4)
|
||
draw_arc(bow_center, 17.0, -PI * 0.55, PI * 0.55, 24, emblem, 2.2)
|
||
draw_line(bow_center + Vector2(6, -14), bow_center + Vector2(6, 14), Color(0.92, 0.82, 0.56, 0.58), 1.2)
|
||
draw_line(center + Vector2(-14, -5), center + Vector2(8, -5), emblem, 1.6)
|
||
elif family == "tactic":
|
||
draw_arc(center + Vector2(0, -3), 20.0, 0.0, TAU, 40, Color(emblem.r, emblem.g, emblem.b, 0.42), 1.8)
|
||
draw_arc(center + Vector2(0, -3), 12.0, -PI * 0.25, PI * 1.25, 30, emblem, 1.6)
|
||
draw_line(center + Vector2(-11, -3), center + Vector2(11, -3), Color(1.0, 0.88, 0.38, 0.42), 1.2)
|
||
draw_line(center + Vector2(0, -14), center + Vector2(0, 8), Color(1.0, 0.88, 0.38, 0.32), 1.2)
|
||
elif family == "heavy":
|
||
draw_line(center + Vector2(-19, -16), center + Vector2(17, 18), shadow, 4.4)
|
||
draw_line(center + Vector2(-19, -16), center + Vector2(17, 18), emblem, 3.0)
|
||
draw_line(center + Vector2(14, -17), center + Vector2(-16, 16), Color(1.0, 0.82, 0.32, 0.48), 2.4)
|
||
draw_circle(center + Vector2(15, 18), 4.5, Color(class_color.r, class_color.g, class_color.b, 0.42))
|
||
else:
|
||
var shield := Rect2(center + Vector2(-13, -17), Vector2(26, 28))
|
||
draw_rect(Rect2(shield.position + Vector2(1, 1), shield.size), shadow)
|
||
draw_rect(shield, Color(class_color.r, class_color.g, class_color.b, 0.38))
|
||
draw_rect(shield.grow(-4.0), Color(0.82, 0.76, 0.58, 0.28), false, 1.4)
|
||
draw_line(shield.position + Vector2(shield.size.x * 0.5, 3), shield.position + Vector2(shield.size.x * 0.5, shield.size.y - 3), emblem, 1.6)
|
||
|
||
|
||
func _unit_class_abbrev(unit: Dictionary) -> String:
|
||
var family := _unit_class_family(unit)
|
||
if family == "cavalry":
|
||
return "기"
|
||
if family == "ranged":
|
||
return "궁"
|
||
if family == "tactic":
|
||
return "책"
|
||
if family == "heavy":
|
||
return "무"
|
||
return "보"
|
||
|
||
|
||
func _unit_class_family(unit: Dictionary) -> String:
|
||
var class_id := str(unit.get("class_id", ""))
|
||
if class_id.contains("cavalry"):
|
||
return "cavalry"
|
||
if class_id.contains("archer") or class_id.contains("marksman"):
|
||
return "ranged"
|
||
if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"):
|
||
return "tactic"
|
||
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
|
||
return "heavy"
|
||
return "infantry"
|
||
|
||
|
||
func _unit_class_badge_color(unit: Dictionary, team_color: Color) -> Color:
|
||
var family := _unit_class_family(unit)
|
||
if family == "cavalry":
|
||
return Color(0.78, 0.34, 0.16, 1.0)
|
||
if family == "ranged":
|
||
return Color(0.16, 0.42, 0.26, 1.0)
|
||
if family == "tactic":
|
||
return Color(0.32, 0.26, 0.62, 1.0)
|
||
if family == "heavy":
|
||
return Color(0.62, 0.16, 0.12, 1.0)
|
||
return team_color.darkened(0.10)
|
||
|
||
|
||
func _unit_hp_color(hp_ratio: float) -> Color:
|
||
if hp_ratio <= LOW_HP_WARNING_RATIO:
|
||
return Color(1.0, 0.24, 0.18)
|
||
if hp_ratio <= 0.55:
|
||
return Color(1.0, 0.72, 0.24)
|
||
return Color(0.28, 0.82, 0.36)
|
||
|
||
|
||
func _draw_unit_status_markers(center: Vector2, hp_ratio: float, unit: Dictionary) -> void:
|
||
if hp_ratio <= LOW_HP_WARNING_RATIO:
|
||
draw_arc(center, 27, 0.0, TAU, 48, Color(1.0, 0.28, 0.14), 3.0)
|
||
|
||
var marker_kinds := _unit_status_marker_kinds(unit)
|
||
if marker_kinds.is_empty():
|
||
return
|
||
|
||
var start_x := -float(marker_kinds.size() - 1) * UNIT_STATUS_MARKER_GAP * 0.5
|
||
var font := _draw_ui_font(true)
|
||
for index in range(marker_kinds.size()):
|
||
var marker_kind := marker_kinds[index]
|
||
var marker_center := center + Vector2(start_x + float(index) * UNIT_STATUS_MARKER_GAP, UNIT_STATUS_MARKER_OFFSET_Y)
|
||
draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS + 1.5, Color(0.02, 0.022, 0.028, 0.88))
|
||
draw_circle(marker_center, UNIT_STATUS_MARKER_RADIUS, _unit_status_marker_color(marker_kind))
|
||
_draw_ink_text(
|
||
font,
|
||
marker_center + Vector2(-UNIT_STATUS_MARKER_RADIUS, 3.0),
|
||
_unit_status_marker_glyph(marker_kind),
|
||
HORIZONTAL_ALIGNMENT_CENTER,
|
||
UNIT_STATUS_MARKER_RADIUS * 2.0,
|
||
8,
|
||
_unit_status_marker_text_color(marker_kind),
|
||
Color(0.02, 0.012, 0.006, 0.80)
|
||
)
|
||
|
||
|
||
func _unit_status_marker_kinds(unit: Dictionary) -> Array[String]:
|
||
var result: Array[String] = []
|
||
var status_effects = unit.get("status_effects", [])
|
||
if typeof(status_effects) != TYPE_ARRAY:
|
||
return result
|
||
|
||
for effect in status_effects:
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
if int(effect.get("remaining_phases", 0)) <= 0:
|
||
continue
|
||
|
||
var marker_kind := _unit_status_marker_kind(effect)
|
||
if marker_kind.is_empty() or result.has(marker_kind):
|
||
continue
|
||
|
||
result.append(marker_kind)
|
||
if result.size() >= MAX_UNIT_STATUS_MARKERS:
|
||
break
|
||
|
||
return result
|
||
|
||
|
||
func _unit_status_marker_kind(effect: Dictionary) -> String:
|
||
var effect_type := str(effect.get("type", "stat_bonus"))
|
||
if effect_type == "action_lock":
|
||
return str(effect.get("status", "status"))
|
||
if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0:
|
||
return str(effect.get("status", "poison"))
|
||
if effect_type == "stat_bonus":
|
||
var amount := int(effect.get("amount", 0))
|
||
if amount > 0:
|
||
return "support"
|
||
if amount < 0:
|
||
return "debuff"
|
||
return ""
|
||
|
||
|
||
func _unit_status_marker_color(marker_kind: String) -> Color:
|
||
if marker_kind == "support":
|
||
return Color(1.0, 0.88, 0.30)
|
||
if marker_kind == "debuff":
|
||
return Color(0.78, 0.55, 1.0)
|
||
if marker_kind == "poison":
|
||
return Color(0.54, 0.95, 0.36)
|
||
if marker_kind == "seal":
|
||
return Color(0.44, 0.86, 1.0)
|
||
if marker_kind == "snare":
|
||
return Color(1.0, 0.64, 0.22)
|
||
if marker_kind == "disarm":
|
||
return Color(1.0, 0.48, 0.42)
|
||
return Color(0.72, 0.86, 1.0)
|
||
|
||
|
||
func _unit_status_marker_glyph(marker_kind: String) -> String:
|
||
if marker_kind == "support":
|
||
return "강"
|
||
if marker_kind == "debuff":
|
||
return "약"
|
||
if marker_kind == "poison":
|
||
return "독"
|
||
if marker_kind == "seal":
|
||
return "봉"
|
||
if marker_kind == "snare":
|
||
return "묶"
|
||
if marker_kind == "disarm":
|
||
return "해"
|
||
return "상"
|
||
|
||
|
||
func _unit_status_marker_text_color(marker_kind: String) -> Color:
|
||
if marker_kind == "poison" or marker_kind == "support":
|
||
return UI_AGED_INK
|
||
return UI_PARCHMENT_TEXT
|
||
|
||
|
||
func _visual_rect_for_unit(unit: Dictionary) -> Rect2:
|
||
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
|
||
var rect := _rect_for_cell(cell)
|
||
var unit_id := str(unit.get("id", ""))
|
||
if unit_id.is_empty():
|
||
return rect
|
||
|
||
if unit_motion_by_unit.has(unit_id):
|
||
var move_motion: Dictionary = unit_motion_by_unit[unit_id]
|
||
var move_from_cell: Vector2i = move_motion.get("from", cell)
|
||
var move_to_cell: Vector2i = move_motion.get("to", cell)
|
||
var move_duration: float = maxf(0.01, float(move_motion.get("duration", UNIT_MOVE_ANIMATION_DURATION)))
|
||
var move_age := float(move_motion.get("age", 0.0))
|
||
var move_progress := clampf(move_age / move_duration, 0.0, 1.0)
|
||
var smooth_progress := move_progress * move_progress * (3.0 - 2.0 * move_progress)
|
||
var from_rect := _rect_for_cell(move_from_cell)
|
||
var to_rect := _rect_for_cell(move_to_cell)
|
||
rect.position = from_rect.position.lerp(to_rect.position, smooth_progress)
|
||
|
||
if unit_action_motion_by_unit.has(unit_id):
|
||
var action_motion: Dictionary = unit_action_motion_by_unit[unit_id]
|
||
var action_from_cell: Vector2i = action_motion.get("from", cell)
|
||
var action_to_cell: Vector2i = action_motion.get("to", cell)
|
||
var direction := _cell_center(action_to_cell) - _cell_center(action_from_cell)
|
||
if direction.length() > 0.01:
|
||
direction = direction.normalized()
|
||
var action_duration: float = maxf(0.01, float(action_motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
|
||
var action_age := float(action_motion.get("age", 0.0))
|
||
var action_progress := clampf(action_age / action_duration, 0.0, 1.0)
|
||
var profile := str(action_motion.get("profile", "slash"))
|
||
var lunge_scale := _action_motion_lunge_scale(profile)
|
||
rect.position += direction * _action_motion_lunge_curve(profile, action_progress) * UNIT_ATTACK_LUNGE_DISTANCE * lunge_scale
|
||
return rect
|
||
|
||
|
||
func _cell_center(cell: Vector2i) -> Vector2:
|
||
var rect := _rect_for_cell(cell)
|
||
return rect.position + rect.size * 0.5
|
||
|
||
|
||
func _draw_attack_effects() -> void:
|
||
if unit_action_motion_by_unit.is_empty():
|
||
return
|
||
for unit_id in unit_action_motion_by_unit.keys():
|
||
var motion: Dictionary = unit_action_motion_by_unit[unit_id]
|
||
var duration: float = maxf(0.01, float(motion.get("duration", UNIT_ATTACK_ANIMATION_DURATION)))
|
||
var age := float(motion.get("age", 0.0))
|
||
var progress := clampf(age / duration, 0.0, 1.0)
|
||
var profile := str(motion.get("profile", "slash"))
|
||
var peak := _action_motion_effect_peak(profile, progress)
|
||
if peak <= 0.0:
|
||
continue
|
||
var from_cell: Vector2i = motion.get("from", Vector2i(-1, -1))
|
||
var to_cell: Vector2i = motion.get("to", Vector2i(-1, -1))
|
||
if not state.is_inside(from_cell) or not state.is_inside(to_cell):
|
||
continue
|
||
var start := _cell_center(from_cell)
|
||
var end := _cell_center(to_cell)
|
||
var team_color := PLAYER_COLOR
|
||
var unit := state.get_unit(str(unit_id))
|
||
if not unit.is_empty() and unit.get("team", "") == BattleState.TEAM_ENEMY:
|
||
team_color = ENEMY_COLOR
|
||
var impact_cue := str(motion.get("impact_cue", _action_motion_impact_cue(profile)))
|
||
var impact_radius := float(motion.get("impact_radius", _action_motion_impact_radius(profile)))
|
||
_draw_action_effect(profile, start, end, progress, peak, team_color)
|
||
_draw_action_impact_cue(impact_cue, start, end, progress, peak, team_color, impact_radius)
|
||
|
||
|
||
func _draw_action_effect(profile: String, start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
if profile == "arrow":
|
||
_draw_arrow_attack_effect(start, end, progress, peak, team_color)
|
||
elif profile == "cavalry_charge":
|
||
_draw_cavalry_attack_effect(start, end, progress, peak, team_color)
|
||
elif profile == "infantry_strike":
|
||
_draw_infantry_attack_effect(end, peak, team_color)
|
||
elif profile == "command_strike":
|
||
_draw_command_attack_effect(start, end, progress, peak, team_color)
|
||
elif profile == "heavy_strike":
|
||
_draw_heavy_attack_effect(end, peak, team_color)
|
||
elif profile == "tactic_damage":
|
||
_draw_tactic_damage_effect(start, end, progress, peak, team_color)
|
||
elif profile == "tactic_heal":
|
||
_draw_tactic_heal_effect(start, end, progress, peak, team_color)
|
||
elif profile == "tactic_support":
|
||
_draw_tactic_support_effect(start, end, progress, peak, team_color)
|
||
else:
|
||
_draw_slash_attack_effect(end, peak, team_color)
|
||
|
||
|
||
func _draw_arrow_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var direction := end - start
|
||
if direction.length() <= 0.01:
|
||
return
|
||
direction = direction.normalized()
|
||
var side := Vector2(-direction.y, direction.x)
|
||
var head := start.lerp(end, clampf(progress + 0.20, 0.0, 1.0))
|
||
var tail := start.lerp(end, clampf(progress - 0.18, 0.0, 1.0))
|
||
var shaft := Color(1.0, 0.86, 0.42, 0.76 * peak)
|
||
draw_line(tail, head, shaft, 3.0)
|
||
draw_line(head, head - direction * 9.0 + side * 4.5, Color(1.0, 0.96, 0.66, 0.70 * peak), 2.0)
|
||
draw_line(head, head - direction * 9.0 - side * 4.5, Color(1.0, 0.96, 0.66, 0.70 * peak), 2.0)
|
||
var wake := team_color.lightened(0.48)
|
||
wake.a = 0.22 * peak
|
||
draw_line(tail - side * 5.0, head - side * 5.0, wake, 1.5)
|
||
draw_line(tail + side * 5.0, head + side * 5.0, wake, 1.5)
|
||
draw_circle(head, 3.5 + 4.0 * peak, Color(1.0, 0.92, 0.52, 0.36 * peak))
|
||
|
||
|
||
func _draw_cavalry_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var direction := end - start
|
||
if direction.length() <= 0.01:
|
||
return
|
||
direction = direction.normalized()
|
||
var side := Vector2(-direction.y, direction.x)
|
||
for index in range(3):
|
||
var lane_offset := side * float(index - 1) * 7.0
|
||
var dust_head := start.lerp(end, clampf(progress - float(index) * 0.05, 0.0, 1.0))
|
||
var dust_tail := dust_head - direction * (16.0 + float(index) * 5.0)
|
||
draw_line(dust_tail + lane_offset, dust_head + lane_offset, Color(0.86, 0.72, 0.42, 0.24 * peak), 2.0)
|
||
var lance := team_color.lightened(0.52)
|
||
lance.a = 0.68 * peak
|
||
draw_line(end - direction * 22.0, end + direction * 12.0, lance, 4.0)
|
||
draw_line(end - side * 15.0, end + side * 15.0, Color(1.0, 0.88, 0.34, 0.34 * peak), 2.0)
|
||
draw_circle(end, 9.0 + 13.0 * peak, Color(1.0, 0.66, 0.22, 0.18 * peak))
|
||
|
||
|
||
func _draw_infantry_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
|
||
var guard_flash := team_color.lightened(0.48)
|
||
guard_flash.a = 0.58 * peak
|
||
draw_line(end + Vector2(-16, -10), end + Vector2(17, 11), guard_flash, 3.0)
|
||
draw_line(end + Vector2(-14, 10), end + Vector2(14, -10), Color(1.0, 0.88, 0.42, 0.32 * peak), 2.0)
|
||
draw_rect(Rect2(end + Vector2(-9, -14), Vector2(18, 20)), Color(0.72, 0.82, 0.94, 0.16 * peak), false, 2.0)
|
||
draw_arc(end, 8.0 + 9.0 * peak, -PI * 0.80, PI * 0.20, 24, Color(1.0, 0.92, 0.54, 0.34 * peak), 2.0)
|
||
|
||
|
||
func _draw_command_attack_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var beam := Color(0.72, 0.92, 1.0, 0.34 * peak)
|
||
draw_line(start, end, beam, 2.0)
|
||
var command_color := team_color.lightened(0.58)
|
||
command_color.a = 0.56 * peak
|
||
draw_arc(end, 10.0 + 12.0 * peak, 0.0, TAU, 48, command_color, 2.0)
|
||
draw_arc(end, 20.0 - 6.0 * peak, progress * TAU, progress * TAU + PI * 1.35, 36, Color(1.0, 0.96, 0.62, 0.42 * peak), 2.0)
|
||
draw_circle(end, 6.0 + 8.0 * peak, Color(0.72, 0.92, 1.0, 0.18 * peak))
|
||
|
||
|
||
func _draw_heavy_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
|
||
var heavy := team_color.lightened(0.42)
|
||
heavy.a = 0.62 * peak
|
||
draw_line(end + Vector2(-20, -16), end + Vector2(20, 16), heavy, 5.0)
|
||
draw_line(end + Vector2(18, -18), end + Vector2(-18, 18), Color(1.0, 0.82, 0.28, 0.48 * peak), 4.0)
|
||
draw_arc(end, 10.0 + 18.0 * peak, 0.0, TAU, 48, Color(1.0, 0.68, 0.24, 0.26 * peak), 3.0)
|
||
|
||
|
||
func _draw_tactic_damage_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var caster_color := team_color.lightened(0.58)
|
||
caster_color.a = 0.34 * peak
|
||
var fire_color := Color(1.0, 0.38, 0.18, 0.52 * peak)
|
||
draw_arc(start, 12.0 + 7.0 * peak, progress * TAU, progress * TAU + PI * 1.55, 36, caster_color, 2.0)
|
||
draw_line(start, end, Color(1.0, 0.62, 0.28, 0.30 * peak), 2.0)
|
||
for index in range(3):
|
||
var angle := progress * TAU + float(index) * TAU / 3.0
|
||
var spark := Vector2(cos(angle), sin(angle))
|
||
draw_line(end - spark * (5.0 + 4.0 * peak), end + spark * (12.0 + 6.0 * peak), fire_color, 2.0)
|
||
draw_circle(end, 6.0 + 10.0 * peak, Color(1.0, 0.50, 0.18, 0.18 * peak))
|
||
|
||
|
||
func _draw_tactic_heal_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var line_color := Color(0.48, 1.0, 0.60, 0.24 * peak)
|
||
if start.distance_to(end) > 0.01:
|
||
draw_line(start, end, line_color, 2.0)
|
||
var ring_color := Color(0.42, 1.0, 0.54, 0.42 * peak)
|
||
draw_arc(end, 9.0 + 12.0 * peak, -progress * TAU, -progress * TAU + PI * 1.70, 44, ring_color, 2.0)
|
||
draw_arc(end, 19.0 - 5.0 * peak, progress * TAU, progress * TAU + PI * 1.25, 40, Color(0.88, 1.0, 0.72, 0.32 * peak), 2.0)
|
||
draw_line(end + Vector2(-7, 0), end + Vector2(7, 0), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5)
|
||
draw_line(end + Vector2(0, -7), end + Vector2(0, 7), Color(0.80, 1.0, 0.78, 0.54 * peak), 2.5)
|
||
var caster_glow := team_color.lightened(0.62)
|
||
caster_glow.a = 0.20 * peak
|
||
draw_circle(start, 6.0 + 4.0 * peak, caster_glow)
|
||
|
||
|
||
func _draw_tactic_support_effect(start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color) -> void:
|
||
var banner := team_color.lightened(0.48)
|
||
banner.a = 0.46 * peak
|
||
if start.distance_to(end) > 0.01:
|
||
draw_line(start, end, Color(1.0, 0.86, 0.30, 0.20 * peak), 1.5)
|
||
draw_line(end + Vector2(-10, -16), end + Vector2(-10, 12), banner, 2.5)
|
||
draw_rect(Rect2(end + Vector2(-8, -15), Vector2(20, 12)), Color(1.0, 0.82, 0.22, 0.28 * peak))
|
||
draw_arc(end, 12.0 + 8.0 * peak, progress * TAU, progress * TAU + PI * 1.45, 36, Color(1.0, 0.92, 0.38, 0.36 * peak), 2.0)
|
||
for index in range(3):
|
||
var angle := -PI * 0.5 + float(index - 1) * 0.52
|
||
var ray := Vector2(cos(angle), sin(angle))
|
||
draw_line(end + ray * 7.0, end + ray * (18.0 + 5.0 * peak), Color(1.0, 0.96, 0.62, 0.25 * peak), 1.5)
|
||
|
||
|
||
func _draw_slash_attack_effect(end: Vector2, peak: float, team_color: Color) -> void:
|
||
var flash := team_color.lightened(0.45)
|
||
flash.a = 0.55 * peak
|
||
draw_line(end + Vector2(-18, -13), end + Vector2(18, 13), flash, 3.0)
|
||
draw_line(end + Vector2(16, -14), end + Vector2(-14, 15), Color(1.0, 0.88, 0.42, 0.38 * peak), 2.0)
|
||
draw_circle(end, 8.0 + 8.0 * peak, Color(1.0, 0.82, 0.34, 0.20 * peak))
|
||
|
||
|
||
func _draw_action_impact_cue(cue: String, start: Vector2, end: Vector2, progress: float, peak: float, team_color: Color, radius: float) -> void:
|
||
var direction := end - start
|
||
if direction.length() <= 0.01:
|
||
direction = Vector2.RIGHT
|
||
else:
|
||
direction = direction.normalized()
|
||
var side := Vector2(-direction.y, direction.x)
|
||
var accent := team_color.lightened(0.55)
|
||
accent.a = 0.34 * peak
|
||
var hot := Color(1.0, 0.90, 0.42, 0.42 * peak)
|
||
if cue == "projectile":
|
||
draw_circle(end, 3.5 + radius * 0.22 * peak, Color(1.0, 0.96, 0.62, 0.28 * peak))
|
||
draw_line(end - direction * radius * 0.55, end + direction * radius * 0.30, hot, 2.0)
|
||
draw_line(end - side * radius * 0.22, end + side * radius * 0.22, Color(0.92, 0.96, 1.0, 0.26 * peak), 1.5)
|
||
elif cue == "charge":
|
||
for index in range(3):
|
||
var offset := side * float(index - 1) * radius * 0.34
|
||
draw_circle(end - direction * radius * (0.34 + float(index) * 0.10) + offset, 3.0 + radius * 0.12 * peak, Color(0.76, 0.58, 0.30, 0.18 * peak))
|
||
draw_line(end - side * radius * 0.70, end + side * radius * 0.70, hot, 2.5)
|
||
draw_arc(end, radius * (0.58 + progress * 0.20), -PI * 0.12, PI * 1.12, 32, accent, 2.0)
|
||
elif cue == "guard":
|
||
draw_rect(Rect2(end - Vector2(radius * 0.32, radius * 0.42), Vector2(radius * 0.64, radius * 0.84)), Color(0.80, 0.90, 1.0, 0.14 * peak), false, 2.0)
|
||
draw_line(end - side * radius * 0.52 - direction * radius * 0.22, end + side * radius * 0.52 + direction * radius * 0.22, hot, 2.0)
|
||
draw_line(end - direction * radius * 0.48, end + direction * radius * 0.48, accent, 2.0)
|
||
elif cue == "command":
|
||
draw_arc(end, radius * (0.72 + 0.20 * peak), progress * TAU, progress * TAU + PI * 1.20, 40, Color(0.72, 0.94, 1.0, 0.34 * peak), 2.0)
|
||
draw_arc(end, radius * 0.44, -progress * TAU, -progress * TAU + PI * 1.55, 40, hot, 2.0)
|
||
for index in range(3):
|
||
var angle := progress * TAU + float(index) * TAU / 3.0
|
||
var ray := Vector2(cos(angle), sin(angle))
|
||
draw_line(end + ray * radius * 0.18, end + ray * radius * 0.58, accent, 1.5)
|
||
elif cue == "crush":
|
||
draw_arc(end, radius * (0.42 + peak * 0.42), 0.0, TAU, 48, Color(1.0, 0.68, 0.24, 0.30 * peak), 3.0)
|
||
for index in range(4):
|
||
var angle := float(index) * PI * 0.5 + progress * PI * 0.25
|
||
var crack := Vector2(cos(angle), sin(angle))
|
||
draw_line(end + crack * radius * 0.16, end + crack * radius * (0.54 + 0.16 * peak), Color(0.96, 0.82, 0.42, 0.34 * peak), 2.0)
|
||
elif cue == "tactic_damage":
|
||
draw_arc(end, radius * (0.35 + peak * 0.38), 0.0, TAU, 44, Color(1.0, 0.34, 0.18, 0.28 * peak), 3.0)
|
||
draw_arc(end, radius * 0.62, progress * TAU, progress * TAU + PI * 1.20, 36, Color(1.0, 0.82, 0.32, 0.34 * peak), 2.0)
|
||
elif cue == "tactic_heal":
|
||
draw_arc(end, radius * (0.40 + peak * 0.24), 0.0, TAU, 48, Color(0.48, 1.0, 0.54, 0.30 * peak), 2.5)
|
||
draw_circle(end, radius * 0.14 + radius * 0.10 * peak, Color(0.78, 1.0, 0.74, 0.22 * peak))
|
||
elif cue == "tactic_support":
|
||
draw_arc(end, radius * (0.48 + peak * 0.18), -PI * 0.10, PI * 1.10, 40, Color(1.0, 0.88, 0.28, 0.30 * peak), 2.5)
|
||
draw_line(end + Vector2(-radius * 0.38, radius * 0.26), end + Vector2(radius * 0.38, radius * 0.26), Color(1.0, 0.94, 0.50, 0.30 * peak), 2.0)
|
||
else:
|
||
draw_line(end - side * radius * 0.48, end + side * radius * 0.48, hot, 2.0)
|
||
draw_circle(end, radius * 0.32 + radius * 0.18 * peak, Color(1.0, 0.82, 0.34, 0.18 * peak))
|
||
|
||
|
||
func _draw_floating_texts() -> void:
|
||
if floating_texts.is_empty():
|
||
return
|
||
var font := _draw_ui_font(true)
|
||
for popup in floating_texts:
|
||
var duration: float = maxf(0.01, float(popup.get("duration", FLOATING_TEXT_LIFETIME)))
|
||
var age := float(popup.get("age", 0.0))
|
||
var progress := clampf(age / duration, 0.0, 1.0)
|
||
var alpha := 1.0 - progress
|
||
var color := _floating_text_color(str(popup.get("kind", "")))
|
||
color.a = alpha
|
||
var shadow := Color(0.02, 0.02, 0.025, 0.82 * alpha)
|
||
var center: Vector2 = popup.get("pos", Vector2.ZERO)
|
||
var text := str(popup.get("text", ""))
|
||
var width: float = 118.0
|
||
var draw_position: Vector2 = center - Vector2(width * 0.5, 24.0 + progress * FLOATING_TEXT_RISE)
|
||
draw_string(font, draw_position + Vector2(1, 1), text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, shadow)
|
||
draw_string(font, draw_position, text, HORIZONTAL_ALIGNMENT_CENTER, width, 18, color)
|
||
|
||
|
||
func _floating_text_color(kind: String) -> Color:
|
||
if kind == "damage":
|
||
return Color(1.0, 0.30, 0.20)
|
||
if kind == "heal":
|
||
return Color(0.36, 1.0, 0.46)
|
||
if kind == "mp":
|
||
return Color(0.48, 0.74, 1.0)
|
||
if kind == "item":
|
||
return Color(0.98, 0.76, 0.28)
|
||
if kind == "priority":
|
||
return Color(1.0, 0.54, 0.22)
|
||
if kind == "support":
|
||
return Color(1.0, 0.88, 0.28)
|
||
if kind == "debuff":
|
||
return Color(0.80, 0.52, 1.0)
|
||
if kind == "poison":
|
||
return Color(0.54, 0.95, 0.36)
|
||
if kind == "seal":
|
||
return Color(0.44, 0.86, 1.0)
|
||
if kind == "snare":
|
||
return Color(1.0, 0.64, 0.22)
|
||
if kind == "disarm":
|
||
return Color(1.0, 0.48, 0.42)
|
||
if kind == "miss" or kind == "fade":
|
||
return Color(0.82, 0.84, 0.90)
|
||
if kind == "defeat":
|
||
return Color(1.0, 0.18, 0.16)
|
||
if kind == "progress":
|
||
return Color(1.0, 0.78, 0.28)
|
||
return Color.WHITE
|
||
|
||
|
||
func _draw_target_preview_badge() -> void:
|
||
var badge := _target_preview_badge()
|
||
if badge.is_empty():
|
||
return
|
||
|
||
var cell: Vector2i = badge.get("cell", hover_cell)
|
||
if not state.is_inside(cell):
|
||
return
|
||
|
||
var size := TARGET_PREVIEW_BADGE_SIZE
|
||
var badge_rect := _target_preview_badge_rect(cell, size)
|
||
var kind := str(badge.get("kind", ""))
|
||
var color := _target_preview_badge_color(kind)
|
||
var background := Color(0.025, 0.028, 0.034, 0.88)
|
||
var font := _draw_ui_font(true)
|
||
var text := str(badge.get("text", ""))
|
||
|
||
draw_rect(badge_rect, background)
|
||
draw_rect(badge_rect, color, false, 2.0)
|
||
_draw_ink_text(font, badge_rect.position + Vector2(0, 16), text, HORIZONTAL_ALIGNMENT_CENTER, badge_rect.size.x, 14, color)
|
||
|
||
|
||
func _target_preview_badge_rect(cell: Vector2i, size: Vector2 = TARGET_PREVIEW_BADGE_SIZE) -> Rect2:
|
||
var cell_rect := _rect_for_cell(cell)
|
||
var position := cell_rect.position + Vector2((TILE_SIZE - size.x) * 0.5, 5.0)
|
||
var board := _board_rect()
|
||
var min_x := board.position.x + 2.0
|
||
var min_y := board.position.y + 2.0
|
||
var max_x := maxf(min_x, board.end.x - size.x - 2.0)
|
||
var max_y := maxf(min_y, board.end.y - size.y - 2.0)
|
||
position.x = clampf(position.x, min_x, max_x)
|
||
position.y = clampf(position.y, min_y, max_y)
|
||
return Rect2(position, size)
|
||
|
||
|
||
func _target_preview_badge() -> Dictionary:
|
||
if _is_input_locked() or not state.is_inside(hover_cell):
|
||
return {}
|
||
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return {}
|
||
|
||
var target := state.get_unit_at(hover_cell)
|
||
if not selected_skill_id.is_empty():
|
||
return _skill_target_preview_badge(selected, target)
|
||
if not selected_item_id.is_empty():
|
||
return _item_target_preview_badge(selected, target)
|
||
if _has_pending_move() and not basic_attack_targeting:
|
||
return {}
|
||
var attack_badge := _attack_target_preview_badge(selected, target)
|
||
if not attack_badge.is_empty():
|
||
return attack_badge
|
||
return _hover_intent_preview_badge(selected, target)
|
||
|
||
|
||
func _attack_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
|
||
if target.is_empty() or target.get("team", "") == selected.get("team", ""):
|
||
return {}
|
||
|
||
var preview := state.get_damage_preview(selected["id"], target["id"])
|
||
if preview.is_empty():
|
||
return {}
|
||
|
||
if bool(preview.get("attack_locked", false)):
|
||
return _make_target_preview_badge("무장해제", "disarm")
|
||
if not bool(preview.get("in_range", false)):
|
||
return _make_target_preview_badge("거리밖", "invalid")
|
||
|
||
var hit_chance := int(preview.get("hit_chance", 100))
|
||
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
|
||
var counter_text := _format_attack_counter_badge_suffix(preview)
|
||
return _make_target_preview_badge("%s %d%%%s" % [result_text, hit_chance, counter_text], _attack_preview_badge_kind(preview))
|
||
|
||
|
||
func _format_attack_counter_badge_suffix(preview: Dictionary) -> String:
|
||
if not bool(preview.get("counter_in_range", false)):
|
||
return ""
|
||
if bool(preview.get("counter_would_defeat", false)):
|
||
return " 반격!"
|
||
return " 반%d" % int(preview.get("counter_damage", 0))
|
||
|
||
|
||
func _attack_preview_badge_kind(preview: Dictionary) -> String:
|
||
if bool(preview.get("counter_would_defeat", false)):
|
||
return "danger"
|
||
if bool(preview.get("counter_in_range", false)):
|
||
return "counter"
|
||
return "damage"
|
||
|
||
|
||
func _skill_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
|
||
return _skill_target_preview_badge_for_cell(selected, target, hover_cell)
|
||
|
||
|
||
func _skill_target_preview_badge_for_cell(selected: Dictionary, target: Dictionary, cell: Vector2i) -> Dictionary:
|
||
var preview := state.get_skill_preview(selected["id"], selected_skill_id, cell)
|
||
if preview.is_empty():
|
||
return {}
|
||
|
||
if bool(preview.get("skill_locked", false)):
|
||
return _make_target_preview_badge_at("봉인", "seal", cell)
|
||
if not bool(preview.get("in_range", false)):
|
||
return _make_target_preview_badge_at("거리밖", "invalid", cell)
|
||
if not bool(preview.get("has_mp", false)):
|
||
return _make_target_preview_badge_at("기력부족", "invalid", cell)
|
||
if not bool(preview.get("valid_target", false)):
|
||
return _make_target_preview_badge_at("표식없음" if bool(preview.get("has_area", false)) else "불가", "invalid", cell)
|
||
if target.is_empty() and not bool(preview.get("has_area", false)):
|
||
return {}
|
||
|
||
var kind := str(preview.get("kind", "damage"))
|
||
var target_count := int(preview.get("target_count", 1))
|
||
if kind == "heal":
|
||
var heal := int(preview.get("heal", 0))
|
||
var total_heal := int(preview.get("total_heal", heal))
|
||
if total_heal <= 0:
|
||
return _make_target_preview_badge_at("가득", "invalid", cell)
|
||
if bool(preview.get("has_area", false)):
|
||
return _make_target_preview_badge_at("+%d %d군" % [total_heal, target_count], "heal", cell)
|
||
return _make_target_preview_badge_at("+%d 병력" % heal, "heal", cell)
|
||
if kind == "support":
|
||
var effect_text := str(preview.get("effect_text", "지원"))
|
||
var badge_kind := _support_preview_badge_kind(effect_text)
|
||
if bool(preview.get("has_area", false)):
|
||
return _make_target_preview_badge_at("%s %d군" % [_compact_support_preview_text(effect_text), target_count], badge_kind, cell)
|
||
return _make_target_preview_badge_at(_compact_support_preview_text(effect_text), badge_kind, cell)
|
||
|
||
if bool(preview.get("has_area", false)):
|
||
var defeat_count := int(preview.get("defeat_count", 0))
|
||
if defeat_count > 0:
|
||
return _make_target_preview_badge_at("퇴각 %d군" % defeat_count, "damage", cell)
|
||
return _make_target_preview_badge_at("-%d %d군" % [int(preview.get("total_damage", 0)), target_count], "damage", cell)
|
||
var result_text := "퇴각" if bool(preview.get("would_defeat", false)) else "-%d" % int(preview.get("damage", 0))
|
||
return _make_target_preview_badge_at(result_text, "damage", cell)
|
||
|
||
|
||
func _item_target_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
|
||
return _item_target_preview_badge_for_cell(selected, target, hover_cell)
|
||
|
||
|
||
func _item_target_preview_badge_for_cell(selected: Dictionary, target: Dictionary, cell: Vector2i) -> Dictionary:
|
||
var preview := state.get_item_preview(selected["id"], selected_item_id, cell)
|
||
if preview.is_empty() or target.is_empty():
|
||
return {}
|
||
|
||
if not bool(preview.get("valid_target", false)):
|
||
return _make_target_preview_badge_at("불가", "invalid", cell)
|
||
if not bool(preview.get("in_range", false)):
|
||
return _make_target_preview_badge_at("거리밖", "invalid", cell)
|
||
if int(preview.get("count", 0)) <= 0:
|
||
return _make_target_preview_badge_at("없음", "invalid", cell)
|
||
|
||
var hp_heal := int(preview.get("heal", 0))
|
||
var mp_heal := int(preview.get("mp_heal", 0))
|
||
var cure_statuses: Array = preview.get("cure_statuses", [])
|
||
if hp_heal <= 0 and mp_heal <= 0 and cure_statuses.is_empty():
|
||
return _make_target_preview_badge_at("무효", "invalid", cell)
|
||
if not cure_statuses.is_empty():
|
||
return _make_target_preview_badge_at("치료", "support", cell)
|
||
if hp_heal > 0 and mp_heal > 0:
|
||
return _make_target_preview_badge_at("+%d/%d" % [hp_heal, mp_heal], "heal", cell)
|
||
if hp_heal > 0:
|
||
return _make_target_preview_badge_at("+%d 병력" % hp_heal, "heal", cell)
|
||
return _make_target_preview_badge_at("+%d 기력" % mp_heal, "mp", cell)
|
||
|
||
|
||
func _hover_intent_preview_badge(selected: Dictionary, target: Dictionary) -> Dictionary:
|
||
if selected.is_empty():
|
||
return {}
|
||
if _has_pending_move():
|
||
return {}
|
||
if not state.can_player_act():
|
||
return {}
|
||
if not bool(selected.get("controllable", true)) or bool(selected.get("acted", false)):
|
||
return {}
|
||
if not target.is_empty():
|
||
if target.get("team", "") == selected.get("team", "") and _can_select(target):
|
||
return _make_target_preview_badge("선택", "select")
|
||
return {}
|
||
if move_cells.has(hover_cell):
|
||
return _make_target_preview_badge("행군", "move")
|
||
return {}
|
||
|
||
|
||
func _make_target_preview_badge(text: String, kind: String) -> Dictionary:
|
||
return _make_target_preview_badge_at(text, kind, hover_cell)
|
||
|
||
|
||
func _make_target_preview_badge_at(text: String, kind: String, cell: Vector2i) -> Dictionary:
|
||
if text.is_empty():
|
||
return {}
|
||
return {
|
||
"text": text,
|
||
"kind": kind,
|
||
"cell": cell
|
||
}
|
||
|
||
|
||
func _compact_support_preview_text(effect_text: String) -> String:
|
||
var parts := []
|
||
for raw_part in effect_text.split(","):
|
||
var part := raw_part.strip_edges()
|
||
var until_index := part.find(" until ")
|
||
if until_index >= 0:
|
||
part = part.substr(0, until_index)
|
||
var for_index := part.find(" for ")
|
||
if for_index >= 0:
|
||
part = part.substr(0, for_index)
|
||
var duration_index := part.find(", ")
|
||
if duration_index >= 0:
|
||
part = part.substr(0, duration_index)
|
||
if not part.is_empty() and part != "No support effect." and part != "효험 없음":
|
||
parts.append(_compact_support_badge_text(part))
|
||
if parts.is_empty():
|
||
return "지원"
|
||
if parts.size() > 1:
|
||
return "%s+" % parts[0]
|
||
return str(parts[0])
|
||
|
||
|
||
func _compact_support_badge_text(text: String) -> String:
|
||
if text.contains("Disarm") or text.contains("무장해제"):
|
||
return "무장해제"
|
||
if text.contains("Snare") or text.contains("발묶임"):
|
||
return "속박"
|
||
if text.contains("Seal") or text.contains("봉인"):
|
||
return "봉인"
|
||
if text.contains("Poison") or text.contains("독"):
|
||
return "독"
|
||
if text.contains("Cleanse") or text.contains("해소"):
|
||
return "해소"
|
||
return text
|
||
|
||
|
||
func _support_preview_badge_kind(effect_text: String) -> String:
|
||
if effect_text.contains("Disarm") or effect_text.contains("무장해제"):
|
||
return "disarm"
|
||
if effect_text.contains("Snare") or effect_text.contains("발묶임"):
|
||
return "snare"
|
||
if effect_text.contains("Seal") or effect_text.contains("봉인"):
|
||
return "seal"
|
||
if effect_text.contains("Poison") or effect_text.contains("독"):
|
||
return "poison"
|
||
if effect_text.contains("-"):
|
||
return "debuff"
|
||
return "support"
|
||
|
||
|
||
func _target_preview_badge_color(kind: String) -> Color:
|
||
if kind == "damage":
|
||
return Color(1.0, 0.38, 0.26)
|
||
if kind == "counter":
|
||
return Color(1.0, 0.64, 0.22)
|
||
if kind == "danger":
|
||
return Color(1.0, 0.18, 0.14)
|
||
if kind == "move":
|
||
return Color(0.38, 0.70, 1.0)
|
||
if kind == "select":
|
||
return Color(1.0, 0.86, 0.34)
|
||
if kind == "heal":
|
||
return Color(0.44, 1.0, 0.52)
|
||
if kind == "mp":
|
||
return Color(0.54, 0.78, 1.0)
|
||
if kind == "support":
|
||
return Color(1.0, 0.88, 0.30)
|
||
if kind == "debuff":
|
||
return Color(0.78, 0.55, 1.0)
|
||
if kind == "poison":
|
||
return Color(0.54, 0.95, 0.36)
|
||
if kind == "seal":
|
||
return Color(0.44, 0.86, 1.0)
|
||
if kind == "snare":
|
||
return Color(1.0, 0.64, 0.22)
|
||
if kind == "disarm":
|
||
return Color(1.0, 0.48, 0.42)
|
||
return Color(0.78, 0.80, 0.86)
|
||
|
||
|
||
func _cell_from_screen(screen_position: Vector2) -> Vector2i:
|
||
var local := screen_position - _board_origin()
|
||
if local.x < 0 or local.y < 0:
|
||
return Vector2i(-1, -1)
|
||
return Vector2i(int(floor(local.x / TILE_SIZE)), int(floor(local.y / TILE_SIZE)))
|
||
|
||
|
||
func _rect_for_cell(cell: Vector2i) -> Rect2:
|
||
return Rect2(_board_origin() + Vector2(cell.x, cell.y) * TILE_SIZE, Vector2(TILE_SIZE, TILE_SIZE))
|
||
|
||
|
||
func _board_rect() -> Rect2:
|
||
return Rect2(_board_origin(), _board_size())
|
||
|
||
|
||
func _board_origin() -> Vector2:
|
||
return BOARD_OFFSET + board_scroll_offset
|
||
|
||
|
||
func _board_size() -> Vector2:
|
||
return Vector2(state.map_size.x, state.map_size.y) * TILE_SIZE
|
||
|
||
|
||
func _map_view_rect() -> Rect2:
|
||
var viewport_size := _viewport_size_for_layout()
|
||
var right_edge := minf(SIDE_PANEL_POSITION.x - 18.0, viewport_size.x - 18.0)
|
||
var bottom_edge := viewport_size.y - 20.0
|
||
return Rect2(
|
||
BOARD_OFFSET,
|
||
Vector2(maxf(160.0, right_edge - BOARD_OFFSET.x), maxf(160.0, bottom_edge - BOARD_OFFSET.y))
|
||
)
|
||
|
||
|
||
func _viewport_size_for_layout() -> Vector2:
|
||
if is_inside_tree():
|
||
return get_viewport_rect().size
|
||
var window_size := Vector2(DisplayServer.window_get_size())
|
||
if window_size.x > 0.0 and window_size.y > 0.0:
|
||
return window_size
|
||
return Vector2(1280, 720)
|
||
|
||
|
||
func _reset_board_scroll() -> void:
|
||
board_scroll_offset = _clamped_board_scroll_offset(Vector2.ZERO)
|
||
|
||
|
||
func _clamped_board_scroll_offset(offset: Vector2) -> Vector2:
|
||
var view_rect := _map_view_rect()
|
||
var board_size := _board_size()
|
||
var min_x := minf(0.0, view_rect.size.x - board_size.x)
|
||
var min_y := minf(0.0, view_rect.size.y - board_size.y)
|
||
return Vector2(clampf(offset.x, min_x, 0.0), clampf(offset.y, min_y, 0.0))
|
||
|
||
|
||
func _edge_scroll_velocity_for_position(mouse_position: Vector2, view_rect: Rect2) -> Vector2:
|
||
if not _edge_scroll_activation_rect(view_rect).has_point(mouse_position):
|
||
return Vector2.ZERO
|
||
var velocity := Vector2(
|
||
_edge_scroll_axis_velocity(mouse_position.x, view_rect.position.x, view_rect.end.x, 1.0),
|
||
_edge_scroll_axis_velocity(mouse_position.y, view_rect.position.y, view_rect.end.y, 1.0)
|
||
)
|
||
return _capped_edge_scroll_velocity(velocity)
|
||
|
||
|
||
func _edge_scroll_activation_rect(view_rect: Rect2) -> Rect2:
|
||
return view_rect.grow(EDGE_SCROLL_OUTER_MARGIN)
|
||
|
||
|
||
func _capped_edge_scroll_velocity(velocity: Vector2) -> Vector2:
|
||
if velocity == Vector2.ZERO or velocity.length() <= EDGE_SCROLL_MAX_SPEED:
|
||
return velocity
|
||
return velocity.normalized() * EDGE_SCROLL_MAX_SPEED
|
||
|
||
|
||
func _edge_scroll_axis_velocity(position: float, start: float, end: float, direction_multiplier: float) -> float:
|
||
var left_pressure := _edge_scroll_pressure(start + EDGE_SCROLL_MARGIN - position)
|
||
if left_pressure > 0.0:
|
||
return _edge_scroll_speed_for_pressure(left_pressure) * direction_multiplier
|
||
var right_pressure := _edge_scroll_pressure(position - (end - EDGE_SCROLL_MARGIN))
|
||
if right_pressure > 0.0:
|
||
return -_edge_scroll_speed_for_pressure(right_pressure) * direction_multiplier
|
||
return 0.0
|
||
|
||
|
||
func _edge_scroll_pressure(distance_inside_margin: float) -> float:
|
||
return clampf(distance_inside_margin / EDGE_SCROLL_MARGIN, 0.0, 1.0)
|
||
|
||
|
||
func _edge_scroll_speed_for_pressure(pressure: float) -> float:
|
||
var eased := pressure * pressure * (3.0 - 2.0 * pressure)
|
||
return lerpf(EDGE_SCROLL_MIN_SPEED, EDGE_SCROLL_MAX_SPEED, eased)
|
||
|
||
|
||
func _edge_scroll_next_offset(current_offset: Vector2, mouse_position: Vector2, view_rect: Rect2, delta: float) -> Vector2:
|
||
var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect)
|
||
if velocity == Vector2.ZERO:
|
||
return current_offset
|
||
return _clamped_board_scroll_offset(current_offset + velocity * delta)
|
||
|
||
|
||
func _update_edge_scroll(delta: float) -> bool:
|
||
if _is_input_locked():
|
||
return false
|
||
var view_rect := _map_view_rect()
|
||
var mouse_position := get_viewport().get_mouse_position()
|
||
var velocity := _edge_scroll_velocity_for_position(mouse_position, view_rect)
|
||
if velocity == Vector2.ZERO:
|
||
return false
|
||
var previous_offset := board_scroll_offset
|
||
board_scroll_offset = _edge_scroll_next_offset(board_scroll_offset, mouse_position, view_rect, delta)
|
||
if previous_offset.distance_squared_to(board_scroll_offset) <= 0.01:
|
||
return false
|
||
var next_hover := _cell_from_screen(mouse_position)
|
||
if next_hover != hover_cell:
|
||
hover_cell = next_hover
|
||
_update_hud()
|
||
return true
|
||
|
||
|
||
func _short_name(unit_name: String) -> String:
|
||
if unit_name.length() <= 9:
|
||
return unit_name
|
||
return unit_name.substr(0, 8) + "."
|
||
|
||
|
||
func _refresh_ranges() -> void:
|
||
move_cells.clear()
|
||
attack_cells.clear()
|
||
skill_cells.clear()
|
||
item_cells.clear()
|
||
threat_cells.clear()
|
||
if show_threat_overlay and battle_started and state.battle_status == BattleState.STATUS_ACTIVE:
|
||
threat_cells = state.get_threat_cells(BattleState.TEAM_ENEMY)
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
return
|
||
move_cells = state.get_movement_range(selected["id"])
|
||
if not selected_skill_id.is_empty():
|
||
skill_cells = state.get_skill_cells(selected["id"], selected_skill_id)
|
||
if skill_cells.is_empty():
|
||
selected_skill_id = ""
|
||
if not _has_pending_move() or basic_attack_targeting:
|
||
attack_cells = state.get_attack_cells(selected["id"])
|
||
elif not selected_item_id.is_empty():
|
||
item_cells = state.get_item_target_cells(selected["id"], selected_item_id)
|
||
if item_cells.is_empty():
|
||
selected_item_id = ""
|
||
if not _has_pending_move() or basic_attack_targeting:
|
||
attack_cells = state.get_attack_cells(selected["id"])
|
||
else:
|
||
if not _has_pending_move() or basic_attack_targeting:
|
||
attack_cells = state.get_attack_cells(selected["id"])
|
||
|
||
|
||
func _update_hud() -> void:
|
||
status_label.text = state.get_status_text()
|
||
objective_label.text = _format_objective_hud_text()
|
||
_fit_label_font_size_to_text(
|
||
objective_label,
|
||
objective_label.text,
|
||
OBJECTIVE_HUD_FONT_SIZE,
|
||
OBJECTIVE_HUD_MIN_FONT_SIZE
|
||
)
|
||
campaign_status_label.text = campaign_state.get_progress_text()
|
||
_update_mission_panel()
|
||
_update_cell_info()
|
||
_update_forecast()
|
||
_update_result_panel()
|
||
if inventory_label != null:
|
||
inventory_label.text = _format_inventory_status_text()
|
||
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
var hovered_unit := _hovered_unit_for_hud_portrait()
|
||
_update_hud_unit_portrait(hovered_unit)
|
||
if hovered_unit.is_empty():
|
||
selected_label.text = "표식 없는 빈 전장입니다."
|
||
else:
|
||
selected_label.text = _format_unit_focus_text(hovered_unit, "Hover")
|
||
_update_wait_button({})
|
||
_update_tactic_button({})
|
||
_hide_tactic_menu()
|
||
_update_item_button({})
|
||
_hide_item_menu()
|
||
_update_equip_button({})
|
||
_hide_equip_menu()
|
||
else:
|
||
_update_hud_unit_portrait(selected)
|
||
selected_label.text = _format_unit_focus_text(selected, "Selected")
|
||
_update_wait_button(selected)
|
||
_update_tactic_button(selected)
|
||
_update_item_button(selected)
|
||
_update_equip_button(selected)
|
||
if tactic_menu != null and tactic_menu.visible:
|
||
_rebuild_tactic_menu(selected)
|
||
if item_menu != null and item_menu.visible:
|
||
_rebuild_item_menu(selected)
|
||
if equip_menu != null and equip_menu.visible:
|
||
_rebuild_equip_menu(selected)
|
||
|
||
_update_end_turn_button()
|
||
restart_button.disabled = campaign_complete_screen
|
||
_update_threat_button()
|
||
_update_post_move_menu()
|
||
_update_post_move_picker()
|
||
_update_targeting_hint_panel()
|
||
|
||
|
||
func _format_objective_hud_text() -> String:
|
||
var lines := []
|
||
var victory := String(state.objectives.get("victory", "적군을 모두 격파하라."))
|
||
if not victory.is_empty():
|
||
lines.append("승리: %s" % victory)
|
||
var defeat := String(state.objectives.get("defeat", ""))
|
||
if not defeat.is_empty():
|
||
lines.append("패배: %s" % defeat)
|
||
if lines.is_empty():
|
||
return "목표 대기"
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _format_unit_focus_text(unit: Dictionary, prefix: String) -> String:
|
||
if unit.is_empty():
|
||
return "표식 없는 빈 전장입니다."
|
||
var roster_mark := " *" if unit.get("loaded_from_roster", false) else ""
|
||
var control_label := " 호위" if not bool(unit.get("controllable", true)) else ""
|
||
var lines := []
|
||
lines.append("%s: %s%s%s" % [
|
||
_unit_focus_prefix_text(prefix),
|
||
str(unit.get("name", "부대")),
|
||
roster_mark,
|
||
control_label
|
||
])
|
||
lines.append("%s 품계 %d %s %s" % [
|
||
_unit_team_text(str(unit.get("team", ""))),
|
||
int(unit.get("level", 1)),
|
||
_unit_class_display_name(unit),
|
||
_unit_role_text(unit)
|
||
])
|
||
lines.append("병력 %d/%d 기력 %d/%d 전공 %d" % [
|
||
int(unit.get("hp", 0)),
|
||
int(unit.get("max_hp", 1)),
|
||
int(unit.get("mp", 0)),
|
||
int(unit.get("max_mp", 0)),
|
||
int(unit.get("exp", 0))
|
||
])
|
||
lines.append("무위 %d 방비 %d 지략 %d 기동 %d" % [
|
||
int(unit.get("atk", 0)),
|
||
int(unit.get("def", 0)),
|
||
int(unit.get("int", 0)),
|
||
int(unit.get("agi", 0))
|
||
])
|
||
lines.append("행군 %d (%s) 타격 %d-%d" % [
|
||
int(unit.get("move", 0)),
|
||
_format_move_type(str(unit.get("move_type", "foot"))),
|
||
int(unit.get("min_range", 1)),
|
||
int(unit.get("range", 1))
|
||
])
|
||
lines.append(_unit_action_status_text(unit))
|
||
var terrain_text := _unit_current_terrain_text(unit)
|
||
if not terrain_text.is_empty():
|
||
lines.append(terrain_text)
|
||
var status_effects := state.get_status_effect_summary(str(unit.get("id", "")))
|
||
if not status_effects.is_empty():
|
||
lines.append("상태: %s" % status_effects)
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _unit_focus_prefix_text(prefix: String) -> String:
|
||
if prefix == "Selected":
|
||
return "군기"
|
||
if prefix == "Hover":
|
||
return "관측"
|
||
return prefix
|
||
|
||
|
||
func _unit_action_status_text(unit: Dictionary) -> String:
|
||
var unit_id := str(unit.get("id", ""))
|
||
if unit_id.is_empty():
|
||
return "군령: 봉인"
|
||
var team := str(unit.get("team", ""))
|
||
if team != BattleState.TEAM_PLAYER:
|
||
return "군령: 적군 군기"
|
||
if not bool(unit.get("controllable", true)):
|
||
return "군령: 호위 대상"
|
||
if not bool(unit.get("alive", true)) or not bool(unit.get("deployed", true)):
|
||
return "군령: 봉인"
|
||
if bool(unit.get("acted", false)):
|
||
return "군령: 봉인"
|
||
if not state.can_player_act():
|
||
return "군령: 차례 대기"
|
||
var actions: Array[String] = []
|
||
if not bool(unit.get("moved", false)) and not state.get_movement_range(unit_id).is_empty():
|
||
actions.append("행군")
|
||
if not state.get_attack_cells(unit_id).is_empty():
|
||
actions.append("타격")
|
||
var skill_id := state.get_first_usable_skill_id(unit_id)
|
||
if not skill_id.is_empty() and not state.get_skill_cells(unit_id, skill_id).is_empty():
|
||
actions.append("책략")
|
||
if _unit_has_usable_item_target(unit_id):
|
||
actions.append("도구")
|
||
if not bool(unit.get("moved", false)):
|
||
actions.append("병장")
|
||
actions.append("대기")
|
||
var suffix := " (행군 완료)" if bool(unit.get("moved", false)) else ""
|
||
return "군령: %s%s" % [_join_strings(actions, ", "), suffix]
|
||
|
||
|
||
func _unit_has_usable_item_target(unit_id: String) -> bool:
|
||
for item_id in state.get_usable_item_ids():
|
||
if _item_has_valid_target(unit_id, str(item_id)):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _unit_has_usable_skill_target(unit_id: String) -> bool:
|
||
for skill_id in state.get_usable_skill_ids(unit_id):
|
||
if _skill_has_valid_target(unit_id, str(skill_id)):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _skill_has_valid_target(unit_id: String, skill_id: String) -> bool:
|
||
for cell in state.get_skill_cells(unit_id, skill_id):
|
||
var preview := state.get_skill_preview(unit_id, skill_id, cell)
|
||
if bool(preview.get("valid_target", false)):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _item_has_valid_target(unit_id: String, item_id: String) -> bool:
|
||
for cell in state.get_item_target_cells(unit_id, item_id):
|
||
var preview := state.get_item_preview(unit_id, item_id, cell)
|
||
if _item_preview_can_apply(preview):
|
||
return true
|
||
return false
|
||
|
||
|
||
func _item_preview_can_apply(preview: Dictionary) -> bool:
|
||
if preview.is_empty() or not bool(preview.get("in_range", false)) or not bool(preview.get("valid_target", false)):
|
||
return false
|
||
if int(preview.get("count", 0)) <= 0:
|
||
return false
|
||
if int(preview.get("heal", 0)) > 0 or int(preview.get("mp_heal", 0)) > 0:
|
||
return true
|
||
var cured = preview.get("cure_statuses", [])
|
||
return typeof(cured) == TYPE_ARRAY and not cured.is_empty()
|
||
|
||
|
||
func _unit_role_text(unit: Dictionary) -> String:
|
||
var class_id := str(unit.get("class_id", ""))
|
||
if class_id.contains("cavalry"):
|
||
return "돌격 기병"
|
||
if class_id.contains("archer") or class_id.contains("marksman"):
|
||
return "원거리 견제"
|
||
if class_id.contains("strategist") or class_id.contains("advisor"):
|
||
return "책략 참모"
|
||
if class_id.contains("hero") or class_id.contains("commander"):
|
||
return "군령 지휘"
|
||
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
|
||
return "선봉 돌파"
|
||
return "전열 보병"
|
||
|
||
|
||
func _unit_team_text(team: String) -> String:
|
||
if team == BattleState.TEAM_PLAYER:
|
||
return "아군"
|
||
if team == BattleState.TEAM_ENEMY:
|
||
return "적군"
|
||
return team.capitalize()
|
||
|
||
|
||
func _unit_class_display_name(unit: Dictionary) -> String:
|
||
var class_id := str(unit.get("class_id", ""))
|
||
if class_id.contains("cavalry"):
|
||
return "기병"
|
||
if class_id.contains("archer") or class_id.contains("marksman"):
|
||
return "궁병"
|
||
if class_id.contains("strategist") or class_id.contains("advisor"):
|
||
return "책사"
|
||
if class_id.contains("hero") or class_id.contains("commander"):
|
||
return "지휘관"
|
||
if class_id.contains("warrior") or class_id.contains("champion"):
|
||
return "무장"
|
||
if class_id.contains("bandit"):
|
||
return "산적"
|
||
return str(unit.get("class", "부대"))
|
||
|
||
|
||
func _unit_current_terrain_text(unit: Dictionary) -> String:
|
||
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
|
||
if not state.is_inside(cell):
|
||
return ""
|
||
var text := "지형 %d,%d %s 행군 %d 방비 +%d 회피 +%d%%" % [
|
||
cell.x + 1,
|
||
cell.y + 1,
|
||
_terrain_display_name(state.get_terrain_name(cell)),
|
||
state.get_move_cost(cell, str(unit.get("move_type", "foot"))),
|
||
state.get_terrain_defense(cell),
|
||
state.get_terrain_avoid(cell)
|
||
]
|
||
var heal := state.get_terrain_heal(cell)
|
||
if heal > 0:
|
||
text += " 회복 +%d" % heal
|
||
return text
|
||
|
||
|
||
func _update_mission_panel() -> void:
|
||
if mission_detail_panel != null:
|
||
mission_detail_panel.visible = not mission_detail_collapsed
|
||
if mission_toggle_button != null:
|
||
mission_toggle_button.text = "보기" if mission_detail_collapsed else "접기"
|
||
if mission_detail_label == null or mission_detail_collapsed:
|
||
return
|
||
mission_detail_label.text = _format_mission_panel_text()
|
||
|
||
|
||
func _format_mission_panel_text() -> String:
|
||
var lines := []
|
||
var victory := str(state.objectives.get("victory", ""))
|
||
if not victory.is_empty():
|
||
lines.append("승리: %s" % victory)
|
||
var defeat := str(state.objectives.get("defeat", ""))
|
||
if not defeat.is_empty():
|
||
lines.append("패배: %s" % defeat)
|
||
var progress_lines := state.get_objective_progress_lines(false, false)
|
||
if not progress_lines.is_empty():
|
||
lines.append("진행: %s" % _join_strings(progress_lines, " · "))
|
||
var risk_lines := state.get_defeat_progress_lines()
|
||
if not risk_lines.is_empty():
|
||
lines.append("주의: %s" % _join_strings(risk_lines, " · "))
|
||
if lines.is_empty():
|
||
return "열린 목표 없음"
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _hovered_unit_for_hud_portrait() -> Dictionary:
|
||
if not state.is_inside(hover_cell):
|
||
return {}
|
||
return state.get_unit_at(hover_cell)
|
||
|
||
|
||
func _update_hud_unit_portrait(unit: Dictionary) -> void:
|
||
if hud_unit_portrait_panel == null:
|
||
return
|
||
if unit.is_empty():
|
||
hud_unit_portrait_panel.visible = false
|
||
hud_unit_portrait_texture.texture = null
|
||
hud_unit_portrait_texture.visible = false
|
||
hud_unit_portrait_label.text = ""
|
||
hud_unit_portrait_label.visible = false
|
||
return
|
||
hud_unit_portrait_panel.visible = true
|
||
var texture := _load_portrait_texture(str(unit.get("portrait", "")))
|
||
if texture == null:
|
||
texture = _load_art_texture(str(unit.get("sprite", "")))
|
||
var has_portrait := texture != null
|
||
hud_unit_portrait_texture.texture = texture
|
||
hud_unit_portrait_texture.visible = has_portrait
|
||
hud_unit_portrait_label.text = _dialogue_portrait_initials(str(unit.get("name", "")))
|
||
hud_unit_portrait_label.visible = not has_portrait
|
||
|
||
|
||
func _update_cell_info() -> void:
|
||
if not state.is_inside(hover_cell):
|
||
cell_info_label.text = "지형: -"
|
||
return
|
||
|
||
var summary := state.get_cell_summary(hover_cell)
|
||
var cell: Vector2i = summary["cell"]
|
||
var text := "지형 %d,%d %s\n행군 %d 방비 +%d 회피 +%d%%" % [
|
||
cell.x + 1,
|
||
cell.y + 1,
|
||
_terrain_display_name(str(summary["terrain"])),
|
||
summary["move_cost"],
|
||
summary["defense"],
|
||
summary.get("avoid", 0)
|
||
]
|
||
var heal := int(summary.get("heal", 0))
|
||
if heal > 0:
|
||
text += " 회복 +%d" % heal
|
||
var objective_cell_label := state.get_objective_cell_label(cell)
|
||
if not objective_cell_label.is_empty():
|
||
text += "\n목표: %s" % objective_cell_label
|
||
if show_threat_overlay:
|
||
var threat_names := state.get_threatening_unit_names(cell, BattleState.TEAM_ENEMY)
|
||
if not threat_names.is_empty():
|
||
text += "\n감시: %s" % _compact_name_list(threat_names, 3)
|
||
|
||
var unit: Dictionary = summary["unit"]
|
||
if not unit.is_empty():
|
||
var control_label := " 호위" if not bool(unit.get("controllable", true)) else ""
|
||
text += "\n군기: %s%s %s 품계 %d\n%s %s 병력 %d/%d 기력 %d/%d\n병장: %s" % [
|
||
unit["name"],
|
||
control_label,
|
||
_unit_team_text(str(unit["team"])),
|
||
unit["level"],
|
||
_unit_class_display_name(unit),
|
||
_unit_role_text(unit),
|
||
unit["hp"],
|
||
unit["max_hp"],
|
||
unit.get("mp", 0),
|
||
unit.get("max_mp", 0),
|
||
_format_hover_unit_equipment(unit)
|
||
]
|
||
var status_effects := state.get_status_effect_summary(unit["id"])
|
||
if not status_effects.is_empty():
|
||
text += "\n상태: %s" % status_effects
|
||
if show_threat_overlay:
|
||
var physical_threats := state.get_physical_threat_previews(cell, BattleState.TEAM_ENEMY)
|
||
if not physical_threats.is_empty():
|
||
text += "\n병장 위협: %s" % _format_physical_threat_preview_text(physical_threats, 3)
|
||
var skill_threats := state.get_skill_threat_previews(cell, BattleState.TEAM_ENEMY)
|
||
if not skill_threats.is_empty():
|
||
text += "\n책략 위협: %s" % _format_skill_threat_preview_text(skill_threats, 3)
|
||
|
||
cell_info_label.text = text
|
||
|
||
|
||
func _format_physical_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
|
||
var parts := []
|
||
var safe_limit: int = maxi(1, limit)
|
||
for index in range(mini(previews.size(), safe_limit)):
|
||
var preview: Dictionary = previews[index]
|
||
var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
|
||
var effective_text := ""
|
||
var effective_bonus := int(preview.get("effective_bonus", 0))
|
||
if effective_bonus > 0:
|
||
var effective_type := _format_move_type(str(preview.get("effective_target_type", "")))
|
||
var effective_target_text := "" if effective_type.is_empty() else " 대 %s" % effective_type
|
||
effective_text = " 특효 +%d%s" % [effective_bonus, effective_target_text]
|
||
parts.append("%s 피해 %d/명중 %d%%%s%s" % [
|
||
str(preview.get("source_name", "부대")),
|
||
int(preview.get("damage", 0)),
|
||
int(preview.get("hit_chance", 0)),
|
||
effective_text,
|
||
defeat_text
|
||
])
|
||
if previews.size() > safe_limit:
|
||
parts.append("+%d" % (previews.size() - safe_limit))
|
||
return _join_strings(parts, "; ")
|
||
|
||
|
||
func _format_skill_threat_preview_text(previews: Array[Dictionary], limit: int) -> String:
|
||
var parts := []
|
||
var safe_limit: int = maxi(1, limit)
|
||
for index in range(mini(previews.size(), safe_limit)):
|
||
var preview: Dictionary = previews[index]
|
||
var source_text := str(preview.get("source_name", "부대"))
|
||
var skill_text := str(preview.get("skill_name", "책략"))
|
||
var area_text := _format_skill_threat_area_text(preview)
|
||
if str(preview.get("kind", "")) == "damage":
|
||
var defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
|
||
parts.append("%s %s 피해 %d%s%s" % [
|
||
source_text,
|
||
skill_text,
|
||
int(preview.get("damage", 0)),
|
||
area_text,
|
||
defeat_text
|
||
])
|
||
else:
|
||
var active_text := " 지속" if bool(preview.get("already_active", false)) else ""
|
||
parts.append("%s %s %s%s%s" % [
|
||
source_text,
|
||
skill_text,
|
||
str(preview.get("effect_text", "Effect")),
|
||
area_text,
|
||
active_text
|
||
])
|
||
if previews.size() > safe_limit:
|
||
parts.append("+%d" % (previews.size() - safe_limit))
|
||
return _join_strings(parts, "; ")
|
||
|
||
|
||
func _format_skill_threat_area_text(preview: Dictionary) -> String:
|
||
var target_count := int(preview.get("target_count", 0))
|
||
if target_count <= 1:
|
||
return ""
|
||
return " %d군" % target_count
|
||
|
||
|
||
func _compact_name_list(names: Array[String], limit: int) -> String:
|
||
var visible_names: Array[String] = []
|
||
var safe_limit: int = maxi(1, limit)
|
||
for index in range(mini(names.size(), safe_limit)):
|
||
visible_names.append(names[index])
|
||
if names.size() > safe_limit:
|
||
visible_names.append("+%d" % (names.size() - safe_limit))
|
||
return _join_strings(visible_names, ", ")
|
||
|
||
|
||
func _update_forecast() -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty() or not state.is_inside(hover_cell):
|
||
forecast_label.text = "전황: -"
|
||
return
|
||
|
||
if not selected_skill_id.is_empty():
|
||
_update_skill_forecast(selected)
|
||
return
|
||
if not selected_item_id.is_empty():
|
||
_update_item_forecast(selected)
|
||
return
|
||
|
||
var target := state.get_unit_at(hover_cell)
|
||
if target.is_empty() or target.get("team", "") == selected.get("team", ""):
|
||
forecast_label.text = "전황: -"
|
||
return
|
||
|
||
var preview := state.get_damage_preview(selected["id"], target["id"])
|
||
if preview.is_empty():
|
||
forecast_label.text = "전황: -"
|
||
return
|
||
|
||
if bool(preview.get("attack_locked", false)):
|
||
forecast_label.text = "전황: 병장 봉인\n무기로 공격할 수 없습니다."
|
||
return
|
||
|
||
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
|
||
var defeat_text := " 퇴각" if preview["would_defeat"] else ""
|
||
var effective_text := _format_effective_forecast_text(preview, "effective_bonus", "effective_target_type")
|
||
var counter_text := ""
|
||
if preview["counter_in_range"]:
|
||
var counter_defeat := " 퇴각" if preview["counter_would_defeat"] else ""
|
||
var counter_effective_text := _format_effective_forecast_text(preview, "counter_effective_bonus", "counter_effective_target_type")
|
||
counter_text = "\n반격 %d%s, 명중 %d%%, 아군 병력 %d/%d.%s" % [
|
||
preview["counter_damage"],
|
||
counter_effective_text,
|
||
preview.get("counter_hit_chance", 0),
|
||
preview["attacker_hp_after"],
|
||
selected["max_hp"],
|
||
counter_defeat
|
||
]
|
||
forecast_label.text = "전황: %s\n피해 %d%s, 명중 %d%%, 적 병력 %d/%d.%s%s" % [
|
||
range_text,
|
||
preview["damage"],
|
||
effective_text,
|
||
preview.get("hit_chance", 100),
|
||
preview["target_hp_after"],
|
||
target["max_hp"],
|
||
defeat_text,
|
||
counter_text
|
||
]
|
||
|
||
|
||
func _format_effective_forecast_text(preview: Dictionary, bonus_key: String, type_key: String) -> String:
|
||
var bonus := int(preview.get(bonus_key, 0))
|
||
if bonus <= 0:
|
||
return ""
|
||
var move_type := _format_move_type(str(preview.get(type_key, "")))
|
||
if move_type.is_empty():
|
||
move_type = "대상"
|
||
return " (특효 +%d 대 %s)" % [bonus, move_type]
|
||
|
||
|
||
func _update_skill_forecast(selected: Dictionary) -> void:
|
||
var preview := state.get_skill_preview(selected["id"], selected_skill_id, hover_cell)
|
||
if preview.is_empty():
|
||
forecast_label.text = "전황: -"
|
||
return
|
||
|
||
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
|
||
var mp_text := "" if preview["has_mp"] else " 기력이 부족합니다."
|
||
if bool(preview.get("skill_locked", false)):
|
||
forecast_label.text = "%s: 봉인, 기력 %d.\n책략을 펼칠 수 없습니다." % [
|
||
preview["skill_name"],
|
||
preview["mp_cost"]
|
||
]
|
||
return
|
||
if not preview["valid_target"]:
|
||
forecast_label.text = "%s: %s, 기력 %d.%s\n지목할 대상이 없습니다." % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text
|
||
]
|
||
return
|
||
|
||
var target := state.get_unit(str(preview.get("target_id", "")))
|
||
var target_max_hp := int(target.get("max_hp", 1)) if not target.is_empty() else 1
|
||
var target_count := int(preview.get("target_count", 1))
|
||
var has_area := bool(preview.get("has_area", false))
|
||
var area_text := " 범위 대상: %d." % target_count if has_area else ""
|
||
if preview["kind"] == "heal":
|
||
var total_heal := int(preview.get("total_heal", preview.get("heal", 0)))
|
||
if not has_area:
|
||
forecast_label.text = "%s: %s, 기력 %d.%s\n병력 %d 회복, 군기 병력 %d/%d." % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text,
|
||
preview["heal"],
|
||
preview["target_hp_after"],
|
||
target_max_hp
|
||
]
|
||
return
|
||
forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 병력 %d 회복, 지목 병력 %d/%d." % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text,
|
||
area_text,
|
||
total_heal,
|
||
preview["target_hp_after"],
|
||
target_max_hp
|
||
]
|
||
elif preview["kind"] == "support":
|
||
var refresh_text := " 갱신." if bool(preview.get("already_active", false)) else ""
|
||
forecast_label.text = "%s: %s, 기력 %d.%s%s\n%s.%s" % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text,
|
||
area_text,
|
||
preview.get("effect_text", "지원"),
|
||
refresh_text
|
||
]
|
||
else:
|
||
var defeat_count := int(preview.get("defeat_count", 0))
|
||
var defeat_text := " 퇴각 %d군" % defeat_count if defeat_count > 0 else ""
|
||
var total_damage := int(preview.get("total_damage", preview.get("damage", 0)))
|
||
if not has_area:
|
||
var single_defeat_text := " 퇴각" if bool(preview.get("would_defeat", false)) else ""
|
||
forecast_label.text = "%s: %s, 기력 %d.%s\n피해 %d, 적 병력 %d/%d.%s" % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text,
|
||
preview["damage"],
|
||
preview["target_hp_after"],
|
||
target_max_hp,
|
||
single_defeat_text
|
||
]
|
||
return
|
||
forecast_label.text = "%s: %s, 기력 %d.%s%s\n총 피해 %d, 지목 병력 %d/%d.%s" % [
|
||
preview["skill_name"],
|
||
range_text,
|
||
preview["mp_cost"],
|
||
mp_text,
|
||
area_text,
|
||
total_damage,
|
||
preview["target_hp_after"],
|
||
target_max_hp,
|
||
defeat_text
|
||
]
|
||
|
||
|
||
func _update_item_forecast(selected: Dictionary) -> void:
|
||
var preview := state.get_item_preview(selected["id"], selected_item_id, hover_cell)
|
||
if preview.is_empty():
|
||
forecast_label.text = "전황: -"
|
||
return
|
||
|
||
var target := state.get_unit_at(hover_cell)
|
||
var range_text := "사거리 안" if preview["in_range"] else "사거리 밖"
|
||
var count_text := "잔량 %d" % int(preview.get("count", 0))
|
||
if target.is_empty() or not preview["valid_target"]:
|
||
forecast_label.text = "%s: %s, %s\n지목할 대상이 없습니다." % [
|
||
preview["item_name"],
|
||
range_text,
|
||
count_text
|
||
]
|
||
return
|
||
|
||
var hp_heal := int(preview.get("heal", 0))
|
||
var mp_heal := int(preview.get("mp_heal", 0))
|
||
var cure_statuses: Array = preview.get("cure_statuses", [])
|
||
if hp_heal <= 0 and mp_heal <= 0 and cure_statuses.is_empty():
|
||
forecast_label.text = "%s: %s, %s\n보급 효과가 필요하지 않습니다." % [
|
||
preview["item_name"],
|
||
range_text,
|
||
count_text
|
||
]
|
||
return
|
||
|
||
var recovery_parts := []
|
||
if hp_heal > 0:
|
||
recovery_parts.append("병력 %d 회복, 군기 병력 %d/%d" % [
|
||
hp_heal,
|
||
preview["target_hp_after"],
|
||
target["max_hp"]
|
||
])
|
||
if mp_heal > 0:
|
||
recovery_parts.append("기력 %d 회복, 군기 기력 %d/%d" % [
|
||
mp_heal,
|
||
preview["target_mp_after"],
|
||
target.get("max_mp", 0)
|
||
])
|
||
if not cure_statuses.is_empty():
|
||
recovery_parts.append("치료 %s" % _join_strings(cure_statuses, ", "))
|
||
|
||
forecast_label.text = "%s: %s, %s\n%s." % [
|
||
preview["item_name"],
|
||
range_text,
|
||
count_text,
|
||
_join_strings(recovery_parts, "; ")
|
||
]
|
||
|
||
|
||
func _update_result_panel() -> void:
|
||
if result_panel == null:
|
||
return
|
||
_announce_battle_status_audio()
|
||
if campaign_complete_screen:
|
||
_clear_dialogue()
|
||
_clear_result_choices()
|
||
_apply_panel_style(result_panel, "result_victory_tablet")
|
||
result_panel.visible = true
|
||
if result_restart_button != null:
|
||
result_restart_button.visible = false
|
||
if next_battle_button != null:
|
||
next_battle_button.visible = false
|
||
result_label.text = "%s 전기 완결\n현 병서는 모두 정리되었습니다.\n군자금 %d냥\n새 전기를 열어 다시 진행합니다." % [
|
||
campaign_state.campaign_title,
|
||
campaign_state.gold
|
||
]
|
||
_refresh_screen_backdrop_visibility()
|
||
return
|
||
if state.battle_status == BattleState.STATUS_VICTORY:
|
||
if _try_show_post_battle_dialogue():
|
||
_clear_result_choices()
|
||
result_panel.visible = false
|
||
_refresh_screen_backdrop_visibility()
|
||
return
|
||
if _is_dialogue_visible():
|
||
_clear_result_choices()
|
||
result_panel.visible = false
|
||
_refresh_screen_backdrop_visibility()
|
||
return
|
||
_apply_battle_result_once()
|
||
_apply_panel_style(result_panel, "result_victory_tablet")
|
||
result_panel.visible = true
|
||
_rebuild_result_choices()
|
||
if result_restart_button != null:
|
||
result_restart_button.visible = true
|
||
_update_result_next_button_visibility()
|
||
result_label.text = _format_victory_result_text()
|
||
elif state.battle_status == BattleState.STATUS_DEFEAT:
|
||
_clear_dialogue()
|
||
_clear_result_choices()
|
||
_apply_panel_style(result_panel, "result_defeat_tablet")
|
||
result_panel.visible = true
|
||
if result_restart_button != null:
|
||
result_restart_button.visible = true
|
||
if next_battle_button != null:
|
||
next_battle_button.visible = false
|
||
result_label.text = _format_defeat_result_text()
|
||
else:
|
||
_clear_result_choices()
|
||
result_panel.visible = false
|
||
if result_restart_button != null:
|
||
result_restart_button.visible = true
|
||
if next_battle_button != null:
|
||
next_battle_button.visible = false
|
||
_refresh_screen_backdrop_visibility()
|
||
|
||
|
||
func _format_victory_result_text() -> String:
|
||
var lines := ["승리"]
|
||
_append_edict_clause(lines, "승리 조건", "완료", str(state.objectives.get("victory", "")))
|
||
var summary := _format_battle_result_summary()
|
||
if not summary.is_empty():
|
||
lines.append(summary)
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _format_defeat_result_text() -> String:
|
||
var lines := ["패배"]
|
||
_append_edict_clause(lines, "패배 조건", "확인", str(state.objectives.get("defeat", "")))
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _on_state_changed() -> void:
|
||
if state.get_selected_unit().is_empty():
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_log_added(message: String) -> void:
|
||
_play_log_sfx(message)
|
||
if log_box == null:
|
||
return
|
||
log_box.append_text(_format_log_entry_text(message) + "\n")
|
||
log_box.scroll_to_line(max(0, log_box.get_line_count() - 1))
|
||
|
||
|
||
func _format_log_entry_text(message: String) -> String:
|
||
if message.begins_with("Objective updated:"):
|
||
return "군령 갱신:%s" % message.substr("Objective updated:".length())
|
||
if message.begins_with("Defeat condition updated:"):
|
||
return "패배 조건 갱신:%s" % message.substr("Defeat condition updated:".length())
|
||
return message
|
||
|
||
|
||
func _format_hanja_number(value: int) -> String:
|
||
return str(value)
|
||
|
||
|
||
func _on_objective_updated(victory: String, defeat: String) -> void:
|
||
var notice_lines := ["군령 갱신"]
|
||
if not victory.is_empty():
|
||
notice_lines.append("목표: %s" % victory)
|
||
if not defeat.is_empty():
|
||
notice_lines.append("주의: %s" % defeat)
|
||
if objective_notice_label != null:
|
||
objective_notice_label.text = "\n".join(notice_lines)
|
||
_fit_label_font_size_to_text(
|
||
objective_notice_label,
|
||
objective_notice_label.text,
|
||
OBJECTIVE_NOTICE_FONT_SIZE,
|
||
OBJECTIVE_NOTICE_MIN_FONT_SIZE
|
||
)
|
||
if objective_notice_panel != null:
|
||
objective_notice_panel.visible = true
|
||
objective_notice_timer = OBJECTIVE_NOTICE_DURATION
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_dialogue_requested(lines: Array) -> void:
|
||
var normalized_lines := _normalized_dialogue_lines(lines)
|
||
if normalized_lines.is_empty():
|
||
return
|
||
dialogue_queue.append(normalized_lines)
|
||
if _can_show_dialogue_now():
|
||
_show_next_dialogue()
|
||
|
||
|
||
func _on_combat_feedback_requested(unit_id: String, text: String, kind: String) -> void:
|
||
var unit := state.get_unit(unit_id)
|
||
if unit.is_empty():
|
||
return
|
||
var cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
|
||
if not state.is_inside(cell):
|
||
return
|
||
var jitter := float((floating_texts.size() % 3) - 1) * 12.0
|
||
floating_texts.append({
|
||
"text": text,
|
||
"kind": kind,
|
||
"pos": _floating_text_origin(cell) + Vector2(jitter, 0),
|
||
"age": 0.0,
|
||
"duration": FLOATING_TEXT_LIFETIME
|
||
})
|
||
queue_redraw()
|
||
|
||
|
||
func _on_unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> void:
|
||
if unit_id.is_empty() or from_cell == to_cell:
|
||
return
|
||
unit_motion_by_unit[unit_id] = {
|
||
"from": from_cell,
|
||
"to": to_cell,
|
||
"age": 0.0,
|
||
"duration": UNIT_MOVE_ANIMATION_DURATION
|
||
}
|
||
queue_redraw()
|
||
|
||
|
||
func _on_unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) -> void:
|
||
if unit_id.is_empty():
|
||
return
|
||
var unit := state.get_unit(unit_id)
|
||
var profile := _unit_action_motion_profile(unit, from_cell, to_cell, action_kind)
|
||
if from_cell == to_cell and not _action_motion_allows_same_cell(profile):
|
||
return
|
||
unit_action_motion_by_unit[unit_id] = {
|
||
"from": from_cell,
|
||
"to": to_cell,
|
||
"kind": action_kind,
|
||
"profile": profile,
|
||
"attack_family": profile,
|
||
"style": _action_motion_style(profile),
|
||
"vfx_signature": _action_motion_vfx_signature(profile),
|
||
"impact_cue": _action_motion_impact_cue(profile),
|
||
"impact_radius": _action_motion_impact_radius(profile),
|
||
"age": 0.0,
|
||
"duration": _action_motion_duration(profile)
|
||
}
|
||
if is_inside_tree():
|
||
_play_sfx(_action_motion_sfx(profile))
|
||
queue_redraw()
|
||
|
||
|
||
func _unit_action_motion_profile(unit: Dictionary, from_cell: Vector2i, to_cell: Vector2i, _action_kind: String) -> String:
|
||
if _action_kind == "skill_heal":
|
||
return "tactic_heal"
|
||
if _action_kind == "skill_support":
|
||
return "tactic_support"
|
||
if _action_kind.begins_with("skill_"):
|
||
return "tactic_damage"
|
||
var class_id := str(unit.get("class_id", ""))
|
||
var distance := absi(from_cell.x - to_cell.x) + absi(from_cell.y - to_cell.y)
|
||
if class_id.contains("archer") or class_id.contains("marksman") or (int(unit.get("range", 1)) > 1 and distance > 1):
|
||
return "arrow"
|
||
if class_id.contains("cavalry"):
|
||
return "cavalry_charge"
|
||
if class_id == "infantry" or class_id == "guard_captain":
|
||
return "infantry_strike"
|
||
if class_id.contains("strategist") or class_id.contains("advisor") or class_id.contains("hero") or class_id.contains("commander"):
|
||
return "command_strike"
|
||
if class_id.contains("warrior") or class_id.contains("champion") or class_id.contains("bandit"):
|
||
return "heavy_strike"
|
||
return "slash"
|
||
|
||
|
||
func _action_motion_style(profile: String) -> String:
|
||
if profile == "arrow":
|
||
return "ranged"
|
||
if profile.begins_with("tactic_"):
|
||
return "tactic"
|
||
return "melee"
|
||
|
||
|
||
func _action_motion_duration(profile: String) -> float:
|
||
if profile == "arrow":
|
||
return UNIT_ARROW_ATTACK_DURATION
|
||
if profile == "cavalry_charge":
|
||
return UNIT_CAVALRY_ATTACK_DURATION
|
||
if profile == "infantry_strike":
|
||
return UNIT_INFANTRY_ATTACK_DURATION
|
||
if profile == "command_strike":
|
||
return UNIT_COMMAND_ATTACK_DURATION
|
||
if profile == "heavy_strike":
|
||
return UNIT_HEAVY_ATTACK_DURATION
|
||
if profile.begins_with("tactic_"):
|
||
return UNIT_TACTIC_CAST_DURATION
|
||
return UNIT_ATTACK_ANIMATION_DURATION
|
||
|
||
|
||
func _action_motion_lunge_scale(profile: String) -> float:
|
||
if profile == "arrow":
|
||
return 0.22
|
||
if profile == "cavalry_charge":
|
||
return 1.65
|
||
if profile == "infantry_strike":
|
||
return 0.95
|
||
if profile == "command_strike":
|
||
return 0.20
|
||
if profile == "heavy_strike":
|
||
return 1.15
|
||
if profile.begins_with("tactic_"):
|
||
return 0.12
|
||
return 1.0
|
||
|
||
|
||
func _action_motion_lunge_curve(profile: String, progress: float) -> float:
|
||
var p := clampf(progress, 0.0, 1.0)
|
||
if profile == "arrow":
|
||
return sin(p * PI) * 0.32
|
||
if profile == "cavalry_charge":
|
||
if p < 0.75:
|
||
return _action_motion_smoothstep(p / 0.75)
|
||
return 1.0 - _action_motion_smoothstep((p - 0.75) / 0.25)
|
||
if profile == "infantry_strike":
|
||
return sin(p * PI) * (0.72 + 0.28 * (1.0 - p))
|
||
if profile == "command_strike" or profile.begins_with("tactic_"):
|
||
return sin(p * PI) * 0.30
|
||
if profile == "heavy_strike":
|
||
var late := _action_motion_smoothstep(clampf((p - 0.18) / 0.72, 0.0, 1.0))
|
||
var recovery := 1.0 - _action_motion_smoothstep(clampf((p - 0.72) / 0.28, 0.0, 1.0))
|
||
return late * recovery
|
||
return sin(p * PI)
|
||
|
||
|
||
func _action_motion_effect_peak(profile: String, progress: float) -> float:
|
||
var p := clampf(progress, 0.0, 1.0)
|
||
if profile == "arrow":
|
||
return pow(1.0 - abs(p - 0.34) / 0.34, 2.0) if p <= 0.68 else 0.0
|
||
if profile == "cavalry_charge":
|
||
return _action_motion_smoothstep(p) * (1.0 - _action_motion_smoothstep(clampf((p - 0.82) / 0.18, 0.0, 1.0)) * 0.22)
|
||
if profile == "infantry_strike":
|
||
return sin(p * PI)
|
||
if profile == "command_strike":
|
||
return 0.42 + 0.58 * sin(p * PI)
|
||
if profile == "heavy_strike":
|
||
return pow(maxf(0.0, sin(p * PI)), 0.62) * _action_motion_smoothstep(clampf((p - 0.18) / 0.40, 0.0, 1.0))
|
||
if profile == "tactic_damage":
|
||
return 0.28 + 0.72 * sin(p * PI)
|
||
if profile == "tactic_heal":
|
||
return 0.36 + 0.64 * sin(p * PI)
|
||
if profile == "tactic_support":
|
||
return 0.32 + 0.68 * sin(p * PI)
|
||
return sin(p * PI)
|
||
|
||
|
||
func _action_motion_smoothstep(value: float) -> float:
|
||
var p := clampf(value, 0.0, 1.0)
|
||
return p * p * (3.0 - 2.0 * p)
|
||
|
||
|
||
func _action_motion_allows_same_cell(profile: String) -> bool:
|
||
return profile.begins_with("tactic_")
|
||
|
||
|
||
func _action_motion_vfx_signature(profile: String) -> String:
|
||
if profile == "arrow":
|
||
return "piercing_trace"
|
||
if profile == "cavalry_charge":
|
||
return "charge_dust"
|
||
if profile == "infantry_strike":
|
||
return "shield_spark"
|
||
if profile == "command_strike":
|
||
return "command_ring"
|
||
if profile == "heavy_strike":
|
||
return "shock_crack"
|
||
if profile == "tactic_damage":
|
||
return "tactic_flare"
|
||
if profile == "tactic_heal":
|
||
return "healing_sigil"
|
||
if profile == "tactic_support":
|
||
return "order_banner"
|
||
return "slash_flash"
|
||
|
||
|
||
func _action_motion_impact_cue(profile: String) -> String:
|
||
if profile == "arrow":
|
||
return "projectile"
|
||
if profile == "cavalry_charge":
|
||
return "charge"
|
||
if profile == "infantry_strike":
|
||
return "guard"
|
||
if profile == "command_strike":
|
||
return "command"
|
||
if profile == "heavy_strike":
|
||
return "crush"
|
||
if profile == "tactic_damage":
|
||
return "tactic_damage"
|
||
if profile == "tactic_heal":
|
||
return "tactic_heal"
|
||
if profile == "tactic_support":
|
||
return "tactic_support"
|
||
return "slash"
|
||
|
||
|
||
func _action_motion_impact_radius(profile: String) -> float:
|
||
if profile == "arrow":
|
||
return 14.0
|
||
if profile == "cavalry_charge":
|
||
return 25.0
|
||
if profile == "infantry_strike":
|
||
return 18.0
|
||
if profile == "command_strike":
|
||
return 24.0
|
||
if profile == "heavy_strike":
|
||
return 28.0
|
||
if profile == "tactic_damage":
|
||
return 26.0
|
||
if profile == "tactic_heal":
|
||
return 23.0
|
||
if profile == "tactic_support":
|
||
return 24.0
|
||
return 16.0
|
||
|
||
|
||
func _action_motion_sfx(profile: String) -> String:
|
||
if profile == "arrow":
|
||
return "bow_release"
|
||
if profile == "cavalry_charge":
|
||
return "footstep"
|
||
if profile == "infantry_strike":
|
||
return "slash"
|
||
if profile == "command_strike":
|
||
return "skill_cast"
|
||
if profile == "heavy_strike":
|
||
return "slash"
|
||
if profile.begins_with("tactic_"):
|
||
return "skill_cast"
|
||
return "slash"
|
||
|
||
|
||
func _floating_text_origin(cell: Vector2i) -> Vector2:
|
||
var rect := _rect_for_cell(cell)
|
||
return rect.position + Vector2(TILE_SIZE * 0.5, 18.0)
|
||
|
||
|
||
func _normalized_dialogue_lines(lines: Array) -> Array:
|
||
var result := []
|
||
for line in lines:
|
||
if typeof(line) == TYPE_DICTIONARY:
|
||
var text := str(line.get("text", ""))
|
||
if text.is_empty():
|
||
continue
|
||
var speaker := str(line.get("speaker", ""))
|
||
var display_speaker := _localized_speaker_name(str(line.get("display_speaker", speaker)))
|
||
var portrait := str(line.get("portrait", ""))
|
||
if portrait.is_empty() and not speaker.strip_edges().is_empty():
|
||
portrait = state.data_catalog.get_portrait_for_speaker(_canonical_speaker_name(speaker))
|
||
result.append({
|
||
"speaker": display_speaker,
|
||
"text": text,
|
||
"portrait": portrait,
|
||
"side": _dialogue_side_for_line(line)
|
||
})
|
||
elif typeof(line) == TYPE_STRING:
|
||
var text := str(line)
|
||
if text.is_empty():
|
||
continue
|
||
result.append({"speaker": "", "text": text, "portrait": "", "side": "left"})
|
||
return result
|
||
|
||
|
||
func _normalized_dialogue_side(value) -> String:
|
||
var side := str(value).strip_edges().to_lower()
|
||
if side == "right":
|
||
return "right"
|
||
return "left"
|
||
|
||
|
||
func _dialogue_side_for_line(line: Dictionary) -> String:
|
||
var explicit_side := str(line.get("side", "")).strip_edges()
|
||
if not explicit_side.is_empty():
|
||
return _normalized_dialogue_side(explicit_side)
|
||
var speaker := _canonical_speaker_name(str(line.get("speaker", line.get("display_speaker", ""))))
|
||
if DIALOGUE_RIGHT_SIDE_SPEAKERS.has(speaker):
|
||
return "right"
|
||
return "left"
|
||
|
||
|
||
func _can_show_dialogue_now() -> bool:
|
||
if not battle_started or campaign_complete_screen:
|
||
return false
|
||
if state.battle_status != BattleState.STATUS_ACTIVE:
|
||
return false
|
||
if briefing_panel != null and briefing_panel.visible:
|
||
return false
|
||
if result_panel != null and result_panel.visible:
|
||
return false
|
||
return not _is_dialogue_visible()
|
||
|
||
|
||
func _show_next_dialogue() -> void:
|
||
if dialogue_panel == null or dialogue_queue.is_empty():
|
||
return
|
||
if _is_dialogue_visible():
|
||
return
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_menu()
|
||
_refresh_ranges()
|
||
active_dialogue_lines = dialogue_queue.pop_front()
|
||
active_dialogue_index = 0
|
||
_render_dialogue_line()
|
||
|
||
|
||
func _render_dialogue_line() -> void:
|
||
if active_dialogue_index < 0 or active_dialogue_index >= active_dialogue_lines.size():
|
||
_hide_dialogue_panel()
|
||
return
|
||
var line: Dictionary = active_dialogue_lines[active_dialogue_index]
|
||
var speaker := str(line.get("speaker", ""))
|
||
var display_speaker := _localized_speaker_name(str(line.get("display_speaker", speaker)))
|
||
var side := _dialogue_side_for_line(line)
|
||
_apply_dialogue_side(side)
|
||
dialogue_speaker_label.text = display_speaker if not display_speaker.is_empty() else ""
|
||
dialogue_speaker_label.visible = not display_speaker.is_empty()
|
||
_fit_label_font_size_to_text(
|
||
dialogue_speaker_label,
|
||
dialogue_speaker_label.text,
|
||
DIALOGUE_SPEAKER_FONT_SIZE,
|
||
DIALOGUE_SPEAKER_MIN_FONT_SIZE
|
||
)
|
||
dialogue_text_label.text = _format_dialogue_body_text(str(line.get("text", "")))
|
||
_fit_label_font_size_to_text(
|
||
dialogue_text_label,
|
||
dialogue_text_label.text,
|
||
DIALOGUE_TEXT_FONT_SIZE,
|
||
DIALOGUE_TEXT_MIN_FONT_SIZE
|
||
)
|
||
_update_dialogue_portrait(display_speaker, str(line.get("portrait", "")))
|
||
_update_dialogue_controls()
|
||
dialogue_panel.visible = true
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _format_dialogue_body_text(text: String) -> String:
|
||
var stripped := text.strip_edges()
|
||
if stripped.is_empty():
|
||
return ""
|
||
if stripped.begins_with("「") or stripped.begins_with("『"):
|
||
return stripped
|
||
return "「%s」" % stripped
|
||
|
||
|
||
func _update_dialogue_controls() -> void:
|
||
if dialogue_progress_label != null:
|
||
dialogue_progress_label.text = "대화 %d/%d" % [active_dialogue_index + 1, active_dialogue_lines.size()]
|
||
if dialogue_previous_button != null:
|
||
dialogue_previous_button.disabled = active_dialogue_index <= 0
|
||
dialogue_continue_button.text = "닫기" if active_dialogue_index >= active_dialogue_lines.size() - 1 else "다음"
|
||
|
||
|
||
func _apply_dialogue_side(side: String) -> void:
|
||
if dialogue_row == null or dialogue_portrait_panel == null or dialogue_column == null:
|
||
return
|
||
var portrait_index: int = maxi(0, dialogue_row.get_child_count() - 2) if side == "right" else 1
|
||
dialogue_row.move_child(dialogue_portrait_panel, portrait_index)
|
||
dialogue_speaker_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT
|
||
dialogue_text_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT if side == "right" else HORIZONTAL_ALIGNMENT_LEFT
|
||
|
||
|
||
func _update_dialogue_portrait(speaker: String, portrait_path: String) -> void:
|
||
if dialogue_portrait_panel == null:
|
||
return
|
||
var texture := _load_portrait_texture(portrait_path)
|
||
var has_portrait := texture != null
|
||
var has_placeholder := not speaker.is_empty()
|
||
dialogue_portrait_panel.visible = has_portrait or has_placeholder
|
||
dialogue_portrait_texture.texture = texture
|
||
dialogue_portrait_texture.visible = has_portrait
|
||
dialogue_portrait_label.text = _dialogue_portrait_initials(speaker)
|
||
dialogue_portrait_label.visible = not has_portrait and has_placeholder
|
||
|
||
|
||
func _localized_speaker_name(speaker: String) -> String:
|
||
var normalized_speaker := speaker.strip_edges()
|
||
if normalized_speaker.is_empty():
|
||
return ""
|
||
return str(SPEAKER_DISPLAY_NAMES.get(normalized_speaker, normalized_speaker))
|
||
|
||
|
||
func _canonical_speaker_name(speaker: String) -> String:
|
||
var normalized_speaker := speaker.strip_edges()
|
||
if normalized_speaker.is_empty():
|
||
return ""
|
||
return str(SPEAKER_CANONICAL_NAMES.get(normalized_speaker, normalized_speaker))
|
||
|
||
|
||
func _load_art_texture(texture_path: String) -> Texture2D:
|
||
return _load_portrait_texture(texture_path)
|
||
|
||
|
||
func _load_portrait_texture(portrait_path: String) -> Texture2D:
|
||
var normalized_path := portrait_path.strip_edges()
|
||
if normalized_path.is_empty():
|
||
return null
|
||
if not normalized_path.begins_with("res://") and not normalized_path.begins_with("user://"):
|
||
normalized_path = "res://%s" % normalized_path
|
||
if missing_portrait_paths.has(normalized_path):
|
||
return null
|
||
if portrait_texture_cache.has(normalized_path):
|
||
return portrait_texture_cache[normalized_path] as Texture2D
|
||
if ResourceLoader.exists(normalized_path, "Texture2D"):
|
||
var imported_texture := load(normalized_path) as Texture2D
|
||
if imported_texture != null:
|
||
portrait_texture_cache[normalized_path] = imported_texture
|
||
return imported_texture
|
||
if not FileAccess.file_exists(normalized_path):
|
||
missing_portrait_paths[normalized_path] = true
|
||
return null
|
||
var image := Image.new()
|
||
if image.load(normalized_path) != OK:
|
||
missing_portrait_paths[normalized_path] = true
|
||
return null
|
||
var raw_texture := ImageTexture.create_from_image(image)
|
||
portrait_texture_cache[normalized_path] = raw_texture
|
||
return raw_texture
|
||
|
||
|
||
func _dialogue_portrait_initials(speaker: String) -> String:
|
||
var normalized_speaker := speaker.strip_edges()
|
||
if normalized_speaker.is_empty():
|
||
return ""
|
||
var parts := normalized_speaker.split(" ", false)
|
||
var initials := ""
|
||
for part in parts:
|
||
if str(part).is_empty():
|
||
continue
|
||
initials += str(part).substr(0, 1).to_upper()
|
||
if initials.length() >= 2:
|
||
break
|
||
if initials.is_empty():
|
||
return normalized_speaker.substr(0, 1).to_upper()
|
||
return initials
|
||
|
||
|
||
func _advance_dialogue() -> void:
|
||
if not _is_dialogue_visible():
|
||
return
|
||
_play_ui_click()
|
||
active_dialogue_index += 1
|
||
if active_dialogue_index < active_dialogue_lines.size():
|
||
_render_dialogue_line()
|
||
return
|
||
_hide_dialogue_panel()
|
||
if _can_show_dialogue_now():
|
||
_show_next_dialogue()
|
||
else:
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _retreat_dialogue() -> void:
|
||
if not _is_dialogue_visible() or active_dialogue_index <= 0:
|
||
return
|
||
_play_ui_click()
|
||
active_dialogue_index -= 1
|
||
_render_dialogue_line()
|
||
|
||
|
||
func _hide_dialogue_panel() -> void:
|
||
if dialogue_panel != null:
|
||
dialogue_panel.visible = false
|
||
active_dialogue_lines.clear()
|
||
active_dialogue_index = 0
|
||
if dialogue_progress_label != null:
|
||
dialogue_progress_label.text = ""
|
||
if dialogue_previous_button != null:
|
||
dialogue_previous_button.disabled = true
|
||
|
||
|
||
func _clear_dialogue() -> void:
|
||
dialogue_queue.clear()
|
||
_hide_dialogue_panel()
|
||
|
||
|
||
func _is_dialogue_visible() -> bool:
|
||
return dialogue_panel != null and dialogue_panel.visible
|
||
|
||
|
||
func _on_wait_pressed() -> void:
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_picker()
|
||
if _commit_pending_move_before_action() and state.try_wait_selected():
|
||
_play_ui_confirm()
|
||
|
||
|
||
func _on_end_turn_pressed() -> void:
|
||
if state.can_player_act():
|
||
_play_ui_confirm()
|
||
if _has_pending_move():
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_picker()
|
||
if not (_commit_pending_move_before_action() and state.try_wait_selected()):
|
||
return
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_picker()
|
||
state.end_player_turn()
|
||
|
||
|
||
func _on_tactic_pressed() -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return
|
||
if _has_pending_move():
|
||
_on_post_move_tactic_pressed()
|
||
return
|
||
if state.is_unit_skill_sealed(selected["id"]):
|
||
_play_ui_cancel()
|
||
selected_skill_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_post_move_picker()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
return
|
||
_play_ui_click()
|
||
if tactic_menu != null and tactic_menu.visible:
|
||
_hide_tactic_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
else:
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_post_move_menu()
|
||
_hide_post_move_picker()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_show_tactic_menu(selected)
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_item_pressed() -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return
|
||
if _has_pending_move():
|
||
_on_post_move_item_pressed()
|
||
return
|
||
_play_ui_click()
|
||
if item_menu != null and item_menu.visible:
|
||
_hide_item_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
else:
|
||
selected_skill_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_post_move_menu()
|
||
_hide_post_move_picker()
|
||
_hide_tactic_menu()
|
||
_hide_equip_menu()
|
||
_show_item_menu(selected)
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_equip_pressed() -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return
|
||
if selected.get("moved", false) or selected.get("acted", false):
|
||
return
|
||
_play_ui_click()
|
||
if equip_menu != null and equip_menu.visible:
|
||
_hide_equip_menu()
|
||
else:
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_post_move_menu()
|
||
_hide_post_move_picker()
|
||
_show_equip_menu(selected)
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_threat_pressed() -> void:
|
||
if not battle_started or state.battle_status != BattleState.STATUS_ACTIVE or campaign_complete_screen:
|
||
if threat_button != null:
|
||
threat_button.button_pressed = show_threat_overlay
|
||
return
|
||
show_threat_overlay = not show_threat_overlay
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_mission_toggle_pressed() -> void:
|
||
_play_ui_click()
|
||
mission_detail_collapsed = not mission_detail_collapsed
|
||
_update_mission_panel()
|
||
|
||
|
||
func _on_restart_pressed() -> void:
|
||
if campaign_complete_screen:
|
||
return
|
||
_play_ui_confirm()
|
||
if log_box != null:
|
||
log_box.clear()
|
||
battle_started = false
|
||
battle_result_applied = false
|
||
battle_result_summary.clear()
|
||
post_battle_dialogue_started = false
|
||
floating_texts.clear()
|
||
unit_motion_by_unit.clear()
|
||
shop_sell_mode = false
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
armory_unit_id = ""
|
||
formation_unit_id = ""
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_load_scenario(active_scenario_id)
|
||
_show_briefing()
|
||
|
||
|
||
func _load_current_battle() -> void:
|
||
_load_scenario(campaign_state.current_scenario_id)
|
||
|
||
|
||
func _load_pending_post_battle_choice() -> void:
|
||
_load_scenario(campaign_state.get_pending_post_battle_choice_scenario_id())
|
||
battle_started = true
|
||
battle_result_applied = true
|
||
post_battle_dialogue_started = true
|
||
battle_result_summary = campaign_state.get_pending_post_battle_choice_summary()
|
||
state.battle_status = BattleState.STATUS_VICTORY
|
||
last_announced_battle_status = BattleState.STATUS_VICTORY
|
||
_play_bgm("menu")
|
||
if briefing_panel != null:
|
||
briefing_panel.visible = false
|
||
if log_box != null:
|
||
log_box.append_text("군의 논의가 이어지고 있다.\n")
|
||
|
||
|
||
func _load_scenario(scenario_id: String) -> void:
|
||
campaign_complete_screen = false
|
||
active_scenario_id = scenario_id
|
||
last_announced_battle_status = ""
|
||
var scenario_path := campaign_state.get_scenario_path(active_scenario_id)
|
||
if scenario_path.is_empty():
|
||
push_error("Missing scenario path for %s." % active_scenario_id)
|
||
return
|
||
battle_started = false
|
||
battle_result_applied = false
|
||
battle_result_summary.clear()
|
||
post_battle_dialogue_started = false
|
||
floating_texts.clear()
|
||
unit_motion_by_unit.clear()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_clear_dialogue()
|
||
state.load_battle(
|
||
scenario_path,
|
||
campaign_state.get_roster_overrides(),
|
||
campaign_state.get_inventory_snapshot(),
|
||
campaign_state.get_flags_snapshot(),
|
||
campaign_state.get_joined_officers_snapshot(),
|
||
campaign_state.get_scenario_title(active_scenario_id)
|
||
)
|
||
_reset_board_scroll()
|
||
|
||
|
||
func _format_briefing_title(briefing: Dictionary) -> String:
|
||
var title := campaign_state.get_scenario_title(active_scenario_id)
|
||
if title.is_empty():
|
||
title = str(briefing.get("title", state.battle_name))
|
||
if title.is_empty():
|
||
title = state.battle_name
|
||
var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
|
||
if not chapter_title.is_empty():
|
||
var chapter_position := campaign_state.get_chapter_position_for_scenario(active_scenario_id)
|
||
var chapter_count := campaign_state.get_chapter_count()
|
||
if chapter_position > 0 and chapter_count > 0:
|
||
return "장 %s/%s · %s" % [chapter_position, chapter_count, chapter_title]
|
||
return chapter_title
|
||
var position := campaign_state.get_scenario_position(active_scenario_id)
|
||
var count := campaign_state.get_scenario_count()
|
||
if position > 0 and count > 0:
|
||
return "전장 %s/%s · %s" % [position, count, title]
|
||
return title
|
||
|
||
|
||
func _format_briefing_location(briefing: Dictionary) -> String:
|
||
var location := str(briefing.get("location", ""))
|
||
var chapter_title := campaign_state.get_chapter_title_for_scenario(active_scenario_id)
|
||
if not chapter_title.is_empty():
|
||
var battle_text := _format_briefing_chapter_battle_text(briefing)
|
||
if location.is_empty():
|
||
return battle_text
|
||
return "%s · %s" % [battle_text, location]
|
||
if campaign_state.campaign_title.is_empty():
|
||
return location
|
||
if location.is_empty():
|
||
return campaign_state.campaign_title
|
||
return "%s · %s" % [campaign_state.campaign_title, location]
|
||
|
||
|
||
func _format_briefing_chapter_battle_text(briefing: Dictionary) -> String:
|
||
var title := campaign_state.get_scenario_title(active_scenario_id)
|
||
if title.is_empty():
|
||
title = str(briefing.get("title", state.battle_name))
|
||
if title.is_empty():
|
||
title = state.battle_name
|
||
var position := campaign_state.get_scenario_position_in_chapter(active_scenario_id)
|
||
var count := campaign_state.get_scenario_count_in_chapter(active_scenario_id)
|
||
if position > 0 and count > 0:
|
||
return "전장 %s/%s · %s" % [position, count, title]
|
||
return title
|
||
|
||
|
||
func _append_edict_clause(lines: Array, heading: String, seal: String, text: String) -> void:
|
||
var body := text.strip_edges()
|
||
if body.is_empty():
|
||
return
|
||
lines.append(heading)
|
||
lines.append(" %s|%s" % [seal, body])
|
||
|
||
|
||
func _format_briefing_objectives() -> String:
|
||
var lines := []
|
||
var victory := str(state.objectives.get("victory", ""))
|
||
if not victory.is_empty():
|
||
lines.append("승리: %s" % victory)
|
||
var defeat := str(state.objectives.get("defeat", ""))
|
||
if not defeat.is_empty():
|
||
lines.append("패배: %s" % defeat)
|
||
var rewards_text := _format_briefing_rewards()
|
||
if not rewards_text.is_empty():
|
||
lines.append("보상: %s" % rewards_text)
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _format_briefing_rewards() -> String:
|
||
if campaign_state.is_scenario_completed(active_scenario_id):
|
||
return "이미 수령"
|
||
var rewards := state.get_rewards()
|
||
var parts := []
|
||
var gold: int = maxi(0, int(rewards.get("gold", 0)))
|
||
if gold > 0:
|
||
parts.append("군자금 %d냥" % gold)
|
||
var items = rewards.get("items", [])
|
||
if typeof(items) == TYPE_ARRAY and not items.is_empty():
|
||
var item_text := _format_reward_items(items)
|
||
if item_text != "없음":
|
||
parts.append(item_text)
|
||
var joined_text := _format_officer_id_list(rewards.get("join_officers", []))
|
||
if not joined_text.is_empty():
|
||
parts.append("합류 %s" % joined_text)
|
||
var left_text := _format_officer_id_list(rewards.get("leave_officers", []))
|
||
if not left_text.is_empty():
|
||
parts.append("이탈 %s" % left_text)
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _update_briefing_camp_overview(briefing: Dictionary, prep_locked: bool) -> void:
|
||
if briefing_camp_overview_row == null:
|
||
return
|
||
var overview_text := _format_briefing_camp_overview_text(briefing, prep_locked)
|
||
briefing_camp_overview_row.visible = not overview_text.is_empty()
|
||
if briefing_camp_overview_panel != null:
|
||
briefing_camp_overview_panel.visible = not overview_text.is_empty()
|
||
if briefing_camp_overview_label != null:
|
||
briefing_camp_overview_label.text = overview_text
|
||
var texture := _current_battle_background_texture()
|
||
var has_texture := texture != null
|
||
if briefing_camp_overview_texture != null:
|
||
briefing_camp_overview_texture.texture = texture
|
||
briefing_camp_overview_texture.visible = has_texture
|
||
if briefing_camp_overview_fallback_label != null:
|
||
briefing_camp_overview_fallback_label.text = "비단 전장도"
|
||
briefing_camp_overview_fallback_label.visible = not has_texture
|
||
|
||
|
||
func _format_briefing_camp_overview_text(briefing: Dictionary, prep_locked: bool) -> String:
|
||
var lines := []
|
||
var location := str(briefing.get("location", "")).strip_edges()
|
||
if not location.is_empty():
|
||
lines.append("위치: %s" % location)
|
||
var battlefield_text := _format_briefing_battlefield_overview_text()
|
||
if not battlefield_text.is_empty():
|
||
lines.append("전장: %s" % battlefield_text)
|
||
var camp_parts := ["군자금 %d냥" % campaign_state.gold]
|
||
var talk_text := _format_briefing_talk_overview_text()
|
||
if not talk_text.is_empty():
|
||
camp_parts.append(talk_text)
|
||
var shop_text := _format_briefing_shop_overview_text()
|
||
if not shop_text.is_empty():
|
||
camp_parts.append(shop_text)
|
||
if not camp_parts.is_empty():
|
||
lines.append("군막: %s" % _join_strings(camp_parts, " · "))
|
||
var prep_parts := []
|
||
var roster_text := _format_briefing_roster_overview_text()
|
||
if not roster_text.is_empty():
|
||
prep_parts.append(roster_text)
|
||
var formation_text := _format_briefing_formation_overview_text()
|
||
if not formation_text.is_empty():
|
||
prep_parts.append(formation_text)
|
||
if not state.get_controllable_player_units().is_empty():
|
||
prep_parts.append("비축 물자")
|
||
if not prep_parts.is_empty():
|
||
lines.append("준비: %s" % _join_strings(prep_parts, " · "))
|
||
if prep_locked:
|
||
lines.append("회상 전투")
|
||
return _join_strings(lines, "\n")
|
||
|
||
|
||
func _format_briefing_battlefield_overview_text() -> String:
|
||
var parts := []
|
||
var battle_map_size := state.get_map_size()
|
||
if battle_map_size.x > 0 and battle_map_size.y > 0:
|
||
parts.append("%d칸 x %d칸" % [battle_map_size.x, battle_map_size.y])
|
||
var enemy_text := _format_briefing_enemy_force_text()
|
||
if not enemy_text.is_empty():
|
||
parts.append(enemy_text)
|
||
var recovery_text := _format_briefing_recovery_terrain_text()
|
||
if not recovery_text.is_empty():
|
||
parts.append(recovery_text)
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _format_briefing_enemy_force_text() -> String:
|
||
var enemies := state.get_living_units(BattleState.TEAM_ENEMY)
|
||
if enemies.is_empty():
|
||
return ""
|
||
var class_order: Array[String] = []
|
||
var class_counts := {}
|
||
for enemy in enemies:
|
||
var class_label := _unit_class_display_name(enemy)
|
||
if class_label.is_empty():
|
||
class_label = "부대"
|
||
if not class_counts.has(class_label):
|
||
class_order.append(class_label)
|
||
class_counts[class_label] = 0
|
||
class_counts[class_label] = int(class_counts[class_label]) + 1
|
||
var class_parts := []
|
||
for class_label in class_order:
|
||
class_parts.append("%s %d" % [class_label, int(class_counts.get(class_label, 0))])
|
||
return "적세 %d명 (%s)" % [enemies.size(), _join_strings(class_parts, ", ")]
|
||
|
||
|
||
func _format_briefing_recovery_terrain_text() -> String:
|
||
var recovery_summaries := state.get_recovery_terrain_summaries()
|
||
if recovery_summaries.is_empty():
|
||
return ""
|
||
var parts := []
|
||
for summary in recovery_summaries:
|
||
var name := _terrain_display_name(str(summary.get("name", "지형")))
|
||
var count := int(summary.get("count", 0))
|
||
var heal := int(summary.get("heal", 0))
|
||
if count <= 0 or heal <= 0:
|
||
continue
|
||
parts.append("%s %d칸 +%d" % [name, count, heal])
|
||
if parts.is_empty():
|
||
return ""
|
||
return "회복지 %s" % _join_strings(parts, ", ")
|
||
|
||
|
||
func _format_briefing_talk_overview_text() -> String:
|
||
var conversations := _camp_conversation_entries()
|
||
if conversations.is_empty():
|
||
return ""
|
||
var supply_counts := _camp_conversation_supply_counts(conversations)
|
||
var ready := int(supply_counts.get("ready", 0))
|
||
var claimed := int(supply_counts.get("claimed", 0))
|
||
var parts := ["군막 회의 · %d건" % conversations.size()]
|
||
if ready > 0:
|
||
parts.append("보급 대기 %d건" % ready)
|
||
if claimed > 0:
|
||
parts.append("수령 완료 %d건" % claimed)
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _format_briefing_shop_overview_text() -> String:
|
||
var buy_count := state.get_shop_item_ids().size()
|
||
var sell_count := _get_sellable_item_ids().size()
|
||
if buy_count <= 0 and sell_count <= 0:
|
||
return ""
|
||
if sell_count > 0:
|
||
return "군상 물품 · %d종, 매각 · %d종" % [buy_count, sell_count]
|
||
return "군상 물품 · %d종" % buy_count
|
||
|
||
|
||
func _format_briefing_roster_overview_text() -> String:
|
||
if not state.has_deployment_roster():
|
||
return ""
|
||
var deployed_count := state.get_deployed_player_count()
|
||
var max_units := state.get_deployment_max_units()
|
||
if max_units > 0:
|
||
return "출진 명부 %d명 중 %d명" % [max_units, deployed_count]
|
||
return "출진 명부 %d명" % deployed_count
|
||
|
||
|
||
func _format_briefing_formation_overview_text() -> String:
|
||
var formation_count := state.get_formation_cells().size()
|
||
if formation_count <= 0:
|
||
return ""
|
||
return "진형 배치 %d칸" % formation_count
|
||
|
||
|
||
func _camp_conversation_supply_counts(conversations: Array) -> Dictionary:
|
||
var counts := {"ready": 0, "claimed": 0}
|
||
for conversation in conversations:
|
||
if typeof(conversation) != TYPE_DICTIONARY:
|
||
continue
|
||
var effects: Array = conversation.get("effects", [])
|
||
var has_supply := false
|
||
for effect in effects:
|
||
if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == "grant_item":
|
||
has_supply = true
|
||
break
|
||
if not has_supply:
|
||
continue
|
||
var conversation_id := str(conversation.get("id", ""))
|
||
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
|
||
counts["claimed"] = int(counts.get("claimed", 0)) + 1
|
||
else:
|
||
counts["ready"] = int(counts.get("ready", 0)) + 1
|
||
return counts
|
||
|
||
|
||
func _show_briefing() -> void:
|
||
_play_bgm("menu")
|
||
var briefing := state.get_briefing()
|
||
if briefing.is_empty():
|
||
_start_battle_from_briefing()
|
||
return
|
||
|
||
var body := ""
|
||
for line in briefing.get("lines", []):
|
||
if not body.is_empty():
|
||
body += "\n"
|
||
body += str(line)
|
||
|
||
briefing_title_label.text = _format_briefing_title(briefing)
|
||
briefing_location_label.text = _format_briefing_location(briefing)
|
||
briefing_location_label.visible = not briefing_location_label.text.is_empty()
|
||
briefing_objective_collapsed = false
|
||
briefing_objective_label.text = _format_briefing_objectives()
|
||
_fit_label_font_size_to_text(
|
||
briefing_objective_label,
|
||
briefing_objective_label.text,
|
||
BRIEFING_OBJECTIVE_FONT_SIZE,
|
||
BRIEFING_OBJECTIVE_MIN_FONT_SIZE
|
||
)
|
||
briefing_objective_label.visible = not briefing_objective_label.text.is_empty()
|
||
_update_briefing_objective_visibility()
|
||
briefing_label.text = body
|
||
briefing_panel.visible = true
|
||
_refresh_screen_backdrop_visibility()
|
||
battle_started = false
|
||
var prep_locked := _is_prebattle_prep_locked()
|
||
_update_briefing_camp_overview(briefing, prep_locked)
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_clear_pending_move_state()
|
||
if chapter_button != null:
|
||
chapter_button.disabled = campaign_state.get_chapter_progress_entries().is_empty()
|
||
if shop_button != null:
|
||
shop_button.disabled = prep_locked or (state.get_shop_item_ids().is_empty() and _get_sellable_item_ids().is_empty())
|
||
if talk_button != null:
|
||
talk_button.disabled = prep_locked or _camp_conversation_entries().is_empty()
|
||
if armory_button != null:
|
||
armory_button.disabled = prep_locked or state.get_controllable_player_units().is_empty()
|
||
if roster_button != null:
|
||
roster_button.disabled = prep_locked or not state.has_deployment_roster_choices()
|
||
if formation_button != null:
|
||
formation_button.disabled = prep_locked or state.get_formation_cells().is_empty()
|
||
if save_button != null:
|
||
save_button.disabled = prep_locked
|
||
|
||
|
||
func _on_briefing_objective_toggle_pressed() -> void:
|
||
_play_ui_click()
|
||
_set_briefing_objective_collapsed(not briefing_objective_collapsed)
|
||
|
||
|
||
func _set_briefing_objective_collapsed(collapsed: bool) -> bool:
|
||
if briefing_objective_label == null or briefing_objective_label.text.strip_edges().is_empty():
|
||
return false
|
||
if briefing_objective_collapsed == collapsed:
|
||
return false
|
||
briefing_objective_collapsed = collapsed
|
||
_update_briefing_objective_visibility()
|
||
return true
|
||
|
||
|
||
func _update_briefing_objective_visibility() -> void:
|
||
var has_text := briefing_objective_label != null and not briefing_objective_label.text.strip_edges().is_empty()
|
||
if briefing_objective_panel != null:
|
||
briefing_objective_panel.visible = has_text and not briefing_objective_collapsed
|
||
if briefing_objective_toggle_button != null:
|
||
briefing_objective_toggle_button.visible = has_text
|
||
briefing_objective_toggle_button.text = "펼치기" if briefing_objective_collapsed else "닫기"
|
||
|
||
|
||
func _on_begin_battle_pressed() -> void:
|
||
_play_ui_confirm()
|
||
_start_battle_from_briefing()
|
||
|
||
|
||
func _start_battle_from_briefing() -> void:
|
||
battle_started = true
|
||
_play_bgm("battle")
|
||
if briefing_panel != null:
|
||
briefing_panel.visible = false
|
||
_refresh_screen_backdrop_visibility()
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_clear_pending_move_state()
|
||
state.run_battle_begin_events()
|
||
_show_next_dialogue()
|
||
_update_hud()
|
||
|
||
|
||
func _on_chapter_pressed() -> void:
|
||
if battle_started or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if chapter_menu != null and chapter_menu.visible:
|
||
_hide_chapter_menu()
|
||
else:
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_rebuild_chapter_menu()
|
||
chapter_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_chapter_menu() -> void:
|
||
if chapter_menu == null:
|
||
return
|
||
chapter_menu.visible = false
|
||
|
||
|
||
func _rebuild_chapter_menu() -> void:
|
||
if chapter_list == null:
|
||
return
|
||
for child in chapter_list.get_children():
|
||
chapter_list.remove_child(child)
|
||
child.queue_free()
|
||
var entries := campaign_state.get_chapter_progress_entries()
|
||
if chapter_status_label != null:
|
||
chapter_status_label.text = "%s 병서 · 봉인 전장 %d/%d" % [
|
||
campaign_state.campaign_title,
|
||
campaign_state.completed_scenarios.size(),
|
||
campaign_state.get_scenario_count()
|
||
]
|
||
if entries.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "아직 엮인 병서가 없습니다."
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
chapter_list.add_child(empty_label)
|
||
return
|
||
for entry in entries:
|
||
var completed := int(entry.get("completed", 0))
|
||
var total := int(entry.get("total", 0))
|
||
var current := bool(entry.get("current", false))
|
||
var status := "현 전장" if current else ("봉인 완료" if total > 0 and completed >= total else "미개봉")
|
||
var current_title := str(entry.get("current_scenario_title", ""))
|
||
var line := "병서 %02d/%02d · %s [%s] %d/%d" % [
|
||
int(entry.get("position", 0)),
|
||
int(entry.get("chapter_count", 0)),
|
||
entry.get("title", ""),
|
||
status,
|
||
completed,
|
||
total
|
||
]
|
||
if current and not current_title.is_empty():
|
||
line += "\n현 전장: %s" % current_title
|
||
var chapter_label := Label.new()
|
||
chapter_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
chapter_label.custom_minimum_size = Vector2(560, 0)
|
||
chapter_label.text = line
|
||
_apply_label_style(chapter_label, UI_AGED_INK)
|
||
chapter_list.add_child(chapter_label)
|
||
for scenario in entry.get("scenarios", []):
|
||
_add_chapter_scenario_entry(scenario)
|
||
|
||
|
||
func _add_chapter_scenario_entry(scenario: Dictionary) -> void:
|
||
var scenario_id := str(scenario.get("id", ""))
|
||
var title := str(scenario.get("title", scenario_id))
|
||
var position := int(scenario.get("position", 0))
|
||
var total := int(scenario.get("total", 0))
|
||
var completed := bool(scenario.get("completed", false))
|
||
var current := bool(scenario.get("current", false))
|
||
var available := bool(scenario.get("available", false))
|
||
var status := "현 전장" if current else ("회상 가능" if completed else "미개봉")
|
||
var text := " %02d/%02d %s [%s]" % [position, total, title, status]
|
||
if available:
|
||
var scenario_button := Button.new()
|
||
scenario_button.custom_minimum_size = Vector2(560, 0)
|
||
scenario_button.text = text
|
||
_apply_button_style(scenario_button)
|
||
scenario_button.pressed.connect(_on_chapter_scenario_pressed.bind(scenario_id))
|
||
chapter_list.add_child(scenario_button)
|
||
else:
|
||
var scenario_label := Label.new()
|
||
scenario_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
scenario_label.custom_minimum_size = Vector2(560, 0)
|
||
scenario_label.text = text
|
||
_apply_label_style(scenario_label, UI_AGED_INK)
|
||
chapter_list.add_child(scenario_label)
|
||
|
||
|
||
func _on_chapter_scenario_pressed(scenario_id: String) -> void:
|
||
if battle_started or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
if scenario_id.is_empty():
|
||
return
|
||
if not campaign_state.is_scenario_completed(scenario_id) and scenario_id != campaign_state.current_scenario_id:
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_confirm()
|
||
if log_box != null:
|
||
log_box.clear()
|
||
_load_scenario(scenario_id)
|
||
_show_briefing()
|
||
_update_hud()
|
||
|
||
|
||
func _on_shop_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if shop_menu != null and shop_menu.visible:
|
||
_hide_shop_menu()
|
||
else:
|
||
shop_sell_mode = state.get_shop_item_ids().is_empty() and not _get_sellable_item_ids().is_empty()
|
||
_hide_chapter_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_rebuild_shop_menu()
|
||
shop_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_shop_menu() -> void:
|
||
if shop_menu == null:
|
||
return
|
||
shop_menu.visible = false
|
||
|
||
|
||
func _on_talk_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
if _camp_conversation_entries().is_empty():
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_click()
|
||
if talk_menu != null and talk_menu.visible:
|
||
_hide_talk_menu()
|
||
else:
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_rebuild_talk_menu()
|
||
talk_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_talk_menu() -> void:
|
||
if talk_menu == null:
|
||
return
|
||
talk_menu.visible = false
|
||
|
||
|
||
func _rebuild_talk_menu() -> void:
|
||
if talk_list == null:
|
||
return
|
||
_clear_talk_list()
|
||
var conversations := _camp_conversation_entries()
|
||
if talk_status_label != null:
|
||
talk_status_label.text = _format_talk_status_text(conversations)
|
||
if conversations.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "군막에 들을 말이 없습니다."
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
talk_list.add_child(empty_label)
|
||
return
|
||
for conversation in conversations:
|
||
_add_camp_conversation_row(conversation)
|
||
|
||
|
||
func _add_camp_conversation_row(conversation: Dictionary) -> void:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(580, 64)
|
||
row.add_theme_constant_override("separation", 8)
|
||
talk_list.add_child(row)
|
||
|
||
row.add_child(_make_camp_conversation_portrait(conversation))
|
||
|
||
var column := VBoxContainer.new()
|
||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
column.add_theme_constant_override("separation", 3)
|
||
row.add_child(column)
|
||
|
||
var conversation_id := str(conversation.get("id", ""))
|
||
var button := Button.new()
|
||
button.text = _format_camp_conversation_row_title(conversation)
|
||
button.custom_minimum_size = Vector2(500, 32)
|
||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
_apply_button_style(button)
|
||
button.pressed.connect(_on_camp_conversation_pressed.bind(conversation_id))
|
||
column.add_child(button)
|
||
|
||
var preview_label := Label.new()
|
||
preview_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
preview_label.custom_minimum_size = Vector2(500, 24)
|
||
preview_label.text = _format_camp_conversation_preview_text(conversation)
|
||
_apply_label_style(preview_label, UI_AGED_INK)
|
||
column.add_child(preview_label)
|
||
|
||
|
||
func _make_camp_conversation_portrait(conversation: Dictionary) -> PanelContainer:
|
||
var portrait_panel := PanelContainer.new()
|
||
portrait_panel.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
|
||
_apply_panel_style(portrait_panel, "portrait")
|
||
|
||
var stack := Control.new()
|
||
stack.custom_minimum_size = CAMP_TALK_PORTRAIT_SIZE
|
||
portrait_panel.add_child(stack)
|
||
|
||
var texture_rect := TextureRect.new()
|
||
texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
stack.add_child(texture_rect)
|
||
|
||
var initials_label := Label.new()
|
||
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
initials_label.add_theme_font_size_override("font_size", 18)
|
||
_apply_label_style(initials_label, UI_PARCHMENT_TEXT)
|
||
stack.add_child(initials_label)
|
||
|
||
var speaker := _camp_conversation_preview_speaker(conversation)
|
||
var texture := _load_art_texture(_camp_conversation_preview_portrait_path(conversation))
|
||
var has_texture := texture != null
|
||
texture_rect.texture = texture
|
||
texture_rect.visible = has_texture
|
||
initials_label.text = _dialogue_portrait_initials(speaker)
|
||
initials_label.visible = not has_texture
|
||
return portrait_panel
|
||
|
||
|
||
func _format_talk_status_text(conversations: Array) -> String:
|
||
var supply_available := 0
|
||
var supply_claimed := 0
|
||
for conversation in conversations:
|
||
if typeof(conversation) != TYPE_DICTIONARY:
|
||
continue
|
||
var effects: Array = conversation.get("effects", [])
|
||
if effects.is_empty():
|
||
continue
|
||
var has_supply := false
|
||
for effect in effects:
|
||
if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == "grant_item":
|
||
has_supply = true
|
||
break
|
||
if not has_supply:
|
||
continue
|
||
var conversation_id := str(conversation.get("id", ""))
|
||
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
|
||
supply_claimed += 1
|
||
else:
|
||
supply_available += 1
|
||
var parts := ["군막 회의", "%d건" % conversations.size()]
|
||
if supply_available > 0:
|
||
parts.append("보급 대기 %d건" % supply_available)
|
||
if supply_claimed > 0:
|
||
parts.append("수령 완료 %d건" % supply_claimed)
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _clear_talk_list() -> void:
|
||
for child in talk_list.get_children():
|
||
talk_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _camp_conversation_entries() -> Array:
|
||
var briefing := state.get_briefing()
|
||
var conversations = briefing.get("camp_conversations", [])
|
||
if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty():
|
||
return conversations
|
||
var lines = briefing.get("camp_dialogue", [])
|
||
if typeof(lines) == TYPE_ARRAY and not lines.is_empty():
|
||
return [{
|
||
"id": "camp_dialogue",
|
||
"group": "topic",
|
||
"officer_id": "",
|
||
"label": "군막 회의",
|
||
"speaker": "",
|
||
"summary": "눈앞의 정세를 살핍니다.",
|
||
"lines": lines
|
||
}]
|
||
return []
|
||
|
||
|
||
func _format_camp_conversation_button_text(conversation: Dictionary) -> String:
|
||
var label := str(conversation.get("label", "군막 담화"))
|
||
var speaker := _localized_speaker_name(str(conversation.get("speaker", "")))
|
||
var summary := str(conversation.get("summary", ""))
|
||
var group := _camp_conversation_group_text(str(conversation.get("group", "topic")))
|
||
var parts := [group, label]
|
||
if not speaker.is_empty():
|
||
parts.append(speaker)
|
||
if not summary.is_empty():
|
||
parts.append(summary)
|
||
var effects_text := _format_camp_conversation_effects_text(conversation)
|
||
if not effects_text.is_empty():
|
||
parts.append(effects_text)
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _format_camp_conversation_row_title(conversation: Dictionary) -> String:
|
||
var label := str(conversation.get("label", "군막 담화"))
|
||
var speaker := _camp_conversation_preview_speaker(conversation)
|
||
var group := _camp_conversation_group_text(str(conversation.get("group", "topic")))
|
||
var parts := [group, label]
|
||
if not speaker.is_empty():
|
||
parts.append(speaker)
|
||
var effects_text := _format_camp_conversation_effects_text(conversation)
|
||
if not effects_text.is_empty():
|
||
parts.append(effects_text)
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _format_camp_conversation_preview_text(conversation: Dictionary) -> String:
|
||
var parts := []
|
||
var summary := str(conversation.get("summary", "")).strip_edges()
|
||
if not summary.is_empty():
|
||
parts.append(summary)
|
||
var first_text := _camp_conversation_preview_line(conversation)
|
||
if not first_text.is_empty():
|
||
parts.append(_short_preview_text(first_text, 96))
|
||
if parts.is_empty():
|
||
return "아직 적힌 말이 없습니다."
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _camp_conversation_group_text(group: String) -> String:
|
||
if group == "officer":
|
||
return "장수"
|
||
if group == "topic":
|
||
return "의제"
|
||
return _format_identifier_label(group)
|
||
|
||
|
||
func _camp_conversation_preview_speaker(conversation: Dictionary) -> String:
|
||
var speaker := str(conversation.get("speaker", "")).strip_edges()
|
||
if not speaker.is_empty():
|
||
return _localized_speaker_name(speaker)
|
||
for line in conversation.get("lines", []):
|
||
if typeof(line) != TYPE_DICTIONARY:
|
||
continue
|
||
speaker = str(line.get("speaker", "")).strip_edges()
|
||
if not speaker.is_empty():
|
||
return _localized_speaker_name(speaker)
|
||
return ""
|
||
|
||
|
||
func _camp_conversation_preview_line(conversation: Dictionary) -> String:
|
||
for line in conversation.get("lines", []):
|
||
if typeof(line) != TYPE_DICTIONARY:
|
||
continue
|
||
var text := str(line.get("text", "")).strip_edges()
|
||
if not text.is_empty():
|
||
return text
|
||
return ""
|
||
|
||
|
||
func _camp_conversation_preview_portrait_path(conversation: Dictionary) -> String:
|
||
var portrait := str(conversation.get("portrait", "")).strip_edges()
|
||
if not portrait.is_empty():
|
||
return portrait
|
||
for line in conversation.get("lines", []):
|
||
if typeof(line) != TYPE_DICTIONARY:
|
||
continue
|
||
portrait = str(line.get("portrait", "")).strip_edges()
|
||
if not portrait.is_empty():
|
||
return portrait
|
||
var officer_id := str(conversation.get("officer_id", "")).strip_edges()
|
||
if not officer_id.is_empty():
|
||
portrait = state.data_catalog.get_portrait_for_officer(officer_id)
|
||
if not portrait.is_empty():
|
||
return portrait
|
||
var speaker := _camp_conversation_preview_speaker(conversation)
|
||
if not speaker.is_empty():
|
||
return state.data_catalog.get_portrait_for_speaker(_canonical_speaker_name(speaker))
|
||
return ""
|
||
|
||
|
||
func _short_preview_text(text: String, max_length: int) -> String:
|
||
var normalized := text.strip_edges()
|
||
if max_length <= 3 or normalized.length() <= max_length:
|
||
return normalized
|
||
return "%s..." % normalized.substr(0, max_length - 3).strip_edges()
|
||
|
||
|
||
func _on_camp_conversation_pressed(conversation_id: String) -> void:
|
||
var conversation := _camp_conversation_by_id(conversation_id)
|
||
if conversation.is_empty():
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_confirm()
|
||
_hide_talk_menu()
|
||
_show_camp_dialogue(conversation.get("lines", []))
|
||
_apply_camp_conversation_effects(conversation)
|
||
_update_hud()
|
||
|
||
|
||
func _format_camp_conversation_effects_text(conversation: Dictionary) -> String:
|
||
var effects: Array = conversation.get("effects", [])
|
||
if effects.is_empty():
|
||
return ""
|
||
var parts := []
|
||
for effect in effects:
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
if str(effect.get("type", "")) != "grant_item":
|
||
continue
|
||
var item_id := str(effect.get("item_id", ""))
|
||
var item := state.get_item_def(item_id)
|
||
if item.is_empty():
|
||
continue
|
||
var count := maxi(1, int(effect.get("count", 1)))
|
||
var count_text := "" if count == 1 else " %d점" % count
|
||
parts.append("보급 %s%s" % [_item_display_name(item_id), count_text])
|
||
if parts.is_empty():
|
||
return ""
|
||
var conversation_id := str(conversation.get("id", ""))
|
||
var claimed_text := "수령 완료" if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id) else "수령 가능"
|
||
return "%s (%s)" % [_join_strings(parts, ", "), claimed_text]
|
||
|
||
|
||
func _apply_camp_conversation_effects(conversation: Dictionary) -> void:
|
||
if battle_started or _is_prebattle_prep_locked():
|
||
return
|
||
var conversation_id := str(conversation.get("id", ""))
|
||
if conversation_id.is_empty():
|
||
return
|
||
if campaign_state.has_claimed_camp_conversation_effects(active_scenario_id, conversation_id):
|
||
return
|
||
var effects: Array = conversation.get("effects", [])
|
||
if effects.is_empty():
|
||
return
|
||
if campaign_state.try_claim_camp_conversation_effects(active_scenario_id, conversation):
|
||
state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
|
||
_on_log_added(_format_camp_conversation_effect_claim_text(conversation))
|
||
if talk_menu != null and talk_menu.visible:
|
||
_rebuild_talk_menu()
|
||
return
|
||
_play_ui_cancel()
|
||
_on_log_added("군막 보급을 거두지 못했습니다.")
|
||
|
||
|
||
func _format_camp_conversation_effect_claim_text(conversation: Dictionary) -> String:
|
||
var messages := []
|
||
var effects: Array = conversation.get("effects", [])
|
||
for effect in effects:
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
if str(effect.get("type", "")) != "grant_item":
|
||
continue
|
||
var message := str(effect.get("text", "")).strip_edges()
|
||
if not message.is_empty():
|
||
messages.append(message)
|
||
continue
|
||
var item_id := str(effect.get("item_id", ""))
|
||
if item_id.is_empty():
|
||
continue
|
||
var count := maxi(1, int(effect.get("count", 1)))
|
||
var count_text := "" if count == 1 else " %d점" % count
|
||
messages.append("군막 보급 수령: %s%s." % [_item_display_name(item_id), count_text])
|
||
if messages.is_empty():
|
||
return "군막 보급을 수령했습니다."
|
||
return _join_strings(messages, " ")
|
||
|
||
|
||
func _camp_conversation_by_id(conversation_id: String) -> Dictionary:
|
||
for conversation in _camp_conversation_entries():
|
||
if str(conversation.get("id", "")) == conversation_id:
|
||
return conversation
|
||
return {}
|
||
|
||
|
||
func _show_camp_dialogue(lines: Array) -> void:
|
||
var normalized_lines := _normalized_dialogue_lines(lines)
|
||
if normalized_lines.is_empty():
|
||
return
|
||
dialogue_queue.clear()
|
||
active_dialogue_lines = normalized_lines
|
||
active_dialogue_index = 0
|
||
_render_dialogue_line()
|
||
|
||
|
||
func _on_armory_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if armory_menu != null and armory_menu.visible:
|
||
_hide_armory_menu()
|
||
else:
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_rebuild_armory_menu()
|
||
armory_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_armory_menu() -> void:
|
||
if armory_menu == null:
|
||
return
|
||
armory_menu.visible = false
|
||
|
||
|
||
func _on_roster_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or not state.has_deployment_roster_choices() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if roster_menu != null and roster_menu.visible:
|
||
_hide_roster_menu()
|
||
else:
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_rebuild_roster_menu()
|
||
roster_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_roster_menu() -> void:
|
||
if roster_menu == null:
|
||
return
|
||
roster_menu.visible = false
|
||
|
||
|
||
func _on_formation_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if formation_menu != null and formation_menu.visible:
|
||
_hide_formation_menu()
|
||
else:
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_save_menu()
|
||
_rebuild_formation_menu()
|
||
formation_menu.visible = true
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _hide_formation_menu() -> void:
|
||
if formation_menu == null:
|
||
return
|
||
formation_menu.visible = false
|
||
|
||
|
||
func _on_save_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or briefing_panel == null or not briefing_panel.visible:
|
||
return
|
||
_play_ui_click()
|
||
if save_menu != null and save_menu.visible:
|
||
_hide_save_menu()
|
||
else:
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_rebuild_save_menu()
|
||
save_menu.visible = true
|
||
_update_hud()
|
||
|
||
|
||
func _hide_save_menu() -> void:
|
||
if save_menu == null:
|
||
return
|
||
save_menu.visible = false
|
||
|
||
|
||
func _rebuild_save_menu() -> void:
|
||
if save_status_label != null:
|
||
save_status_label.text = "%s\n%s" % [
|
||
_format_current_checkpoint_text(),
|
||
_format_manual_checkpoint_text()
|
||
]
|
||
if manual_load_button != null:
|
||
manual_load_button.disabled = not campaign_state.has_manual_save()
|
||
|
||
|
||
func _format_current_checkpoint_text() -> String:
|
||
var title := campaign_state.get_scenario_title(campaign_state.current_scenario_id)
|
||
if title.is_empty():
|
||
title = campaign_state.get_scenario_title(campaign_state.get_start_scenario_id())
|
||
return "현재 봉인: %s · %d/%d 봉인 · 군자금 %d냥" % [
|
||
title,
|
||
campaign_state.completed_scenarios.size(),
|
||
campaign_state.get_scenario_count(),
|
||
campaign_state.gold
|
||
]
|
||
|
||
|
||
func _format_manual_checkpoint_text() -> String:
|
||
var summary := campaign_state.get_manual_save_summary()
|
||
if summary.is_empty():
|
||
return "수동 봉인: 비어 있음"
|
||
var title := str(summary.get("current_scenario_title", ""))
|
||
if title.is_empty():
|
||
title = str(summary.get("current_scenario_id", "Unknown"))
|
||
var pending_suffix := " · 회의 미결" if bool(summary.get("pending_choice", false)) else ""
|
||
return "수동 봉인: %s · %d/%d 봉인 · 군자금 %d냥%s" % [
|
||
title,
|
||
int(summary.get("completed_count", 0)),
|
||
int(summary.get("total_count", 0)),
|
||
int(summary.get("gold", 0)),
|
||
pending_suffix
|
||
]
|
||
|
||
|
||
func _on_manual_save_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked():
|
||
return
|
||
_play_ui_confirm()
|
||
if campaign_state.save_manual_game():
|
||
_on_log_added("수동 전기가 봉인되었다.")
|
||
else:
|
||
_on_log_added("수동 전기를 봉하지 못했다.")
|
||
_rebuild_save_menu()
|
||
|
||
|
||
func _on_manual_load_pressed() -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or not campaign_state.has_manual_save():
|
||
return
|
||
_play_ui_confirm()
|
||
if not campaign_state.load_manual_game():
|
||
_on_log_added("수동 봉인을 열지 못했다.")
|
||
_rebuild_save_menu()
|
||
return
|
||
if log_box != null:
|
||
log_box.clear()
|
||
log_box.append_text("수동 봉인을 열었다.\n")
|
||
_reload_from_campaign_state()
|
||
|
||
|
||
func _reload_from_campaign_state() -> void:
|
||
campaign_complete_screen = false
|
||
battle_started = false
|
||
battle_result_applied = false
|
||
battle_result_summary.clear()
|
||
post_battle_dialogue_started = false
|
||
floating_texts.clear()
|
||
unit_motion_by_unit.clear()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
armory_unit_id = ""
|
||
formation_unit_id = ""
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_talk_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
if result_panel != null:
|
||
result_panel.visible = false
|
||
if campaign_state.has_pending_post_battle_choice():
|
||
_load_pending_post_battle_choice()
|
||
_update_result_panel()
|
||
elif campaign_state.is_campaign_complete():
|
||
_show_campaign_complete()
|
||
else:
|
||
_load_current_battle()
|
||
_show_briefing()
|
||
_update_hud()
|
||
|
||
|
||
func _is_prep_menu_visible() -> bool:
|
||
return (
|
||
(chapter_menu != null and chapter_menu.visible)
|
||
or (shop_menu != null and shop_menu.visible)
|
||
or (talk_menu != null and talk_menu.visible)
|
||
or (armory_menu != null and armory_menu.visible)
|
||
or (roster_menu != null and roster_menu.visible)
|
||
or (formation_menu != null and formation_menu.visible)
|
||
or (save_menu != null and save_menu.visible)
|
||
)
|
||
|
||
|
||
func _is_prebattle_prep_locked() -> bool:
|
||
return campaign_state.is_scenario_completed(active_scenario_id)
|
||
|
||
|
||
func _rebuild_roster_menu() -> void:
|
||
if roster_list == null:
|
||
return
|
||
_clear_roster_list()
|
||
var units := state.get_player_units(true)
|
||
var max_units := state.get_deployment_max_units()
|
||
var deployed_count := state.get_deployed_player_count()
|
||
if roster_status_label != null:
|
||
roster_status_label.text = "출진 명부 %d/%d" % [deployed_count, max_units]
|
||
|
||
if units.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "장수가 없습니다"
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
roster_list.add_child(empty_label)
|
||
return
|
||
|
||
for unit in units:
|
||
var unit_id := str(unit.get("id", ""))
|
||
var roster_unit_button := Button.new()
|
||
roster_unit_button.text = _format_roster_unit_button_text(unit)
|
||
_apply_button_style(roster_unit_button)
|
||
var deployed := bool(unit.get("deployed", true))
|
||
roster_unit_button.disabled = (
|
||
bool(unit.get("required_deployment", false))
|
||
or (not deployed and deployed_count >= max_units)
|
||
)
|
||
roster_unit_button.pressed.connect(_on_roster_unit_pressed.bind(unit_id))
|
||
roster_list.add_child(roster_unit_button)
|
||
|
||
|
||
func _clear_roster_list() -> void:
|
||
for child in roster_list.get_children():
|
||
roster_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_roster_unit_button_text(unit: Dictionary) -> String:
|
||
var state_text := "출진" if bool(unit.get("deployed", true)) else "예비"
|
||
var required_text := " 필수" if bool(unit.get("required_deployment", false)) else ""
|
||
var protected_text := " 호위" if not bool(unit.get("controllable", true)) else ""
|
||
return "%s%s 품계 %d %s %s%s" % [
|
||
str(unit.get("name", "장수")),
|
||
protected_text,
|
||
int(unit.get("level", 1)),
|
||
_unit_class_display_name(unit),
|
||
state_text,
|
||
required_text
|
||
]
|
||
|
||
|
||
func _on_roster_unit_pressed(unit_id: String) -> void:
|
||
var unit := state.get_unit(unit_id)
|
||
if unit.is_empty():
|
||
return
|
||
var next_deployed := not bool(unit.get("deployed", true))
|
||
if state.try_set_unit_deployed(unit_id, next_deployed):
|
||
_play_ui_confirm()
|
||
if not state.get_unit(formation_unit_id).get("deployed", false):
|
||
formation_unit_id = ""
|
||
if not state.get_unit(armory_unit_id).get("deployed", false):
|
||
armory_unit_id = ""
|
||
_rebuild_roster_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _rebuild_formation_menu() -> void:
|
||
if formation_list == null:
|
||
return
|
||
_clear_formation_list()
|
||
var units := state.get_controllable_player_units()
|
||
if formation_unit_id.is_empty() or _find_unit_in_list(units, formation_unit_id).is_empty():
|
||
formation_unit_id = str(units[0].get("id", "")) if not units.is_empty() else ""
|
||
|
||
if formation_status_label != null:
|
||
var selected := state.get_unit(formation_unit_id)
|
||
if selected.is_empty():
|
||
formation_status_label.text = "진형"
|
||
else:
|
||
formation_status_label.text = "진형: %s · %s" % [
|
||
str(selected.get("name", "장수")),
|
||
_format_cell_label(selected.get("pos", Vector2i.ZERO))
|
||
]
|
||
|
||
if units.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "출진 장수가 없습니다"
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
formation_list.add_child(empty_label)
|
||
return
|
||
|
||
for unit in units:
|
||
var unit_id := str(unit.get("id", ""))
|
||
var unit_button := Button.new()
|
||
unit_button.text = _format_formation_unit_button_text(unit)
|
||
_apply_button_style(unit_button)
|
||
unit_button.disabled = unit_id == formation_unit_id
|
||
unit_button.pressed.connect(_on_formation_unit_pressed.bind(unit_id))
|
||
formation_list.add_child(unit_button)
|
||
|
||
|
||
func _clear_formation_list() -> void:
|
||
for child in formation_list.get_children():
|
||
formation_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_formation_unit_button_text(unit: Dictionary) -> String:
|
||
var marker := "> " if str(unit.get("id", "")) == formation_unit_id else ""
|
||
return "%s%s %s" % [
|
||
marker,
|
||
str(unit.get("name", "장수")),
|
||
_format_cell_label(unit.get("pos", Vector2i.ZERO))
|
||
]
|
||
|
||
|
||
func _format_cell_label(value) -> String:
|
||
if typeof(value) == TYPE_VECTOR2I:
|
||
return "%d,%d" % [value.x + 1, value.y + 1]
|
||
return "-"
|
||
|
||
|
||
func _on_formation_unit_pressed(unit_id: String) -> void:
|
||
_play_ui_click()
|
||
formation_unit_id = unit_id
|
||
_rebuild_formation_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _handle_formation_board_click(screen_position: Vector2) -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or formation_unit_id.is_empty():
|
||
return
|
||
var cell := _cell_from_screen(screen_position)
|
||
if not state.is_inside(cell):
|
||
return
|
||
if state.try_set_prebattle_formation(formation_unit_id, cell):
|
||
_play_ui_confirm()
|
||
_rebuild_formation_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _rebuild_armory_menu() -> void:
|
||
if armory_list == null:
|
||
return
|
||
_clear_armory_list()
|
||
var units := state.get_controllable_player_units()
|
||
if armory_unit_id.is_empty() or _find_unit_in_list(units, armory_unit_id).is_empty():
|
||
armory_unit_id = str(units[0].get("id", "")) if not units.is_empty() else ""
|
||
|
||
if armory_status_label != null:
|
||
armory_status_label.text = _format_inventory_status_text()
|
||
|
||
if units.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "출진 장수가 없습니다"
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
armory_list.add_child(empty_label)
|
||
return
|
||
|
||
var unit_title := Label.new()
|
||
unit_title.text = "장수 명부"
|
||
_apply_label_style(unit_title, UI_CINNABAR, 14)
|
||
armory_list.add_child(unit_title)
|
||
|
||
for unit in units:
|
||
var unit_id := str(unit.get("id", ""))
|
||
var unit_button := Button.new()
|
||
unit_button.text = _format_armory_unit_button_text(unit)
|
||
_apply_button_style(unit_button)
|
||
unit_button.disabled = unit_id == armory_unit_id
|
||
unit_button.pressed.connect(_on_armory_unit_pressed.bind(unit_id))
|
||
armory_list.add_child(unit_button)
|
||
|
||
var selected := state.get_unit(armory_unit_id)
|
||
if selected.is_empty():
|
||
return
|
||
|
||
var equipment_label := Label.new()
|
||
equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
equipment_label.text = "소지 병장: %s" % _format_unit_equipment(selected)
|
||
_apply_label_style(equipment_label, UI_AGED_INK)
|
||
armory_list.add_child(equipment_label)
|
||
|
||
var equipped_slots := state.get_equipped_equipment_slots(armory_unit_id)
|
||
if not equipped_slots.is_empty():
|
||
var unequip_title := Label.new()
|
||
unequip_title.text = "병장 해제"
|
||
_apply_label_style(unequip_title, UI_CINNABAR, 14)
|
||
armory_list.add_child(unequip_title)
|
||
var equipment := state.get_equipment_snapshot(armory_unit_id)
|
||
for slot in equipped_slots:
|
||
var normalized_slot := str(slot)
|
||
var item_id := _equipment_item_id(equipment, normalized_slot)
|
||
var item := state.get_item_def(item_id)
|
||
var unequip_button := Button.new()
|
||
unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
|
||
_apply_button_style(unequip_button)
|
||
unequip_button.pressed.connect(_on_armory_unequip_pressed.bind(normalized_slot))
|
||
armory_list.add_child(unequip_button)
|
||
|
||
var item_ids := state.get_equippable_item_ids(armory_unit_id)
|
||
if item_ids.is_empty():
|
||
var empty_equipment_label := Label.new()
|
||
empty_equipment_label.text = "군수고에 맞는 병장이 없습니다"
|
||
_apply_label_style(empty_equipment_label, UI_AGED_INK)
|
||
armory_list.add_child(empty_equipment_label)
|
||
else:
|
||
var inventory := state.get_inventory_snapshot()
|
||
for item_id in item_ids:
|
||
var normalized_id := str(item_id)
|
||
var item := state.get_item_def(normalized_id)
|
||
var armory_equip_button := Button.new()
|
||
armory_equip_button.text = _format_equipment_option_text(
|
||
selected,
|
||
normalized_id,
|
||
item,
|
||
int(inventory.get(normalized_id, 0))
|
||
)
|
||
_apply_button_style(armory_equip_button)
|
||
armory_equip_button.pressed.connect(_on_armory_equip_pressed.bind(normalized_id))
|
||
armory_list.add_child(armory_equip_button)
|
||
|
||
|
||
func _clear_armory_list() -> void:
|
||
for child in armory_list.get_children():
|
||
armory_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _find_unit_in_list(units: Array, unit_id: String) -> Dictionary:
|
||
for unit in units:
|
||
if str(unit.get("id", "")) == unit_id:
|
||
return unit
|
||
return {}
|
||
|
||
|
||
func _format_armory_unit_button_text(unit: Dictionary) -> String:
|
||
var marker := "> " if str(unit.get("id", "")) == armory_unit_id else ""
|
||
return "%s%s 품계 %d %s" % [
|
||
marker,
|
||
str(unit.get("name", "장수")),
|
||
int(unit.get("level", 1)),
|
||
_format_unit_equipment(unit)
|
||
]
|
||
|
||
|
||
func _on_armory_unit_pressed(unit_id: String) -> void:
|
||
_play_ui_click()
|
||
armory_unit_id = unit_id
|
||
_rebuild_armory_menu()
|
||
_update_hud()
|
||
|
||
|
||
func _on_armory_equip_pressed(item_id: String) -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
|
||
return
|
||
if not state.try_equip_item(armory_unit_id, item_id):
|
||
_rebuild_armory_menu()
|
||
_update_hud()
|
||
return
|
||
if not _save_prebattle_equipment_change():
|
||
return
|
||
_rebuild_armory_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_armory_unequip_pressed(slot: String) -> void:
|
||
if battle_started or _is_prebattle_prep_locked() or armory_unit_id.is_empty():
|
||
return
|
||
if not state.try_unequip_item(armory_unit_id, slot):
|
||
_rebuild_armory_menu()
|
||
_update_hud()
|
||
return
|
||
if not _save_prebattle_equipment_change():
|
||
return
|
||
_rebuild_armory_menu()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _save_prebattle_equipment_change() -> bool:
|
||
if campaign_state.try_save_prebattle_loadout(state.get_player_roster_snapshot(), state.get_inventory_snapshot()):
|
||
_on_log_added("출진 병장을 고쳐 봉하였다.")
|
||
return true
|
||
_play_ui_cancel()
|
||
_on_log_added("출진 병장 기록을 봉하지 못했다.")
|
||
_load_scenario(active_scenario_id)
|
||
_show_briefing()
|
||
return false
|
||
|
||
|
||
func _rebuild_shop_menu() -> void:
|
||
if shop_list == null:
|
||
return
|
||
_clear_shop_list()
|
||
if shop_status_label != null:
|
||
var mode_text := "매각" if shop_sell_mode else "매입"
|
||
shop_status_label.text = "%s · 군자금 %d냥 · %s" % [
|
||
mode_text,
|
||
campaign_state.gold,
|
||
_format_inventory_status_text()
|
||
]
|
||
if shop_buy_button != null:
|
||
shop_buy_button.disabled = not shop_sell_mode
|
||
if shop_sell_button != null:
|
||
shop_sell_button.disabled = shop_sell_mode or _get_sellable_item_ids().is_empty()
|
||
|
||
if not shop_sell_mode:
|
||
_add_shop_merchant_notice()
|
||
|
||
if shop_sell_mode:
|
||
_rebuild_shop_sell_list()
|
||
else:
|
||
_rebuild_shop_buy_list()
|
||
|
||
|
||
func _rebuild_shop_buy_list() -> void:
|
||
var item_ids := state.get_shop_item_ids()
|
||
if item_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "군상이 내놓은 물품이 없습니다."
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
shop_list.add_child(empty_label)
|
||
return
|
||
|
||
var inventory := campaign_state.get_inventory_snapshot()
|
||
for item_id in item_ids:
|
||
var normalized_id := str(item_id)
|
||
var item := state.get_item_def(normalized_id)
|
||
var price := int(item.get("price", 0))
|
||
var owned := int(inventory.get(normalized_id, 0))
|
||
var stock_limit := state.get_shop_stock_limit(normalized_id)
|
||
var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, normalized_id)
|
||
var remaining := stock_limit - purchased if stock_limit >= 0 else -1
|
||
var buy_button := Button.new()
|
||
var detail_text := _format_shop_buy_item_detail_text(normalized_id, item, price, owned, stock_limit, remaining)
|
||
buy_button.text = _format_shop_buy_action_text(normalized_id, item, price)
|
||
buy_button.custom_minimum_size = Vector2(500, 32)
|
||
buy_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
buy_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_item_button_text(normalized_id, item, price, owned, stock_limit, remaining), detail_text)
|
||
buy_button.disabled = campaign_state.gold < price or (stock_limit >= 0 and remaining <= 0)
|
||
_apply_button_style(buy_button)
|
||
buy_button.pressed.connect(_on_shop_item_pressed.bind(normalized_id))
|
||
_add_shop_item_row(normalized_id, item, buy_button, detail_text)
|
||
|
||
|
||
func _rebuild_shop_sell_list() -> void:
|
||
var item_ids := _get_sellable_item_ids()
|
||
if item_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "매각할 물품이 없습니다."
|
||
_apply_label_style(empty_label, UI_AGED_INK)
|
||
shop_list.add_child(empty_label)
|
||
return
|
||
|
||
var inventory := campaign_state.get_inventory_snapshot()
|
||
for item_id in item_ids:
|
||
var normalized_id := str(item_id)
|
||
var item := state.get_item_def(normalized_id)
|
||
var sale_price := _sale_price_for_item(item)
|
||
var owned := int(inventory.get(normalized_id, 0))
|
||
var sell_button := Button.new()
|
||
var detail_text := _format_shop_sell_item_detail_text(normalized_id, item, sale_price, owned)
|
||
sell_button.text = _format_shop_sell_action_text(normalized_id, item, sale_price)
|
||
sell_button.custom_minimum_size = Vector2(500, 32)
|
||
sell_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
sell_button.tooltip_text = _format_shop_item_tooltip_text(_format_shop_sell_item_button_text(normalized_id, item, sale_price, owned), detail_text)
|
||
_apply_button_style(sell_button)
|
||
sell_button.pressed.connect(_on_shop_sell_item_pressed.bind(normalized_id))
|
||
_add_shop_item_row(normalized_id, item, sell_button, detail_text)
|
||
|
||
|
||
func _clear_shop_list() -> void:
|
||
for child in shop_list.get_children():
|
||
shop_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _add_shop_merchant_notice() -> void:
|
||
var merchant := state.get_shop_merchant()
|
||
var notice_text := _format_shop_merchant_notice_text(merchant)
|
||
if notice_text.is_empty():
|
||
return
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(580, 58)
|
||
row.add_theme_constant_override("separation", 8)
|
||
shop_list.add_child(row)
|
||
|
||
row.add_child(_make_initials_badge(str(merchant.get("name", "야상")), SHOP_MERCHANT_BADGE_SIZE, 18))
|
||
|
||
var merchant_label := Label.new()
|
||
merchant_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
merchant_label.custom_minimum_size = Vector2(500, 0)
|
||
merchant_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
merchant_label.text = notice_text
|
||
_apply_label_style(merchant_label, UI_AGED_INK)
|
||
row.add_child(merchant_label)
|
||
|
||
|
||
func _format_shop_merchant_notice_text(merchant: Dictionary) -> String:
|
||
if merchant.is_empty():
|
||
return ""
|
||
var lines := []
|
||
for line in merchant.get("lines", []):
|
||
var text := str(line).strip_edges()
|
||
if not text.is_empty():
|
||
lines.append(text)
|
||
if lines.is_empty():
|
||
return ""
|
||
return "%s: %s" % [
|
||
str(merchant.get("name", "야상")),
|
||
_join_strings(lines, " ")
|
||
]
|
||
|
||
|
||
func _make_initials_badge(label_text: String, badge_size: Vector2, font_size: int) -> PanelContainer:
|
||
var panel := PanelContainer.new()
|
||
panel.custom_minimum_size = badge_size
|
||
_apply_panel_style(panel, "portrait")
|
||
var label := Label.new()
|
||
label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.add_theme_font_size_override("font_size", font_size)
|
||
_apply_label_style(label, UI_PARCHMENT_TEXT)
|
||
label.text = _dialogue_portrait_initials(label_text)
|
||
panel.add_child(label)
|
||
return panel
|
||
|
||
|
||
func _add_shop_item_row(item_id: String, item: Dictionary, action_button: Button, detail_text: String) -> void:
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(580, 58)
|
||
row.add_theme_constant_override("separation", 8)
|
||
shop_list.add_child(row)
|
||
|
||
row.add_child(_make_shop_item_icon_panel(item_id, item))
|
||
|
||
var column := VBoxContainer.new()
|
||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
column.add_theme_constant_override("separation", 3)
|
||
row.add_child(column)
|
||
|
||
column.add_child(action_button)
|
||
|
||
var detail_label := Label.new()
|
||
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
detail_label.custom_minimum_size = Vector2(500, 20)
|
||
detail_label.text = detail_text
|
||
_apply_label_style(detail_label, UI_AGED_INK)
|
||
column.add_child(detail_label)
|
||
|
||
|
||
func _make_shop_item_icon_panel(item_id: String, item: Dictionary) -> PanelContainer:
|
||
var icon_panel := PanelContainer.new()
|
||
icon_panel.custom_minimum_size = SHOP_ITEM_ICON_SIZE
|
||
_apply_panel_style(icon_panel, "portrait")
|
||
|
||
var stack := Control.new()
|
||
stack.custom_minimum_size = SHOP_ITEM_ICON_SIZE
|
||
icon_panel.add_child(stack)
|
||
|
||
var icon_rect := TextureRect.new()
|
||
icon_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
stack.add_child(icon_rect)
|
||
|
||
var initials_label := Label.new()
|
||
initials_label.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
initials_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
initials_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
initials_label.add_theme_font_size_override("font_size", 16)
|
||
_apply_label_style(initials_label, UI_PARCHMENT_TEXT)
|
||
stack.add_child(initials_label)
|
||
|
||
var texture := _load_art_texture(str(item.get("icon", "")))
|
||
var has_texture := texture != null
|
||
icon_rect.texture = texture
|
||
icon_rect.visible = has_texture
|
||
initials_label.text = _dialogue_portrait_initials(_item_display_name(item_id))
|
||
initials_label.visible = not has_texture
|
||
return icon_panel
|
||
|
||
|
||
func _format_shop_buy_action_text(item_id: String, _item: Dictionary, price: int) -> String:
|
||
return "매입 %s %d냥" % [_item_display_name(item_id), price]
|
||
|
||
|
||
func _format_shop_sell_action_text(item_id: String, _item: Dictionary, sale_price: int) -> String:
|
||
return "매각 %s %d냥" % [_item_display_name(item_id), sale_price]
|
||
|
||
|
||
func _format_shop_buy_item_detail_text(_item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String:
|
||
var parts := _shop_item_common_detail_parts(item, owned)
|
||
if stock_limit >= 0:
|
||
parts.append("잔량 %d/%d" % [max(0, remaining), stock_limit])
|
||
if stock_limit >= 0 and remaining <= 0:
|
||
parts.append("매진")
|
||
elif campaign_state.gold < price:
|
||
parts.append("부족 %d냥" % (price - campaign_state.gold))
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _format_shop_sell_item_detail_text(_item_id: String, item: Dictionary, _sale_price: int, owned: int) -> String:
|
||
var parts := _shop_item_common_detail_parts(item, owned)
|
||
parts.append("반값으로 넘김")
|
||
return _join_strings(parts, " · ")
|
||
|
||
|
||
func _shop_item_common_detail_parts(item: Dictionary, owned: int) -> Array:
|
||
var parts := []
|
||
var effect_text := _format_shop_item_effect_text(item)
|
||
if not effect_text.is_empty():
|
||
parts.append(effect_text)
|
||
var kind := str(item.get("kind", "item")).capitalize()
|
||
if not kind.is_empty():
|
||
parts.append(_item_kind_text(kind))
|
||
parts.append("보유 %d점" % owned)
|
||
return parts
|
||
|
||
|
||
func _format_shop_item_tooltip_text(primary_text: String, detail_text: String) -> String:
|
||
if detail_text.is_empty():
|
||
return primary_text
|
||
return "%s\n%s" % [primary_text, detail_text]
|
||
|
||
|
||
func _format_shop_item_button_text(item_id: String, item: Dictionary, price: int, owned: int, stock_limit: int = -1, remaining: int = -1) -> String:
|
||
var unavailable := ""
|
||
if stock_limit >= 0 and remaining <= 0:
|
||
unavailable = " · 매진"
|
||
elif campaign_state.gold < price:
|
||
unavailable = " · 부족 %d냥" % (price - campaign_state.gold)
|
||
var stock_text := ""
|
||
if stock_limit >= 0:
|
||
stock_text = " 잔량 %d/%d" % [max(0, remaining), stock_limit]
|
||
return "%s %d냥 %s 보유 %d점%s%s" % [
|
||
_item_display_name(item_id),
|
||
price,
|
||
_format_shop_item_effect_text(item),
|
||
owned,
|
||
stock_text,
|
||
unavailable
|
||
]
|
||
|
||
|
||
func _format_shop_sell_item_button_text(item_id: String, item: Dictionary, sale_price: int, owned: int) -> String:
|
||
return "%s 매각 %d냥 %s 보유 %d점" % [
|
||
_item_display_name(item_id),
|
||
sale_price,
|
||
_format_shop_item_effect_text(item),
|
||
owned
|
||
]
|
||
|
||
|
||
func _format_shop_item_effect_text(item: Dictionary) -> String:
|
||
var kind := str(item.get("kind", "item"))
|
||
if kind == "consumable":
|
||
return _format_item_effect_text(item)
|
||
if kind == "weapon" or kind == "armor" or kind == "accessory":
|
||
var parts := []
|
||
var equipment_type := str(item.get("%s_type" % kind, ""))
|
||
if not equipment_type.is_empty():
|
||
parts.append(_equipment_type_text(equipment_type))
|
||
if item.has("range"):
|
||
var range_value: Array = item.get("range", [])
|
||
if range_value.size() >= 2:
|
||
parts.append("사거리 %d-%d" % [int(range_value[0]), int(range_value[1])])
|
||
parts.append(_format_equipment_bonus_text(item))
|
||
return _join_strings(parts, ", ")
|
||
return _item_kind_text(kind)
|
||
|
||
|
||
func _item_kind_text(kind: String) -> String:
|
||
var normalized := kind.strip_edges().to_lower()
|
||
if normalized == "consumable":
|
||
return "소모 물자"
|
||
if normalized == "weapon":
|
||
return "무기"
|
||
if normalized == "armor":
|
||
return "갑옷"
|
||
if normalized == "accessory":
|
||
return "보물"
|
||
if normalized == "item":
|
||
return "도구"
|
||
return kind.capitalize()
|
||
|
||
|
||
func _equipment_type_text(equipment_type: String) -> String:
|
||
var normalized := equipment_type.strip_edges().to_lower()
|
||
if normalized == "sword":
|
||
return "검"
|
||
if normalized == "spear":
|
||
return "창"
|
||
if normalized == "bow":
|
||
return "궁"
|
||
if normalized == "axe":
|
||
return "부"
|
||
if normalized == "robe":
|
||
return "도포"
|
||
if normalized == "light_armor":
|
||
return "경갑"
|
||
if normalized == "heavy_armor":
|
||
return "중갑"
|
||
if normalized == "accessory":
|
||
return "보물"
|
||
return _format_identifier_label(equipment_type)
|
||
|
||
|
||
func _apply_item_button_icon(button: Button, item: Dictionary) -> void:
|
||
var icon := _load_art_texture(str(item.get("icon", "")))
|
||
if icon == null:
|
||
return
|
||
button.icon = icon
|
||
button.expand_icon = true
|
||
button.add_theme_constant_override("icon_max_width", 30)
|
||
|
||
|
||
func _on_shop_item_pressed(item_id: String) -> void:
|
||
if battle_started or _is_prebattle_prep_locked():
|
||
return
|
||
var item := state.get_item_def(item_id)
|
||
var price := int(item.get("price", 0))
|
||
var item_name := _item_display_name(item_id)
|
||
if item.is_empty() or price <= 0:
|
||
return
|
||
var stock_limit := state.get_shop_stock_limit(item_id)
|
||
var purchased := campaign_state.get_shop_purchase_count(active_scenario_id, item_id)
|
||
if stock_limit >= 0 and purchased >= stock_limit:
|
||
_play_ui_cancel()
|
||
_on_log_added("%s 매진." % item_name)
|
||
_rebuild_shop_menu()
|
||
return
|
||
if campaign_state.try_buy_item(item_id, price, active_scenario_id, stock_limit):
|
||
state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
|
||
_on_log_added("%s 매입: %d냥." % [item_name, price])
|
||
else:
|
||
_play_ui_cancel()
|
||
_on_log_added("%s 매입 실패." % item_name)
|
||
_rebuild_shop_menu()
|
||
_update_hud()
|
||
|
||
|
||
func _on_shop_sell_item_pressed(item_id: String) -> void:
|
||
if battle_started or _is_prebattle_prep_locked():
|
||
return
|
||
var item := state.get_item_def(item_id)
|
||
var sale_price := _sale_price_for_item(item)
|
||
var item_name := _item_display_name(item_id)
|
||
if item.is_empty() or sale_price <= 0:
|
||
return
|
||
if campaign_state.try_sell_item(item_id, sale_price):
|
||
state.set_inventory_snapshot(campaign_state.get_inventory_snapshot())
|
||
_on_log_added("%s 매각: %d냥." % [item_name, sale_price])
|
||
else:
|
||
_play_ui_cancel()
|
||
_on_log_added("%s 매각 실패." % item_name)
|
||
if _get_sellable_item_ids().is_empty():
|
||
shop_sell_mode = false
|
||
_rebuild_shop_menu()
|
||
_update_hud()
|
||
|
||
|
||
func _on_shop_buy_mode_pressed() -> void:
|
||
if not shop_sell_mode:
|
||
return
|
||
_play_ui_click()
|
||
shop_sell_mode = false
|
||
_rebuild_shop_menu()
|
||
_update_hud()
|
||
|
||
|
||
func _on_shop_sell_mode_pressed() -> void:
|
||
if shop_sell_mode or _get_sellable_item_ids().is_empty():
|
||
return
|
||
_play_ui_click()
|
||
shop_sell_mode = true
|
||
_rebuild_shop_menu()
|
||
_update_hud()
|
||
|
||
|
||
func _get_sellable_item_ids() -> Array:
|
||
var item_ids := []
|
||
var inventory := campaign_state.get_inventory_snapshot()
|
||
for item_id in inventory.keys():
|
||
var normalized_id := str(item_id)
|
||
if normalized_id.is_empty() or int(inventory.get(normalized_id, 0)) <= 0:
|
||
continue
|
||
var item := state.get_item_def(normalized_id)
|
||
if _sale_price_for_item(item) <= 0:
|
||
continue
|
||
item_ids.append(normalized_id)
|
||
item_ids.sort()
|
||
return item_ids
|
||
|
||
|
||
func _sale_price_for_item(item: Dictionary) -> int:
|
||
var price := int(item.get("price", 0))
|
||
if price <= 0:
|
||
return 0
|
||
return max(1, int(floor(float(price) * 0.5)))
|
||
|
||
|
||
func _is_input_locked() -> bool:
|
||
return campaign_complete_screen or _is_dialogue_visible() or not battle_started or state.battle_status != BattleState.STATUS_ACTIVE
|
||
|
||
|
||
func _apply_battle_result_once() -> void:
|
||
if battle_result_applied:
|
||
return
|
||
battle_result_summary = campaign_state.apply_battle_result(state)
|
||
battle_result_applied = true
|
||
|
||
|
||
func _try_show_post_battle_dialogue() -> bool:
|
||
if post_battle_dialogue_started:
|
||
return false
|
||
var lines := _normalized_dialogue_lines(state.get_post_battle_dialogue())
|
||
if lines.is_empty():
|
||
post_battle_dialogue_started = true
|
||
return false
|
||
post_battle_dialogue_started = true
|
||
dialogue_queue.clear()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_refresh_ranges()
|
||
active_dialogue_lines = lines
|
||
active_dialogue_index = 0
|
||
_render_dialogue_line()
|
||
return true
|
||
|
||
|
||
func _rebuild_result_choices() -> void:
|
||
if result_choice_list == null:
|
||
return
|
||
_clear_result_choices()
|
||
if bool(battle_result_summary.get("choice_applied", true)):
|
||
return
|
||
var choices: Array = battle_result_summary.get("post_battle_choices", [])
|
||
for choice in choices:
|
||
if typeof(choice) != TYPE_DICTIONARY:
|
||
continue
|
||
var choice_button := Button.new()
|
||
choice_button.text = _format_post_battle_choice_text(choice)
|
||
_apply_button_style(choice_button, true)
|
||
choice_button.pressed.connect(_on_post_battle_choice_pressed.bind(choice))
|
||
result_choice_list.add_child(choice_button)
|
||
|
||
|
||
func _clear_result_choices() -> void:
|
||
if result_choice_list == null:
|
||
return
|
||
for child in result_choice_list.get_children():
|
||
result_choice_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_post_battle_choice_text(choice: Dictionary) -> String:
|
||
var label := str(choice.get("label", choice.get("text", choice.get("id", "Choice"))))
|
||
var description := str(choice.get("description", ""))
|
||
var reward_text := _format_post_battle_choice_reward_text(choice)
|
||
var text := label
|
||
if description.is_empty():
|
||
text = label
|
||
else:
|
||
text = "%s\n뜻: %s" % [label, description]
|
||
if not reward_text.is_empty():
|
||
text += "\n전리: %s" % reward_text
|
||
return text
|
||
|
||
|
||
func _format_post_battle_choice_reward_text(choice: Dictionary) -> String:
|
||
var parts := []
|
||
var gold: int = maxi(0, int(choice.get("gold", 0)))
|
||
if gold > 0:
|
||
parts.append("+%d냥" % gold)
|
||
if typeof(choice.get("items", [])) == TYPE_ARRAY:
|
||
var items: Array = choice.get("items", [])
|
||
if not items.is_empty():
|
||
parts.append(_format_reward_items(items))
|
||
var joined_text := _format_officer_id_list(choice.get("join_officers", []))
|
||
if not joined_text.is_empty():
|
||
parts.append("합류 %s" % joined_text)
|
||
var left_text := _format_officer_id_list(choice.get("leave_officers", []))
|
||
if not left_text.is_empty():
|
||
parts.append("이탈 %s" % left_text)
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _on_post_battle_choice_pressed(choice: Dictionary) -> void:
|
||
if battle_result_summary.is_empty() or bool(battle_result_summary.get("choice_applied", true)):
|
||
return
|
||
var scenario_id := str(battle_result_summary.get("scenario_id", state.battle_id))
|
||
if campaign_state.try_apply_post_battle_choice(choice, scenario_id):
|
||
_play_ui_confirm()
|
||
battle_result_summary["pending_choice"] = false
|
||
battle_result_summary["choice_applied"] = true
|
||
battle_result_summary["saved"] = true
|
||
battle_result_summary["choice_label"] = str(choice.get("label", choice.get("id", "")))
|
||
var choice_gold: int = maxi(0, int(choice.get("gold", 0)))
|
||
battle_result_summary["gold"] = int(battle_result_summary.get("gold", 0)) + choice_gold
|
||
_append_result_items(choice.get("items", []))
|
||
_append_unique_result_values("joined_officers", choice.get("join_officers", []))
|
||
_append_unique_result_values("left_officers", choice.get("leave_officers", []))
|
||
_on_log_added("군령 채택: %s." % battle_result_summary["choice_label"])
|
||
else:
|
||
_play_ui_cancel()
|
||
_on_log_added("군령을 봉하지 못했다.")
|
||
_rebuild_result_choices()
|
||
_update_result_next_button_visibility()
|
||
if result_label != null and state.battle_status == BattleState.STATUS_VICTORY:
|
||
result_label.text = _format_victory_result_text()
|
||
_update_hud()
|
||
|
||
|
||
func _append_result_items(values) -> void:
|
||
if typeof(values) != TYPE_ARRAY:
|
||
return
|
||
var current: Array = battle_result_summary.get("items", [])
|
||
for value in values:
|
||
var normalized := str(value)
|
||
if normalized.is_empty():
|
||
continue
|
||
current.append(normalized)
|
||
battle_result_summary["items"] = current
|
||
|
||
|
||
func _append_unique_result_values(key: String, values) -> void:
|
||
if typeof(values) != TYPE_ARRAY:
|
||
return
|
||
var current: Array = battle_result_summary.get(key, [])
|
||
for value in values:
|
||
var normalized := str(value)
|
||
if normalized.is_empty() or current.has(normalized):
|
||
continue
|
||
current.append(normalized)
|
||
battle_result_summary[key] = current
|
||
|
||
|
||
func _format_battle_result_summary() -> String:
|
||
if battle_result_summary.is_empty():
|
||
return ""
|
||
if battle_result_summary.get("pending_choice", false):
|
||
var pending_next := str(battle_result_summary.get("next_scenario_title", ""))
|
||
var pending_text := "전기\n 봉인됨\n군막\n 응답을 고르십시오."
|
||
if not pending_next.is_empty():
|
||
pending_text += "\n다음 행군\n %s" % pending_next
|
||
return pending_text
|
||
if battle_result_summary.get("already_completed", false):
|
||
var already_saved := "전기는 변하지 않았습니다." if battle_result_summary.get("saved", false) else "전기 회상에 실패했습니다."
|
||
var already_next := str(battle_result_summary.get("next_scenario_title", ""))
|
||
var replay_lines := [
|
||
"전리품\n 이미 수령",
|
||
"전기\n %s" % already_saved
|
||
]
|
||
if not already_next.is_empty():
|
||
replay_lines.append("다음 행군\n %s" % already_next)
|
||
var replay_preview := _format_next_camp_preview_text(str(battle_result_summary.get("next_scenario_id", "")))
|
||
if not replay_preview.is_empty():
|
||
replay_lines.append("다음 군막\n %s" % replay_preview)
|
||
return _join_strings(replay_lines, "\n")
|
||
|
||
var items: Array = battle_result_summary.get("items", [])
|
||
var item_text := _format_reward_items(items)
|
||
var reward_lines := []
|
||
if bool(battle_result_summary.get("rewards_applied", false)):
|
||
var gold_reward := int(battle_result_summary.get("gold", 0))
|
||
if gold_reward > 0:
|
||
reward_lines.append("군자금 +%d냥" % gold_reward)
|
||
if item_text != "없음":
|
||
reward_lines.append("물자: %s" % item_text)
|
||
else:
|
||
reward_lines.append("아직 기록되지 않음")
|
||
|
||
var roster_lines := []
|
||
var joined_officers: Array = battle_result_summary.get("joined_officers", [])
|
||
if not joined_officers.is_empty():
|
||
roster_lines.append("합류: %s" % _format_officer_id_list(joined_officers))
|
||
var left_officers: Array = battle_result_summary.get("left_officers", [])
|
||
if not left_officers.is_empty():
|
||
roster_lines.append("이탈: %s" % _format_officer_id_list(left_officers))
|
||
|
||
var sections := [
|
||
"전리품\n %s" % _join_strings(reward_lines, "\n ")
|
||
]
|
||
if not roster_lines.is_empty():
|
||
sections.append("명부\n %s" % _join_strings(roster_lines, "\n "))
|
||
var progression_text := _format_progression_events(battle_result_summary.get("progression_events", []))
|
||
if not progression_text.is_empty():
|
||
sections.append("전공\n %s" % progression_text)
|
||
var saved_text := "전기가 봉인되었습니다." if battle_result_summary.get("saved", false) else "전기 봉인 실패."
|
||
var next_text := "전기 완결."
|
||
var next_title := str(battle_result_summary.get("next_scenario_title", ""))
|
||
if not next_title.is_empty():
|
||
next_text = "다음 행군: %s" % next_title
|
||
sections.append("전기\n 군자금: %d냥\n %s\n %s" % [
|
||
campaign_state.gold,
|
||
saved_text,
|
||
next_text
|
||
])
|
||
if not bool(battle_result_summary.get("choice_applied", true)):
|
||
sections.append("군막\n 응답을 고르십시오.")
|
||
elif not str(battle_result_summary.get("choice_label", "")).is_empty():
|
||
sections.append("군막\n %s" % str(battle_result_summary.get("choice_label", "")))
|
||
var next_preview := _format_next_camp_preview_text(str(battle_result_summary.get("next_scenario_id", "")))
|
||
if not next_preview.is_empty() and bool(battle_result_summary.get("saved", false)):
|
||
sections.append("다음 군막\n %s" % next_preview)
|
||
return _join_strings(sections, "\n")
|
||
|
||
|
||
func _update_result_next_button_visibility() -> void:
|
||
if next_battle_button == null:
|
||
return
|
||
next_battle_button.visible = (
|
||
not str(battle_result_summary.get("next_scenario_id", "")).is_empty()
|
||
and bool(battle_result_summary.get("choice_applied", true))
|
||
and bool(battle_result_summary.get("saved", false))
|
||
)
|
||
|
||
|
||
func _format_next_camp_preview_text(next_scenario_id: String) -> String:
|
||
var scenario_id := next_scenario_id.strip_edges()
|
||
if scenario_id.is_empty():
|
||
return ""
|
||
var scenario_path := campaign_state.get_scenario_path(scenario_id)
|
||
if scenario_path.is_empty():
|
||
return ""
|
||
var preview_state := BattleStateScript.new()
|
||
if not preview_state.load_battle(
|
||
scenario_path,
|
||
campaign_state.get_roster_overrides(),
|
||
campaign_state.get_inventory_snapshot(),
|
||
campaign_state.get_flags_snapshot(),
|
||
campaign_state.get_joined_officers_snapshot()
|
||
):
|
||
return ""
|
||
var parts := []
|
||
var briefing := preview_state.get_briefing()
|
||
var location := str(briefing.get("location", "")).strip_edges()
|
||
if not location.is_empty():
|
||
parts.append(location)
|
||
var talk_count := _briefing_camp_conversation_count(briefing)
|
||
if talk_count > 0:
|
||
parts.append("군막 회의 · %d건" % talk_count)
|
||
var shop_count := preview_state.get_shop_item_ids().size()
|
||
if shop_count > 0:
|
||
parts.append("군상 물품 · %d종" % shop_count)
|
||
if preview_state.has_deployment_roster():
|
||
parts.append("출진 명부 · %d/%d" % [
|
||
preview_state.get_deployed_player_count(),
|
||
preview_state.get_deployment_max_units()
|
||
])
|
||
var formation_count := preview_state.get_formation_cells().size()
|
||
if formation_count > 0:
|
||
parts.append("진형 배치 %d칸" % formation_count)
|
||
return _join_strings(parts, " - ")
|
||
|
||
|
||
func _briefing_camp_conversation_count(briefing: Dictionary) -> int:
|
||
var conversations = briefing.get("camp_conversations", [])
|
||
if typeof(conversations) == TYPE_ARRAY and not conversations.is_empty():
|
||
return conversations.size()
|
||
var lines = briefing.get("camp_dialogue", [])
|
||
if typeof(lines) == TYPE_ARRAY and not lines.is_empty():
|
||
return 1
|
||
return 0
|
||
|
||
|
||
func _format_progression_events(events) -> String:
|
||
if typeof(events) != TYPE_ARRAY:
|
||
return ""
|
||
var parts := []
|
||
var hidden_count := 0
|
||
for event in events:
|
||
if typeof(event) != TYPE_DICTIONARY:
|
||
continue
|
||
if parts.size() >= 4:
|
||
hidden_count += 1
|
||
continue
|
||
var event_type := str(event.get("type", ""))
|
||
var unit_name := str(event.get("name", "부대"))
|
||
if event_type == "promotion":
|
||
parts.append("%s에서 %s로 승급" % [unit_name, str(event.get("to_class", "승급"))])
|
||
elif event_type == "level_up":
|
||
parts.append("%s 품계 %d" % [unit_name, int(event.get("to_level", 1))])
|
||
if hidden_count > 0:
|
||
parts.append("외 %d건" % hidden_count)
|
||
if parts.is_empty():
|
||
return ""
|
||
return _join_strings(parts, "; ")
|
||
|
||
|
||
func _format_inventory_status_text() -> String:
|
||
var inventory := state.get_inventory_snapshot()
|
||
if campaign_complete_screen or state.battle_status != BattleState.STATUS_ACTIVE:
|
||
inventory = campaign_state.get_inventory_snapshot()
|
||
return "군수: %s" % _format_inventory_by_category(inventory)
|
||
|
||
|
||
func _format_reward_items(items: Array) -> String:
|
||
var counts := {}
|
||
for item_id in items:
|
||
var key := str(item_id)
|
||
if key.is_empty():
|
||
continue
|
||
counts[key] = int(counts.get(key, 0)) + 1
|
||
return _format_inventory_items(counts)
|
||
|
||
|
||
func _format_inventory_items(inventory: Dictionary) -> String:
|
||
var item_ids := []
|
||
for item_id in inventory.keys():
|
||
var key := str(item_id)
|
||
if key.is_empty() or int(inventory.get(key, 0)) <= 0:
|
||
continue
|
||
item_ids.append(key)
|
||
item_ids.sort()
|
||
if item_ids.is_empty():
|
||
return "없음"
|
||
|
||
var parts := []
|
||
for item_id in item_ids:
|
||
var key := str(item_id)
|
||
parts.append("%s %d점" % [_item_inventory_display_name(key), int(inventory.get(key, 0))])
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _format_inventory_by_category(inventory: Dictionary) -> String:
|
||
var consumables := {}
|
||
var equipment := {}
|
||
var other := {}
|
||
for item_id in inventory.keys():
|
||
var key := str(item_id)
|
||
var count := int(inventory.get(key, 0))
|
||
if key.is_empty() or count <= 0:
|
||
continue
|
||
var item := state.get_item_def(key)
|
||
var kind := str(item.get("kind", ""))
|
||
if kind == "consumable":
|
||
consumables[key] = count
|
||
elif kind == "weapon" or kind == "armor" or kind == "accessory":
|
||
equipment[key] = count
|
||
else:
|
||
other[key] = count
|
||
|
||
var sections := []
|
||
if not consumables.is_empty():
|
||
sections.append("소모품: %s" % _format_inventory_items(consumables))
|
||
if not equipment.is_empty():
|
||
sections.append("병장: %s" % _format_inventory_items(equipment))
|
||
if not other.is_empty():
|
||
sections.append("잡물: %s" % _format_inventory_items(other))
|
||
if sections.is_empty():
|
||
return "없음"
|
||
return _join_strings(sections, " · ")
|
||
|
||
|
||
func _format_officer_id_list(values) -> String:
|
||
if typeof(values) != TYPE_ARRAY:
|
||
return ""
|
||
var labels := []
|
||
for value in values:
|
||
var label := _officer_display_name_from_id(str(value))
|
||
if label.is_empty():
|
||
continue
|
||
labels.append(label)
|
||
return _join_strings(labels, ", ")
|
||
|
||
|
||
func _officer_display_name_from_id(officer_id: String) -> String:
|
||
match officer_id:
|
||
"cao_cao":
|
||
return "조조"
|
||
"xiahou_dun":
|
||
return "하후돈"
|
||
"xiahou_yuan":
|
||
return "하후연"
|
||
"cao_ren":
|
||
return "조인"
|
||
"dian_wei":
|
||
return "전위"
|
||
"guo_jia":
|
||
return "곽가"
|
||
"zhang_he":
|
||
return "장합"
|
||
_:
|
||
return _format_identifier_label(officer_id)
|
||
|
||
|
||
func _format_identifier_label(value: String) -> String:
|
||
return value.strip_edges().replace("_", " ").capitalize()
|
||
|
||
|
||
func _item_display_name(item_id: String) -> String:
|
||
var localized_name := _localized_item_name(item_id)
|
||
if not localized_name.is_empty():
|
||
return localized_name
|
||
var item := state.get_item_def(item_id)
|
||
if item.is_empty():
|
||
return item_id
|
||
return str(item.get("name", item_id))
|
||
|
||
|
||
func _item_inventory_display_name(item_id: String) -> String:
|
||
var item := state.get_item_def(item_id)
|
||
if item.is_empty():
|
||
return item_id
|
||
var display_name := _item_display_name(item_id)
|
||
var rarity_text := _format_item_rarity_text(item)
|
||
if rarity_text.is_empty():
|
||
return display_name
|
||
return "%s [%s]" % [display_name, rarity_text]
|
||
|
||
|
||
func _localized_item_name(item_id: String) -> String:
|
||
match item_id:
|
||
"bronze_sword":
|
||
return "청동검"
|
||
"iron_sword":
|
||
return "철검"
|
||
"training_spear":
|
||
return "연습창"
|
||
"short_bow":
|
||
return "단궁"
|
||
"hand_axe":
|
||
return "손도끼"
|
||
"war_axe":
|
||
return "전부"
|
||
"war_drum":
|
||
return "전고"
|
||
"imperial_seal":
|
||
return "옥새"
|
||
"yitian_sword":
|
||
return "의천검"
|
||
"dragon_spear":
|
||
return "용담창"
|
||
"wind_chaser_bow":
|
||
return "추풍궁"
|
||
"black_tortoise_robe":
|
||
return "현무도포"
|
||
"bean":
|
||
return "콩"
|
||
"antidote":
|
||
return "해독약"
|
||
"panacea":
|
||
return "만능약"
|
||
"wine":
|
||
return "술"
|
||
_:
|
||
return ""
|
||
|
||
|
||
func _join_strings(values: Array, delimiter: String) -> String:
|
||
var text := ""
|
||
for value in values:
|
||
if not text.is_empty():
|
||
text += delimiter
|
||
text += str(value)
|
||
return text
|
||
|
||
|
||
func _on_next_battle_pressed() -> void:
|
||
var next_scenario_id := str(battle_result_summary.get("next_scenario_id", ""))
|
||
if next_scenario_id.is_empty():
|
||
return
|
||
_play_ui_confirm()
|
||
if log_box != null:
|
||
log_box.clear()
|
||
_load_scenario(next_scenario_id)
|
||
_show_briefing()
|
||
_update_hud()
|
||
|
||
|
||
func _show_campaign_complete() -> void:
|
||
campaign_complete_screen = true
|
||
_play_bgm("menu")
|
||
battle_started = false
|
||
battle_result_applied = true
|
||
battle_result_summary.clear()
|
||
post_battle_dialogue_started = true
|
||
floating_texts.clear()
|
||
unit_motion_by_unit.clear()
|
||
move_cells.clear()
|
||
attack_cells.clear()
|
||
skill_cells.clear()
|
||
item_cells.clear()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_clear_dialogue()
|
||
active_scenario_id = campaign_state.current_scenario_id
|
||
if briefing_panel != null:
|
||
briefing_panel.visible = false
|
||
if log_box != null:
|
||
log_box.append_text("전기가 여기서 매듭지어졌다.\n")
|
||
|
||
|
||
func _on_new_campaign_pressed() -> void:
|
||
_play_ui_confirm()
|
||
if log_box != null:
|
||
log_box.clear()
|
||
if not campaign_state.reset_save(campaign_state.get_start_scenario_id()):
|
||
result_panel.visible = true
|
||
result_label.text = "전기 봉인을 새로 열지 못했습니다.\n저장 권한을 살펴본 뒤 다시 명하십시오."
|
||
if result_restart_button != null:
|
||
result_restart_button.visible = false
|
||
if next_battle_button != null:
|
||
next_battle_button.visible = false
|
||
return
|
||
campaign_complete_screen = false
|
||
battle_started = false
|
||
battle_result_applied = false
|
||
battle_result_summary.clear()
|
||
post_battle_dialogue_started = false
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
_clear_pending_move_state()
|
||
formation_unit_id = ""
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_chapter_menu()
|
||
_hide_shop_menu()
|
||
_hide_armory_menu()
|
||
_hide_roster_menu()
|
||
_hide_formation_menu()
|
||
_hide_save_menu()
|
||
_load_current_battle()
|
||
_show_briefing()
|
||
_update_hud()
|
||
|
||
|
||
func _show_post_move_menu() -> void:
|
||
if post_move_menu == null or not _has_pending_move():
|
||
return
|
||
_update_post_move_menu()
|
||
post_move_menu.visible = true
|
||
post_move_menu.move_to_front()
|
||
|
||
|
||
func _hide_post_move_menu() -> void:
|
||
if post_move_menu == null:
|
||
return
|
||
post_move_menu.visible = false
|
||
|
||
|
||
func _update_post_move_menu() -> void:
|
||
if post_move_menu == null:
|
||
return
|
||
if not _has_pending_move():
|
||
_hide_post_move_menu()
|
||
return
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id or bool(selected.get("acted", false)):
|
||
_hide_post_move_menu()
|
||
return
|
||
_position_post_move_menu()
|
||
if post_move_title_label != null:
|
||
_set_fitted_label_text(
|
||
post_move_title_label,
|
||
"%s: 군령 선택" % str(selected.get("name", "부대")),
|
||
LOCAL_COMMAND_TITLE_FONT_SIZE,
|
||
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
|
||
POST_MOVE_TITLE_SIZE
|
||
)
|
||
if post_move_attack_button != null:
|
||
var attack_blocked := not _has_basic_attack_target(pending_move_unit_id)
|
||
post_move_attack_button.disabled = attack_blocked
|
||
post_move_attack_button.tooltip_text = "사거리 안에 적 부대가 없습니다." if attack_blocked else "이 자리에서 칠 적군을 지목합니다."
|
||
if post_move_tactic_button != null:
|
||
var skill_blocked := not _unit_has_usable_skill_target(pending_move_unit_id)
|
||
post_move_tactic_button.disabled = skill_blocked
|
||
post_move_tactic_button.tooltip_text = "이 자리에서 닿는 책략 대상이 없습니다." if skill_blocked else "군령에 올릴 책략 죽간을 고릅니다."
|
||
if post_move_item_button != null:
|
||
var item_blocked := not _unit_has_usable_item_target(pending_move_unit_id)
|
||
post_move_item_button.disabled = item_blocked
|
||
post_move_item_button.tooltip_text = "이 자리에서 닿는 보급 대상이 없습니다." if item_blocked else "군령에 올릴 보급 물자를 고릅니다."
|
||
if post_move_wait_button != null:
|
||
post_move_wait_button.disabled = false
|
||
post_move_wait_button.tooltip_text = "행군을 확정하고 군령을 봉인합니다."
|
||
if post_move_cancel_button != null:
|
||
post_move_cancel_button.disabled = false
|
||
post_move_cancel_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
|
||
|
||
|
||
func _local_command_panel_position_for_cell(cell: Vector2i, panel_size: Vector2) -> Vector2:
|
||
if not state.is_inside(cell):
|
||
return _board_origin() + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
|
||
var cell_rect := _rect_for_cell(cell)
|
||
var centered_y := cell_rect.position.y + (TILE_SIZE - panel_size.y) * 0.5
|
||
var centered_x := cell_rect.position.x + (TILE_SIZE - panel_size.x) * 0.5
|
||
var candidates: Array[Vector2] = [
|
||
Vector2(cell_rect.end.x + LOCAL_COMMAND_PANEL_GAP, centered_y),
|
||
Vector2(cell_rect.position.x - panel_size.x - LOCAL_COMMAND_PANEL_GAP, centered_y),
|
||
Vector2(centered_x, cell_rect.end.y + LOCAL_COMMAND_PANEL_GAP),
|
||
Vector2(centered_x, cell_rect.position.y - panel_size.y - LOCAL_COMMAND_PANEL_GAP)
|
||
]
|
||
var avoid_rect := cell_rect.grow(2.0)
|
||
var best_position := _clamped_local_command_panel_position(candidates[0], panel_size)
|
||
var best_score := 1000000000.0
|
||
for candidate in candidates:
|
||
var next_position := _clamped_local_command_panel_position(candidate, panel_size)
|
||
var panel_rect := Rect2(next_position, panel_size)
|
||
var score := next_position.distance_squared_to(candidate)
|
||
if panel_rect.intersects(avoid_rect):
|
||
score += 1000000.0
|
||
if score < best_score:
|
||
best_score = score
|
||
best_position = next_position
|
||
if score < 1.0 and not panel_rect.intersects(avoid_rect):
|
||
break
|
||
return best_position
|
||
|
||
|
||
func _clamped_local_command_panel_position(position: Vector2, panel_size: Vector2) -> Vector2:
|
||
var board_rect := _board_rect()
|
||
var view_rect := _map_view_rect()
|
||
var board_min := Vector2(
|
||
maxf(board_rect.position.x, view_rect.position.x),
|
||
maxf(board_rect.position.y, view_rect.position.y)
|
||
) + Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
|
||
var board_max := Vector2(
|
||
minf(board_rect.end.x, view_rect.end.x),
|
||
minf(board_rect.end.y, view_rect.end.y)
|
||
) - panel_size - Vector2(LOCAL_COMMAND_PANEL_BOARD_PADDING, LOCAL_COMMAND_PANEL_BOARD_PADDING)
|
||
board_max.x = maxf(board_min.x, board_max.x)
|
||
board_max.y = maxf(board_min.y, board_max.y)
|
||
return Vector2(
|
||
clampf(position.x, board_min.x, board_max.x),
|
||
clampf(position.y, board_min.y, board_max.y)
|
||
)
|
||
|
||
|
||
func _position_post_move_menu() -> void:
|
||
if post_move_menu == null:
|
||
return
|
||
post_move_menu.position = _local_command_panel_position_for_cell(pending_move_to_cell, POST_MOVE_MENU_SIZE)
|
||
|
||
|
||
func _show_post_move_picker(mode: String) -> void:
|
||
if post_move_picker_panel == null or not _has_pending_move():
|
||
return
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id:
|
||
return
|
||
post_move_picker_mode = mode
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_post_move_menu()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_rebuild_post_move_picker(selected)
|
||
post_move_picker_panel.visible = true
|
||
post_move_picker_panel.move_to_front()
|
||
|
||
|
||
func _hide_post_move_picker() -> void:
|
||
if post_move_picker_panel == null:
|
||
return
|
||
post_move_picker_panel.visible = false
|
||
post_move_picker_mode = ""
|
||
|
||
|
||
func _update_post_move_picker() -> void:
|
||
if post_move_picker_panel == null or not post_move_picker_panel.visible:
|
||
return
|
||
if not _has_pending_move():
|
||
_hide_post_move_picker()
|
||
return
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty() or str(selected.get("id", "")) != pending_move_unit_id or bool(selected.get("acted", false)):
|
||
_hide_post_move_picker()
|
||
return
|
||
_rebuild_post_move_picker(selected)
|
||
|
||
|
||
func _rebuild_post_move_picker(selected: Dictionary) -> void:
|
||
if post_move_picker_panel == null or post_move_picker_list == null:
|
||
return
|
||
_clear_post_move_picker_list()
|
||
_position_post_move_picker()
|
||
if post_move_picker_title_label != null:
|
||
_set_fitted_label_text(
|
||
post_move_picker_title_label,
|
||
_post_move_picker_title_text(selected),
|
||
LOCAL_COMMAND_TITLE_FONT_SIZE,
|
||
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
|
||
POST_MOVE_PICKER_TITLE_SIZE
|
||
)
|
||
if post_move_picker_detail_label != null:
|
||
_set_fitted_label_text(
|
||
post_move_picker_detail_label,
|
||
_post_move_picker_detail_text(),
|
||
LOCAL_COMMAND_DETAIL_FONT_SIZE,
|
||
LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE,
|
||
POST_MOVE_PICKER_DETAIL_SIZE
|
||
)
|
||
if post_move_picker_mode == "tactic":
|
||
_rebuild_post_move_tactic_picker(selected)
|
||
elif post_move_picker_mode == "item":
|
||
_rebuild_post_move_item_picker(selected)
|
||
else:
|
||
var empty_label := Label.new()
|
||
empty_label.text = "열린 군령이 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
post_move_picker_list.add_child(empty_label)
|
||
|
||
|
||
func _clear_post_move_picker_list() -> void:
|
||
if post_move_picker_list == null:
|
||
return
|
||
for child in post_move_picker_list.get_children():
|
||
post_move_picker_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _position_post_move_picker() -> void:
|
||
if post_move_picker_panel == null:
|
||
return
|
||
post_move_picker_panel.position = _local_command_panel_position_for_cell(pending_move_to_cell, POST_MOVE_PICKER_PANEL_SIZE)
|
||
|
||
|
||
func _post_move_picker_title_text(selected: Dictionary) -> String:
|
||
var unit_name := str(selected.get("name", "부대"))
|
||
if post_move_picker_mode == "tactic":
|
||
return "%s: 책략 죽간" % unit_name
|
||
if post_move_picker_mode == "item":
|
||
return "%s: 보급 물자" % unit_name
|
||
return "%s: 군령 죽간" % unit_name
|
||
|
||
|
||
func _post_move_picker_detail_text() -> String:
|
||
if post_move_picker_mode == "tactic":
|
||
return "책략을 고른 뒤 빛나는 표식 칸을 지목합니다."
|
||
if post_move_picker_mode == "item":
|
||
return "보급을 고른 뒤 표식이 뜬 아군을 지목합니다."
|
||
return "군령에 올릴 죽간을 고릅니다."
|
||
|
||
|
||
func _rebuild_post_move_tactic_picker(selected: Dictionary) -> void:
|
||
var skill_ids := state.get_skill_ids(selected["id"])
|
||
if skill_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "사용할 책략이 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
post_move_picker_list.add_child(empty_label)
|
||
return
|
||
for skill_id_value in skill_ids:
|
||
var skill_id := str(skill_id_value)
|
||
var skill := state.get_skill_def(skill_id)
|
||
var skill_button := Button.new()
|
||
skill_button.text = _format_post_move_tactic_button_text(skill_id, skill, selected)
|
||
var disabled_reason := _post_move_tactic_disabled_reason(selected, skill_id, skill)
|
||
skill_button.disabled = not disabled_reason.is_empty()
|
||
skill_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "이 책략 죽간을 군령에 올립니다."
|
||
_apply_button_style(skill_button)
|
||
skill_button.pressed.connect(_on_post_move_tactic_option_pressed.bind(skill_id))
|
||
post_move_picker_list.add_child(skill_button)
|
||
|
||
|
||
func _rebuild_post_move_item_picker(selected: Dictionary) -> void:
|
||
var item_ids := state.get_usable_item_ids()
|
||
if item_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "쓸 보급이 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
post_move_picker_list.add_child(empty_label)
|
||
return
|
||
var inventory := state.get_inventory_snapshot()
|
||
for item_id_value in item_ids:
|
||
var item_id := str(item_id_value)
|
||
var item := state.get_item_def(item_id)
|
||
var count := int(inventory.get(item_id, 0))
|
||
var item_button := Button.new()
|
||
item_button.text = _format_post_move_item_button_text(item_id, item, count)
|
||
_apply_item_button_icon(item_button, item)
|
||
var disabled_reason := _post_move_item_disabled_reason(selected, item_id, count)
|
||
item_button.disabled = not disabled_reason.is_empty()
|
||
item_button.tooltip_text = disabled_reason if not disabled_reason.is_empty() else "이 보급을 군령에 올립니다."
|
||
_apply_button_style(item_button)
|
||
item_button.pressed.connect(_on_post_move_item_option_pressed.bind(item_id))
|
||
post_move_picker_list.add_child(item_button)
|
||
|
||
|
||
func _format_post_move_tactic_button_text(skill_id: String, skill: Dictionary, selected: Dictionary) -> String:
|
||
var marker := "> " if selected_skill_id == skill_id else ""
|
||
var skill_name := str(skill.get("name", skill_id))
|
||
var mp_cost := int(skill.get("mp_cost", 0))
|
||
var kind := _skill_kind_text(str(skill.get("kind", "skill")))
|
||
var range_text := _format_tactic_range_text(skill)
|
||
var disabled_text := ""
|
||
if state.is_unit_skill_sealed(selected["id"]):
|
||
disabled_text = " - 봉인"
|
||
elif int(selected.get("mp", 0)) < mp_cost:
|
||
disabled_text = " - 기력"
|
||
elif not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
|
||
disabled_text = " - 표식 없음"
|
||
return "%s%s 기력 %d %s 사거리 %s%s" % [marker, skill_name, mp_cost, kind, range_text, disabled_text]
|
||
|
||
|
||
func _format_post_move_item_button_text(item_id: String, item: Dictionary, count: int) -> String:
|
||
var marker := "> " if selected_item_id == item_id else ""
|
||
var item_name := _item_display_name(item_id)
|
||
return "%s%s 잔량 %d %s" % [marker, item_name, count, _format_post_move_item_effect_text(item)]
|
||
|
||
|
||
func _format_post_move_item_effect_text(item: Dictionary) -> String:
|
||
var has_cure := false
|
||
for effect in item.get("effects", []):
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
var effect_type := str(effect.get("type", ""))
|
||
if effect_type == "heal_hp":
|
||
return "병력 +%d" % int(effect.get("amount", 0))
|
||
if effect_type == "heal_mp":
|
||
return "기력 +%d" % int(effect.get("amount", 0))
|
||
if effect_type == "cure_status" or effect_type == "cleanse_debuffs":
|
||
has_cure = true
|
||
if has_cure:
|
||
return "치료"
|
||
return _item_kind_text(str(item.get("kind", "item")))
|
||
|
||
|
||
func _post_move_tactic_disabled_reason(selected: Dictionary, skill_id: String, skill: Dictionary) -> String:
|
||
if skill.is_empty():
|
||
return "알 수 없는 책략입니다."
|
||
if state.is_unit_skill_sealed(selected["id"]):
|
||
return "이 군기는 책략이 봉인되었습니다."
|
||
if bool(selected.get("acted", false)):
|
||
return "이 군기는 이미 군령을 마쳤습니다."
|
||
if int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0)):
|
||
return "기력이 부족합니다."
|
||
if not _skill_has_valid_target(str(selected.get("id", "")), skill_id):
|
||
return "이 자리에서 지목할 대상이 없습니다."
|
||
return ""
|
||
|
||
|
||
func _post_move_item_disabled_reason(selected: Dictionary, item_id: String, count: int) -> String:
|
||
if count <= 0:
|
||
return "남은 보급이 없습니다."
|
||
if not _item_has_valid_target(str(selected.get("id", "")), item_id):
|
||
return "이 자리에서 지목할 대상이 없습니다."
|
||
return ""
|
||
|
||
|
||
func _on_post_move_tactic_option_pressed(skill_id: String) -> void:
|
||
_hide_post_move_picker()
|
||
_on_tactic_skill_pressed(skill_id)
|
||
|
||
|
||
func _on_post_move_item_option_pressed(item_id: String) -> void:
|
||
_hide_post_move_picker()
|
||
_on_item_selected_pressed(item_id)
|
||
|
||
|
||
func _on_post_move_picker_back_pressed() -> void:
|
||
_play_ui_cancel()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_post_move_picker()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_post_move_picker_cancel_move_pressed() -> void:
|
||
_cancel_pending_move()
|
||
|
||
|
||
func _on_post_move_attack_pressed() -> void:
|
||
if not _has_pending_move():
|
||
return
|
||
if not _has_basic_attack_target(pending_move_unit_id):
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_click()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = true
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_picker()
|
||
_hide_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_post_move_tactic_pressed() -> void:
|
||
if not _has_pending_move():
|
||
return
|
||
if not _unit_has_usable_skill_target(pending_move_unit_id):
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_click()
|
||
_show_post_move_picker("tactic")
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_post_move_item_pressed() -> void:
|
||
if not _has_pending_move():
|
||
return
|
||
if not _unit_has_usable_item_target(pending_move_unit_id):
|
||
_play_ui_cancel()
|
||
return
|
||
_play_ui_click()
|
||
_show_post_move_picker("item")
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_post_move_wait_pressed() -> void:
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_picker()
|
||
if _commit_pending_move_before_action() and state.try_wait_selected():
|
||
_play_ui_confirm()
|
||
|
||
|
||
func _on_post_move_cancel_pressed() -> void:
|
||
_cancel_pending_move()
|
||
|
||
|
||
func _is_targeting_mode() -> bool:
|
||
return basic_attack_targeting or not selected_skill_id.is_empty() or not selected_item_id.is_empty()
|
||
|
||
|
||
func _show_targeting_hint_panel() -> void:
|
||
if targeting_hint_panel == null:
|
||
return
|
||
_update_targeting_hint_panel()
|
||
if targeting_hint_panel != null and _is_targeting_mode():
|
||
targeting_hint_panel.visible = true
|
||
targeting_hint_panel.move_to_front()
|
||
|
||
|
||
func _hide_targeting_hint_panel() -> void:
|
||
if targeting_hint_panel == null:
|
||
return
|
||
targeting_hint_panel.visible = false
|
||
|
||
|
||
func _update_targeting_hint_panel() -> void:
|
||
if targeting_hint_panel == null:
|
||
return
|
||
if not _is_targeting_mode():
|
||
_hide_targeting_hint_panel()
|
||
return
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty() or bool(selected.get("acted", false)) or _is_input_locked():
|
||
_hide_targeting_hint_panel()
|
||
return
|
||
_position_targeting_hint_panel(selected)
|
||
if targeting_hint_title_label != null:
|
||
_set_fitted_label_text(
|
||
targeting_hint_title_label,
|
||
_targeting_hint_title_text(),
|
||
LOCAL_COMMAND_TITLE_FONT_SIZE,
|
||
LOCAL_COMMAND_TITLE_MIN_FONT_SIZE,
|
||
TARGETING_HINT_TITLE_SIZE
|
||
)
|
||
if targeting_hint_detail_label != null:
|
||
_set_fitted_label_text(
|
||
targeting_hint_detail_label,
|
||
_targeting_hint_detail_text(),
|
||
LOCAL_COMMAND_DETAIL_FONT_SIZE,
|
||
LOCAL_COMMAND_DETAIL_MIN_FONT_SIZE,
|
||
TARGETING_HINT_DETAIL_SIZE
|
||
)
|
||
if targeting_hint_back_button != null:
|
||
targeting_hint_back_button.text = "죽간 거두기"
|
||
targeting_hint_back_button.tooltip_text = "군령 선택으로 되돌립니다." if _has_pending_move() else "지목을 거둡니다."
|
||
if targeting_hint_cancel_move_button != null:
|
||
targeting_hint_cancel_move_button.visible = _has_pending_move()
|
||
targeting_hint_cancel_move_button.disabled = not _has_pending_move()
|
||
targeting_hint_cancel_move_button.tooltip_text = "발걸음을 거두면 원래 자리로 되돌립니다."
|
||
targeting_hint_panel.visible = true
|
||
|
||
|
||
func _position_targeting_hint_panel(selected: Dictionary) -> void:
|
||
if targeting_hint_panel == null:
|
||
return
|
||
var cell: Vector2i = selected.get("pos", Vector2i(-1, -1))
|
||
if not state.is_inside(cell):
|
||
return
|
||
targeting_hint_panel.position = _local_command_panel_position_for_cell(cell, TARGETING_HINT_PANEL_SIZE)
|
||
|
||
|
||
func _targeting_hint_title_text() -> String:
|
||
if basic_attack_targeting:
|
||
return "적장 지목"
|
||
if not selected_skill_id.is_empty():
|
||
var skill := state.get_skill_def(selected_skill_id)
|
||
return "지목: %s" % str(skill.get("name", selected_skill_id))
|
||
if not selected_item_id.is_empty():
|
||
var item := state.get_item_def(selected_item_id)
|
||
return "지목: %s" % str(item.get("name", selected_item_id))
|
||
return "대상 지목"
|
||
|
||
|
||
func _targeting_hint_detail_text() -> String:
|
||
if basic_attack_targeting:
|
||
var markers := _attack_target_marker_entries()
|
||
if markers.is_empty():
|
||
return "사거리 안에 적 부대가 없습니다."
|
||
return "적 표식 %d. 표시된 적을 공격하십시오." % markers.size()
|
||
if not selected_skill_id.is_empty():
|
||
var markers := _skill_target_marker_entries()
|
||
if markers.is_empty():
|
||
return "책략을 쓸 대상이 없습니다."
|
||
return "책략 표식 %d. 표시된 지점을 고르십시오." % markers.size()
|
||
if not selected_item_id.is_empty():
|
||
var markers := _item_target_marker_entries()
|
||
if markers.is_empty():
|
||
return "도구를 쓸 대상이 없습니다."
|
||
return "도구 표식 %d. 표시된 부대를 고르십시오." % markers.size()
|
||
return "표시된 대상을 지목합니다."
|
||
|
||
|
||
func _on_targeting_back_pressed() -> void:
|
||
_play_ui_cancel()
|
||
selected_skill_id = ""
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_targeting_hint_panel()
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_targeting_cancel_move_pressed() -> void:
|
||
_cancel_pending_move()
|
||
|
||
|
||
func _set_action_button_state(button: Button, label: String, disabled: bool, tooltip := "") -> void:
|
||
if button == null:
|
||
return
|
||
button.text = label
|
||
button.disabled = disabled
|
||
button.tooltip_text = tooltip
|
||
|
||
|
||
func _set_action_button_blocked(button: Button, base_label: String, reason_label: String, tooltip: String) -> void:
|
||
_set_action_button_state(button, "%s|%s" % [base_label, reason_label], true, tooltip)
|
||
|
||
|
||
func _action_phase_block_reason() -> Dictionary:
|
||
if campaign_complete_screen:
|
||
return {
|
||
"label": "전기 봉인",
|
||
"tooltip": "전기가 완결되었습니다."
|
||
}
|
||
if not battle_started:
|
||
return {
|
||
"label": "전장도 미개봉",
|
||
"tooltip": "전장도를 펼친 뒤 군령을 내릴 수 있습니다."
|
||
}
|
||
if state.battle_status != BattleState.STATUS_ACTIVE:
|
||
return {
|
||
"label": "전장 종결",
|
||
"tooltip": "전투가 끝난 뒤에는 군령을 낼 수 없습니다."
|
||
}
|
||
if state.current_team != BattleState.TEAM_PLAYER:
|
||
return {
|
||
"label": "적군 차례",
|
||
"tooltip": "아군 군령 차례를 기다립니다."
|
||
}
|
||
if _is_input_locked():
|
||
return {
|
||
"label": "처리 중",
|
||
"tooltip": "현재 대화, 결과, 움직임을 먼저 마칩니다."
|
||
}
|
||
return {}
|
||
|
||
|
||
func _update_wait_button(selected: Dictionary) -> void:
|
||
if wait_button == null:
|
||
return
|
||
if selected.is_empty():
|
||
_set_action_button_blocked(wait_button, "대기령", "부대 미지정", "명령할 부대를 먼저 고릅니다.")
|
||
return
|
||
if bool(selected.get("acted", false)):
|
||
_set_action_button_blocked(wait_button, "대기령", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||
return
|
||
var phase_block := _action_phase_block_reason()
|
||
if not phase_block.is_empty():
|
||
_set_action_button_blocked(wait_button, "대기령", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||
return
|
||
_set_action_button_state(wait_button, "대기령", false, "이 부대의 군령을 봉인합니다.")
|
||
|
||
|
||
func _update_end_turn_button() -> void:
|
||
if end_turn_button == null:
|
||
return
|
||
var phase_block := _action_phase_block_reason()
|
||
if not phase_block.is_empty():
|
||
_set_action_button_blocked(end_turn_button, "군령 봉함", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||
return
|
||
_set_action_button_state(end_turn_button, "군령 봉함", false, "아군 군령을 거두고 적군의 차례로 넘깁니다.")
|
||
|
||
|
||
func _update_threat_button() -> void:
|
||
if threat_button == null:
|
||
return
|
||
threat_button.button_pressed = show_threat_overlay
|
||
if campaign_complete_screen:
|
||
_set_action_button_blocked(threat_button, "적진도", "전기 봉인", "전기가 완결되었습니다.")
|
||
return
|
||
if not battle_started:
|
||
_set_action_button_blocked(threat_button, "적진도", "전장도 미개봉", "전장도를 펼친 뒤 적진을 살필 수 있습니다.")
|
||
return
|
||
if state.battle_status != BattleState.STATUS_ACTIVE:
|
||
_set_action_button_blocked(threat_button, "적진도", "전장 종결", "전투 중에만 적진을 살필 수 있습니다.")
|
||
return
|
||
_set_action_button_state(threat_button, "적진도", false, "적의 무기와 책략 사거리를 살핍니다.")
|
||
|
||
|
||
func _update_tactic_button(selected: Dictionary) -> void:
|
||
if tactic_button == null:
|
||
return
|
||
if selected.is_empty():
|
||
_set_action_button_blocked(tactic_button, "책략첩", "부대 미지정", "책략을 쓸 부대를 먼저 고릅니다.")
|
||
return
|
||
|
||
var skill_ids := state.get_skill_ids(selected["id"])
|
||
if skill_ids.is_empty():
|
||
_set_action_button_blocked(tactic_button, "책략첩", "없음", "이 부대는 사용할 책략이 없습니다.")
|
||
return
|
||
if state.is_unit_skill_sealed(selected["id"]):
|
||
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 책략을 쓸 수 없습니다.")
|
||
return
|
||
if bool(selected.get("acted", false)):
|
||
_set_action_button_blocked(tactic_button, "책략첩", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||
return
|
||
var phase_block := _action_phase_block_reason()
|
||
if not phase_block.is_empty():
|
||
_set_action_button_blocked(tactic_button, "책략첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||
return
|
||
|
||
if not selected_skill_id.is_empty():
|
||
var skill := state.get_skill_def(selected_skill_id)
|
||
_set_action_button_state(tactic_button, "책략첩|%s" % str(skill.get("name", selected_skill_id)), false, "전장에 책략 대상을 지목합니다.")
|
||
elif tactic_menu != null and tactic_menu.visible:
|
||
_set_action_button_state(tactic_button, "책략첩 봉함", false, "책략 목록을 닫습니다.")
|
||
else:
|
||
_set_action_button_state(tactic_button, "책략첩", false, "책략 목록을 펼칩니다. %d개." % skill_ids.size())
|
||
|
||
|
||
func _show_tactic_menu(selected: Dictionary) -> void:
|
||
if tactic_menu == null:
|
||
return
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_rebuild_tactic_menu(selected)
|
||
tactic_menu.visible = true
|
||
|
||
|
||
func _hide_tactic_menu() -> void:
|
||
if tactic_menu == null:
|
||
return
|
||
tactic_menu.visible = false
|
||
|
||
|
||
func _rebuild_tactic_menu(selected: Dictionary) -> void:
|
||
if tactic_list == null:
|
||
return
|
||
_clear_tactic_list()
|
||
var skill_ids := state.get_skill_ids(selected["id"])
|
||
if skill_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "사용할 책략이 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
tactic_list.add_child(empty_label)
|
||
return
|
||
var skill_sealed := state.is_unit_skill_sealed(selected["id"])
|
||
if skill_sealed:
|
||
var sealed_label := Label.new()
|
||
sealed_label.text = "책략 봉인"
|
||
_apply_label_style(sealed_label, UI_PARCHMENT_TEXT)
|
||
tactic_list.add_child(sealed_label)
|
||
|
||
for skill_id in skill_ids:
|
||
var skill := state.get_skill_def(str(skill_id))
|
||
var skill_button := Button.new()
|
||
skill_button.text = _format_tactic_button_text(str(skill_id), skill, selected)
|
||
skill_button.disabled = skill_sealed or selected.get("acted", false) or _is_input_locked() or int(selected.get("mp", 0)) < int(skill.get("mp_cost", 0))
|
||
_apply_button_style(skill_button)
|
||
skill_button.pressed.connect(_on_tactic_skill_pressed.bind(str(skill_id)))
|
||
tactic_list.add_child(skill_button)
|
||
|
||
var cancel_button := Button.new()
|
||
cancel_button.text = "죽간 거두기"
|
||
_apply_button_style(cancel_button)
|
||
cancel_button.pressed.connect(_on_tactic_cancel_pressed)
|
||
tactic_list.add_child(cancel_button)
|
||
|
||
|
||
func _clear_tactic_list() -> void:
|
||
for child in tactic_list.get_children():
|
||
tactic_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_tactic_button_text(skill_id: String, skill: Dictionary, selected: Dictionary) -> String:
|
||
var marker := "> " if selected_skill_id == skill_id else ""
|
||
var skill_name := str(skill.get("name", skill_id))
|
||
var mp_cost := int(skill.get("mp_cost", 0))
|
||
var kind := _skill_kind_text(str(skill.get("kind", "skill")))
|
||
var range_text := _format_tactic_range_text(skill)
|
||
var effect_text := _format_tactic_effect_text(skill)
|
||
var disabled_text := ""
|
||
if state.is_unit_skill_sealed(selected["id"]):
|
||
disabled_text = " - 봉인"
|
||
elif int(selected.get("mp", 0)) < mp_cost:
|
||
disabled_text = " - 기력"
|
||
return "%s%s 기력 %d %s 사거리 %s %s%s" % [marker, skill_name, mp_cost, kind, range_text, effect_text, disabled_text]
|
||
|
||
|
||
func _format_tactic_effect_text(skill: Dictionary) -> String:
|
||
var area_text := _format_tactic_area_text(skill)
|
||
if str(skill.get("kind", "")) == "support":
|
||
var parts := []
|
||
for effect in skill.get("effects", []):
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
var effect_type := str(effect.get("type", ""))
|
||
var amount := int(effect.get("amount", 0))
|
||
if effect_type == "stat_bonus":
|
||
if amount == 0:
|
||
continue
|
||
var sign := "+" if amount > 0 else ""
|
||
parts.append("%s %s%d" % [_battle_stat_display_name(str(effect.get("stat", ""))), sign, amount])
|
||
elif effect_type == "damage_over_time" and amount > 0:
|
||
var status_name := _battle_status_display_name(str(effect.get("status", "status")))
|
||
parts.append("%s -%d" % [status_name, amount])
|
||
elif effect_type == "action_lock":
|
||
parts.append("%s %s" % [
|
||
_battle_status_display_name(str(effect.get("status", "status"))),
|
||
_action_lock_effect_label(str(effect.get("action", "")))
|
||
])
|
||
if not parts.is_empty():
|
||
var support_text := _join_strings(parts, ", ")
|
||
return "%s, %s" % [support_text, area_text] if not area_text.is_empty() else support_text
|
||
var power_text := "위력 %d" % int(skill.get("power", 0))
|
||
return "%s, %s" % [power_text, area_text] if not area_text.is_empty() else power_text
|
||
|
||
|
||
func _format_tactic_area_text(skill: Dictionary) -> String:
|
||
var shape := str(skill.get("area_shape", "single"))
|
||
var radius := int(skill.get("area_radius", 0))
|
||
if shape == "diamond" and radius > 0:
|
||
return "범위 %d" % radius
|
||
return ""
|
||
|
||
|
||
func _format_tactic_range_text(skill: Dictionary) -> String:
|
||
var value = skill.get("range", 1)
|
||
if typeof(value) == TYPE_ARRAY:
|
||
if value.size() >= 2:
|
||
return "%d-%d" % [int(value[0]), int(value[1])]
|
||
if value.size() == 1:
|
||
return "%d" % int(value[0])
|
||
return "1"
|
||
if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
|
||
return "%d" % int(value)
|
||
return "1"
|
||
|
||
|
||
func _action_lock_effect_label(action: String) -> String:
|
||
if action == "skill":
|
||
return "책략 봉인"
|
||
if action == "move":
|
||
return "행군 봉인"
|
||
if action == "attack":
|
||
return "타격 봉인"
|
||
return "군령 봉인"
|
||
|
||
|
||
func _battle_stat_display_name(stat: String) -> String:
|
||
var normalized := stat.strip_edges().to_lower()
|
||
if normalized == "hp":
|
||
return "병력"
|
||
if normalized == "mp":
|
||
return "기력"
|
||
if normalized == "atk":
|
||
return "무력"
|
||
if normalized == "def":
|
||
return "방비"
|
||
if normalized == "int":
|
||
return "지략"
|
||
if normalized == "agi":
|
||
return "순발"
|
||
if normalized == "move":
|
||
return "행군"
|
||
return _format_identifier_label(stat)
|
||
|
||
|
||
func _battle_status_display_name(status: String) -> String:
|
||
var normalized := status.strip_edges().to_lower()
|
||
if normalized == "poison":
|
||
return "독"
|
||
if normalized == "seal":
|
||
return "봉인"
|
||
if normalized == "snare":
|
||
return "발묶임"
|
||
if normalized == "disarm":
|
||
return "무장해제"
|
||
if normalized == "debuff":
|
||
return "약화"
|
||
if normalized.is_empty():
|
||
return "상태"
|
||
return _format_identifier_label(status)
|
||
|
||
|
||
func _skill_kind_text(kind: String) -> String:
|
||
var normalized := kind.strip_edges().to_lower()
|
||
if normalized == "damage":
|
||
return "공격책"
|
||
if normalized == "heal":
|
||
return "회복책"
|
||
if normalized == "support":
|
||
return "보조책"
|
||
if normalized == "skill":
|
||
return "책략"
|
||
return kind.capitalize()
|
||
|
||
|
||
func _on_tactic_skill_pressed(skill_id: String) -> void:
|
||
_play_ui_confirm()
|
||
selected_skill_id = skill_id
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_tactic_cancel_pressed() -> void:
|
||
_play_ui_cancel()
|
||
selected_skill_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_tactic_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _update_item_button(selected: Dictionary) -> void:
|
||
if item_button == null:
|
||
return
|
||
if selected.is_empty():
|
||
_set_action_button_blocked(item_button, "보급첩", "부대 미지정", "도구를 쓸 부대를 먼저 고릅니다.")
|
||
return
|
||
|
||
if bool(selected.get("acted", false)):
|
||
_set_action_button_blocked(item_button, "보급첩", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||
return
|
||
var phase_block := _action_phase_block_reason()
|
||
if not phase_block.is_empty():
|
||
_set_action_button_blocked(item_button, "보급첩", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||
return
|
||
var item_ids := state.get_usable_item_ids()
|
||
if item_ids.is_empty():
|
||
_set_action_button_blocked(item_button, "보급첩", "비어 있음", "사용할 도구가 없습니다.")
|
||
return
|
||
|
||
if not selected_item_id.is_empty():
|
||
var item := state.get_item_def(selected_item_id)
|
||
_set_action_button_state(item_button, "보급첩|%s" % str(item.get("name", selected_item_id)), false, "전장에서 도구 대상을 지목합니다.")
|
||
elif item_menu != null and item_menu.visible:
|
||
_set_action_button_state(item_button, "보급첩 봉함", false, "도구 목록을 닫습니다.")
|
||
else:
|
||
_set_action_button_state(item_button, "보급첩", false, "도구 목록을 펼칩니다. %d종." % item_ids.size())
|
||
|
||
|
||
func _show_item_menu(selected: Dictionary) -> void:
|
||
if item_menu == null:
|
||
return
|
||
_hide_tactic_menu()
|
||
_hide_equip_menu()
|
||
_rebuild_item_menu(selected)
|
||
item_menu.visible = true
|
||
|
||
|
||
func _hide_item_menu() -> void:
|
||
if item_menu == null:
|
||
return
|
||
item_menu.visible = false
|
||
|
||
|
||
func _rebuild_item_menu(selected: Dictionary) -> void:
|
||
if item_list == null:
|
||
return
|
||
_clear_item_list()
|
||
var item_ids := state.get_usable_item_ids()
|
||
if item_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "사용할 도구가 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
item_list.add_child(empty_label)
|
||
return
|
||
|
||
var inventory := state.get_inventory_snapshot()
|
||
for item_id in item_ids:
|
||
var normalized_id := str(item_id)
|
||
var item := state.get_item_def(normalized_id)
|
||
var count := int(inventory.get(normalized_id, 0))
|
||
var use_button := Button.new()
|
||
use_button.text = _format_item_button_text(normalized_id, item, count)
|
||
_apply_item_button_icon(use_button, item)
|
||
use_button.disabled = selected.get("acted", false) or _is_input_locked() or count <= 0
|
||
_apply_button_style(use_button)
|
||
use_button.pressed.connect(_on_item_selected_pressed.bind(normalized_id))
|
||
item_list.add_child(use_button)
|
||
|
||
var cancel_button := Button.new()
|
||
cancel_button.text = "죽간 거두기"
|
||
_apply_button_style(cancel_button)
|
||
cancel_button.pressed.connect(_on_item_cancel_pressed)
|
||
item_list.add_child(cancel_button)
|
||
|
||
|
||
func _clear_item_list() -> void:
|
||
for child in item_list.get_children():
|
||
item_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_item_button_text(item_id: String, item: Dictionary, count: int) -> String:
|
||
var marker := "> " if selected_item_id == item_id else ""
|
||
var item_name := _item_display_name(item_id)
|
||
return "%s%s 잔량 %d %s" % [marker, item_name, count, _format_item_effect_text(item)]
|
||
|
||
|
||
func _format_item_effect_text(item: Dictionary) -> String:
|
||
var parts := []
|
||
for effect in item.get("effects", []):
|
||
if typeof(effect) != TYPE_DICTIONARY:
|
||
continue
|
||
if str(effect.get("type", "")) == "heal_hp":
|
||
parts.append("병력 +%d" % int(effect.get("amount", 0)))
|
||
elif str(effect.get("type", "")) == "heal_mp":
|
||
parts.append("기력 +%d" % int(effect.get("amount", 0)))
|
||
elif str(effect.get("type", "")) == "cure_status":
|
||
parts.append("치료 %s" % _battle_status_display_name(str(effect.get("status", "status"))))
|
||
elif str(effect.get("type", "")) == "cleanse_debuffs":
|
||
parts.append("약화 해소")
|
||
if parts.is_empty():
|
||
return _item_kind_text(str(item.get("kind", "item")))
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _on_item_selected_pressed(item_id: String) -> void:
|
||
_play_ui_confirm()
|
||
selected_item_id = item_id
|
||
selected_skill_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_item_menu()
|
||
_hide_tactic_menu()
|
||
_hide_equip_menu()
|
||
_hide_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_item_cancel_pressed() -> void:
|
||
_play_ui_cancel()
|
||
selected_item_id = ""
|
||
basic_attack_targeting = false
|
||
_hide_item_menu()
|
||
if _has_pending_move():
|
||
_show_post_move_menu()
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _update_equip_button(selected: Dictionary) -> void:
|
||
if equip_button == null:
|
||
return
|
||
if selected.is_empty():
|
||
_set_action_button_blocked(equip_button, "병장", "부대 미지정", "병장을 바꿀 부대를 먼저 고릅니다.")
|
||
return
|
||
|
||
if bool(selected.get("acted", false)):
|
||
_set_action_button_blocked(equip_button, "병장", "봉인됨", "이 부대는 이미 행동했습니다.")
|
||
return
|
||
if bool(selected.get("moved", false)):
|
||
_set_action_button_blocked(equip_button, "병장", "행군 후", "병장은 행군 전에만 바꿀 수 있습니다.")
|
||
return
|
||
var phase_block := _action_phase_block_reason()
|
||
if not phase_block.is_empty():
|
||
_set_action_button_blocked(equip_button, "병장", str(phase_block.get("label", "처리 중")), str(phase_block.get("tooltip", "")))
|
||
return
|
||
if equip_menu != null and equip_menu.visible:
|
||
_set_action_button_state(equip_button, "병장 닫기", false, "병장 장부를 닫습니다.")
|
||
else:
|
||
_set_action_button_state(equip_button, "병장", false, "행군 전에 무기, 갑옷, 장신구를 바꿉니다.")
|
||
|
||
|
||
func _show_equip_menu(selected: Dictionary) -> void:
|
||
if equip_menu == null:
|
||
return
|
||
_hide_tactic_menu()
|
||
_hide_item_menu()
|
||
_rebuild_equip_menu(selected)
|
||
equip_menu.visible = true
|
||
|
||
|
||
func _hide_equip_menu() -> void:
|
||
if equip_menu == null:
|
||
return
|
||
equip_menu.visible = false
|
||
|
||
|
||
func _rebuild_equip_menu(selected: Dictionary) -> void:
|
||
if equip_list == null:
|
||
return
|
||
_clear_equip_list()
|
||
var equipment_label := Label.new()
|
||
equipment_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
equipment_label.text = "소지 병장: %s" % _format_unit_equipment(selected)
|
||
_apply_label_style(equipment_label, UI_PARCHMENT_TEXT)
|
||
equip_list.add_child(equipment_label)
|
||
|
||
var equipped_slots := state.get_equipped_equipment_slots(str(selected["id"]))
|
||
if not equipped_slots.is_empty():
|
||
var unequip_title := Label.new()
|
||
unequip_title.text = "병장 해제"
|
||
_apply_label_style(unequip_title, UI_OLD_BRONZE, 14)
|
||
equip_list.add_child(unequip_title)
|
||
var equipment := state.get_equipment_snapshot(str(selected["id"]))
|
||
for slot in equipped_slots:
|
||
var normalized_slot := str(slot)
|
||
var item_id := _equipment_item_id(equipment, normalized_slot)
|
||
var item := state.get_item_def(item_id)
|
||
var unequip_button := Button.new()
|
||
unequip_button.text = _format_equipment_unequip_text(selected, normalized_slot, item_id, item)
|
||
_apply_item_button_icon(unequip_button, item)
|
||
unequip_button.disabled = _is_input_locked()
|
||
_apply_button_style(unequip_button)
|
||
unequip_button.pressed.connect(_on_equip_unequip_pressed.bind(normalized_slot))
|
||
equip_list.add_child(unequip_button)
|
||
|
||
var item_ids := state.get_equippable_item_ids(selected["id"])
|
||
if item_ids.is_empty():
|
||
var empty_label := Label.new()
|
||
empty_label.text = "맞는 병장이 없습니다"
|
||
_apply_label_style(empty_label, UI_PARCHMENT_TEXT)
|
||
equip_list.add_child(empty_label)
|
||
else:
|
||
var inventory := state.get_inventory_snapshot()
|
||
for item_id in item_ids:
|
||
var normalized_id := str(item_id)
|
||
var item := state.get_item_def(normalized_id)
|
||
var equip_item_button := Button.new()
|
||
equip_item_button.text = _format_equipment_option_text(
|
||
selected,
|
||
normalized_id,
|
||
item,
|
||
int(inventory.get(normalized_id, 0))
|
||
)
|
||
_apply_item_button_icon(equip_item_button, item)
|
||
equip_item_button.disabled = _is_input_locked()
|
||
_apply_button_style(equip_item_button)
|
||
equip_item_button.pressed.connect(_on_equip_item_pressed.bind(normalized_id))
|
||
equip_list.add_child(equip_item_button)
|
||
|
||
var cancel_button := Button.new()
|
||
cancel_button.text = "죽간 거두기"
|
||
_apply_button_style(cancel_button)
|
||
cancel_button.pressed.connect(_on_equip_cancel_pressed)
|
||
equip_list.add_child(cancel_button)
|
||
|
||
|
||
func _clear_equip_list() -> void:
|
||
for child in equip_list.get_children():
|
||
equip_list.remove_child(child)
|
||
child.queue_free()
|
||
|
||
|
||
func _format_unit_equipment(unit: Dictionary) -> String:
|
||
var equipment := state.get_equipment_snapshot(unit["id"])
|
||
var parts := []
|
||
for slot in ["weapon", "armor", "accessory"]:
|
||
var item_id := _equipment_item_id(equipment, slot)
|
||
if item_id.is_empty():
|
||
parts.append("%s -" % _equipment_slot_display_name(slot))
|
||
else:
|
||
parts.append("%s %s" % [_equipment_slot_display_name(slot), _item_display_name(item_id)])
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _format_hover_unit_equipment(unit: Dictionary) -> String:
|
||
var equipment := state.get_equipment_snapshot(unit["id"])
|
||
var parts := []
|
||
for slot in ["weapon", "armor", "accessory"]:
|
||
var item_id := _equipment_item_id(equipment, slot)
|
||
if item_id.is_empty():
|
||
parts.append("-")
|
||
else:
|
||
parts.append(_item_display_name(item_id))
|
||
return _join_strings(parts, " / ")
|
||
|
||
|
||
func _equipment_item_id(equipment: Dictionary, slot: String) -> String:
|
||
var item_id = equipment.get(slot, "")
|
||
if item_id == null:
|
||
return ""
|
||
return str(item_id)
|
||
|
||
|
||
func _format_equipment_option_text(unit: Dictionary, item_id: String, item: Dictionary, count: int) -> String:
|
||
var parts := [
|
||
_item_display_name(item_id),
|
||
"보유 %d점" % count,
|
||
_format_equipment_bonus_text(item)
|
||
]
|
||
var change_text := _format_equipment_change_text(unit, item)
|
||
if not change_text.is_empty():
|
||
parts.append(change_text)
|
||
return _join_strings(parts, " ")
|
||
|
||
|
||
func _format_equipment_unequip_text(unit: Dictionary, slot: String, item_id: String, item: Dictionary) -> String:
|
||
var parts := [
|
||
"%s 해제:" % _equipment_slot_display_name(slot),
|
||
_item_display_name(item_id)
|
||
]
|
||
var change_text := _format_equipment_unequip_change_text(unit, slot, item)
|
||
if not change_text.is_empty():
|
||
parts.append(change_text)
|
||
return _join_strings(parts, " ")
|
||
|
||
|
||
func _format_equipment_unequip_change_text(unit: Dictionary, slot: String, item: Dictionary) -> String:
|
||
if unit.is_empty() or item.is_empty():
|
||
return ""
|
||
var parts := []
|
||
var current_bonuses := _equipment_bonus_map(item)
|
||
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
|
||
var delta := -int(current_bonuses.get(stat, 0))
|
||
if delta == 0:
|
||
continue
|
||
parts.append("%s %d" % [_battle_stat_display_name(stat), delta])
|
||
if slot == "weapon":
|
||
var current_range := _format_equipment_item_range_text(item)
|
||
if not current_range.is_empty():
|
||
parts.append("사거리 기본값")
|
||
var effectiveness_text := _format_equipment_effectiveness_text(item)
|
||
if not effectiveness_text.is_empty():
|
||
parts.append("%s 상실" % effectiveness_text)
|
||
if parts.is_empty():
|
||
return "군수고로 반환"
|
||
return "변화 %s" % _join_strings(parts, ", ")
|
||
|
||
|
||
func _format_equipment_change_text(unit: Dictionary, item: Dictionary) -> String:
|
||
if unit.is_empty() or item.is_empty():
|
||
return ""
|
||
var slot := _equipment_slot_for_display(item)
|
||
if slot.is_empty():
|
||
return ""
|
||
|
||
var equipment := state.get_equipment_snapshot(str(unit.get("id", "")))
|
||
var current_item_id := _equipment_item_id(equipment, slot)
|
||
var current_item := state.get_item_def(current_item_id)
|
||
var parts := []
|
||
var candidate_bonuses := _equipment_bonus_map(item)
|
||
var current_bonuses := _equipment_bonus_map(current_item)
|
||
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
|
||
var delta := int(candidate_bonuses.get(stat, 0)) - int(current_bonuses.get(stat, 0))
|
||
if delta == 0:
|
||
continue
|
||
var sign := "+" if delta > 0 else ""
|
||
parts.append("%s %s%d" % [_battle_stat_display_name(stat), sign, delta])
|
||
|
||
var range_text := _format_equipment_range_change_text(current_item, item)
|
||
if not range_text.is_empty():
|
||
parts.append(range_text)
|
||
var effectiveness_text := _format_equipment_effectiveness_change_text(current_item, item)
|
||
if not effectiveness_text.is_empty():
|
||
parts.append(effectiveness_text)
|
||
if parts.is_empty():
|
||
return "교체 없음"
|
||
return "교체 %s" % _join_strings(parts, ", ")
|
||
|
||
|
||
func _equipment_slot_for_display(item: Dictionary) -> String:
|
||
var kind := str(item.get("kind", ""))
|
||
if kind == "weapon" or kind == "armor" or kind == "accessory":
|
||
return kind
|
||
return ""
|
||
|
||
|
||
func _equipment_slot_display_name(slot: String) -> String:
|
||
var normalized := slot.strip_edges().to_lower()
|
||
if normalized == "weapon":
|
||
return "병장"
|
||
if normalized == "armor":
|
||
return "갑주"
|
||
if normalized == "accessory":
|
||
return "보물"
|
||
return _format_identifier_label(slot)
|
||
|
||
|
||
func _equipment_bonus_map(item: Dictionary) -> Dictionary:
|
||
if item.is_empty() or typeof(item.get("bonuses", {})) != TYPE_DICTIONARY:
|
||
return {}
|
||
return item.get("bonuses", {})
|
||
|
||
|
||
func _format_equipment_range_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
|
||
if str(candidate_item.get("kind", "")) != "weapon":
|
||
return ""
|
||
var candidate_range := _format_equipment_item_range_text(candidate_item)
|
||
var current_range := _format_equipment_item_range_text(current_item)
|
||
if candidate_range.is_empty() or candidate_range == current_range:
|
||
return ""
|
||
if current_range.is_empty():
|
||
current_range = "-"
|
||
return "사거리 %s에서 %s로" % [current_range, candidate_range]
|
||
|
||
|
||
func _format_equipment_item_range_text(item: Dictionary) -> String:
|
||
if item.is_empty() or not item.has("range"):
|
||
return ""
|
||
var value = item.get("range", 1)
|
||
if typeof(value) == TYPE_ARRAY:
|
||
if value.size() >= 2:
|
||
return "%d-%d" % [int(value[0]), int(value[1])]
|
||
if value.size() == 1:
|
||
return "%d" % int(value[0])
|
||
return ""
|
||
if typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT:
|
||
return "%d" % int(value)
|
||
return ""
|
||
|
||
|
||
func _format_equipment_effectiveness_change_text(current_item: Dictionary, candidate_item: Dictionary) -> String:
|
||
if str(candidate_item.get("kind", "")) != "weapon":
|
||
return ""
|
||
var candidate_text := _format_equipment_effectiveness_text(candidate_item)
|
||
var current_text := _format_equipment_effectiveness_text(current_item)
|
||
if candidate_text == current_text:
|
||
return ""
|
||
if current_text.is_empty():
|
||
return "특효 획득 %s" % candidate_text
|
||
if candidate_text.is_empty():
|
||
return "특효 상실 %s" % current_text
|
||
return "특효 변화"
|
||
|
||
|
||
func _format_equipment_bonus_text(item: Dictionary) -> String:
|
||
var parts := []
|
||
var rarity_text := _format_equipment_rarity_text(item)
|
||
if not rarity_text.is_empty():
|
||
parts.append(rarity_text)
|
||
var bonuses: Dictionary = item.get("bonuses", {})
|
||
for stat in ["hp", "mp", "atk", "def", "int", "agi"]:
|
||
var amount := int(bonuses.get(stat, 0))
|
||
if amount == 0:
|
||
continue
|
||
var sign := "+" if amount > 0 else ""
|
||
parts.append("%s %s%d" % [_battle_stat_display_name(stat), sign, amount])
|
||
var effective_text := _format_equipment_effectiveness_text(item)
|
||
if not effective_text.is_empty():
|
||
parts.append(effective_text)
|
||
if parts.is_empty():
|
||
return _item_kind_text(str(item.get("kind", "item")))
|
||
return _join_strings(parts, ", ")
|
||
|
||
|
||
func _format_equipment_rarity_text(item: Dictionary) -> String:
|
||
return _format_item_rarity_text(item)
|
||
|
||
|
||
func _format_item_rarity_text(item: Dictionary) -> String:
|
||
var rarity := str(item.get("rarity", ""))
|
||
if rarity == "named":
|
||
return "명품"
|
||
return ""
|
||
|
||
|
||
func _format_equipment_effectiveness_text(item: Dictionary) -> String:
|
||
if str(item.get("kind", "")) != "weapon":
|
||
return ""
|
||
var bonus := int(item.get("effective_bonus_damage", 0))
|
||
if bonus <= 0:
|
||
return ""
|
||
var move_types = item.get("effective_vs_move_types", [])
|
||
if typeof(move_types) != TYPE_ARRAY or move_types.is_empty():
|
||
return ""
|
||
var labels := []
|
||
for move_type in move_types:
|
||
var label := _format_move_type(str(move_type))
|
||
if not label.is_empty():
|
||
labels.append(label)
|
||
if labels.is_empty():
|
||
return ""
|
||
return "%s 특효 +%d" % [_join_strings(labels, "/"), bonus]
|
||
|
||
|
||
func _format_move_type(move_type: String) -> String:
|
||
var normalized := move_type.strip_edges().to_lower()
|
||
if normalized == "foot":
|
||
return "보병"
|
||
if normalized == "cavalry" or normalized == "mounted":
|
||
return "기병"
|
||
if normalized == "archer":
|
||
return "궁병"
|
||
if normalized == "water":
|
||
return "수군"
|
||
if normalized == "flying":
|
||
return "비행"
|
||
return move_type.strip_edges().replace("_", " ").capitalize()
|
||
|
||
|
||
func _terrain_display_name(terrain_name: String) -> String:
|
||
var normalized := terrain_name.strip_edges().to_lower()
|
||
if normalized == "plain":
|
||
return "평지"
|
||
if normalized == "forest":
|
||
return "수풀"
|
||
if normalized == "hill":
|
||
return "산지"
|
||
if normalized == "water":
|
||
return "하천"
|
||
if normalized == "road":
|
||
return "길"
|
||
if normalized == "castle":
|
||
return "성채"
|
||
if normalized == "town" or normalized == "village":
|
||
return "마을"
|
||
return terrain_name
|
||
|
||
|
||
func _on_equip_item_pressed(item_id: String) -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return
|
||
if state.try_equip_item(selected["id"], item_id):
|
||
_rebuild_equip_menu(state.get_selected_unit())
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_equip_unequip_pressed(slot: String) -> void:
|
||
var selected := state.get_selected_unit()
|
||
if selected.is_empty():
|
||
return
|
||
if state.try_unequip_item(selected["id"], slot):
|
||
_rebuild_equip_menu(state.get_selected_unit())
|
||
_refresh_ranges()
|
||
_update_hud()
|
||
queue_redraw()
|
||
|
||
|
||
func _on_equip_cancel_pressed() -> void:
|
||
_play_ui_cancel()
|
||
_hide_equip_menu()
|
||
_update_hud()
|
||
queue_redraw()
|