extends RefCounted class_name BattleState const DataCatalogScript := preload("res://scripts/core/data_catalog.gd") signal changed signal log_added(message: String) signal dialogue_requested(lines: Array) signal combat_feedback_requested(unit_id: String, text: String, kind: String) signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) signal unit_action_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i, action_kind: String) signal objective_updated(victory: String, defeat: String) const TEAM_PLAYER := "player" const TEAM_ENEMY := "enemy" const STATUS_ACTIVE := "active" const STATUS_VICTORY := "victory" const STATUS_DEFEAT := "defeat" const EXP_ATTACK := 10 const EXP_COUNTER := 5 const EXP_MISS := 2 const EXP_SKILL := 12 const EXP_HEAL := 8 const EXP_SUPPORT := 8 const EXP_DEFEAT_BONUS := 30 const EXP_LEVEL := 100 const BASE_HIT_CHANCE := 90 const MIN_HIT_CHANCE := 25 const CARDINAL_DIRECTIONS := [ Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1) ] const GROWTH_GRADE_GAINS := { "hp": {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, "mp": {"A": 3, "B": 2, "C": 1, "D": 1, "E": 0}, "atk": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "def": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "int": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "agi": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0} } const DEFAULT_TERRAIN_DEFS := { "G": { "name": "평원", "move_cost": 1, "defense": 0, "avoid": 0, "heal": 0, "color": Color(0.43, 0.62, 0.31) }, "F": { "name": "숲", "move_cost": 2, "defense": 2, "avoid": 10, "heal": 0, "color": Color(0.20, 0.45, 0.23) }, "H": { "name": "산지", "move_cost": 2, "defense": 1, "avoid": 5, "heal": 0, "color": Color(0.55, 0.50, 0.34) }, "D": { "name": "황무지", "move_cost": {"foot": 1, "mounted": 2, "archer": 1, "water": 99}, "defense": 0, "avoid": 0, "heal": 0, "color": Color(0.59, 0.45, 0.29) }, "R": { "name": "관도", "move_cost": 1, "defense": 0, "avoid": 0, "heal": 0, "color": Color(0.64, 0.55, 0.42) }, "W": { "name": "하천", "move_cost": 99, "defense": 0, "avoid": 0, "heal": 0, "color": Color(0.18, 0.36, 0.62) }, "T": { "name": "마을", "move_cost": {"foot": 1, "mounted": 2, "archer": 1, "water": 99}, "defense": 1, "avoid": 5, "heal": 6, "color": Color(0.71, 0.54, 0.33) }, "C": { "name": "성채", "move_cost": {"foot": 1, "mounted": 2, "archer": 1, "water": 99}, "defense": 3, "avoid": 0, "heal": 8, "color": Color(0.50, 0.48, 0.44) } } var battle_id := "" var battle_name := "" var objectives := {} var battle_conditions := {} var briefing := {} var rewards := {} var post_battle_choices: Array = [] var post_battle_dialogue: Array = [] var shop := {} var deployment_rules := {} var formation_cells: Array[Vector2i] = [] var map_size := Vector2i.ZERO var map_background := "" var terrain_rows: Array[String] = [] var terrain_defs := DEFAULT_TERRAIN_DEFS.duplicate(true) var units: Array[Dictionary] = [] var current_team := TEAM_PLAYER var turn_number := 1 var selected_unit_id := "" var battle_status := STATUS_ACTIVE var battle_events: Array[Dictionary] = [] var fired_event_ids := {} var roster_overrides_snapshot := {} var battle_inventory := {} var battle_gold_reward := 0 var campaign_flags := {} var campaign_joined_officers := {} var progression_events: Array[Dictionary] = [] var data_catalog := DataCatalogScript.new() var rng := RandomNumberGenerator.new() func load_battle(path: String, roster_overrides := {}, inventory_overrides := {}, flag_overrides := {}, joined_officer_overrides := [], display_name_override := "") -> bool: var text := FileAccess.get_file_as_string(path) if text.is_empty(): push_error("Battle file is empty or missing: %s" % path) return false var parsed = JSON.parse_string(text) if typeof(parsed) != TYPE_DICTIONARY: push_error("Battle file is not valid JSON: %s" % path) return false data_catalog.load_defaults() rng.randomize() if typeof(roster_overrides) == TYPE_DICTIONARY: roster_overrides_snapshot = roster_overrides.duplicate(true) else: roster_overrides_snapshot = {} if typeof(inventory_overrides) == TYPE_DICTIONARY: battle_inventory = inventory_overrides.duplicate(true) else: battle_inventory = {} if typeof(flag_overrides) == TYPE_DICTIONARY: campaign_flags = flag_overrides.duplicate(true) else: campaign_flags = {} campaign_joined_officers = _joined_officer_lookup(joined_officer_overrides) terrain_defs = data_catalog.get_runtime_terrain_defs(DEFAULT_TERRAIN_DEFS) _apply_battle_data(parsed, roster_overrides_snapshot) var display_name := str(display_name_override).strip_edges() if not display_name.is_empty(): battle_name = display_name _emit_log("전장도 펼침: %s" % battle_name) _run_events("battle_start") _run_events("turn_start", current_team, turn_number) _notify_changed() return true func run_battle_begin_events() -> void: if battle_status != STATUS_ACTIVE: return _run_events("battle_begin") _notify_changed() func _apply_battle_data(data: Dictionary, roster_overrides := {}) -> void: battle_id = String(data.get("id", "unknown")) battle_name = String(data.get("name", "Untitled Battle")) objectives = data.get("objectives", {}) battle_conditions = _normalized_battle_conditions(data.get("conditions", {})) briefing = _normalized_briefing(data.get("briefing", {})) rewards = data.get("rewards", {}) post_battle_choices = _normalized_post_battle_choices(data.get("post_battle_choices", [])) post_battle_dialogue = _normalized_dialogue_lines(data.get("post_battle_dialogue", [])) shop = _normalized_shop(data.get("shop", {})) deployment_rules = _normalized_deployment_rules(data.get("roster", {})) formation_cells = _normalized_formation_cells(data.get("formation", {})) battle_events.clear() for event in data.get("events", []): if typeof(event) == TYPE_DICTIONARY: battle_events.append(event) fired_event_ids.clear() battle_gold_reward = 0 progression_events.clear() var map_data: Dictionary = data.get("map", {}) map_size = Vector2i(int(map_data.get("width", 0)), int(map_data.get("height", 0))) map_background = str(map_data.get("background", "")) terrain_rows.clear() for row in map_data.get("terrain", []): terrain_rows.append(String(row)) units.clear() var source_units: Array = [] if data.has("deployments"): for deployment in data.get("deployments", []): if typeof(deployment) != TYPE_DICTIONARY: push_error("Skipping malformed deployment in %s." % battle_id) continue var hydrated_unit := data_catalog.hydrate_deployment(deployment) _apply_roster_overlay(hydrated_unit, roster_overrides) if not _is_deployment_available(hydrated_unit): continue source_units.append(hydrated_unit) else: source_units = data.get("units", []) for source_unit in source_units: var unit := _prepare_unit(source_unit) if unit.is_empty(): continue units.append(unit) _apply_deployment_rules() if formation_cells.is_empty(): for unit in units: if unit.get("team", "") == TEAM_PLAYER and unit.get("deployed", true): formation_cells.append(unit["pos"]) current_team = TEAM_PLAYER turn_number = 1 selected_unit_id = "" battle_status = STATUS_ACTIVE func _normalized_battle_conditions(source) -> Dictionary: var conditions := {} if typeof(source) == TYPE_DICTIONARY: conditions = source.duplicate(true) if not conditions.has("victory") or not _is_condition_shape_valid(conditions["victory"]): conditions["victory"] = {"type": "all_enemies_defeated"} if not conditions.has("defeat") or not _is_condition_shape_valid(conditions["defeat"]): conditions["defeat"] = {"type": "all_players_defeated"} return conditions func _normalized_briefing(source) -> Dictionary: if typeof(source) != TYPE_DICTIONARY: return {} var result: Dictionary = source.duplicate(true) var lines := [] for line in result.get("lines", []): lines.append(str(line)) for block in result.get("conditional_lines", []): if typeof(block) != TYPE_DICTIONARY: continue if not _campaign_flags_match(block.get("campaign_flags", {})): continue for line in block.get("lines", []): lines.append(str(line)) result["lines"] = lines result["camp_dialogue"] = _normalized_dialogue_lines(result.get("camp_dialogue", [])) result["camp_conversations"] = _normalized_camp_conversations(result.get("camp_conversations", [])) result.erase("conditional_lines") return result func _normalized_camp_conversations(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result var seen := {} for entry in source: if typeof(entry) != TYPE_DICTIONARY: continue if entry.has("campaign_flags"): var required_flags = entry.get("campaign_flags", {}) if typeof(required_flags) != TYPE_DICTIONARY or not _campaign_flags_match(required_flags): continue var conversation_id := str(entry.get("id", "")).strip_edges() if conversation_id.is_empty() or seen.has(conversation_id): continue var lines := _normalized_dialogue_lines(entry.get("lines", [])) if lines.is_empty(): continue seen[conversation_id] = true var label := str(entry.get("label", conversation_id.capitalize())).strip_edges() if label.is_empty(): label = conversation_id.capitalize() var speaker := str(entry.get("speaker", "")).strip_edges() var display_speaker := str(entry.get("display_speaker", speaker)).strip_edges() result.append({ "id": conversation_id, "group": str(entry.get("group", "topic")).strip_edges(), "officer_id": str(entry.get("officer_id", "")).strip_edges(), "label": label, "speaker": display_speaker, "summary": str(entry.get("summary", "")).strip_edges(), "lines": lines, "effects": _normalized_camp_conversation_effects(entry.get("effects", [])) }) return result func _normalized_camp_conversation_effects(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result for effect in source: if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")).strip_edges() if effect_type == "grant_item": var item_id := str(effect.get("item_id", effect.get("id", ""))).strip_edges() if item_id.is_empty(): continue result.append({ "type": "grant_item", "item_id": item_id, "count": maxi(1, int(effect.get("count", 1))), "text": str(effect.get("text", "")).strip_edges() }) return result func _is_condition_shape_valid(condition) -> bool: return typeof(condition) == TYPE_DICTIONARY or typeof(condition) == TYPE_ARRAY func _normalized_shop(source) -> Dictionary: var item_ids := [] var seen := {} var stock := {} if typeof(source) != TYPE_DICTIONARY: return {"items": item_ids, "stock": stock, "merchant": {}} for entry in source.get("items", []): var item_id := _shop_item_id_from_entry(entry) if item_id.is_empty() or seen.has(item_id): continue seen[item_id] = true item_ids.append(item_id) _apply_shop_stock_entry(stock, item_id, entry) _apply_shop_stock_block(stock, source.get("stock", {}), seen) for block in source.get("conditional_items", []): if typeof(block) != TYPE_DICTIONARY: continue if not _campaign_flags_match(block.get("campaign_flags", {})): continue for entry in block.get("items", []): var item_id := _shop_item_id_from_entry(entry) if item_id.is_empty() or seen.has(item_id): continue seen[item_id] = true item_ids.append(item_id) _apply_shop_stock_entry(stock, item_id, entry) _apply_shop_stock_block(stock, block.get("stock", {}), seen) return {"items": item_ids, "stock": stock, "merchant": _normalized_shop_merchant(source.get("merchant", {}))} func _normalized_shop_merchant(source) -> Dictionary: if typeof(source) != TYPE_DICTIONARY: return {} var lines := [] for line in source.get("lines", []): var text := str(line).strip_edges() if not text.is_empty(): lines.append(text) var name := str(source.get("name", "Merchant")).strip_edges() if name.is_empty() and lines.is_empty(): return {} return { "name": "Merchant" if name.is_empty() else name, "lines": lines } func _shop_item_id_from_entry(entry) -> String: if typeof(entry) == TYPE_DICTIONARY: return str(entry.get("id", "")) return str(entry) func _apply_shop_stock_entry(stock: Dictionary, item_id: String, entry) -> void: if item_id.is_empty() or typeof(entry) != TYPE_DICTIONARY or not entry.has("stock"): return var count := int(entry.get("stock", -1)) if count >= 0: stock[item_id] = count func _apply_shop_stock_block(stock: Dictionary, block, seen: Dictionary) -> void: if typeof(block) != TYPE_DICTIONARY: return for item_id in block.keys(): var normalized_id := str(item_id) if normalized_id.is_empty() or not seen.has(normalized_id): continue var count := int(block[item_id]) if count >= 0: stock[normalized_id] = count func _normalized_deployment_rules(source) -> Dictionary: var rules := { "max_units": 0, "required_officers": [], "required_units": [] } if typeof(source) != TYPE_DICTIONARY: return rules rules["max_units"] = max(0, int(source.get("max_units", 0))) var required_officers := [] var seen_officers := {} for officer_id in source.get("required_officers", []): var normalized := str(officer_id) if normalized.is_empty() or seen_officers.has(normalized): continue seen_officers[normalized] = true required_officers.append(normalized) rules["required_officers"] = required_officers var required_units := [] var seen_units := {} for unit_id in source.get("required_units", []): var normalized := str(unit_id) if normalized.is_empty() or seen_units.has(normalized): continue seen_units[normalized] = true required_units.append(normalized) rules["required_units"] = required_units return rules func _normalized_post_battle_choices(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result var seen := {} for choice in source: if typeof(choice) != TYPE_DICTIONARY: continue var choice_id := str(choice.get("id", "")) if choice_id.is_empty() or seen.has(choice_id): continue seen[choice_id] = true result.append(choice.duplicate(true)) return result func _normalized_dialogue_lines(source) -> Array: var result := [] if typeof(source) != TYPE_ARRAY: return result for line in source: var normalized := _normalize_dialogue_line(line) if not normalized.is_empty(): result.append(normalized) return result func _normalized_formation_cells(source) -> Array[Vector2i]: var result: Array[Vector2i] = [] if typeof(source) != TYPE_DICTIONARY: return result var seen := {} for entry in source.get("cells", []): if typeof(entry) != TYPE_ARRAY or entry.size() < 2: continue var cell := Vector2i(int(entry[0]), int(entry[1])) var key := "%d,%d" % [cell.x, cell.y] if seen.has(key): continue seen[key] = true result.append(cell) return result func _joined_officer_lookup(source) -> Dictionary: var result := {} if typeof(source) == TYPE_DICTIONARY: for officer_id in source.keys(): if bool(source[officer_id]): result[str(officer_id)] = true return result if typeof(source) == TYPE_ARRAY: for officer_id in source: var normalized := str(officer_id) if normalized.is_empty(): continue result[normalized] = true return result func _is_deployment_available(unit: Dictionary) -> bool: if unit.get("team", "") != TEAM_PLAYER: return true var required_officers: Array = deployment_rules.get("required_officers", []) var required_units: Array = deployment_rules.get("required_units", []) var officer_id := str(unit.get("officer_id", "")) var unit_id := str(unit.get("id", "")) if required_units.has(unit_id) or required_officers.has(officer_id): return true if not bool(unit.get("requires_joined", false)): return true return not officer_id.is_empty() and campaign_joined_officers.has(officer_id) func _apply_deployment_rules() -> void: var player_units := get_player_units(true) var max_units := get_deployment_max_units() var required_officers: Array = deployment_rules.get("required_officers", []) var required_units: Array = deployment_rules.get("required_units", []) var deployed_count := 0 for unit in player_units: var required := required_units.has(str(unit.get("id", ""))) or required_officers.has(str(unit.get("officer_id", ""))) unit["required_deployment"] = required unit["deployed"] = required if required: deployed_count += 1 for unit in player_units: if unit.get("deployed", false): continue var deploy := max_units <= 0 or deployed_count < max_units unit["deployed"] = deploy if deploy: deployed_count += 1 func _prepare_unit(source_unit: Dictionary) -> Dictionary: if source_unit.is_empty(): return {} var unit: Dictionary = source_unit.duplicate(true) if not unit.has("id") and unit.has("unit_id"): unit["id"] = unit["unit_id"] if str(unit.get("id", "")).is_empty(): push_error("Unit is missing id: %s" % str(source_unit)) return {} var pos_array: Array = unit.get("pos", [0, 0]) if pos_array.size() < 2: push_error("Unit has malformed position: %s" % str(source_unit)) return {} unit["pos"] = Vector2i(int(pos_array[0]), int(pos_array[1])) unit["class"] = str(unit.get("class", unit.get("class_id", "Unit"))).capitalize() unit["hp"] = int(unit.get("hp", unit.get("max_hp", 1))) unit["max_hp"] = int(unit.get("max_hp", unit["hp"])) unit["max_mp"] = int(unit.get("max_mp", unit.get("mp", 0))) unit["mp"] = int(unit.get("mp", unit["max_mp"])) unit["atk"] = int(unit.get("atk", 1)) unit["def"] = int(unit.get("def", 0)) unit["int"] = int(unit.get("int", 0)) unit["agi"] = int(unit.get("agi", 0)) unit["move"] = int(unit.get("move", 3)) unit["move_type"] = str(unit.get("move_type", "foot")) if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: unit["skills"] = [] else: unit["skills"] = unit["skills"].duplicate(true) if typeof(unit.get("range", 1)) == TYPE_ARRAY: var range_array: Array = unit["range"] if range_array.size() >= 2: unit["min_range"] = max(1, int(unit.get("min_range", range_array[0]))) unit["range"] = max(int(unit["min_range"]), int(range_array[1])) elif range_array.size() == 1: unit["range"] = max(1, int(range_array[0])) unit["min_range"] = int(unit["range"]) else: unit["range"] = 1 unit["min_range"] = 1 else: unit["range"] = max(1, int(unit.get("range", 1))) unit["min_range"] = clampi(int(unit.get("min_range", min(1, int(unit["range"])))), 1, int(unit["range"])) unit["level"] = int(unit.get("level", 1)) unit["exp"] = int(unit.get("exp", 0)) unit["portrait"] = _portrait_for_unit(unit) if not unit.has("growth") or typeof(unit["growth"]) != TYPE_DICTIONARY: unit["growth"] = {} if not unit.has("growth_bonus") or typeof(unit["growth_bonus"]) != TYPE_DICTIONARY: unit["growth_bonus"] = {} unit["alive"] = bool(unit.get("alive", true)) unit["deployed"] = bool(unit.get("deployed", true)) unit["required_deployment"] = bool(unit.get("required_deployment", false)) unit["controllable"] = bool(unit.get("controllable", true)) unit["persist_progression"] = bool(unit.get("persist_progression", true)) unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0))) unit["status_effects"] = [] unit["acted"] = false unit["moved"] = false return unit func _apply_roster_overlay(unit: Dictionary, roster_overrides) -> void: if typeof(roster_overrides) != TYPE_DICTIONARY: return if unit.get("team", "") != TEAM_PLAYER: return var overlay := _find_roster_overlay(unit, roster_overrides) if overlay.is_empty(): return for key in ["class_id", "class", "level", "exp", "max_hp", "max_mp", "atk", "def", "int", "agi"]: if overlay.has(key): unit[key] = overlay[key] if overlay.has("equipment") and typeof(overlay["equipment"]) == TYPE_DICTIONARY: unit["equipment"] = overlay["equipment"].duplicate(true) if overlay.has("skills") and typeof(overlay["skills"]) == TYPE_ARRAY: unit["skills"] = overlay["skills"].duplicate(true) if overlay.has("class_id"): _apply_class_runtime_fields(unit, str(unit.get("class_id", ""))) else: _refresh_attack_range_from_equipment(unit) unit["hp"] = int(unit.get("max_hp", unit.get("hp", 1))) unit["mp"] = int(unit.get("max_mp", unit.get("mp", 0))) unit["loaded_from_roster"] = true func _portrait_for_unit(unit: Dictionary) -> String: var portrait := str(unit.get("portrait", "")) if not portrait.is_empty(): return portrait return data_catalog.get_portrait_for_officer(str(unit.get("officer_id", ""))) func _find_roster_overlay(unit: Dictionary, roster_overrides: Dictionary) -> Dictionary: var unit_id := str(unit.get("id", "")) if roster_overrides.has(unit_id) and typeof(roster_overrides[unit_id]) == TYPE_DICTIONARY: return roster_overrides[unit_id] var officer_id := str(unit.get("officer_id", "")) if not officer_id.is_empty() and roster_overrides.has(officer_id) and typeof(roster_overrides[officer_id]) == TYPE_DICTIONARY: return roster_overrides[officer_id] return {} func select_unit(unit_id: String) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or not unit.get("alive", false): return false if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return false if not bool(unit.get("controllable", true)): return false if unit.get("team", "") != current_team: return false if unit.get("acted", false): _emit_log("%s는 이미 군령을 마쳤다." % unit["name"]) return false selected_unit_id = unit_id _emit_log("%s을 지목하였다." % unit["name"]) _notify_changed() return true func clear_selection() -> void: selected_unit_id = "" _notify_changed() func try_move_selected(cell: Vector2i, defer_reach_events := false) -> bool: var unit := get_selected_unit() if unit.is_empty() or battle_status != STATUS_ACTIVE: return false if not bool(unit.get("controllable", true)): return false if unit.get("moved", false) or unit.get("acted", false): return false if _unit_has_action_lock(unit, "move"): _emit_log("%s는 발이 묶여 행군하지 못한다." % unit["name"]) return false if not get_movement_range(unit["id"]).has(cell): return false if not get_unit_at(cell).is_empty(): return false var from_cell: Vector2i = unit["pos"] unit["pos"] = cell unit["moved"] = true unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell) _emit_log("%s, %s으로 진군." % [unit["name"], _format_cell(cell)]) if not defer_reach_events: _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) _check_battle_status() _notify_changed() return true func commit_pending_move_events(unit_id: String) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or battle_status != STATUS_ACTIVE: return false _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) _check_battle_status() _notify_changed() return battle_status == STATUS_ACTIVE func cancel_pending_move(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or battle_status != STATUS_ACTIVE: return false if bool(unit.get("acted", false)) or not bool(unit.get("moved", false)): return false if unit.get("pos", Vector2i(-1, -1)) != to_cell: return false var blocker := get_unit_at(from_cell) if not blocker.is_empty() and str(blocker.get("id", "")) != unit_id: return false unit["pos"] = from_cell unit["moved"] = false unit_motion_requested.emit(unit_id, to_cell, from_cell) _emit_log("%s의 행군을 거두었다." % unit["name"]) _notify_changed() return true func try_attack_selected(target_id: String) -> bool: var attacker := get_selected_unit() var target := get_unit(target_id) if attacker.is_empty() or target.is_empty() or battle_status != STATUS_ACTIVE: return false if not bool(attacker.get("controllable", true)): return false if attacker.get("acted", false): return false if _unit_has_action_lock(attacker, "attack"): _emit_log("%s는 병장을 빼앗겨 타격하지 못한다." % attacker["name"]) return false if attacker.get("team", "") == target.get("team", ""): return false if not _is_in_attack_range(attacker, target["pos"]): return false _resolve_combat(attacker, target) attacker["acted"] = true attacker["moved"] = true selected_unit_id = "" _check_battle_status() _notify_changed() return true func try_cast_selected_skill(skill_id: String, target_cell: Vector2i) -> bool: var caster := get_selected_unit() var skill := get_skill_def(skill_id) if caster.is_empty() or skill.is_empty() or battle_status != STATUS_ACTIVE: return false if not bool(caster.get("controllable", true)): return false if caster.get("acted", false): return false if _unit_has_action_lock(caster, "skill"): _emit_log("%s는 봉인되어 책략을 펼치지 못한다." % caster["name"]) return false if not _unit_has_skill(caster, skill_id): return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): _emit_log("%s의 기력이 모자라다." % caster["name"]) return false if not _skill_cells_from(caster, skill).has(target_cell): return false var skill_kind := str(skill.get("kind", "damage")) var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell) if targets.is_empty(): if skill_kind == "heal": _emit_log("%s의 범위 안에 다친 아군이 없다." % skill.get("name", "책략")) else: _emit_log("%s의 표적이 없다." % skill.get("name", "책략")) return false if skill_kind == "support" and not _skill_has_support_effects(skill): return false _emit_skill_motion(caster, target_cell, skill_kind) caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var total_healed := 0 for target in targets: total_healed += _resolve_skill_heal(caster, target, skill) if total_healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": var applied_support := false for target in targets: applied_support = _resolve_skill_support(caster, target, skill_id, skill) or applied_support if applied_support: _grant_experience(caster, EXP_SUPPORT) else: var defeated_count := 0 for target in targets: if _resolve_skill_damage(caster, target, skill): defeated_count += 1 _grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS) caster["acted"] = true caster["moved"] = true selected_unit_id = "" _check_battle_status() _notify_changed() return true func try_use_selected_item_on_cell(item_id: String, target_cell: Vector2i) -> bool: var user := get_selected_unit() var item := get_item_def(item_id) if user.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE: return false if not bool(user.get("controllable", true)): return false if user.get("acted", false): return false if int(battle_inventory.get(item_id, 0)) <= 0: _emit_log("%s은 이미 다하였다." % _item_display_name(item_id)) return false if str(item.get("kind", "")) != "consumable": return false if not _item_target_cells_from(user, item).has(target_cell): return false var target := get_unit_at(target_cell) if not _item_target_valid(user, target, item): return false var applied := _apply_item_effects(user, target, item) if not applied: return false battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1) user["acted"] = true user["moved"] = true selected_unit_id = "" _emit_log("%s, %s을 %s에게 사용." % [user["name"], _item_display_name(item_id), target["name"]]) _check_battle_status() _notify_changed() return true func get_damage_preview(attacker_id: String, target_id: String) -> Dictionary: var attacker := get_unit(attacker_id) var target := get_unit(target_id) if attacker.is_empty() or target.is_empty(): return {} if not attacker.get("alive", false) or not target.get("alive", false): return {} if attacker.get("team", "") == target.get("team", ""): return {} var attack_locked := _unit_has_action_lock(attacker, "attack") var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(attacker, target) var damage := 0 if attack_locked else calculate_damage(attacker, target) var target_hp_after: int = maxi(0, int(target["hp"]) - damage) var counter_damage := 0 var attacker_hp_after := int(attacker["hp"]) var hit_chance := 0 if attack_locked else calculate_hit_chance(attacker, target) var counter_hit_chance := 0 var counter_effective_bonus := 0 var counter_in_range := false if not attack_locked and (target_hp_after > 0 or hit_chance < 100): counter_in_range = _is_in_attack_range(target, attacker["pos"]) if counter_in_range: counter_effective_bonus = _weapon_effectiveness_bonus(target, attacker) counter_damage = calculate_damage(target, attacker) counter_hit_chance = calculate_hit_chance(target, attacker) attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage) return { "attacker_id": attacker_id, "target_id": target_id, "damage": damage, "effective_bonus": effective_bonus, "effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "", "hit_chance": hit_chance, "target_hp_after": target_hp_after, "would_defeat": int(target["hp"]) - damage <= 0, "in_range": not attack_locked and _is_in_attack_range(attacker, target["pos"]), "attack_locked": attack_locked, "counter_in_range": counter_in_range, "counter_damage": counter_damage, "counter_effective_bonus": counter_effective_bonus, "counter_effective_target_type": _weapon_effectiveness_target_type(attacker) if counter_effective_bonus > 0 else "", "counter_hit_chance": counter_hit_chance, "attacker_hp_after": attacker_hp_after, "counter_would_defeat": attacker_hp_after <= 0 } func get_skill_preview(caster_id: String, skill_id: String, target_cell: Vector2i) -> Dictionary: var caster := get_unit(caster_id) var skill := get_skill_def(skill_id) if caster.is_empty() or skill.is_empty() or not is_inside(target_cell): return {} if not caster.get("alive", false) or not _unit_has_skill(caster, skill_id): return {} var target := get_unit_at(target_cell) var skill_kind := str(skill.get("kind", "damage")) var skill_locked := _unit_has_action_lock(caster, "skill") var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell) var area_cells := _skill_area_cells(target_cell, skill) var has_area := _skill_has_area(skill) var preview := { "skill_id": skill_id, "skill_name": str(skill.get("name", skill_id)), "kind": skill_kind, "mp_cost": int(skill.get("mp_cost", 0)), "has_mp": int(caster.get("mp", 0)) >= int(skill.get("mp_cost", 0)), "in_range": not skill_locked and _skill_cells_from(caster, skill).has(target_cell), "valid_target": not skill_locked and not targets.is_empty(), "skill_locked": skill_locked, "has_area": has_area, "area_cells": area_cells, "target_count": targets.size() } if targets.is_empty(): return preview var primary_target: Dictionary = target if not target.is_empty() and _skill_target_valid(caster, target, skill) else targets[0] preview["target_id"] = primary_target.get("id", "") if skill_kind == "heal": var heal_amount := calculate_skill_heal(caster, primary_target, skill) var total_heal := 0 for area_target in targets: total_heal += calculate_skill_heal(caster, area_target, skill) preview["heal"] = heal_amount preview["total_heal"] = total_heal preview["target_hp_after"] = min(int(primary_target.get("max_hp", 1)), int(primary_target.get("hp", 0)) + heal_amount) elif skill_kind == "support": preview["effect_text"] = _format_support_effects(skill) preview["already_active"] = _has_status_effect_from_skill(primary_target, skill_id) else: var damage := calculate_skill_damage(caster, primary_target, skill) var total_damage := 0 var defeat_count := 0 for area_target in targets: var area_damage := calculate_skill_damage(caster, area_target, skill) total_damage += area_damage if int(area_target.get("hp", 0)) - area_damage <= 0: defeat_count += 1 preview["damage"] = damage preview["total_damage"] = total_damage preview["defeat_count"] = defeat_count preview["target_hp_after"] = max(0, int(primary_target.get("hp", 0)) - damage) preview["would_defeat"] = int(primary_target.get("hp", 0)) - damage <= 0 return preview func get_item_preview(user_id: String, item_id: String, target_cell: Vector2i) -> Dictionary: var user := get_unit(user_id) var item := get_item_def(item_id) if user.is_empty() or item.is_empty() or not is_inside(target_cell): return {} var target := get_unit_at(target_cell) var preview := { "item_id": item_id, "item_name": str(item.get("name", item_id)), "count": int(battle_inventory.get(item_id, 0)), "in_range": _item_target_cells_from(user, item).has(target_cell), "valid_target": _item_target_valid(user, target, item) } if target.is_empty(): return preview var hp_heal_amount := _item_hp_heal_amount(target, item) var mp_heal_amount := _item_mp_heal_amount(target, item) var cured_statuses := _item_curable_status_names(target, item) cured_statuses.append_array(_item_cleansable_debuff_names(target, item)) preview["heal"] = hp_heal_amount preview["mp_heal"] = mp_heal_amount preview["cure_statuses"] = cured_statuses preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + hp_heal_amount) preview["target_mp_after"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + mp_heal_amount) return preview func calculate_damage(attacker: Dictionary, target: Dictionary) -> int: var terrain_defense := get_terrain_defense(target["pos"]) var effective_bonus := _weapon_effectiveness_bonus(attacker, target) return max(1, _effective_stat(attacker, "atk") + effective_bonus - _effective_stat(target, "def") - terrain_defense) func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int: var terrain_avoid := get_terrain_avoid(target["pos"]) var agility_delta := _effective_stat(attacker, "agi") - _effective_stat(target, "agi") return clampi(BASE_HIT_CHANCE + agility_delta - terrain_avoid, MIN_HIT_CHANCE, 100) func calculate_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var terrain_defense := get_terrain_defense(target["pos"]) var stat_name := str(skill.get("stat", "int")) var stat_value := _effective_stat(caster, stat_name) return max(1, int(skill.get("power", 1)) + stat_value - _effective_stat(target, "def") - terrain_defense) func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var stat_name := str(skill.get("stat", "int")) var raw_heal := int(skill.get("power", 1)) + _effective_stat(caster, stat_name) var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) return max(0, min(raw_heal, missing_hp)) func _weapon_effectiveness_bonus(attacker: Dictionary, target: Dictionary) -> int: var weapon := _equipped_weapon(attacker) if weapon.is_empty(): return 0 if not _weapon_is_effective_against(weapon, target): return 0 return max(0, int(weapon.get("effective_bonus_damage", 0))) func _weapon_is_effective_against(weapon: Dictionary, target: Dictionary) -> bool: var target_move_type := _weapon_effectiveness_target_type(target) if target_move_type.is_empty(): return false var effective_types = weapon.get("effective_vs_move_types", []) if typeof(effective_types) != TYPE_ARRAY: return false for move_type in effective_types: if str(move_type) == target_move_type: return true return false func _weapon_effectiveness_target_type(target: Dictionary) -> String: return str(target.get("move_type", "foot")).strip_edges() func _format_move_type(move_type: String) -> String: return move_type.replace("_", " ").capitalize() func _equipped_weapon(unit: Dictionary) -> Dictionary: var equipment = unit.get("equipment", {}) if typeof(equipment) != TYPE_DICTIONARY: return {} var weapon_id := str(equipment.get("weapon", "")) if weapon_id.is_empty(): return {} var weapon := get_item_def(weapon_id) if str(weapon.get("kind", "")) != "weapon": return {} return weapon func get_status_effect_summary(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty(): return "" return _format_active_status_effects(unit) func is_unit_skill_sealed(unit_id: String) -> bool: var unit := get_unit(unit_id) if unit.is_empty(): return false return _unit_has_action_lock(unit, "skill") func _effective_stat(unit: Dictionary, stat: String) -> int: var base_value := int(unit.get(stat, 0)) for effect in _active_status_effects(unit): if str(effect.get("stat", "")) != stat: continue base_value += int(effect.get("amount", 0)) if stat == "atk": return max(1, base_value) return max(0, base_value) func _active_status_effects(unit: Dictionary) -> Array: var result := [] if typeof(unit.get("status_effects", [])) != TYPE_ARRAY: return result for effect in unit.get("status_effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if int(effect.get("remaining_phases", 0)) <= 0: continue result.append(effect) return result func _skill_has_support_effects(skill: Dictionary) -> bool: for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "stat_bonus" and _is_supported_status_stat(str(effect.get("stat", ""))) and int(effect.get("amount", 0)) != 0: return true if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0: return true if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty() and not str(effect.get("status", "")).strip_edges().is_empty(): return true return false func _resolve_skill_support(caster: Dictionary, target: Dictionary, skill_id: String, skill: Dictionary) -> bool: if not _skill_has_support_effects(skill): return false var status_effects := _status_effects_without_skill(target, skill_id) for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "stat_bonus": var stat := str(effect.get("stat", "")) var amount := int(effect.get("amount", 0)) if not _is_supported_status_stat(stat) or amount == 0: continue status_effects.append({ "source_skill": skill_id, "name": str(skill.get("name", skill_id)), "type": effect_type, "stat": stat, "amount": amount, "remaining_phases": max(1, int(effect.get("duration_turns", 1))) }) elif effect_type == "damage_over_time": var damage: int = max(0, int(effect.get("amount", 0))) if damage <= 0: continue var status := str(effect.get("status", "poison")) status_effects.append({ "source_skill": skill_id, "name": str(skill.get("name", skill_id)), "type": effect_type, "status": status, "amount": damage, "remaining_phases": max(1, int(effect.get("duration_turns", 1))) }) elif effect_type == "action_lock": var action := str(effect.get("action", "")) var status := str(effect.get("status", "")) if action.strip_edges().is_empty() or status.strip_edges().is_empty(): continue status_effects.append({ "source_skill": skill_id, "name": str(skill.get("name", skill_id)), "type": effect_type, "status": status, "action": action, "remaining_phases": max(1, int(effect.get("duration_turns", 1))) }) target["status_effects"] = status_effects _emit_log("%s, %s을 펼쳐 %s에게 %s." % [ caster["name"], skill.get("name", "책략"), target["name"], _format_support_effects(skill) ]) _emit_combat_feedback(target, _format_support_popup(skill), _support_feedback_kind(skill)) return true func _status_effects_without_skill(unit: Dictionary, skill_id: String) -> Array: var result := [] for effect in _active_status_effects(unit): if str(effect.get("source_skill", "")) == skill_id: continue result.append(effect) return result func _has_status_effect_from_skill(unit: Dictionary, skill_id: String) -> bool: for effect in _active_status_effects(unit): if str(effect.get("source_skill", "")) == skill_id: return true return false func _format_support_effects(skill: Dictionary) -> String: var parts := [] for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) var duration: int = maxi(1, int(effect.get("duration_turns", 1))) var duration_text := _support_duration_text(duration) if effect_type == "stat_bonus": var stat := str(effect.get("stat", "")) var amount := int(effect.get("amount", 0)) if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" parts.append("%s %s%d, %s 지속" % [_status_stat_display_name(stat), sign, amount, duration_text]) elif effect_type == "damage_over_time": var damage := int(effect.get("amount", 0)) if damage <= 0: continue var status_name := _status_display_name(str(effect.get("status", "poison"))) parts.append("%s -%d 병력, %s 지속" % [status_name, damage, duration_text]) elif effect_type == "action_lock": var status_name := _status_display_name(str(effect.get("status", "status"))) parts.append("%s %s, %s 지속" % [status_name, _action_lock_display_name(str(effect.get("action", ""))), duration_text]) if parts.is_empty(): return "효험 없음" return _join_strings(parts, ", ") func _format_support_popup(skill: Dictionary) -> String: var parts := [] for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "stat_bonus": var stat := str(effect.get("stat", "")) var amount := int(effect.get("amount", 0)) if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" parts.append("%s %s%d" % [_status_stat_display_name(stat), sign, amount]) elif effect_type == "damage_over_time": var damage := int(effect.get("amount", 0)) if damage > 0: parts.append(_status_display_name(str(effect.get("status", "poison")))) elif effect_type == "action_lock": parts.append(_status_display_name(str(effect.get("status", "status")))) if parts.is_empty(): return "지원" return _join_strings(parts, ", ") func _support_feedback_kind(skill: Dictionary) -> String: for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) == "action_lock": return str(effect.get("status", "status")) for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) == "damage_over_time" and int(effect.get("amount", 0)) > 0: return str(effect.get("status", "poison")) for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) == "stat_bonus" and int(effect.get("amount", 0)) < 0: return "debuff" return "support" func _format_active_status_effects(unit: Dictionary) -> String: var parts := [] for effect in _active_status_effects(unit): var effect_type := str(effect.get("type", "stat_bonus")) var amount := int(effect.get("amount", 0)) var duration_text := _format_status_remaining_phases(effect) if effect_type == "damage_over_time": if amount > 0: parts.append("%s -%d 병력 %s" % [ _status_display_name(str(effect.get("status", "poison"))), amount, duration_text ]) continue if effect_type == "action_lock": parts.append("%s %s %s" % [ _status_display_name(str(effect.get("status", "status"))), _action_lock_display_name(str(effect.get("action", ""))), duration_text ]) continue var stat := str(effect.get("stat", "")) if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" parts.append("%s %s%d %s" % [_status_stat_display_name(stat), sign, amount, duration_text]) return _join_strings(parts, ", ") func _format_status_remaining_phases(effect: Dictionary) -> String: var remaining: int = maxi(1, int(effect.get("remaining_phases", 1))) return "(%d회)" % remaining func _status_display_name(status: String) -> String: var normalized := status.strip_edges().to_lower() if normalized.is_empty(): return "상태" if normalized == "poison": return "독" if normalized == "seal": return "봉인" if normalized == "snare": return "발묶임" if normalized == "disarm": return "무장해제" if normalized == "debuff": return "약화" return normalized.replace("_", " ").capitalize() func _status_stat_display_name(stat: String) -> String: var normalized := stat.strip_edges().to_lower() if normalized == "atk": return "무력" if normalized == "def": return "방비" if normalized == "int": return "지략" if normalized == "agi": return "순발" if normalized == "move": return "행군" return normalized.to_upper() func _support_duration_text(duration: int) -> String: if duration <= 1: return "다음 차례" return "%d차례" % duration func _is_supported_status_stat(stat: String) -> bool: return stat == "atk" or stat == "def" or stat == "int" or stat == "agi" or stat == "move" func _unit_has_action_lock(unit: Dictionary, action: String) -> bool: var normalized_action := action.strip_edges().to_lower() if normalized_action.is_empty(): return false for effect in _active_status_effects(unit): if str(effect.get("type", "")) == "action_lock" and str(effect.get("action", "")).strip_edges().to_lower() == normalized_action: return true return false func _action_lock_display_name(action: String) -> String: if action == "skill": return "책략 봉인" if action == "move": return "행군 봉인" if action == "attack": return "타격 봉인" return "행동 봉인" func try_wait_selected() -> bool: var unit := get_selected_unit() if unit.is_empty() or battle_status != STATUS_ACTIVE: return false if not bool(unit.get("controllable", true)): return false unit["acted"] = true unit["moved"] = true selected_unit_id = "" _emit_log("%s 대기." % unit["name"]) _notify_changed() return true func end_player_turn() -> void: if battle_status != STATUS_ACTIVE or current_team != TEAM_PLAYER: return selected_unit_id = "" current_team = TEAM_ENEMY _emit_log("적군 차례가 열렸습니다.") _reset_team_actions(TEAM_ENEMY) if battle_status == STATUS_ACTIVE: _run_events("turn_start", current_team, turn_number) _check_battle_status() if battle_status == STATUS_ACTIVE: _run_enemy_turn() else: _notify_changed() func get_unit(unit_id: String) -> Dictionary: for unit in units: if unit.get("id", "") == unit_id: return unit return {} func get_selected_unit() -> Dictionary: if selected_unit_id.is_empty(): return {} return get_unit(selected_unit_id) func get_unit_at(cell: Vector2i, include_dead := false) -> Dictionary: for unit in units: if not unit.get("deployed", true): continue if unit.get("pos", Vector2i(-99, -99)) == cell: if include_dead or unit.get("alive", false): return unit return {} func get_player_units(include_reserve := false) -> Array[Dictionary]: var result: Array[Dictionary] = [] for unit in units: if unit.get("team", "") != TEAM_PLAYER: continue if not include_reserve and not unit.get("deployed", true): continue result.append(unit) return result func get_controllable_player_units(include_reserve := false) -> Array[Dictionary]: var result: Array[Dictionary] = [] for unit in get_player_units(include_reserve): if not unit.get("alive", false): continue if not bool(unit.get("controllable", true)): continue result.append(unit) return result func get_living_units(team := "") -> Array[Dictionary]: var result: Array[Dictionary] = [] for unit in units: if not unit.get("deployed", true): continue if not unit.get("alive", false): continue if not team.is_empty() and unit.get("team", "") != team: continue result.append(unit) return result func get_movement_range(unit_id: String) -> Array[Vector2i]: var unit := get_unit(unit_id) if unit.is_empty() or unit.get("acted", false) or unit.get("moved", false): return [] if not bool(unit.get("controllable", true)): return [] if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return [] if _unit_has_action_lock(unit, "move"): return [] return _movement_range_for_unit(unit) func get_attack_cells(unit_id: String) -> Array[Vector2i]: var unit := get_unit(unit_id) if unit.is_empty() or unit.get("acted", false): return [] if not bool(unit.get("controllable", true)): return [] if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return [] if _unit_has_action_lock(unit, "attack"): return [] return _attack_cells_from(unit, unit["pos"]) func get_skill_cells(unit_id: String, skill_id: String) -> Array[Vector2i]: var unit := get_unit(unit_id) var skill := get_skill_def(skill_id) if unit.is_empty() or skill.is_empty() or unit.get("acted", false): return [] if not bool(unit.get("controllable", true)): return [] if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return [] if _unit_has_action_lock(unit, "skill"): return [] if not _unit_has_skill(unit, skill_id): return [] if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): return [] return _skill_cells_from(unit, skill) func get_skill_area_cells(unit_id: String, skill_id: String, target_cell: Vector2i) -> Array[Vector2i]: var unit := get_unit(unit_id) var skill := get_skill_def(skill_id) if unit.is_empty() or skill.is_empty() or not is_inside(target_cell): return [] if not _unit_has_skill(unit, skill_id): return [] if not _skill_cells_from(unit, skill).has(target_cell): return [] return _skill_area_cells(target_cell, skill) func get_item_target_cells(unit_id: String, item_id: String) -> Array[Vector2i]: var unit := get_unit(unit_id) var item := get_item_def(item_id) if unit.is_empty() or item.is_empty() or unit.get("acted", false): return [] if not bool(unit.get("controllable", true)): return [] if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return [] if int(battle_inventory.get(item_id, 0)) <= 0: return [] return _item_target_cells_from(unit, item) func get_threat_cells(source_team := TEAM_ENEMY) -> Array[Vector2i]: var result: Array[Vector2i] = [] for unit in get_living_units(source_team): for cell in _unit_threat_cells(unit): if not result.has(cell): result.append(cell) return result func get_threatening_unit_names(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[String]: var result: Array[String] = [] if not is_inside(cell): return result for unit in get_living_units(source_team): if not _unit_threat_cells(unit).has(cell): continue var unit_name := str(unit.get("name", unit.get("id", "Unit"))) if not result.has(unit_name): result.append(unit_name) return result func get_physical_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[Dictionary]: var result: Array[Dictionary] = [] if not is_inside(cell): return result var target := get_unit_at(cell) if target.is_empty() or target.get("team", "") == source_team: return result for unit in get_living_units(source_team): if not _unit_physical_threat_cells(unit).has(cell): continue var damage := calculate_damage(unit, target) var hit_chance := calculate_hit_chance(unit, target) var effective_bonus := _weapon_effectiveness_bonus(unit, target) result.append({ "source_id": str(unit.get("id", "")), "source_name": str(unit.get("name", unit.get("id", "Unit"))), "damage": damage, "hit_chance": hit_chance, "target_hp_after": max(0, int(target.get("hp", 0)) - damage), "would_defeat": int(target.get("hp", 0)) - damage <= 0, "effective_bonus": effective_bonus, "effective_target_type": _weapon_effectiveness_target_type(target) if effective_bonus > 0 else "" }) result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: if bool(a.get("would_defeat", false)) != bool(b.get("would_defeat", false)): return bool(a.get("would_defeat", false)) if int(a.get("damage", 0)) != int(b.get("damage", 0)): return int(a.get("damage", 0)) > int(b.get("damage", 0)) return str(a.get("source_name", "")) < str(b.get("source_name", "")) ) return result func get_skill_threat_previews(cell: Vector2i, source_team := TEAM_ENEMY) -> Array[Dictionary]: var result: Array[Dictionary] = [] if not is_inside(cell): return result var target := get_unit_at(cell) if target.is_empty() or target.get("team", "") == source_team: return result for unit in get_living_units(source_team): if _unit_has_action_lock(unit, "skill"): continue if typeof(unit.get("skills", [])) != TYPE_ARRAY: continue for skill_id_value in unit.get("skills", []): var skill_id := str(skill_id_value) var skill := get_skill_def(skill_id) if skill.is_empty(): continue if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue if not _skill_threatens_opponents(skill): continue if not _skill_target_valid(unit, target, skill): continue var preview := _best_skill_threat_preview_for_target(unit, skill_id, skill, target, cell) if not preview.is_empty(): result.append(preview) result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: var a_score := _skill_threat_preview_sort_score(a) var b_score := _skill_threat_preview_sort_score(b) if a_score != b_score: return a_score > b_score if str(a.get("source_name", "")) != str(b.get("source_name", "")): return str(a.get("source_name", "")) < str(b.get("source_name", "")) return str(a.get("skill_name", "")) < str(b.get("skill_name", "")) ) return result func get_objective_cells() -> Array[Vector2i]: var result: Array[Vector2i] = [] _collect_objective_cells(battle_conditions.get("victory", {}), result) return result func get_objective_cell_label(cell: Vector2i) -> String: if not is_inside(cell): return "" return _objective_cell_label_for_condition(battle_conditions.get("victory", {}), cell) func get_objective_progress_lines(include_defeat_risks := false, include_turn_limit := true) -> Array[String]: var result: Array[String] = [] _append_condition_progress_lines(battle_conditions.get("victory", {}), result, "victory") if include_defeat_risks: _append_condition_progress_lines(battle_conditions.get("defeat", {}), result, "defeat") elif include_turn_limit: _append_turn_limit_progress_line(result) return result func get_objective_progress_text(include_defeat_risks := false, include_turn_limit := true) -> String: return _join_strings(get_objective_progress_lines(include_defeat_risks, include_turn_limit), " | ") func get_defeat_progress_lines() -> Array[String]: var result: Array[String] = [] _append_condition_progress_lines(battle_conditions.get("defeat", {}), result, "defeat") return result func get_defeat_progress_text() -> String: return _join_strings(get_defeat_progress_lines(), " | ") func get_first_skill_id(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: return "" for skill_id in unit.get("skills", []): if not get_skill_def(str(skill_id)).is_empty(): return str(skill_id) return "" func get_skill_ids(unit_id: String) -> Array: var result := [] var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: return result if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return result for skill_id in unit.get("skills", []): var normalized := str(skill_id) if not get_skill_def(normalized).is_empty(): result.append(normalized) return result func get_first_usable_skill_id(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: return "" if _unit_has_action_lock(unit, "skill"): return "" for skill_id in unit.get("skills", []): var normalized := str(skill_id) var skill := get_skill_def(normalized) if skill.is_empty(): continue if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)): return normalized return "" func get_usable_skill_ids(unit_id: String) -> Array: var result := [] var unit := get_unit(unit_id) if unit.is_empty() or typeof(unit.get("skills", [])) != TYPE_ARRAY: return result if unit.get("team", "") == TEAM_PLAYER and not unit.get("deployed", true): return result if _unit_has_action_lock(unit, "skill"): return result for skill_id in unit.get("skills", []): var normalized := str(skill_id) var skill := get_skill_def(normalized) if skill.is_empty(): continue if int(unit.get("mp", 0)) >= int(skill.get("mp_cost", 0)): result.append(normalized) return result func get_skill_def(skill_id: String) -> Dictionary: return data_catalog.get_skill(skill_id) func get_item_def(item_id: String) -> Dictionary: return data_catalog.get_item(item_id) func get_shop_item_ids() -> Array: var result := [] for item_id in shop.get("items", []): var normalized := str(item_id) var item := get_item_def(normalized) if item.is_empty() or int(item.get("price", 0)) <= 0: continue result.append(normalized) result.sort() return result func get_shop_stock_limit(item_id: String) -> int: var normalized := str(item_id) if normalized.is_empty(): return -1 var stock: Dictionary = shop.get("stock", {}) if not stock.has(normalized): return -1 return int(stock.get(normalized, -1)) func get_shop_merchant() -> Dictionary: var merchant = shop.get("merchant", {}) if typeof(merchant) != TYPE_DICTIONARY: return {} return merchant.duplicate(true) func get_formation_cells() -> Array[Vector2i]: return formation_cells.duplicate() func get_deployment_max_units() -> int: var configured := int(deployment_rules.get("max_units", 0)) if configured > 0: return configured return get_player_units(true).size() func has_deployment_roster() -> bool: return ( int(deployment_rules.get("max_units", 0)) > 0 or not Array(deployment_rules.get("required_officers", [])).is_empty() or not Array(deployment_rules.get("required_units", [])).is_empty() ) func has_deployment_roster_choices() -> bool: if not has_deployment_roster(): return false var deployed_count := get_deployed_player_count() var max_units := get_deployment_max_units() for unit in get_player_units(true): if bool(unit.get("required_deployment", false)): continue if bool(unit.get("deployed", true)): return true if deployed_count < max_units: return true return false func get_deployed_player_count() -> int: return get_player_units(false).size() func is_required_deployment(unit_id: String) -> bool: var unit := get_unit(unit_id) if unit.is_empty(): return false return bool(unit.get("required_deployment", false)) func try_set_unit_deployed(unit_id: String, deployed: bool) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: return false if bool(unit.get("required_deployment", false)) and not deployed: _emit_log("%s는 반드시 출진해야 한다." % unit["name"]) return false if bool(unit.get("deployed", true)) == deployed: return true if deployed: if get_deployed_player_count() >= get_deployment_max_units(): _emit_log("출진 명부가 이미 찼다.") return false var target_cell := _formation_cell_for_deployment(unit) if not is_inside(target_cell): _emit_log("%s이 설 진형 자리가 없다." % unit["name"]) return false unit["pos"] = target_cell unit["alive"] = true unit["deployed"] = true _emit_log("%s, 출진 명부에 들었다." % unit["name"]) else: unit["deployed"] = false _emit_log("%s, 예비대로 물러났다." % unit["name"]) _notify_changed() return true func is_formation_cell(cell: Vector2i) -> bool: return formation_cells.has(cell) func try_set_prebattle_formation(unit_id: String, cell: Vector2i) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: return false if not bool(unit.get("controllable", true)): return false if not unit.get("deployed", true): return false if not is_formation_cell(unit.get("pos", Vector2i(-99, -99))): return false if not is_formation_cell(cell) or not is_inside(cell): return false if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99: return false var occupant := get_unit_at(cell) if not occupant.is_empty(): if occupant.get("team", "") != TEAM_PLAYER: return false if not bool(occupant.get("controllable", true)): return false if occupant.get("id", "") == unit_id: return true occupant["pos"] = unit["pos"] unit["pos"] = cell _emit_log("%s, %s에 진형을 잡았다." % [unit["name"], _format_cell(cell)]) _notify_changed() return true func _formation_cell_for_deployment(unit: Dictionary) -> Vector2i: var current: Vector2i = unit.get("pos", Vector2i(-99, -99)) if is_formation_cell(current) and get_unit_at(current).is_empty() and get_move_cost(current, str(unit.get("move_type", "foot"))) < 99: return current for cell in formation_cells: if not get_unit_at(cell).is_empty(): continue if get_move_cost(cell, str(unit.get("move_type", "foot"))) >= 99: continue return cell return Vector2i(-99, -99) func try_equip_item(unit_id: String, item_id: String) -> bool: var unit := get_unit(unit_id) var item := get_item_def(item_id) if unit.is_empty() or item.is_empty() or battle_status != STATUS_ACTIVE: return false if unit.get("team", "") != TEAM_PLAYER or current_team != TEAM_PLAYER: return false if not bool(unit.get("controllable", true)): return false if unit.get("moved", false) or unit.get("acted", false): _emit_log("%s는 행군이나 군령 전에만 병장을 고칠 수 있다." % unit["name"]) return false var slot := _equipment_slot_for_item(item) if slot.is_empty(): return false if int(battle_inventory.get(item_id, 0)) <= 0: _emit_log("병장고에 %s이 없다." % item.get("name", item_id)) return false if not _unit_can_equip_item(unit, item): _emit_log("%s는 %s을 들 수 없다." % [unit["name"], item.get("name", item_id)]) return false var equipment: Dictionary = unit.get("equipment", {}).duplicate(true) var old_item_id := _equipment_item_id_for_slot(equipment, slot) if old_item_id == item_id: _emit_log("%s는 이미 %s을 지녔다." % [unit["name"], item.get("name", item_id)]) return false battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1) var old_item := get_item_def(old_item_id) if not old_item_id.is_empty() and not old_item.is_empty(): battle_inventory[old_item_id] = int(battle_inventory.get(old_item_id, 0)) + 1 equipment[slot] = item_id unit["equipment"] = equipment _apply_equipment_stat_delta(unit, old_item, item) _refresh_attack_range_from_equipment(unit) _emit_log("%s, %s을 장착." % [unit["name"], item.get("name", item_id)]) _notify_changed() return true func try_unequip_item(unit_id: String, slot: String) -> bool: var unit := get_unit(unit_id) if unit.is_empty() or battle_status != STATUS_ACTIVE: return false if unit.get("team", "") != TEAM_PLAYER or current_team != TEAM_PLAYER: return false if not bool(unit.get("controllable", true)): return false if unit.get("moved", false) or unit.get("acted", false): _emit_log("%s는 행군이나 군령 전에만 병장을 고칠 수 있다." % unit["name"]) return false var normalized_slot := _normalized_equipment_slot(slot) if normalized_slot.is_empty(): return false var equipment: Dictionary = unit.get("equipment", {}).duplicate(true) var old_item_id := _equipment_item_id_for_slot(equipment, normalized_slot) if old_item_id.is_empty(): _emit_log("%s는 %s에 찬 병장이 없다." % [unit["name"], _equipment_slot_display_name(normalized_slot)]) return false var old_item := get_item_def(old_item_id) if old_item.is_empty(): push_error("Cannot unequip unknown item %s from %s." % [old_item_id, unit["name"]]) return false equipment.erase(normalized_slot) unit["equipment"] = equipment _apply_equipment_stat_delta(unit, old_item, {}) _refresh_attack_range_from_equipment(unit) battle_inventory[old_item_id] = int(battle_inventory.get(old_item_id, 0)) + 1 _emit_log("%s, %s을 해제." % [unit["name"], old_item.get("name", old_item_id)]) _notify_changed() return true func get_equippable_item_ids(unit_id: String) -> Array: var result := [] var unit := get_unit(unit_id) if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: return result if not bool(unit.get("controllable", true)): return result if not unit.get("deployed", true): return result var equipment: Dictionary = unit.get("equipment", {}) for item_id in battle_inventory.keys(): var normalized := str(item_id) if int(battle_inventory.get(normalized, 0)) <= 0: continue var item := get_item_def(normalized) if item.is_empty() or _equipment_slot_for_item(item).is_empty(): continue var slot := _equipment_slot_for_item(item) if _equipment_item_id_for_slot(equipment, slot) == normalized: continue if not _unit_can_equip_item(unit, item): continue result.append(normalized) result.sort() return result func get_equipped_equipment_slots(unit_id: String) -> Array: var result := [] var unit := get_unit(unit_id) if unit.is_empty() or unit.get("team", "") != TEAM_PLAYER: return result if not bool(unit.get("controllable", true)): return result var equipment: Dictionary = unit.get("equipment", {}) for slot in ["weapon", "armor", "accessory"]: var item_id := _equipment_item_id_for_slot(equipment, slot) if item_id.is_empty(): continue if get_item_def(item_id).is_empty(): continue result.append(slot) return result func get_equipment_snapshot(unit_id: String) -> Dictionary: var unit := get_unit(unit_id) if unit.is_empty(): return {} return unit.get("equipment", {}).duplicate(true) func get_usable_item_ids() -> Array: var result := [] for item_id in battle_inventory.keys(): var normalized := str(item_id) if int(battle_inventory.get(normalized, 0)) <= 0: continue var item := get_item_def(normalized) if item.is_empty() or str(item.get("kind", "")) != "consumable": continue result.append(normalized) result.sort() return result func get_inventory_snapshot() -> Dictionary: return battle_inventory.duplicate(true) func set_inventory_snapshot(inventory_snapshot: Dictionary) -> void: battle_inventory = inventory_snapshot.duplicate(true) _notify_changed() func get_battle_gold_reward() -> int: return max(0, battle_gold_reward) func get_terrain_key(cell: Vector2i) -> String: if not is_inside(cell): return "G" if cell.y >= terrain_rows.size(): return "G" var row := terrain_rows[cell.y] if cell.x >= row.length(): return "G" return row.substr(cell.x, 1) func get_map_size() -> Vector2i: return map_size func get_recovery_terrain_summaries() -> Array[Dictionary]: var summaries: Array[Dictionary] = [] var seen := {} for y in range(map_size.y): for x in range(map_size.x): var cell := Vector2i(x, y) var heal := get_terrain_heal(cell) if heal <= 0: continue var key := get_terrain_key(cell) if seen.has(key): seen[key]["count"] = int(seen[key].get("count", 0)) + 1 continue var summary := { "key": key, "name": get_terrain_name(cell), "heal": heal, "count": 1 } seen[key] = summary summaries.append(summary) return summaries func get_map_background_path() -> String: return map_background func get_terrain_name(cell: Vector2i) -> String: return String(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("name", "평원")) func get_terrain_color(cell: Vector2i) -> Color: return terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("color", DEFAULT_TERRAIN_DEFS["G"]["color"]) func get_move_cost(cell: Vector2i, move_type := "foot") -> int: var move_cost = terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("move_cost", 1) if typeof(move_cost) == TYPE_DICTIONARY: return int(move_cost.get(move_type, move_cost.get("foot", 1))) return int(move_cost) func get_terrain_defense(cell: Vector2i) -> int: return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("defense", 0)) func get_terrain_avoid(cell: Vector2i) -> int: return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("avoid", 0)) func get_terrain_heal(cell: Vector2i) -> int: return int(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("heal", 0)) func get_cell_summary(cell: Vector2i) -> Dictionary: if not is_inside(cell): return {} var unit := get_unit_at(cell) return { "cell": cell, "terrain": get_terrain_name(cell), "move_cost": get_move_cost(cell), "defense": get_terrain_defense(cell), "avoid": get_terrain_avoid(cell), "heal": get_terrain_heal(cell), "unit": unit } func get_briefing() -> Dictionary: return briefing.duplicate(true) func get_rewards() -> Dictionary: return rewards.duplicate(true) func get_post_battle_choices() -> Array: return post_battle_choices.duplicate(true) func get_post_battle_dialogue() -> Array: return post_battle_dialogue.duplicate(true) func get_progression_events() -> Array: return progression_events.duplicate(true) func get_player_roster_snapshot() -> Dictionary: var snapshot := {} for unit in units: if unit.get("team", "") != TEAM_PLAYER: continue if not bool(unit.get("persist_progression", true)): continue var roster_key := str(unit.get("officer_id", unit["id"])) if roster_key.is_empty(): roster_key = str(unit["id"]) snapshot[roster_key] = { "id": unit["id"], "officer_id": roster_key, "name": unit["name"], "class_id": unit.get("class_id", ""), "class": unit["class"], "move": int(unit.get("move", 3)), "move_type": str(unit.get("move_type", "foot")), "min_range": int(unit.get("min_range", 1)), "range": int(unit.get("range", 1)), "level": int(unit.get("level", 1)), "exp": int(unit.get("exp", 0)), "hp": int(unit.get("hp", 0)), "max_hp": int(unit.get("max_hp", 1)), "mp": int(unit.get("mp", 0)), "max_mp": int(unit.get("max_mp", 0)), "atk": int(unit.get("atk", 1)), "def": int(unit.get("def", 0)), "int": int(unit.get("int", 0)), "agi": int(unit.get("agi", 0)), "growth": unit.get("growth", {}).duplicate(true), "skills": unit.get("skills", []).duplicate(true), "equipment": unit.get("equipment", {}).duplicate(true) } return snapshot func is_inside(cell: Vector2i) -> bool: return cell.x >= 0 and cell.y >= 0 and cell.x < map_size.x and cell.y < map_size.y func can_player_act() -> bool: return battle_status == STATUS_ACTIVE and current_team == TEAM_PLAYER func get_status_text() -> String: if battle_status == STATUS_VICTORY: return "승전" if battle_status == STATUS_DEFEAT: return "패전" var turn_limit := get_turn_limit() if turn_limit > 0: return "제%d군령/%d · %s" % [turn_number, turn_limit, _team_status_text(current_team)] return "제%d군령 · %s" % [turn_number, _team_status_text(current_team)] func _team_status_text(team: String) -> String: if team == TEAM_PLAYER: return "아군" if team == TEAM_ENEMY: return "적군" return team.capitalize() func get_turn_limit() -> int: return _find_turn_limit(battle_conditions.get("defeat", {})) func _movement_range_for_unit(unit: Dictionary) -> Array[Vector2i]: if _unit_has_action_lock(unit, "move"): return [] var start: Vector2i = unit["pos"] var frontier: Array[Vector2i] = [start] var costs := {start: 0} var result: Array[Vector2i] = [] while not frontier.is_empty(): var current: Vector2i = frontier.pop_front() for direction in CARDINAL_DIRECTIONS: var next_cell: Vector2i = current + direction if not is_inside(next_cell): continue var terrain_cost := get_move_cost(next_cell, str(unit.get("move_type", "foot"))) if terrain_cost >= 99: continue if next_cell != start and not get_unit_at(next_cell).is_empty(): continue var new_cost := int(costs[current]) + terrain_cost if new_cost > _effective_stat(unit, "move"): continue if not costs.has(next_cell) or new_cost < int(costs[next_cell]): costs[next_cell] = new_cost frontier.append(next_cell) if next_cell != start and not result.has(next_cell): result.append(next_cell) return result func _unit_threat_cells(unit: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] if unit.is_empty() or not unit.get("alive", false) or not unit.get("deployed", true): return result if not bool(unit.get("controllable", true)): return result for cell in _unit_physical_threat_cells(unit): if not result.has(cell): result.append(cell) for cell in _unit_skill_threat_cells(unit): if not result.has(cell): result.append(cell) return result func _unit_threat_origins(unit: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] var origin: Vector2i = unit.get("pos", Vector2i(-1, -1)) if not is_inside(origin): return result result.append(origin) for cell in _movement_range_for_unit(unit): if not result.has(cell): result.append(cell) return result func _unit_physical_threat_cells(unit: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] if _unit_has_action_lock(unit, "attack"): return result for origin in _unit_threat_origins(unit): for cell in _attack_cells_from(unit, origin): if not result.has(cell): result.append(cell) return result func _unit_skill_threat_cells(unit: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] if _unit_has_action_lock(unit, "skill"): return result if typeof(unit.get("skills", [])) != TYPE_ARRAY: return result for skill_id_value in unit.get("skills", []): var skill_id := str(skill_id_value) var skill := get_skill_def(skill_id) if skill.is_empty(): continue if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue if not _skill_threatens_opponents(skill): continue for origin in _unit_threat_origins(unit): for center_cell in _skill_cells_from_origin(unit, skill, origin): for cell in _skill_area_cells(center_cell, skill): if not result.has(cell): result.append(cell) return result func _best_skill_threat_preview_for_target(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, target_cell: Vector2i) -> Dictionary: var best_preview := {} var best_score := -999999 for origin in _unit_threat_origins(caster): for center_cell in _skill_cells_from_origin(caster, skill, origin): if not _skill_area_cells(center_cell, skill).has(target_cell): continue var targets := _skill_targets_for_cast(caster, skill_id, skill, center_cell) if targets.is_empty(): continue var preview := _build_skill_threat_preview(caster, skill_id, skill, target, origin, center_cell, targets) var score := _skill_threat_preview_sort_score(preview) if score > best_score: best_score = score best_preview = preview return best_preview func _build_skill_threat_preview(caster: Dictionary, skill_id: String, skill: Dictionary, target: Dictionary, origin: Vector2i, center_cell: Vector2i, targets: Array[Dictionary]) -> Dictionary: var skill_kind := str(skill.get("kind", "damage")) var preview := { "source_id": str(caster.get("id", "")), "source_name": str(caster.get("name", caster.get("id", "Unit"))), "skill_id": skill_id, "skill_name": str(skill.get("name", skill_id)), "kind": skill_kind, "mp_cost": int(skill.get("mp_cost", 0)), "origin_cell": origin, "cast_cell": center_cell, "requires_move": origin != caster.get("pos", origin), "has_area": _skill_has_area(skill), "area_shape": _skill_area_shape(skill), "area_radius": _skill_area_radius(skill), "target_count": targets.size() } if skill_kind == "support": preview["effect_text"] = _format_skill_threat_effect_summary(skill) preview["already_active"] = _has_status_effect_from_skill(target, skill_id) else: var damage := calculate_skill_damage(caster, target, skill) var total_damage := 0 var defeat_count := 0 for area_target in targets: var area_damage := calculate_skill_damage(caster, area_target, skill) total_damage += area_damage if int(area_target.get("hp", 0)) - area_damage <= 0: defeat_count += 1 preview["damage"] = damage preview["total_damage"] = total_damage preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage) preview["would_defeat"] = int(target.get("hp", 0)) - damage <= 0 preview["defeat_count"] = defeat_count return preview func _skill_threat_preview_sort_score(preview: Dictionary) -> int: var score := 0 if str(preview.get("kind", "")) == "damage": score += int(preview.get("damage", 0)) * 10 score += int(preview.get("defeat_count", 0)) * 2000 if bool(preview.get("would_defeat", false)): score += 10000 else: score += _skill_threat_effect_score(preview) if bool(preview.get("already_active", false)): score -= 250 score += int(preview.get("target_count", 0)) * 20 if bool(preview.get("has_area", false)): score += 5 if not bool(preview.get("requires_move", false)): score += 2 return score func _skill_threat_effect_score(preview: Dictionary) -> int: var skill := get_skill_def(str(preview.get("skill_id", ""))) if skill.is_empty(): return 0 var score := 400 for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty(): score += 350 elif effect_type == "damage_over_time": score += 250 + max(0, int(effect.get("amount", 0))) * 20 elif effect_type == "stat_bonus": score += 150 + abs(int(effect.get("amount", 0))) * 25 return score func _format_skill_threat_effect_summary(skill: Dictionary) -> String: var parts := [] for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "stat_bonus": var stat := str(effect.get("stat", "")) var amount := int(effect.get("amount", 0)) if not _is_supported_status_stat(stat) or amount == 0: continue var sign := "+" if amount > 0 else "" parts.append("%s %s%d" % [_status_stat_display_name(stat), sign, amount]) elif effect_type == "damage_over_time": var damage := int(effect.get("amount", 0)) if damage <= 0: continue parts.append("%s -%d 병력" % [_status_display_name(str(effect.get("status", "poison"))), damage]) elif effect_type == "action_lock": var status_name := _status_display_name(str(effect.get("status", "status"))) parts.append("%s %s" % [status_name, _action_lock_display_name(str(effect.get("action", "")))]) if parts.is_empty(): return _format_support_effects(skill) return _join_strings(parts, ", ") func _skill_threatens_opponents(skill: Dictionary) -> bool: var target_rule := str(skill.get("target", "enemy")) if target_rule != "enemy" and target_rule != "any": return false var skill_kind := str(skill.get("kind", "damage")) if skill_kind == "damage": return true if skill_kind == "support": return _skill_has_harmful_support_effects(skill) return false func _skill_has_harmful_support_effects(skill: Dictionary) -> bool: for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "damage_over_time" and int(effect.get("amount", 0)) > 0: return true if effect_type == "action_lock" and not str(effect.get("action", "")).strip_edges().is_empty(): return true if effect_type == "stat_bonus" and int(effect.get("amount", 0)) < 0: return true return false func _attack_cells_from(unit: Dictionary, origin: Vector2i) -> Array[Vector2i]: var result: Array[Vector2i] = [] var attack_range := int(unit.get("range", 1)) var min_range := int(unit.get("min_range", min(1, attack_range))) for y in range(origin.y - attack_range, origin.y + attack_range + 1): for x in range(origin.x - attack_range, origin.x + attack_range + 1): var cell := Vector2i(x, y) if cell == origin or not is_inside(cell): continue var distance := _manhattan(origin, cell) if distance >= min_range and distance <= attack_range: result.append(cell) return result func _is_in_attack_range(attacker: Dictionary, target_cell: Vector2i) -> bool: if _unit_has_action_lock(attacker, "attack"): return false return _attack_cells_from(attacker, attacker["pos"]).has(target_cell) func _skill_cells_from(unit: Dictionary, skill: Dictionary) -> Array[Vector2i]: return _skill_cells_from_origin(unit, skill, unit["pos"]) func _skill_cells_from_origin(_unit: Dictionary, skill: Dictionary, origin: Vector2i) -> Array[Vector2i]: var result: Array[Vector2i] = [] var range_pair := _skill_range_pair(skill) var max_range := int(range_pair["max"]) var min_range := int(range_pair["min"]) for y in range(origin.y - max_range, origin.y + max_range + 1): for x in range(origin.x - max_range, origin.x + max_range + 1): var cell := Vector2i(x, y) if not is_inside(cell): continue var distance := _manhattan(origin, cell) if distance >= min_range and distance <= max_range: result.append(cell) return result func _skill_area_cells(center: Vector2i, skill: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] if not is_inside(center): return result var radius: int = _skill_area_radius(skill) var shape := _skill_area_shape(skill) if radius <= 0 or shape == "single": result.append(center) return result for y in range(center.y - radius, center.y + radius + 1): for x in range(center.x - radius, center.x + radius + 1): var cell := Vector2i(x, y) if not is_inside(cell): continue var distance := _manhattan(center, cell) if shape == "diamond" and distance <= radius: result.append(cell) return result func _skill_has_area(skill: Dictionary) -> bool: return _skill_area_radius(skill) > 0 and _skill_area_shape(skill) != "single" func _skill_area_shape(skill: Dictionary) -> String: var shape := str(skill.get("area_shape", "single")).strip_edges().to_lower() if shape == "diamond": return shape return "single" func _skill_area_radius(skill: Dictionary) -> int: return maxi(0, int(skill.get("area_radius", 0))) func _skill_targets_for_cast(caster: Dictionary, skill_id: String, skill: Dictionary, target_cell: Vector2i, skip_existing_support := false) -> Array[Dictionary]: var result: Array[Dictionary] = [] var skill_kind := str(skill.get("kind", "damage")) for cell in _skill_area_cells(target_cell, skill): var target := get_unit_at(cell) if not _skill_target_valid(caster, target, skill): continue if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: continue if skill_kind == "support" and skip_existing_support and _has_status_effect_from_skill(target, skill_id): continue result.append(target) return result func _skill_range_pair(skill: Dictionary) -> Dictionary: var value = skill.get("range", 1) if typeof(value) == TYPE_ARRAY: if value.size() >= 2: return _normalized_skill_range(int(value[0]), int(value[1])) if value.size() == 1: return _normalized_skill_range(int(value[0]), int(value[0])) return _normalized_skill_range(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_skill_range(1, 1) var scalar: int = maxi(0, int(value)) return _normalized_skill_range(scalar, scalar) func _normalized_skill_range(min_range: int, max_range: int) -> Dictionary: var safe_min: int = maxi(0, min_range) var safe_max: int = maxi(safe_min, max_range) return {"min": safe_min, "max": safe_max} func _unit_has_skill(unit: Dictionary, skill_id: String) -> bool: if typeof(unit.get("skills", [])) != TYPE_ARRAY: return false for known_skill_id in unit.get("skills", []): if str(known_skill_id) == skill_id: return true return false func _skill_target_valid(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: if target.is_empty() or not target.get("alive", false): return false var target_rule := str(skill.get("target", "enemy")) if target_rule == "ally": return target.get("team", "") == caster.get("team", "") if target_rule == "self": return target.get("id", "") == caster.get("id", "") if target_rule == "any": return true return target.get("team", "") != caster.get("team", "") func _item_target_cells_from(unit: Dictionary, item: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] var range_pair := _item_range_pair(item) var max_range := int(range_pair["max"]) var min_range := int(range_pair["min"]) var origin: Vector2i = unit["pos"] for y in range(origin.y - max_range, origin.y + max_range + 1): for x in range(origin.x - max_range, origin.x + max_range + 1): var cell := Vector2i(x, y) if not is_inside(cell): continue var distance := _manhattan(origin, cell) if distance >= min_range and distance <= max_range: result.append(cell) return result func _item_range_pair(item: Dictionary) -> Dictionary: var value = item.get("range", [0, 1]) if typeof(value) == TYPE_ARRAY: if value.size() >= 2: return _normalized_skill_range(int(value[0]), int(value[1])) if value.size() == 1: return _normalized_skill_range(int(value[0]), int(value[0])) return _normalized_skill_range(0, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_skill_range(0, 1) var scalar: int = maxi(0, int(value)) return _normalized_skill_range(scalar, scalar) func _item_target_valid(user: Dictionary, target: Dictionary, item: Dictionary) -> bool: if target.is_empty() or not target.get("alive", false): return false var target_rule := str(item.get("target", "ally")) if target_rule == "ally": return target.get("team", "") == user.get("team", "") if target_rule == "self": return target.get("id", "") == user.get("id", "") if target_rule == "any": return true return target.get("team", "") != user.get("team", "") func _apply_item_effects(user: Dictionary, target: Dictionary, item: Dictionary) -> bool: var applied := 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": var healed := _apply_item_heal(target, int(effect.get("amount", 0))) if healed > 0: applied = true _emit_log("%s의 병력 %d 회복." % [target["name"], healed]) _emit_combat_feedback(target, "+%d 병력" % healed, "heal") elif effect_type == "heal_mp": var restored := _apply_item_mp_heal(target, int(effect.get("amount", 0))) if restored > 0: applied = true _emit_log("%s의 기력 %d 회복." % [target["name"], restored]) _emit_combat_feedback(target, "+%d 기력" % restored, "mp") elif effect_type == "cure_status": var cured_statuses := _apply_item_status_cure(target, str(effect.get("status", ""))) if not cured_statuses.is_empty(): applied = true _emit_log("%s의 %s 해소." % [target["name"], _join_strings(cured_statuses, ", ")]) _emit_combat_feedback(target, "해소", "support") elif effect_type == "cleanse_debuffs": var cleansed_debuffs := _apply_item_debuff_cleanse(target) if not cleansed_debuffs.is_empty(): applied = true _emit_log("%s의 약화 해소: %s." % [target["name"], _join_strings(cleansed_debuffs, ", ")]) _emit_combat_feedback(target, "정화", "support") if not applied: _emit_log("%s은 %s에게 효험이 없었다." % [str(item.get("name", "도구")), target["name"]]) return applied func _apply_item_heal(target: Dictionary, amount: int) -> int: if amount <= 0: return 0 var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) var healed = min(amount, max(0, missing_hp)) target["hp"] = min(int(target.get("max_hp", 1)), int(target.get("hp", 0)) + healed) return healed func _apply_item_mp_heal(target: Dictionary, amount: int) -> int: if amount <= 0: return 0 var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0)) var restored = min(amount, max(0, missing_mp)) target["mp"] = min(int(target.get("max_mp", 0)), int(target.get("mp", 0)) + restored) return restored func _apply_item_status_cure(target: Dictionary, status: String) -> Array: var cured_statuses := [] if typeof(target.get("status_effects", [])) != TYPE_ARRAY: return cured_statuses var normalized_status := status.strip_edges().to_lower() var kept_effects := [] for effect in _active_status_effects(target): var effect_status := str(effect.get("status", "")).strip_edges().to_lower() var should_cure := not effect_status.is_empty() and (normalized_status.is_empty() or effect_status == normalized_status) if should_cure: var display_name := _status_display_name(str(effect.get("status", normalized_status))) if not cured_statuses.has(display_name): cured_statuses.append(display_name) continue kept_effects.append(effect) target["status_effects"] = kept_effects return cured_statuses func _apply_item_debuff_cleanse(target: Dictionary) -> Array: var cleansed_debuffs := [] if typeof(target.get("status_effects", [])) != TYPE_ARRAY: return cleansed_debuffs var kept_effects := [] for effect in _active_status_effects(target): if _is_negative_stat_bonus_effect(effect): var display_name := _stat_debuff_display_name(effect) if not cleansed_debuffs.has(display_name): cleansed_debuffs.append(display_name) continue kept_effects.append(effect) target["status_effects"] = kept_effects return cleansed_debuffs func _item_hp_heal_amount(target: Dictionary, item: Dictionary) -> int: var total := 0 for effect in item.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) == "heal_hp": total += int(effect.get("amount", 0)) var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) return max(0, min(total, missing_hp)) func _item_mp_heal_amount(target: Dictionary, item: Dictionary) -> int: var total := 0 for effect in item.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) == "heal_mp": total += int(effect.get("amount", 0)) var missing_mp := int(target.get("max_mp", 0)) - int(target.get("mp", 0)) return max(0, min(total, missing_mp)) func _item_curable_status_names(target: Dictionary, item: Dictionary) -> Array: var result := [] if target.is_empty(): return result for effect in item.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue if str(effect.get("type", "")) != "cure_status": continue var status := str(effect.get("status", "")).strip_edges().to_lower() for active_effect in _active_status_effects(target): var active_status := str(active_effect.get("status", "")).strip_edges().to_lower() if active_status.is_empty(): continue if not status.is_empty() and active_status != status: continue var display_name := _status_display_name(str(active_effect.get("status", status))) if not result.has(display_name): result.append(display_name) return result func _item_cleansable_debuff_names(target: Dictionary, item: Dictionary) -> Array: var result := [] if target.is_empty() or not _item_has_effect_type(item, "cleanse_debuffs"): return result for active_effect in _active_status_effects(target): if not _is_negative_stat_bonus_effect(active_effect): continue var display_name := _stat_debuff_display_name(active_effect) if not result.has(display_name): result.append(display_name) return result func _item_has_effect_type(item: Dictionary, effect_type: String) -> bool: for effect in item.get("effects", []): if typeof(effect) == TYPE_DICTIONARY and str(effect.get("type", "")) == effect_type: return true return false func _is_negative_stat_bonus_effect(effect: Dictionary) -> bool: if str(effect.get("type", "")) != "stat_bonus": return false var stat := str(effect.get("stat", "")) return _is_supported_status_stat(stat) and int(effect.get("amount", 0)) < 0 func _stat_debuff_display_name(effect: Dictionary) -> String: var stat := str(effect.get("stat", "stat")) if not _is_supported_status_stat(stat): return "약화" return "%s 저하" % _status_stat_display_name(stat) func _equipment_slot_for_item(item: Dictionary) -> String: var kind := str(item.get("kind", "")) if kind == "weapon" or kind == "armor" or kind == "accessory": return kind return "" func _normalized_equipment_slot(slot: String) -> String: var normalized := slot.strip_edges().to_lower() if normalized == "weapon" or normalized == "armor" or normalized == "accessory": return normalized return "" func _equipment_slot_display_name(slot: String) -> String: var normalized := _normalized_equipment_slot(slot) if normalized == "weapon": return "무기" if normalized == "armor": return "갑옷" if normalized == "accessory": return "보물" return "병장" func _equipment_item_id_for_slot(equipment: Dictionary, slot: String) -> String: var item_id = equipment.get(slot, "") if item_id == null: return "" return str(item_id) func _unit_can_equip_item(unit: Dictionary, item: Dictionary) -> bool: var slot := _equipment_slot_for_item(item) if slot.is_empty(): return false var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) var equipment_slots: Dictionary = class_def.get("equipment_slots", {}) var allowed = equipment_slots.get(slot, []) if typeof(allowed) != TYPE_ARRAY: return false if slot == "weapon": return allowed.has(str(item.get("weapon_type", ""))) if slot == "armor": return allowed.has(str(item.get("armor_type", ""))) if slot == "accessory": var accessory_type := str(item.get("accessory_type", "accessory")) return allowed.has(accessory_type) return false func _apply_equipment_stat_delta(unit: Dictionary, old_item: Dictionary, new_item: Dictionary) -> void: var old_bonuses: Dictionary = old_item.get("bonuses", {}) if not old_item.is_empty() else {} var new_bonuses: Dictionary = new_item.get("bonuses", {}) if not new_item.is_empty() else {} _apply_stat_bonus_delta(unit, old_bonuses, new_bonuses) func _apply_stat_bonus_delta(unit: Dictionary, old_bonuses: Dictionary, new_bonuses: Dictionary) -> void: for stat in ["hp", "mp", "atk", "def", "int", "agi"]: var delta := int(new_bonuses.get(stat, 0)) - int(old_bonuses.get(stat, 0)) if delta == 0: continue if stat == "hp": unit["max_hp"] = max(1, int(unit.get("max_hp", 1)) + delta) unit["hp"] = clampi(int(unit.get("hp", 1)) + delta, 1, int(unit["max_hp"])) elif stat == "mp": unit["max_mp"] = max(0, int(unit.get("max_mp", 0)) + delta) unit["mp"] = clampi(int(unit.get("mp", 0)) + delta, 0, int(unit["max_mp"])) elif stat == "atk": unit[stat] = max(1, int(unit.get(stat, 1)) + delta) else: unit[stat] = max(0, int(unit.get(stat, 0)) + delta) func _apply_class_runtime_fields(unit: Dictionary, class_id: String) -> void: var class_def := data_catalog.get_class_def(class_id) if class_def.is_empty(): return unit["class"] = str(class_def.get("name", class_id.capitalize())) unit["move"] = int(class_def.get("move", unit.get("move", 3))) unit["move_type"] = str(class_def.get("move_type", unit.get("move_type", "foot"))) unit["growth"] = class_def.get("growth", {}).duplicate(true) _add_missing_skills(unit, class_def.get("skills", [])) _refresh_attack_range_from_equipment(unit) func _refresh_attack_range_from_equipment(unit: Dictionary) -> void: var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) var range_pair := _equipment_attack_range_pair(class_def, unit.get("equipment", {})) unit["min_range"] = int(range_pair["min"]) unit["range"] = int(range_pair["max"]) func _equipment_attack_range_pair(class_def: Dictionary, equipment: Dictionary) -> Dictionary: var attack_range := _attack_range_pair_from_value(class_def.get("attack_range", 1)) for slot in equipment.keys(): var item_id = equipment[slot] if item_id == null: continue var item := get_item_def(str(item_id)) if item.has("range"): var item_range := _attack_range_pair_from_value(item["range"]) attack_range["min"] = min(int(attack_range["min"]), int(item_range["min"])) attack_range["max"] = max(int(attack_range["max"]), int(item_range["max"])) return attack_range func _attack_range_pair_from_value(value) -> Dictionary: if typeof(value) == TYPE_ARRAY: if value.size() >= 2: return _normalized_attack_range_pair(int(value[0]), int(value[1])) if value.size() == 1: return _normalized_attack_range_pair(int(value[0]), int(value[0])) return _normalized_attack_range_pair(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_attack_range_pair(1, 1) var scalar: int = maxi(1, int(value)) return _normalized_attack_range_pair(scalar, scalar) func _normalized_attack_range_pair(min_range: int, max_range: int) -> Dictionary: var safe_min: int = maxi(1, min_range) var safe_max: int = maxi(safe_min, max_range) return {"min": safe_min, "max": safe_max} func _run_enemy_turn() -> void: for enemy in get_living_units(TEAM_ENEMY): if battle_status != STATUS_ACTIVE: break if enemy.get("acted", false): continue _enemy_take_action(enemy) enemy["acted"] = true enemy["moved"] = true _check_battle_status() if battle_status == STATUS_ACTIVE: turn_number += 1 current_team = TEAM_PLAYER _emit_log("아군 차례가 열렸습니다.") _reset_team_actions(TEAM_PLAYER) if battle_status == STATUS_ACTIVE: _run_events("turn_start", current_team, turn_number) _check_battle_status() _notify_changed() func _enemy_take_action(enemy: Dictionary) -> void: if _try_enemy_skill_action(enemy): return var attack_action := _best_ai_physical_attack_action(enemy, [enemy["pos"]]) if not attack_action.is_empty(): _resolve_combat(enemy, attack_action["target"]) return var best_cell: Vector2i = enemy["pos"] var move_attack_action := _best_ai_physical_attack_action(enemy, _movement_range_for_unit(enemy)) if not move_attack_action.is_empty(): best_cell = move_attack_action["origin"] else: var target := _find_nearest_target(enemy, TEAM_PLAYER) if target.is_empty(): return var best_distance := _manhattan(best_cell, target["pos"]) for cell in _movement_range_for_unit(enemy): var distance := _manhattan(cell, target["pos"]) if distance < best_distance: best_distance = distance best_cell = cell if best_cell != enemy["pos"]: var from_cell: Vector2i = enemy["pos"] enemy["pos"] = best_cell enemy["moved"] = true unit_motion_requested.emit(str(enemy.get("id", "")), from_cell, best_cell) _emit_log("%s, %s으로 압박." % [enemy["name"], _format_cell(best_cell)]) _run_events("unit_reaches_tile", str(enemy.get("team", "")), turn_number, enemy) if battle_status != STATUS_ACTIVE: return if _try_enemy_skill_action(enemy): return attack_action = _best_ai_physical_attack_action(enemy, [enemy["pos"]]) if not attack_action.is_empty(): _resolve_combat(enemy, attack_action["target"]) func _best_ai_physical_attack_action(attacker: Dictionary, origins: Array[Vector2i]) -> Dictionary: var best_action := {} var best_score := -999999 var target_team := _opposing_team(str(attacker.get("team", ""))) if target_team.is_empty() or _unit_has_action_lock(attacker, "attack"): return {} for origin in origins: if not is_inside(origin): continue for target in get_living_units(target_team): if not _attack_cells_from(attacker, origin).has(target["pos"]): continue var damage := calculate_damage(attacker, target) var score := _physical_attack_ai_score(attacker, target, damage) score -= _manhattan(attacker["pos"], origin) * 2 if score > best_score: best_score = score best_action = { "origin": origin, "target": target, "damage": damage, "score": score } return best_action func _physical_attack_ai_score(attacker: Dictionary, target: Dictionary, damage: int) -> int: var score := damage * 10 score += _weapon_effectiveness_bonus(attacker, target) * 6 score += _ai_target_priority_score(target) if int(target.get("hp", 0)) - damage <= 0: score += 1000 return score func _try_enemy_skill_action(enemy: Dictionary) -> bool: if enemy.is_empty() or enemy.get("acted", false): return false if typeof(enemy.get("skills", [])) != TYPE_ARRAY: return false if _unit_has_action_lock(enemy, "skill"): return false var finishing_action := _best_ai_damage_skill_action(enemy, true) if not finishing_action.is_empty() and _execute_ai_skill_action(enemy, finishing_action): return true var heal_action := _best_ai_heal_skill_action(enemy) if not heal_action.is_empty() and _execute_ai_skill_action(enemy, heal_action): return true var support_action := _best_ai_support_skill_action(enemy) if not support_action.is_empty() and _execute_ai_skill_action(enemy, support_action): return true var damage_action := _best_ai_damage_skill_action(enemy, false) if not damage_action.is_empty() and _execute_ai_skill_action(enemy, damage_action): return true return false func _best_ai_heal_skill_action(caster: Dictionary) -> Dictionary: var best_action := {} var best_score := -1 for skill_id in caster.get("skills", []): var normalized_skill_id := str(skill_id) var skill := get_skill_def(normalized_skill_id) if skill.is_empty() or str(skill.get("kind", "")) != "heal": continue if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue for target_cell in _skill_cells_from(caster, skill): var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell) if targets.is_empty(): continue var total_heal := 0 var total_missing_hp := 0 var lowest_hp_ratio := 1.0 for ally in targets: var heal_amount := calculate_skill_heal(caster, ally, skill) var missing_hp := int(ally.get("max_hp", 1)) - int(ally.get("hp", 0)) var hp_ratio := float(ally.get("hp", 0)) / float(max(1, int(ally.get("max_hp", 1)))) total_heal += heal_amount total_missing_hp += missing_hp lowest_hp_ratio = minf(lowest_hp_ratio, hp_ratio) if lowest_hp_ratio > 0.5 and total_missing_hp < 10: continue var score := total_heal * 10 + total_missing_hp + targets.size() * 4 score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": targets[0], "target_cell": target_cell } return best_action func _best_ai_support_skill_action(caster: Dictionary) -> Dictionary: var best_action := {} var best_score := -1 for skill_id in caster.get("skills", []): var normalized_skill_id := str(skill_id) var skill := get_skill_def(normalized_skill_id) if skill.is_empty() or str(skill.get("kind", "")) != "support": continue if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue if not _skill_has_support_effects(skill): continue for target_cell in _skill_cells_from(caster, skill): var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell, true) if targets.is_empty(): continue var score := 0 for target in targets: score += _support_ai_score(caster, target, skill) if score <= 0: continue score += targets.size() * 3 score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": targets[0], "target_cell": target_cell } return best_action func _support_ai_targets_for_skill(caster: Dictionary, skill: Dictionary) -> Array[Dictionary]: var target_rule := str(skill.get("target", "ally")) if target_rule == "self": return [caster] if target_rule == "enemy": var target_team: String = _opposing_team(str(caster.get("team", ""))) return get_living_units(target_team) if not target_team.is_empty() else [] if target_rule == "any": var targets: Array[Dictionary] = get_living_units(str(caster.get("team", ""))) var opposing_team: String = _opposing_team(str(caster.get("team", ""))) if not opposing_team.is_empty(): targets.append_array(get_living_units(opposing_team)) return targets return get_living_units(str(caster.get("team", ""))) func _support_ai_score(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var score: int = 0 var target_is_ally: bool = target.get("team", "") == caster.get("team", "") for effect in skill.get("effects", []): if typeof(effect) != TYPE_DICTIONARY: continue var effect_type := str(effect.get("type", "")) if effect_type == "stat_bonus": var stat := str(effect.get("stat", "")) var amount := int(effect.get("amount", 0)) if not _is_supported_status_stat(stat) or amount == 0: continue if amount > 0 and target_is_ally: score += amount * _support_ai_stat_weight(stat) if target.get("id", "") == caster.get("id", ""): score += 3 elif amount < 0 and not target_is_ally: score += absi(amount) * _support_ai_stat_weight(stat) score += _ai_target_priority_score(target) elif effect_type == "damage_over_time" and not target_is_ally: var damage := int(effect.get("amount", 0)) if damage <= 0: continue var duration: int = max(1, int(effect.get("duration_turns", 1))) score += damage * duration * 5 score += _ai_target_priority_score(target) elif effect_type == "action_lock" and not target_is_ally: var action := str(effect.get("action", "")) if _unit_has_action_lock(target, action): continue var threat_score := _unit_action_threat_score(target, action) if threat_score <= 0: continue score += 18 + threat_score score += _ai_target_priority_score(target) return score func _support_ai_stat_weight(stat: String) -> int: if stat == "atk": return 9 if stat == "move": return 8 if stat == "def": return 7 if stat == "int": return 6 if stat == "agi": return 4 return 1 func _unit_skill_threat_score(unit: Dictionary) -> int: if typeof(unit.get("skills", [])) != TYPE_ARRAY: return 0 if int(unit.get("mp", 0)) <= 0: return 0 var score := 0 for skill_id in unit.get("skills", []): var skill := get_skill_def(str(skill_id)) if skill.is_empty(): continue if int(unit.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue var kind := str(skill.get("kind", "damage")) if kind == "damage": score += 12 elif kind == "heal": score += 9 elif kind == "support": score += 7 return score func _unit_action_threat_score(unit: Dictionary, action: String) -> int: if action == "skill": return _unit_skill_threat_score(unit) if action == "move": var move_score := int(unit.get("move", 0)) * 5 if int(unit.get("range", 1)) <= 1: move_score += 10 return move_score if action == "attack": return _effective_stat(unit, "atk") * 4 + int(unit.get("range", 1)) * 4 return 0 func _best_ai_damage_skill_action(caster: Dictionary, defeating_only := false) -> Dictionary: var best_action := {} var best_score := -1 var target_team := _opposing_team(str(caster.get("team", ""))) if target_team.is_empty(): return {} for skill_id in caster.get("skills", []): var normalized_skill_id := str(skill_id) var skill := get_skill_def(normalized_skill_id) if skill.is_empty() or str(skill.get("kind", "")) != "damage": continue if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): continue for target_cell in _skill_cells_from(caster, skill): var targets := _skill_targets_for_cast(caster, normalized_skill_id, skill, target_cell) if targets.is_empty(): continue var total_damage := 0 var defeat_count := 0 var priority_score := 0 for target in targets: var damage := calculate_skill_damage(caster, target, skill) total_damage += damage priority_score += _ai_target_priority_score(target) if int(target.get("hp", 0)) - damage <= 0: defeat_count += 1 if defeating_only and defeat_count <= 0: continue var score := total_damage + priority_score + targets.size() * 5 if defeat_count > 0: score += 1000 * defeat_count score -= _manhattan(caster["pos"], target_cell) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": targets[0], "target_cell": target_cell } return best_action func _execute_ai_skill_action(caster: Dictionary, action: Dictionary) -> bool: var skill_id := str(action.get("skill_id", "")) var skill: Dictionary = action.get("skill", {}) var target: Dictionary = action.get("target", {}) if skill_id.is_empty() or skill.is_empty(): return false if not _unit_has_skill(caster, skill_id): return false if _unit_has_action_lock(caster, "skill"): return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): return false var target_cell: Vector2i = action.get("target_cell", target.get("pos", Vector2i(-999, -999))) if not is_inside(target_cell): return false var skill_kind := str(skill.get("kind", "damage")) var skip_existing_support := skill_kind == "support" var targets := _skill_targets_for_cast(caster, skill_id, skill, target_cell, skip_existing_support) if targets.is_empty(): return false if not _skill_cells_from(caster, skill).has(target_cell): return false if skill_kind == "support" and not _skill_has_support_effects(skill): return false _emit_skill_motion(caster, target_cell, skill_kind) caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var total_healed := 0 for area_target in targets: total_healed += _resolve_skill_heal(caster, area_target, skill) if total_healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": var applied_support := false for area_target in targets: applied_support = _resolve_skill_support(caster, area_target, skill_id, skill) or applied_support if applied_support: _grant_experience(caster, EXP_SUPPORT) else: var defeated_count := 0 for area_target in targets: if _resolve_skill_damage(caster, area_target, skill): defeated_count += 1 _grant_experience(caster, EXP_SKILL + defeated_count * EXP_DEFEAT_BONUS) _emit_log("%s 기력 %d 소모." % [caster["name"], int(skill.get("mp_cost", 0))]) return true func _find_nearest_target(unit: Dictionary, target_team: String) -> Dictionary: var best_target := {} var best_score := 9999 for target in get_living_units(target_team): var distance := _manhattan(unit["pos"], target["pos"]) var score := distance - int(target.get("ai_target_priority", 0)) if score < best_score: best_score = score best_target = target return best_target func _ai_target_priority_score(target: Dictionary) -> int: return max(0, int(target.get("ai_target_priority", 0))) * 8 func _opposing_team(team: String) -> String: if team == TEAM_PLAYER: return TEAM_ENEMY if team == TEAM_ENEMY: return TEAM_PLAYER return "" func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void: var attack_result := _resolve_attack(attacker, target, false) var attack_exp := EXP_ATTACK if bool(attack_result.get("hit", false)) else EXP_MISS if bool(attack_result.get("defeated", false)) or battle_status != STATUS_ACTIVE: _grant_experience(attacker, attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0)) return var counter_exp := 0 if bool(target.get("controllable", true)) and _is_in_attack_range(target, attacker["pos"]): var counter_result := _resolve_attack(target, attacker, true) counter_exp = (EXP_COUNTER if bool(counter_result.get("hit", false)) else EXP_MISS) + (EXP_DEFEAT_BONUS if bool(counter_result.get("defeated", false)) else 0) _grant_experience(attacker, attack_exp) _grant_experience(target, counter_exp) func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary: var hit_chance := calculate_hit_chance(attacker, target) var verb := "반격" if is_counter else "공격" unit_action_motion_requested.emit( str(attacker.get("id", "")), attacker.get("pos", Vector2i(-1, -1)), target.get("pos", Vector2i(-1, -1)), "counter" if is_counter else "attack" ) if rng.randi_range(1, 100) > hit_chance: _emit_log("%s %s %s, 헛쳤다. 명중 %d%%." % [attacker["name"], verb, target["name"], hit_chance]) _emit_combat_feedback(target, "헛침", "miss") return {"hit": false, "defeated": false} var damage := calculate_damage(attacker, target) target["hp"] = max(0, int(target["hp"]) - damage) var effective_bonus := _weapon_effectiveness_bonus(attacker, target) var effective_text := "" if effective_bonus > 0: effective_text = " %s 특효 +%d." % [_format_move_type(_weapon_effectiveness_target_type(target)), effective_bonus] _emit_log("%s %s %s, %d 피해. 명중 %d%%.%s" % [attacker["name"], verb, target["name"], damage, hit_chance, effective_text]) _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s 퇴각." % target["name"]) _emit_combat_feedback(target, "퇴각", "defeat") _handle_unit_defeated(target) return {"hit": true, "defeated": true} return {"hit": true, "defeated": false} func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: var damage := calculate_skill_damage(caster, target, skill) target["hp"] = max(0, int(target["hp"]) - damage) _emit_log("%s, %s을 펼쳐 %s에게 %d 피해." % [ caster["name"], skill.get("name", "책략"), target["name"], damage ]) _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s 퇴각." % target["name"]) _emit_combat_feedback(target, "퇴각", "defeat") _handle_unit_defeated(target) return true return false func _emit_skill_motion(caster: Dictionary, target_cell: Vector2i, skill_kind: String) -> void: if caster.is_empty() or not is_inside(target_cell): return unit_action_motion_requested.emit( str(caster.get("id", "")), caster.get("pos", Vector2i(-1, -1)), target_cell, "skill_%s" % skill_kind ) func _handle_unit_defeated(unit: Dictionary) -> void: if unit.is_empty(): return _run_events("unit_defeated", str(unit.get("team", "")), turn_number, unit) func _resolve_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var healed := calculate_skill_heal(caster, target, skill) target["hp"] = min(int(target["max_hp"]), int(target["hp"]) + healed) _emit_log("%s, %s으로 %s의 병력 %d 회복." % [ caster["name"], skill.get("name", "책략"), target["name"], healed ]) _emit_combat_feedback(target, "+%d 병력" % healed, "heal") return healed func _grant_experience(unit: Dictionary, amount: int) -> void: if unit.is_empty() or not unit.get("alive", false) or amount <= 0: return unit["exp"] = int(unit.get("exp", 0)) + amount _emit_log("%s 공적 +%d." % [unit["name"], amount]) while int(unit["exp"]) >= EXP_LEVEL: unit["exp"] = int(unit["exp"]) - EXP_LEVEL _level_up(unit) func _level_up(unit: Dictionary) -> void: var previous_level := int(unit.get("level", 1)) unit["level"] = int(unit.get("level", 1)) + 1 var hp_gain := _growth_gain(unit, "hp", 3) var mp_gain := _growth_gain(unit, "mp", 1) unit["max_hp"] = int(unit.get("max_hp", 1)) + hp_gain unit["hp"] = min(int(unit["max_hp"]), int(unit.get("hp", 1)) + hp_gain) unit["max_mp"] = int(unit.get("max_mp", 0)) + mp_gain unit["mp"] = min(int(unit["max_mp"]), int(unit.get("mp", 0)) + mp_gain) for stat in ["atk", "def", "int", "agi"]: unit[stat] = int(unit.get(stat, 0)) + _growth_gain(unit, stat, 1) _emit_log("%s 품계 %d에 이르렀다." % [unit["name"], unit["level"]]) _emit_combat_feedback(unit, "품계 %d" % int(unit["level"]), "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "level_up", "unit_id": str(unit.get("id", "")), "officer_id": str(unit.get("officer_id", "")), "name": str(unit.get("name", "Unit")), "from_level": previous_level, "to_level": int(unit.get("level", previous_level + 1)), "class_id": str(unit.get("class_id", "")), "class": str(unit.get("class", "")) }) _try_promote_unit(unit) func _try_promote_unit(unit: Dictionary) -> void: var class_id := str(unit.get("class_id", "")) var class_def := data_catalog.get_class_def(class_id) if class_def.is_empty(): return var promotion = class_def.get("promotion", {}) if typeof(promotion) != TYPE_DICTIONARY: return var required_level := int(promotion.get("level", 0)) var next_class_id := str(promotion.get("to", "")) if required_level <= 0 or next_class_id.is_empty() or int(unit.get("level", 1)) < required_level: return var next_class_def := data_catalog.get_class_def(next_class_id) if next_class_def.is_empty(): return var previous_class_id := class_id var previous_class_name := str(class_def.get("name", class_id.capitalize())) _apply_stat_bonus_delta(unit, class_def.get("base_bonus", {}), next_class_def.get("base_bonus", {})) unit["class_id"] = next_class_id _apply_class_runtime_fields(unit, next_class_id) _emit_log("%s, %s으로 승급하였다." % [unit["name"], unit["class"]]) _emit_combat_feedback(unit, "승급", "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "promotion", "unit_id": str(unit.get("id", "")), "officer_id": str(unit.get("officer_id", "")), "name": str(unit.get("name", "Unit")), "level": int(unit.get("level", 1)), "from_class_id": previous_class_id, "from_class": previous_class_name, "to_class_id": next_class_id, "to_class": str(unit.get("class", next_class_id.capitalize())) }) func _add_missing_skills(unit: Dictionary, source) -> void: if typeof(source) != TYPE_ARRAY: return if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: unit["skills"] = [] for skill_id in source: var normalized := str(skill_id) if normalized.is_empty() or unit["skills"].has(normalized): continue unit["skills"].append(normalized) func _record_progression_event(event: Dictionary) -> void: if str(event.get("name", "")).is_empty() or str(event.get("type", "")).is_empty(): return progression_events.append(event.duplicate(true)) func _growth_gain(unit: Dictionary, stat: String, fallback: int) -> int: var growth: Dictionary = unit.get("growth", {}) var grade := str(growth.get(stat, "C")) var table: Dictionary = GROWTH_GRADE_GAINS.get(stat, {}) var gain := int(table.get(grade, fallback)) var bonus: Dictionary = unit.get("growth_bonus", {}) return max(0, gain + int(bonus.get(stat, 0))) func _check_battle_status() -> void: if _is_condition_group_met(battle_conditions.get("defeat", {})): battle_status = STATUS_DEFEAT current_team = TEAM_PLAYER selected_unit_id = "" _emit_log("패전의 조짐이 굳어졌다.") elif _is_condition_group_met(battle_conditions.get("victory", {})): battle_status = STATUS_VICTORY current_team = TEAM_PLAYER selected_unit_id = "" _emit_log("승전의 군령이 이루어졌다.") func _is_condition_group_met(condition_group) -> bool: if typeof(condition_group) == TYPE_ARRAY: for condition in condition_group: if _is_condition_met(condition): return true return false return _is_condition_met(condition_group) func _is_condition_met(condition) -> bool: if typeof(condition) != TYPE_DICTIONARY: return false if not _condition_gate_open(condition): return false var condition_type := str(condition.get("type", "")) if condition_type == "all": return _all_conditions_met(condition.get("conditions", [])) if condition_type == "any": return _is_condition_group_met(condition.get("conditions", [])) if condition_type == "all_units_defeated": return get_living_units(str(condition.get("team", ""))).is_empty() if condition_type == "all_enemies_defeated": return get_living_units(TEAM_ENEMY).is_empty() if condition_type == "all_players_defeated": return get_living_units(TEAM_PLAYER).is_empty() if condition_type == "any_unit_defeated": return _any_unit_defeated(condition.get("unit_ids", [])) if condition_type == "any_officer_defeated": return _any_officer_defeated(condition.get("officer_ids", []), str(condition.get("team", ""))) if condition_type == "unit_reaches_tile": return _unit_reaches_tile(condition) if condition_type == "turn_limit": return _is_turn_limit_exceeded(condition) if condition_type == "turn_reached": return _is_turn_reached(condition) return false func _append_condition_progress_lines(condition_group, result: Array[String], mode: String, pending_parent := false) -> void: if typeof(condition_group) == TYPE_ARRAY: for condition in condition_group: _append_condition_progress_lines(condition, result, mode, pending_parent) return if typeof(condition_group) != TYPE_DICTIONARY: return var pending := pending_parent or not _condition_gate_open(condition_group) if not _condition_gate_open(condition_group): _append_after_event_progress_line(result, str(condition_group.get("after_event", ""))) var condition_type := str(condition_group.get("type", "")) if condition_type == "all" or condition_type == "any": _append_condition_progress_lines(condition_group.get("conditions", []), result, mode, pending) return if condition_type == "all_units_defeated": _append_all_units_progress_line(result, str(condition_group.get("team", "")), mode, pending) elif condition_type == "all_enemies_defeated": _append_all_units_progress_line(result, TEAM_ENEMY, mode, pending) elif condition_type == "all_players_defeated": _append_all_units_progress_line(result, TEAM_PLAYER, mode, pending) elif condition_type == "any_unit_defeated": _append_target_units_progress_line(result, condition_group.get("unit_ids", []), mode, pending) elif condition_type == "any_officer_defeated": _append_target_officers_progress_line( result, condition_group.get("officer_ids", []), str(condition_group.get("team", "")), mode, pending ) elif condition_type == "unit_reaches_tile": _append_destination_progress_line(result, condition_group, pending) elif condition_type == "turn_limit" or condition_type == "turn_reached": _append_turn_condition_progress_line(result, condition_group, mode, pending) func _append_all_units_progress_line(result: Array[String], team: String, mode: String, pending := false) -> void: var total := _count_deployed_units(team) if total <= 0: return var living := _count_living_deployed_units(team) if mode == "defeat": _append_progress_line(result, "%s 잔존 %d/%d" % [_condition_team_label(team), living, total], pending) return _append_progress_line(result, "%s 격파 %d/%d" % [_condition_team_label(team), total - living, total], pending) func _append_target_units_progress_line(result: Array[String], unit_ids, mode: String, pending := false) -> void: var counts := _count_condition_units(unit_ids) var total := int(counts.get("total", 0)) if total <= 0: return var defeated := int(counts.get("defeated", 0)) if mode == "defeat" and total == 1: var unit_name := _condition_unit_display_name(unit_ids[0]) var status := "퇴각" if defeated > 0 else "건재" _append_progress_line(result, "%s %s" % [unit_name, status], pending) return var label := "호위 손실" if mode == "defeat" else "격파 표식" var suffix := " · 하나라도 잃으면 패전" if mode == "defeat" else "" _append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending) func _append_target_officers_progress_line( result: Array[String], officer_ids, team: String, mode: String, pending := false ) -> void: var counts := _count_condition_officers(officer_ids, team) var total := int(counts.get("total", 0)) if total <= 0: return var defeated := int(counts.get("defeated", 0)) if mode == "defeat" and total == 1: var officer_name := _condition_officer_display_name(str(officer_ids[0]), team) var status := "퇴각" if defeated > 0 else "건재" _append_progress_line(result, "%s %s" % [officer_name, status], pending) return var label := "장수 손실" if mode == "defeat" else "대상 장수 격파" var suffix := " · 하나라도 잃으면 패전" if mode == "defeat" else "" _append_progress_line(result, "%s %d/%d%s" % [label, defeated, total, suffix], pending) func _append_destination_progress_line(result: Array[String], condition: Dictionary, pending := false) -> void: var targets := _condition_cells(condition) if targets.is_empty(): return var status := "도달" if _unit_reaches_tile(condition) else "미도달" var label := _condition_destination_label(condition) _append_progress_line(result, "%s %s · %s" % [label, _format_cells(targets), status], pending) func _append_turn_limit_progress_line(result: Array[String]) -> void: var limit := get_turn_limit() if limit <= 0: return var turns_left: int = maxi(0, limit - turn_number + 1) _append_progress_line(result, "잔여 군령 %d" % turns_left) func _append_after_event_progress_line(result: Array[String], event_id: String) -> void: if event_id.is_empty() or fired_event_ids.has(event_id): return var event := _event_by_id(event_id) if event.is_empty(): return var when: Dictionary = event.get("when", {}) var trigger_type := str(when.get("type", "")) if trigger_type == "unit_reaches_tile": _append_event_destination_progress_line(result, when) elif trigger_type == "turn_start": var target_turn := int(when.get("turn", 0)) if target_turn > 0: _append_progress_line(result, "제%d군령에 전공 갱신" % target_turn) func _append_event_destination_progress_line(result: Array[String], when: Dictionary) -> void: var targets := _condition_cells(when) if targets.is_empty(): return var status := "도달" if _unit_reaches_tile(when) else "미도달" var label := _condition_destination_label(when) _append_progress_line(result, "%s %s · %s" % [label, _format_cells(targets), status]) func _condition_destination_label(condition: Dictionary) -> String: var label := str(condition.get("label", "")).strip_edges() if label.is_empty(): label = str(condition.get("objective_label", "")).strip_edges() if label.is_empty(): return "전장 표식" return label func _append_turn_condition_progress_line(result: Array[String], condition: Dictionary, mode: String, pending := false) -> void: var condition_type := str(condition.get("type", "")) if condition_type == "turn_limit": var limit := int(condition.get("turn", condition.get("limit", 0))) if limit <= 0: return _append_progress_line(result, "잔여 군령 %d" % maxi(0, limit - turn_number + 1), pending) elif condition_type == "turn_reached": var target_turn := int(condition.get("turn", 0)) if target_turn <= 0: return if mode == "defeat": var target_team := str(condition.get("team", "")) var limit_turn := target_turn - 1 if target_team == TEAM_PLAYER else target_turn _append_progress_line(result, "잔여 군령 %d" % maxi(0, limit_turn - turn_number + 1), pending) return _append_progress_line(result, "군령 제%d/%d" % [min(turn_number, target_turn), target_turn], pending) func _append_progress_line(result: Array[String], text: String, pending := false) -> void: if text.is_empty(): return if pending: text += " (대기)" if not result.has(text): result.append(text) func _count_deployed_units(team := "") -> int: var count := 0 for unit in units: if not unit.get("deployed", true): continue if not team.is_empty() and unit.get("team", "") != team: continue count += 1 return count func _count_living_deployed_units(team := "") -> int: var count := 0 for unit in units: if not unit.get("deployed", true): continue if not unit.get("alive", false): continue if not team.is_empty() and unit.get("team", "") != team: continue count += 1 return count func _count_condition_units(unit_ids) -> Dictionary: var result := {"total": 0, "defeated": 0} if typeof(unit_ids) != TYPE_ARRAY: return result var seen := {} for unit_id in unit_ids: var normalized_id := str(unit_id) if normalized_id.is_empty() or seen.has(normalized_id): continue seen[normalized_id] = true var unit := get_unit(normalized_id) if unit.is_empty() or not unit.get("deployed", true): continue result["total"] = int(result["total"]) + 1 if not unit.get("alive", false): result["defeated"] = int(result["defeated"]) + 1 return result func _count_condition_officers(officer_ids, team := "") -> Dictionary: var result := {"total": 0, "defeated": 0} if typeof(officer_ids) != TYPE_ARRAY: return result var seen := {} for officer_id in officer_ids: var normalized_id := str(officer_id) if normalized_id.is_empty() or seen.has(normalized_id): continue seen[normalized_id] = true if not _condition_officer_exists(normalized_id, team): continue result["total"] = int(result["total"]) + 1 if _is_officer_defeated(normalized_id, team): result["defeated"] = int(result["defeated"]) + 1 return result func _condition_officer_exists(officer_id: String, team := "") -> bool: if officer_id.is_empty(): return false for unit in units: if not team.is_empty() and unit.get("team", "") != team: continue if not unit.get("deployed", true): continue if str(unit.get("officer_id", "")) == officer_id: return true return false func _condition_unit_display_name(unit_id: String) -> String: var unit := get_unit(unit_id) if unit.is_empty(): return unit_id return str(unit.get("name", unit_id)) func _condition_officer_display_name(officer_id: String, team := "") -> String: for unit in units: if not team.is_empty() and unit.get("team", "") != team: continue if not unit.get("deployed", true): continue if str(unit.get("officer_id", "")) == officer_id: return str(unit.get("name", officer_id)) var officer: Dictionary = data_catalog.officers.get(officer_id, {}) return str(officer.get("name", officer_id)) func _condition_team_label(team: String) -> String: if team == TEAM_ENEMY: return "적군" if team == TEAM_PLAYER: return "아군" return "부대" func _event_by_id(event_id: String) -> Dictionary: if event_id.is_empty(): return {} for event in battle_events: if str(event.get("id", "")) == event_id: return event return {} func _is_turn_limit_exceeded(condition: Dictionary) -> bool: var limit := int(condition.get("turn", condition.get("limit", 0))) if limit <= 0: return false return turn_number > limit and current_team == TEAM_PLAYER func _is_turn_reached(condition: Dictionary) -> bool: var target_turn := int(condition.get("turn", 0)) if target_turn <= 0: return false var target_team := str(condition.get("team", "")) if not target_team.is_empty() and current_team != target_team: return false return turn_number >= target_turn func _unit_reaches_tile(condition: Dictionary) -> bool: var targets := _condition_cells(condition) if targets.is_empty(): return false var team := str(condition.get("team", "")) var unit_ids = condition.get("unit_ids", []) var officer_ids = condition.get("officer_ids", []) for unit in units: if not unit.get("deployed", true): continue if not unit.get("alive", false): continue if not team.is_empty() and unit.get("team", "") != team: continue if not _unit_matches_condition_ids(unit, unit_ids, officer_ids): continue if targets.has(unit.get("pos", Vector2i(-9999, -9999))): return true return false func _unit_matches_condition_ids(unit: Dictionary, unit_ids, officer_ids) -> bool: var has_unit_filter: bool = typeof(unit_ids) == TYPE_ARRAY and not unit_ids.is_empty() var has_officer_filter: bool = typeof(officer_ids) == TYPE_ARRAY and not officer_ids.is_empty() if not has_unit_filter and not has_officer_filter: return true var unit_id := str(unit.get("id", "")) if has_unit_filter: for allowed_unit_id in unit_ids: if unit_id == str(allowed_unit_id): return true var officer_id := str(unit.get("officer_id", "")) if has_officer_filter: for allowed_officer_id in officer_ids: if officer_id == str(allowed_officer_id): return true return false func _condition_cell(pos_value) -> Vector2i: if typeof(pos_value) != TYPE_ARRAY or pos_value.size() < 2: return Vector2i(-9999, -9999) return Vector2i(int(pos_value[0]), int(pos_value[1])) func _condition_cells(source: Dictionary) -> Array[Vector2i]: var result: Array[Vector2i] = [] if source.has("cells") and typeof(source["cells"]) == TYPE_ARRAY: for pos_value in source["cells"]: var cell := _condition_cell(pos_value) if is_inside(cell) and not result.has(cell): result.append(cell) elif source.has("pos"): var cell := _condition_cell(source.get("pos", [])) if is_inside(cell): result.append(cell) return result func _condition_gate_open(condition: Dictionary) -> bool: var after_event := str(condition.get("after_event", "")) if after_event.is_empty(): return true return fired_event_ids.has(after_event) func _all_conditions_met(conditions) -> bool: if typeof(conditions) != TYPE_ARRAY or conditions.is_empty(): return false for condition in conditions: if not _is_condition_met(condition): return false return true func _collect_objective_cells(condition_group, result: Array[Vector2i]) -> void: if typeof(condition_group) == TYPE_ARRAY: for condition in condition_group: _collect_objective_cells(condition, result) return if typeof(condition_group) != TYPE_DICTIONARY: return if not _condition_gate_open(condition_group): _collect_after_event_objective_cells(str(condition_group.get("after_event", "")), result) return var condition_type := str(condition_group.get("type", "")) if condition_type == "all" or condition_type == "any": _collect_objective_cells(condition_group.get("conditions", []), result) return if condition_type != "unit_reaches_tile": return for cell in _condition_cells(condition_group): if not result.has(cell): result.append(cell) func _collect_after_event_objective_cells(event_id: String, result: Array[Vector2i]) -> void: if event_id.is_empty() or fired_event_ids.has(event_id): return var event := _event_by_id(event_id) if event.is_empty(): return var when: Dictionary = event.get("when", {}) if str(when.get("type", "")) != "unit_reaches_tile": return if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})): return for cell in _condition_cells(when): if not result.has(cell): result.append(cell) func _objective_cell_label_for_condition(condition_group, cell: Vector2i) -> String: if typeof(condition_group) == TYPE_ARRAY: for condition in condition_group: var label := _objective_cell_label_for_condition(condition, cell) if not label.is_empty(): return label return "" if typeof(condition_group) != TYPE_DICTIONARY: return "" if not _condition_gate_open(condition_group): return _objective_cell_label_for_after_event(str(condition_group.get("after_event", "")), cell) var condition_type := str(condition_group.get("type", "")) if condition_type == "all" or condition_type == "any": return _objective_cell_label_for_condition(condition_group.get("conditions", []), cell) if condition_type != "unit_reaches_tile": return "" if _condition_cells(condition_group).has(cell): return _condition_destination_label(condition_group) return "" func _objective_cell_label_for_after_event(event_id: String, cell: Vector2i) -> String: if event_id.is_empty() or fired_event_ids.has(event_id): return "" var event := _event_by_id(event_id) if event.is_empty(): return "" var when: Dictionary = event.get("when", {}) if str(when.get("type", "")) != "unit_reaches_tile": return "" if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})): return "" if _condition_cells(when).has(cell): return _condition_destination_label(when) return "" func _find_turn_limit(condition_group) -> int: if typeof(condition_group) == TYPE_ARRAY: var best_limit := 0 for condition in condition_group: var limit := _find_turn_limit(condition) if limit > 0 and (best_limit == 0 or limit < best_limit): best_limit = limit return best_limit if typeof(condition_group) != TYPE_DICTIONARY: return 0 var condition_type := str(condition_group.get("type", "")) if condition_type == "turn_limit": return int(condition_group.get("turn", condition_group.get("limit", 0))) if condition_type == "turn_reached": var target_team := str(condition_group.get("team", "")) if target_team == TEAM_PLAYER: return max(0, int(condition_group.get("turn", 0)) - 1) return int(condition_group.get("turn", 0)) if condition_type == "all" or condition_type == "any": return _find_turn_limit(condition_group.get("conditions", [])) return 0 func _any_unit_defeated(unit_ids) -> bool: if typeof(unit_ids) != TYPE_ARRAY: return false for unit_id in unit_ids: var unit := get_unit(str(unit_id)) if not unit.is_empty() and unit.get("deployed", true) and not unit.get("alive", false): return true return false func _any_officer_defeated(officer_ids, team := "") -> bool: if typeof(officer_ids) != TYPE_ARRAY: return false for officer_id in officer_ids: if _is_officer_defeated(str(officer_id), team): return true return false func _is_officer_defeated(officer_id: String, team := "") -> bool: if officer_id.is_empty(): return false for unit in units: if not team.is_empty() and unit.get("team", "") != team: continue if not unit.get("deployed", true): continue if str(unit.get("officer_id", "")) == officer_id: return not unit.get("alive", false) return false func _run_events(trigger_type: String, team := "", turn := -1, trigger_unit: Dictionary = {}) -> void: for event_index in range(battle_events.size()): if battle_status != STATUS_ACTIVE: break var event := battle_events[event_index] var event_id := str(event.get("id", "")) if event_id.is_empty(): event_id = "%s_%d" % [trigger_type, event_index] if bool(event.get("once", false)) and fired_event_ids.has(event_id): continue var when: Dictionary = event.get("when", {}) if str(when.get("type", "")) != trigger_type: continue if when.has("team") and str(when["team"]) != team: continue if when.has("turn") and int(when["turn"]) != turn: continue if trigger_type == "unit_reaches_tile" and not _unit_reaches_event_tile(trigger_unit, when): continue if trigger_type == "unit_defeated" and not _unit_defeated_event_matches(trigger_unit, when): continue if not _event_gate_open(when): continue if not _campaign_flags_match(when.get("campaign_flags", {})): continue _execute_event_actions(event.get("actions", []), trigger_unit) if bool(event.get("once", false)) and not event_id.is_empty(): fired_event_ids[event_id] = true _check_battle_status() func _unit_reaches_event_tile(unit: Dictionary, when: Dictionary) -> bool: if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false): return false var targets := _condition_cells(when) if targets.is_empty(): return false if not targets.has(unit.get("pos", Vector2i(-9999, -9999))): return false return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) func _unit_defeated_event_matches(unit: Dictionary, when: Dictionary) -> bool: if unit.is_empty() or not unit.get("deployed", true) or unit.get("alive", true): return false return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) func _event_gate_open(when: Dictionary) -> bool: var after_event := str(when.get("after_event", "")) if after_event.is_empty(): return true return fired_event_ids.has(after_event) func _campaign_flags_match(required_flags) -> bool: if typeof(required_flags) != TYPE_DICTIONARY: return true for flag_name in required_flags.keys(): var key := str(flag_name) if key.is_empty(): return false if not campaign_flags.has(key): return false if campaign_flags[key] != required_flags[flag_name]: return false return true func _execute_event_actions(actions: Array, trigger_unit: Dictionary = {}) -> void: for action in actions: if typeof(action) != TYPE_DICTIONARY: continue _execute_event_action(action, trigger_unit) func _execute_event_action(action: Dictionary, trigger_unit: Dictionary = {}) -> void: var action_type := str(action.get("type", "")) if action_type == "log": _emit_log(str(action.get("text", ""))) elif action_type == "dialogue": var lines := _dialogue_lines_from_action(action) if not lines.is_empty(): dialogue_requested.emit(lines) elif action_type == "set_objective": _apply_objective_event(action) elif action_type == "grant_item": _grant_event_item(action, trigger_unit) elif action_type == "grant_items": _grant_event_items(action.get("items", []), trigger_unit) elif action_type == "grant_gold": _grant_event_gold(action, trigger_unit) elif action_type == "spawn_deployment": _spawn_event_deployment(action.get("deployment", {})) elif action_type == "spawn_deployments": var deployments = action.get("deployments", []) if typeof(deployments) != TYPE_ARRAY: push_error("Skipping malformed deployment list in %s." % battle_id) return for deployment in deployments: _spawn_event_deployment(deployment) elif action_type == "withdraw_unit": _withdraw_event_unit(str(action.get("unit_id", action.get("id", "")))) elif action_type == "withdraw_units": var unit_ids = action.get("unit_ids", []) if typeof(unit_ids) != TYPE_ARRAY: push_error("Skipping malformed withdrawal list in %s." % battle_id) return for unit_id in unit_ids: _withdraw_event_unit(str(unit_id)) elif action_type == "set_ai_target_priority": _set_event_ai_target_priority(action) func _grant_event_items(item_entries, trigger_unit: Dictionary = {}) -> void: if typeof(item_entries) != TYPE_ARRAY: push_error("Skipping malformed event item grants in %s." % battle_id) return for entry in item_entries: if typeof(entry) == TYPE_STRING: _grant_event_item({"item_id": str(entry)}, trigger_unit) elif typeof(entry) == TYPE_DICTIONARY: _grant_event_item(entry, trigger_unit) else: push_error("Skipping malformed event item grant in %s." % battle_id) func _grant_event_item(action: Dictionary, trigger_unit: Dictionary = {}) -> bool: var item_id := str(action.get("item_id", action.get("id", ""))).strip_edges() if item_id.is_empty(): push_error("Skipping event item grant with no item_id in %s." % battle_id) return false var item := get_item_def(item_id) if item.is_empty(): push_error("Skipping unknown event item grant %s in %s." % [item_id, battle_id]) return false var count: int = maxi(1, int(action.get("count", 1))) battle_inventory[item_id] = int(battle_inventory.get(item_id, 0)) + count var item_name: String = _item_display_name(item_id) var count_text: String = "" if count == 1 else " %d점" % count _emit_log("전리품: %s%s." % [item_name, count_text]) if not trigger_unit.is_empty() and bool(trigger_unit.get("alive", false)): _emit_combat_feedback(trigger_unit, "+%s" % item_name, "item") return true func _grant_event_gold(action: Dictionary, trigger_unit: Dictionary = {}) -> bool: var amount := maxi(0, int(action.get("amount", action.get("gold", 0)))) if amount <= 0: push_error("Skipping event gold grant with invalid amount in %s." % battle_id) return false battle_gold_reward += amount var text := str(action.get("text", "")).strip_edges() if text.is_empty(): text = "군자금 %d냥을 거두었다." % amount _emit_log(text) if not trigger_unit.is_empty() and bool(trigger_unit.get("alive", false)): _emit_combat_feedback(trigger_unit, "+%d냥" % amount, "gold") return true func _set_event_ai_target_priority(action: Dictionary) -> void: var unit_ids := _event_action_unit_ids(action) if unit_ids.is_empty(): push_error("Skipping AI target priority event with no unit ids in %s." % battle_id) return var priority := clampi(int(action.get("priority", 0)), 0, 20) for unit_id in unit_ids: var unit := get_unit(unit_id) if unit.is_empty(): push_error("Skipping unknown AI target priority unit %s in %s." % [unit_id, battle_id]) continue var previous_priority := int(unit.get("ai_target_priority", 0)) unit["ai_target_priority"] = priority if bool(action.get("silent", false)): continue _emit_ai_target_priority_feedback(unit, previous_priority, priority, str(action.get("text", ""))) _notify_changed() func _event_action_unit_ids(action: Dictionary) -> Array[String]: var result: Array[String] = [] if action.has("unit_ids") and typeof(action["unit_ids"]) == TYPE_ARRAY: for unit_id in action["unit_ids"]: var unit_id_text := str(unit_id).strip_edges() if not unit_id_text.is_empty() and not result.has(unit_id_text): result.append(unit_id_text) else: var unit_id_text := str(action.get("unit_id", action.get("id", ""))).strip_edges() if not unit_id_text.is_empty(): result.append(unit_id_text) return result func _emit_ai_target_priority_feedback(unit: Dictionary, previous_priority: int, priority: int, text: String = "") -> void: if text.is_empty(): var unit_name := str(unit.get("name", "부대")) if priority > previous_priority: text = "%s에게 적 시선이 몰립니다." % unit_name elif priority < previous_priority: text = "%s를 향한 적 시선이 느슨해집니다." % unit_name else: text = "%s는 여전히 적의 표적입니다." % unit_name _emit_log(text) if bool(unit.get("alive", false)) and bool(unit.get("deployed", true)): _emit_combat_feedback(unit, "표적", "priority") func _apply_objective_event(action: Dictionary) -> void: var updated := false var updated_victory := "" var updated_defeat := "" if action.has("victory"): objectives["victory"] = str(action["victory"]) updated_victory = str(objectives.get("victory", "")) updated = true if action.has("defeat"): objectives["defeat"] = str(action["defeat"]) updated_defeat = str(objectives.get("defeat", "")) updated = true if not updated: return if action.has("victory") and not updated_victory.is_empty(): _emit_log("군령 갱신: %s" % updated_victory) if action.has("defeat") and not updated_defeat.is_empty(): _emit_log("패배 조건 갱신: %s" % updated_defeat) objective_updated.emit(updated_victory, updated_defeat) _notify_changed() func _dialogue_lines_from_action(action: Dictionary) -> Array: var result := [] if action.has("lines") and typeof(action["lines"]) == TYPE_ARRAY: for line in action["lines"]: var normalized := _normalize_dialogue_line(line) if not normalized.is_empty(): result.append(normalized) else: var normalized := _normalize_dialogue_line(action) if not normalized.is_empty(): result.append(normalized) return result func _normalize_dialogue_line(line) -> Dictionary: if typeof(line) == TYPE_STRING: var text := str(line) if text.is_empty(): return {} return {"speaker": "", "text": text, "portrait": "", "side": "left"} if typeof(line) == TYPE_DICTIONARY: var text := str(line.get("text", "")) if text.is_empty(): return {} var speaker := str(line.get("speaker", "")) var display_speaker := str(line.get("display_speaker", speaker)) var portrait := str(line.get("portrait", "")) if portrait.is_empty(): portrait = data_catalog.get_portrait_for_speaker(speaker) var side := "" if line.has("side"): side = _normalize_dialogue_side(line.get("side", "")) return { "speaker": display_speaker, "text": text, "portrait": portrait, "side": side } return {} func _normalize_dialogue_side(value) -> String: var side := str(value).strip_edges().to_lower() if side == "right": return "right" return "left" func _spawn_event_deployment(deployment) -> bool: if typeof(deployment) != TYPE_DICTIONARY: push_error("Skipping malformed event deployment in %s." % battle_id) return false var hydrated_unit := data_catalog.hydrate_deployment(deployment) _apply_roster_overlay(hydrated_unit, roster_overrides_snapshot) if not _is_deployment_available(hydrated_unit): _emit_log("원군 %s은 아직 부를 수 없다." % hydrated_unit.get("name", hydrated_unit.get("id", "unit"))) return false var unit := _prepare_unit(hydrated_unit) if unit.is_empty(): return false if not is_inside(unit["pos"]): _emit_log("원군 %s은 전장 밖에 설 수 없다." % unit["name"]) return false if get_move_cost(unit["pos"], str(unit.get("move_type", "foot"))) >= 99: _emit_log("원군 %s은 험지에 설 수 없다." % unit["name"]) return false if not get_unit(unit["id"]).is_empty(): _emit_log("원군 %s은 이미 전장에 있다." % unit["name"]) return false if not get_unit_at(unit["pos"]).is_empty(): _emit_log("원군 %s은 %s에 설 수 없다." % [unit["name"], _format_cell(unit["pos"])]) return false unit["acted"] = current_team == unit.get("team", "") unit["moved"] = current_team == unit.get("team", "") units.append(unit) _emit_log("%s, %s에 당도." % [unit["name"], _format_cell(unit["pos"])]) return true func _withdraw_event_unit(unit_id: String) -> bool: var normalized_id := unit_id.strip_edges() if normalized_id.is_empty(): push_error("Skipping event withdrawal with no unit_id in %s." % battle_id) return false var unit := get_unit(normalized_id) if unit.is_empty() or not unit.get("deployed", true) or not unit.get("alive", false): return false if selected_unit_id == normalized_id: selected_unit_id = "" _emit_combat_feedback(unit, "퇴각", "fade") unit["deployed"] = false unit["acted"] = true unit["moved"] = true _emit_log("%s 전장을 물러났다." % unit.get("name", normalized_id)) _notify_changed() return true func _reset_team_actions(team: String) -> void: _apply_phase_status_effects_for_team(team) _check_battle_status() if battle_status != STATUS_ACTIVE: return _apply_terrain_recovery_for_team(team) _expire_status_effects_for_team(team) for unit in units: if unit.get("team", "") == team and unit.get("alive", false): unit["acted"] = false unit["moved"] = false func _apply_phase_status_effects_for_team(team: String) -> void: for unit in units: if unit.get("team", "") != team or not unit.get("alive", false): continue for effect in _active_status_effects(unit): if str(effect.get("type", "")) != "damage_over_time": continue var damage: int = max(0, int(effect.get("amount", 0))) if damage <= 0: continue unit["hp"] = max(0, int(unit.get("hp", 0)) - damage) var status := str(effect.get("status", "poison")) var status_name := _status_display_name(status) _emit_log("%s, %s으로 병력 %d 손실." % [unit["name"], status_name, damage]) _emit_combat_feedback(unit, "-%d" % damage, status) if int(unit.get("hp", 0)) <= 0: unit["alive"] = false _emit_log("%s, %s으로 퇴각." % [unit["name"], status_name]) _emit_combat_feedback(unit, "퇴각", "defeat") _handle_unit_defeated(unit) break func _apply_terrain_recovery_for_team(team: String) -> void: for unit in units: if unit.get("team", "") != team or not unit.get("alive", false) or not unit.get("deployed", true): continue var cell: Vector2i = unit.get("pos", Vector2i(-1, -1)) if not is_inside(cell): continue var heal := get_terrain_heal(cell) if heal <= 0: continue var old_hp := int(unit.get("hp", 0)) var max_hp := int(unit.get("max_hp", 1)) var new_hp: int = mini(max_hp, old_hp + heal) if new_hp <= old_hp: continue unit["hp"] = new_hp var recovered := new_hp - old_hp _emit_log("%s, %s에서 병력 %d 회복." % [unit["name"], get_terrain_name(cell), recovered]) _emit_combat_feedback(unit, "+%d" % recovered, "heal") func _expire_status_effects_for_team(team: String) -> void: for unit in units: if unit.get("team", "") != team: continue if typeof(unit.get("status_effects", [])) != TYPE_ARRAY: continue var kept_effects := [] var expired_names := [] for effect in _active_status_effects(unit): var next_remaining := int(effect.get("remaining_phases", 1)) - 1 if next_remaining > 0: var kept_effect: Dictionary = effect.duplicate(true) kept_effect["remaining_phases"] = next_remaining kept_effects.append(kept_effect) else: var effect_name := str(effect.get("name", "지원")) if not expired_names.has(effect_name): expired_names.append(effect_name) unit["status_effects"] = kept_effects for expired_effect_name in expired_names: _emit_log("%s의 %s이 사라졌다." % [unit["name"], expired_effect_name]) _emit_combat_feedback(unit, "해제", "fade") func _manhattan(a: Vector2i, b: Vector2i) -> int: return absi(a.x - b.x) + absi(a.y - b.y) func _format_cell(cell: Vector2i) -> String: return "(%d, %d)" % [cell.x + 1, cell.y + 1] func _format_cells(cells: Array[Vector2i]) -> String: if cells.is_empty(): return "" if cells.size() == 1: return _format_cell(cells[0]) var labels := [] for cell in cells: labels.append(_format_cell(cell)) return _join_strings(labels, ", ") func _item_display_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 "술" _: var item := get_item_def(item_id) if item.is_empty(): return item_id return str(item.get("name", item_id)) func _join_strings(values: Array, delimiter: String) -> String: var text := "" for value in values: if not text.is_empty(): text += delimiter text += str(value) return text func _emit_log(message: String) -> void: log_added.emit(message) func _emit_combat_feedback(unit: Dictionary, text: String, kind: String) -> void: if unit.is_empty() or text.is_empty(): return var unit_id := str(unit.get("id", "")) if unit_id.is_empty(): return combat_feedback_requested.emit(unit_id, text, kind) func _notify_changed() -> void: changed.emit()