Files
heros/scripts/core/battle_state.gd

5286 lines
177 KiB
GDScript

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 combat_result_reported(report: Dictionary)
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 DIRECTIONAL_FRONT_ATTACK := "front"
const DIRECTIONAL_SIDE_ATTACK := "side"
const DIRECTIONAL_BACK_ATTACK := "back"
const DIRECTIONAL_SIDE_DAMAGE_BONUS := 2
const DIRECTIONAL_BACK_DAMAGE_BONUS := 5
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", "대화")).strip_edges()
if label.is_empty():
label = "대화"
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]))
var resolved_class_name := str(unit.get("class", "")).strip_edges()
if resolved_class_name.is_empty():
resolved_class_name = _fallback_class_name(str(unit.get("class_id", "")))
unit["class"] = resolved_class_name
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"))
unit["facing_x"] = _normalize_facing_x(unit.get("facing_x", _default_unit_facing_x(unit)))
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["ai_behavior"] = str(unit.get("ai_behavior", "")).strip_edges().to_lower()
if not ["guard", "sentry"].has(unit["ai_behavior"]):
unit["ai_behavior"] = ""
if unit["ai_behavior"] == "guard" and not unit.has("ai_guard_radius"):
unit["ai_guard_radius"] = 2
unit["ai_guard_radius"] = max(0, int(unit.get("ai_guard_radius", 0)))
var guard_anchor := _condition_cell(unit.get("ai_guard_anchor", [unit["pos"].x, unit["pos"].y]))
if not is_inside(guard_anchor):
guard_anchor = unit["pos"]
unit["ai_guard_anchor"] = guard_anchor
if unit["ai_behavior"] == "sentry" and not unit.has("ai_sentry_radius"):
unit["ai_sentry_radius"] = 4
unit["ai_sentry_radius"] = max(0, int(unit.get("ai_sentry_radius", 0)))
unit["ai_sentry_activation_turn"] = max(0, int(unit.get("ai_sentry_activation_turn", 0)))
var sentry_anchor := _condition_cell(unit.get("ai_sentry_anchor", [unit["pos"].x, unit["pos"].y]))
if not is_inside(sentry_anchor):
sentry_anchor = unit["pos"]
unit["ai_sentry_anchor"] = sentry_anchor
unit["ai_awake"] = bool(unit.get("ai_awake", false))
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["_pending_move_facing_x"] = unit_facing_x(unit)
unit["pos"] = cell
unit["moved"] = true
_face_unit_from_to(unit, from_cell, cell)
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()
unit.erase("_pending_move_facing_x")
_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
unit.erase("_pending_move_facing_x")
_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["facing_x"] = _normalize_facing_x(unit.get("_pending_move_facing_x", unit.get("facing_x", _default_unit_facing_x(unit))))
unit.erase("_pending_move_facing_x")
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
_face_unit_toward_cell(caster, target_cell)
_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
_face_unit_toward_cell(user, target_cell)
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 preview_attacker := attacker.duplicate(true)
var preview_target := target.duplicate(true)
if not attack_locked:
_face_unit_toward_cell(preview_attacker, preview_target["pos"])
var effective_bonus := 0 if attack_locked else _weapon_effectiveness_bonus(preview_attacker, preview_target)
var directional_info := {} if attack_locked else get_directional_attack_info(preview_attacker, preview_target)
var directional_bonus := int(directional_info.get("bonus", 0))
var damage := 0 if attack_locked else calculate_damage(preview_attacker, preview_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(preview_attacker, preview_target)
var counter_hit_chance := 0
var counter_effective_bonus := 0
var counter_directional_info := {}
var counter_directional_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(preview_target, preview_attacker["pos"])
if counter_in_range:
_face_unit_toward_cell(preview_target, preview_attacker["pos"])
counter_effective_bonus = _weapon_effectiveness_bonus(preview_target, preview_attacker)
counter_directional_info = get_directional_attack_info(preview_target, preview_attacker)
counter_directional_bonus = int(counter_directional_info.get("bonus", 0))
counter_damage = calculate_damage(preview_target, preview_attacker)
counter_hit_chance = calculate_hit_chance(preview_target, preview_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 "",
"directional_attack": str(directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"directional_bonus": directional_bonus,
"directional_label": str(directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK))),
"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_directional_attack": str(counter_directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"counter_directional_bonus": counter_directional_bonus,
"counter_directional_label": str(counter_directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK))),
"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)
var directional_bonus := _directional_damage_bonus(attacker, target)
return max(1, _effective_stat(attacker, "atk") + effective_bonus + directional_bonus - _effective_stat(target, "def") - terrain_defense)
func get_directional_attack_info(attacker: Dictionary, target: Dictionary) -> Dictionary:
var attack_kind := _directional_attack_kind(attacker, target)
return {
"kind": attack_kind,
"bonus": _directional_damage_bonus_for_kind(attack_kind),
"label": _directional_attack_label(attack_kind)
}
func unit_facing_x(unit: Dictionary) -> int:
return _normalize_facing_x(unit.get("facing_x", _default_unit_facing_x(unit)))
func _default_unit_facing_x(unit: Dictionary) -> int:
return -1 if str(unit.get("team", "")) == TEAM_ENEMY else 1
func _normalize_facing_x(value) -> int:
if typeof(value) == TYPE_VECTOR2I:
var vector_i: Vector2i = value
return -1 if vector_i.x < 0 else 1
if typeof(value) == TYPE_VECTOR2:
var vector: Vector2 = value
return -1 if vector.x < 0.0 else 1
if typeof(value) == TYPE_STRING:
var text := str(value).strip_edges().to_lower()
if text == "left" or text == "west" or text == "w" or text == "-1":
return -1
if text == "right" or text == "east" or text == "e" or text == "1":
return 1
return -1 if int(value) < 0 else 1
func _face_unit_from_to(unit: Dictionary, from_cell: Vector2i, to_cell: Vector2i) -> void:
if unit.is_empty() or from_cell.x == to_cell.x:
return
unit["facing_x"] = -1 if to_cell.x < from_cell.x else 1
func _face_unit_toward_cell(unit: Dictionary, target_cell: Vector2i) -> void:
if unit.is_empty() or not unit.has("pos"):
return
_face_unit_from_to(unit, unit.get("pos", target_cell), target_cell)
func _directional_attack_kind(attacker: Dictionary, target: Dictionary) -> String:
if attacker.is_empty() or target.is_empty():
return DIRECTIONAL_FRONT_ATTACK
var attacker_cell: Vector2i = attacker.get("pos", Vector2i.ZERO)
var target_cell: Vector2i = target.get("pos", Vector2i.ZERO)
var delta_x := attacker_cell.x - target_cell.x
if delta_x == 0:
return DIRECTIONAL_SIDE_ATTACK
var approach_x := -1 if delta_x < 0 else 1
if approach_x == unit_facing_x(target):
return DIRECTIONAL_FRONT_ATTACK
return DIRECTIONAL_BACK_ATTACK
func _directional_damage_bonus(attacker: Dictionary, target: Dictionary) -> int:
return _directional_damage_bonus_for_kind(_directional_attack_kind(attacker, target))
func _directional_damage_bonus_for_kind(attack_kind: String) -> int:
if attack_kind == DIRECTIONAL_BACK_ATTACK:
return DIRECTIONAL_BACK_DAMAGE_BONUS
if attack_kind == DIRECTIONAL_SIDE_ATTACK:
return DIRECTIONAL_SIDE_DAMAGE_BONUS
return 0
func _directional_attack_label(attack_kind: String) -> String:
if attack_kind == DIRECTIONAL_BACK_ATTACK:
return "후방"
if attack_kind == DIRECTIONAL_SIDE_ATTACK:
return "측면"
return "정면"
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:
match move_type.strip_edges().to_lower():
"foot":
return "보병"
"cavalry", "mounted":
return "기병"
"archer":
return "궁병"
"water":
return "수군"
"flying":
return "비행"
return "행군"
func _fallback_class_name(class_id: String) -> String:
match class_id.strip_edges().to_lower():
"hero":
return "영걸"
"commander":
return "도독"
"cavalry":
return "기병"
"elite_cavalry":
return "정예기병"
"infantry":
return "보병"
"guard_captain":
return "호위장"
"warrior":
return "무인"
"champion":
return "맹장"
"archer":
return "궁병"
"marksman":
return "강궁병"
"strategist":
return "책사"
"military_advisor":
return "군사"
"bandit":
return "산적"
return "부대"
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 "상태"
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", "부대")))
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)
var directional_info := get_directional_attack_info(unit, target)
result.append({
"source_id": str(unit.get("id", "")),
"source_name": str(unit.get("name", unit.get("id", "부대"))),
"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 "",
"directional_attack": str(directional_info.get("kind", DIRECTIONAL_FRONT_ATTACK)),
"directional_bonus": int(directional_info.get("bonus", 0)),
"directional_label": str(directional_info.get("label", _directional_attack_label(DIRECTIONAL_FRONT_ATTACK)))
})
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_cell_hint(cell: Vector2i) -> String:
if not is_inside(cell):
return ""
return _objective_cell_hint_for_condition(battle_conditions.get("victory", {}), cell)
func get_event_markers() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var objective_cells := get_objective_cells()
for event in battle_events:
var event_id := str(event.get("id", ""))
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", "")) != "unit_reaches_tile":
continue
if not _event_gate_open(when) or not _campaign_flags_match(when.get("campaign_flags", {})):
continue
if not _event_should_show_marker(event):
continue
var cells: Array[Vector2i] = []
for cell in _condition_cells(when):
if not objective_cells.has(cell) and not cells.has(cell):
cells.append(cell)
if cells.is_empty():
continue
result.append({
"id": event_id,
"label": _event_marker_label(event),
"kind": _event_marker_kind(event),
"hint": _event_marker_hint(event),
"cells": cells
})
return result
func get_event_marker_cells() -> Array[Vector2i]:
var result: Array[Vector2i] = []
for marker in get_event_markers():
var cells = marker.get("cells", [])
if typeof(cells) != TYPE_ARRAY:
continue
for cell in cells:
if typeof(cell) == TYPE_VECTOR2I and not result.has(cell):
result.append(cell)
return result
func get_event_marker_label(cell: Vector2i) -> String:
if not is_inside(cell):
return ""
for marker in get_event_markers():
var cells = marker.get("cells", [])
if typeof(cells) == TYPE_ARRAY and cells.has(cell):
return str(marker.get("label", "전장 표식"))
return ""
func get_event_marker_kind(cell: Vector2i) -> String:
if not is_inside(cell):
return ""
for marker in get_event_markers():
var cells = marker.get("cells", [])
if typeof(cells) == TYPE_ARRAY and cells.has(cell):
return str(marker.get("kind", "event"))
return ""
func get_event_marker_hint(cell: Vector2i) -> String:
if not is_inside(cell):
return ""
for marker in get_event_markers():
var cells = marker.get("cells", [])
if typeof(cells) == TYPE_ARRAY and cells.has(cell):
return str(marker.get("hint", ""))
return ""
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 "전장"
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", "부대"))),
"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", _fallback_class_name(class_id)))
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:
var movement_cells := _movement_range_for_unit(enemy)
if _is_ai_sentry(enemy) and not _ai_sentry_is_awake(enemy, movement_cells):
var sentry_return_cell := _best_ai_sentry_return_cell(enemy, movement_cells)
if sentry_return_cell != enemy["pos"]:
_move_ai_unit(enemy, sentry_return_cell)
return
if _is_ai_guard(enemy):
movement_cells = _ai_guard_movement_cells(enemy, movement_cells)
if not _ai_guard_is_engaged(enemy, movement_cells):
var return_cell := _best_ai_guard_return_cell(enemy, movement_cells)
if return_cell != enemy["pos"]:
_move_ai_unit(enemy, return_cell)
return
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_cells)
if not move_attack_action.is_empty():
best_cell = move_attack_action["origin"]
else:
var target := _find_nearest_ai_guard_target(enemy, TEAM_PLAYER) if _is_ai_guard(enemy) else _find_nearest_target(enemy, TEAM_PLAYER)
if target.is_empty():
return
var best_distance := _manhattan(best_cell, target["pos"])
for cell in movement_cells:
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
_face_unit_from_to(enemy, from_cell, best_cell)
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 _move_ai_unit(unit: Dictionary, target_cell: Vector2i) -> bool:
if target_cell == unit["pos"]:
return true
var from_cell: Vector2i = unit["pos"]
unit["pos"] = target_cell
unit["moved"] = true
_face_unit_from_to(unit, from_cell, target_cell)
unit_motion_requested.emit(str(unit.get("id", "")), from_cell, target_cell)
_emit_log("%s, %s으로 접근." % [unit["name"], _format_cell(target_cell)])
_run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit)
return battle_status == STATUS_ACTIVE
func _is_ai_guard(unit: Dictionary) -> bool:
return str(unit.get("ai_behavior", "")).strip_edges().to_lower() == "guard"
func _is_ai_sentry(unit: Dictionary) -> bool:
return str(unit.get("ai_behavior", "")).strip_edges().to_lower() == "sentry"
func _ai_sentry_anchor(unit: Dictionary) -> Vector2i:
var anchor: Vector2i = unit.get("ai_sentry_anchor", unit.get("pos", Vector2i(-1, -1)))
if not is_inside(anchor):
return unit.get("pos", Vector2i(-1, -1))
return anchor
func _ai_sentry_radius(unit: Dictionary) -> int:
return max(0, int(unit.get("ai_sentry_radius", 0)))
func _ai_sentry_contains_cell(unit: Dictionary, cell: Vector2i) -> bool:
return _manhattan(_ai_sentry_anchor(unit), cell) <= _ai_sentry_radius(unit)
func _ai_sentry_is_awake(unit: Dictionary, movement_cells: Array[Vector2i]) -> bool:
if bool(unit.get("ai_awake", false)):
return true
var activation_turn := int(unit.get("ai_sentry_activation_turn", 0))
if activation_turn > 0 and turn_number >= activation_turn:
unit["ai_awake"] = true
return true
var target_team := _opposing_team(str(unit.get("team", "")))
if target_team.is_empty():
return false
for target in get_living_units(target_team):
if _ai_sentry_contains_cell(unit, target.get("pos", Vector2i(-1, -1))):
unit["ai_awake"] = true
return true
if not _best_ai_physical_attack_action(unit, [unit.get("pos", Vector2i(-1, -1))]).is_empty():
unit["ai_awake"] = true
return true
if not movement_cells.is_empty() and not _best_ai_physical_attack_action(unit, movement_cells).is_empty():
unit["ai_awake"] = true
return true
return false
func _best_ai_sentry_return_cell(unit: Dictionary, movement_cells: Array[Vector2i]) -> Vector2i:
var best_cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
var anchor := _ai_sentry_anchor(unit)
var best_distance := _manhattan(best_cell, anchor)
for cell in movement_cells:
var distance := _manhattan(cell, anchor)
if distance < best_distance:
best_distance = distance
best_cell = cell
return best_cell
func _ai_guard_anchor(unit: Dictionary) -> Vector2i:
var anchor: Vector2i = unit.get("ai_guard_anchor", unit.get("pos", Vector2i(-1, -1)))
if not is_inside(anchor):
return unit.get("pos", Vector2i(-1, -1))
return anchor
func _ai_guard_radius(unit: Dictionary) -> int:
return max(0, int(unit.get("ai_guard_radius", 0)))
func _ai_guard_contains_cell(unit: Dictionary, cell: Vector2i) -> bool:
return _manhattan(_ai_guard_anchor(unit), cell) <= _ai_guard_radius(unit)
func _ai_guard_movement_cells(unit: Dictionary, movement_cells: Array[Vector2i]) -> Array[Vector2i]:
var filtered: Array[Vector2i] = []
for cell in movement_cells:
if _ai_guard_contains_cell(unit, cell):
filtered.append(cell)
if not filtered.is_empty():
return filtered
if not _ai_guard_contains_cell(unit, unit.get("pos", Vector2i(-1, -1))):
return movement_cells
return filtered
func _ai_guard_attack_origins(unit: Dictionary, movement_cells: Array[Vector2i]) -> Array[Vector2i]:
var origins: Array[Vector2i] = []
var current: Vector2i = unit.get("pos", Vector2i(-1, -1))
if is_inside(current):
origins.append(current)
for cell in movement_cells:
if not origins.has(cell):
origins.append(cell)
return origins
func _ai_guard_is_engaged(unit: Dictionary, movement_cells: Array[Vector2i]) -> bool:
var target_team := _opposing_team(str(unit.get("team", "")))
if target_team.is_empty():
return false
for target in get_living_units(target_team):
if _ai_guard_contains_cell(unit, target.get("pos", Vector2i(-1, -1))):
return true
return not _best_ai_physical_attack_action(unit, _ai_guard_attack_origins(unit, movement_cells)).is_empty()
func _best_ai_guard_return_cell(unit: Dictionary, movement_cells: Array[Vector2i]) -> Vector2i:
var best_cell: Vector2i = unit.get("pos", Vector2i(-1, -1))
var anchor := _ai_guard_anchor(unit)
var best_distance := _manhattan(best_cell, anchor)
for cell in movement_cells:
var distance := _manhattan(cell, anchor)
if distance < best_distance:
best_distance = distance
best_cell = cell
return best_cell
func _find_nearest_ai_guard_target(unit: Dictionary, target_team: String) -> Dictionary:
var best_target := {}
var best_score := 9999
for target in get_living_units(target_team):
if not _ai_guard_contains_cell(unit, target.get("pos", Vector2i(-1, -1))):
continue
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 _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 scoring_attacker := attacker
if attacker.get("pos", Vector2i(-1, -1)) != origin:
scoring_attacker = attacker.duplicate(true)
scoring_attacker["pos"] = origin
_face_unit_from_to(scoring_attacker, attacker.get("pos", origin), origin)
var damage := calculate_damage(scoring_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 report := _make_combat_result_report(attacker, target)
var attack_result := _resolve_attack(attacker, target, false)
(report["entries"] as Array).append(attack_result)
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:
var final_attack_exp := attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0)
var final_attack_progress := _grant_experience(attacker, final_attack_exp)
report["attacker_merit"] = int(final_attack_progress.get("amount", 0))
report["attacker_progress"] = final_attack_progress
combat_result_reported.emit(report)
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)
(report["entries"] as Array).append(counter_result)
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)
var attack_progress := _grant_experience(attacker, attack_exp)
report["attacker_merit"] = int(attack_progress.get("amount", 0))
report["attacker_progress"] = attack_progress
var counter_progress := _grant_experience(target, counter_exp)
report["counter_merit"] = int(counter_progress.get("amount", 0))
report["counter_progress"] = counter_progress
combat_result_reported.emit(report)
func _make_combat_result_report(attacker: Dictionary, target: Dictionary) -> Dictionary:
return {
"attacker_id": str(attacker.get("id", "")),
"attacker_name": str(attacker.get("name", "")),
"target_id": str(target.get("id", "")),
"target_name": str(target.get("name", "")),
"attacker_merit": 0,
"counter_merit": 0,
"attacker_progress": {},
"counter_progress": {},
"entries": []
}
func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary:
_face_unit_toward_cell(attacker, target.get("pos", attacker.get("pos", Vector2i.ZERO)))
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 {
"attacker_id": str(attacker.get("id", "")),
"attacker_name": str(attacker.get("name", "")),
"target_id": str(target.get("id", "")),
"target_name": str(target.get("name", "")),
"is_counter": is_counter,
"hit": false,
"defeated": false,
"damage": 0,
"hit_chance": hit_chance,
"target_hp": int(target.get("hp", 0)),
"target_max_hp": int(target.get("max_hp", 0)),
"directional_label": "",
"directional_bonus": 0,
"effective_bonus": 0
}
var directional_info := get_directional_attack_info(attacker, target)
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])
var directional_bonus := int(directional_info.get("bonus", 0))
if directional_bonus > 0:
_emit_log("%s %s +%d." % [target["name"], str(directional_info.get("label", "")), directional_bonus])
_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 {
"attacker_id": str(attacker.get("id", "")),
"attacker_name": str(attacker.get("name", "")),
"target_id": str(target.get("id", "")),
"target_name": str(target.get("name", "")),
"is_counter": is_counter,
"hit": true,
"defeated": true,
"damage": damage,
"hit_chance": hit_chance,
"target_hp": int(target.get("hp", 0)),
"target_max_hp": int(target.get("max_hp", 0)),
"directional_label": str(directional_info.get("label", "")),
"directional_bonus": directional_bonus,
"effective_bonus": effective_bonus
}
return {
"attacker_id": str(attacker.get("id", "")),
"attacker_name": str(attacker.get("name", "")),
"target_id": str(target.get("id", "")),
"target_name": str(target.get("name", "")),
"is_counter": is_counter,
"hit": true,
"defeated": false,
"damage": damage,
"hit_chance": hit_chance,
"target_hp": int(target.get("hp", 0)),
"target_max_hp": int(target.get("max_hp", 0)),
"directional_label": str(directional_info.get("label", "")),
"directional_bonus": directional_bonus,
"effective_bonus": effective_bonus
}
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) -> Dictionary:
var progress := {
"unit_id": str(unit.get("id", "")),
"name": str(unit.get("name", "부대")),
"amount": 0,
"exp_before": int(unit.get("exp", 0)),
"exp_after": int(unit.get("exp", 0)),
"level_before": int(unit.get("level", 1)),
"level_after": int(unit.get("level", 1)),
"class_before": str(unit.get("class", "")),
"class_after": str(unit.get("class", "")),
"leveled": false,
"promoted": false
}
if unit.is_empty() or not unit.get("alive", false) or amount <= 0:
return progress
var class_id_before := str(unit.get("class_id", ""))
unit["exp"] = int(unit.get("exp", 0)) + amount
progress["amount"] = amount
_emit_log("%s 공적 +%d." % [unit["name"], amount])
_emit_combat_feedback(unit, "공적 +%d" % amount, "progress")
while int(unit["exp"]) >= EXP_LEVEL:
unit["exp"] = int(unit["exp"]) - EXP_LEVEL
_level_up(unit)
progress["exp_after"] = int(unit.get("exp", 0))
progress["level_after"] = int(unit.get("level", progress["level_before"]))
progress["class_after"] = str(unit.get("class", progress["class_before"]))
progress["leveled"] = int(progress["level_after"]) > int(progress["level_before"])
progress["promoted"] = str(unit.get("class_id", "")) != class_id_before
return progress
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", "부대")),
"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", _fallback_class_name(class_id)))
_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", "부대")),
"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", _fallback_class_name(next_class_id)))
})
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" % [label, 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" % [label, 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_hint_for_condition(condition_group, cell: Vector2i) -> String:
if typeof(condition_group) == TYPE_ARRAY:
for condition in condition_group:
var hint := _objective_cell_hint_for_condition(condition, cell)
if not hint.is_empty():
return hint
return ""
if typeof(condition_group) != TYPE_DICTIONARY:
return ""
if not _condition_gate_open(condition_group):
return _objective_cell_hint_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_hint_for_condition(condition_group.get("conditions", []), cell)
if condition_type != "unit_reaches_tile":
return ""
if _condition_cells(condition_group).has(cell):
return _condition_marker_hint(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 _objective_cell_hint_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):
var hint := _condition_marker_hint(when)
if hint.is_empty():
hint = _event_marker_hint(event)
return hint
return ""
func _event_has_reward_action(event: Dictionary) -> bool:
for action in event.get("actions", []):
if typeof(action) != TYPE_DICTIONARY:
continue
var action_type := str((action as Dictionary).get("type", ""))
if action_type == "grant_item" or action_type == "grant_items" or action_type == "grant_gold":
return true
return false
func _event_should_show_marker(event: Dictionary) -> bool:
if _event_declares_marker(event):
return true
return _event_has_reward_action(event)
func _event_declares_marker(event: Dictionary) -> bool:
if bool(event.get("show_marker", false)):
return true
if not str(event.get("marker_kind", "")).strip_edges().is_empty():
return true
var when: Dictionary = event.get("when", {})
if bool(when.get("show_marker", false)):
return true
return not str(when.get("marker_kind", "")).strip_edges().is_empty()
func _event_marker_label(event: Dictionary) -> String:
var when: Dictionary = event.get("when", {})
var label := _condition_destination_label(when)
if label != "전장 표식":
return label
var kind := _event_marker_kind(event)
if kind == "tactic":
return "전술선"
if kind == "gold":
return "군자금"
if kind == "supply":
return "보급"
return "전장 표식"
func _condition_marker_hint(condition: Dictionary) -> String:
for key in ["objective_hint", "marker_hint", "hint"]:
var value := str(condition.get(key, "")).strip_edges()
if not value.is_empty():
return value
return ""
func _event_marker_hint(event: Dictionary) -> String:
for key in ["marker_hint", "hint"]:
var value := str(event.get(key, "")).strip_edges()
if not value.is_empty():
return value
var when: Dictionary = event.get("when", {})
var condition_hint := _condition_marker_hint(when)
if not condition_hint.is_empty():
return condition_hint
var kind := _event_marker_kind(event)
if kind == "tactic":
return "적 앞줄만 끌어내기 좋은 전술 표식입니다."
if kind == "gold":
return "전장 군자금을 거둘 수 있는 표식입니다."
if kind == "supply":
return "소모품이나 회복 거점을 얻을 수 있는 보급 표식입니다."
return ""
func _event_marker_kind(event: Dictionary) -> String:
var explicit_kind := str(event.get("marker_kind", "")).strip_edges().to_lower()
if explicit_kind.is_empty():
var when: Dictionary = event.get("when", {})
explicit_kind = str(when.get("marker_kind", "")).strip_edges().to_lower()
if not explicit_kind.is_empty():
return explicit_kind
var has_item := false
var has_gold := false
for action in event.get("actions", []):
if typeof(action) != TYPE_DICTIONARY:
continue
var action_type := str((action as Dictionary).get("type", ""))
if action_type == "grant_item" or action_type == "grant_items":
has_item = true
elif action_type == "grant_gold":
has_gold = true
if has_gold and not has_item:
return "gold"
if has_item:
return "supply"
return "event"
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_awake":
_set_event_ai_awake(action)
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 _set_event_ai_awake(action: Dictionary) -> void:
var unit_ids := _event_action_unit_ids(action)
if unit_ids.is_empty():
push_error("Skipping AI awake event with no unit ids in %s." % battle_id)
return
var awake := bool(action.get("awake", true))
var changed_units: Array[Dictionary] = []
for unit_id in unit_ids:
var unit := get_unit(unit_id)
if unit.is_empty():
push_error("Skipping unknown AI awake unit %s in %s." % [unit_id, battle_id])
continue
var was_awake := bool(unit.get("ai_awake", false))
unit["ai_awake"] = awake
if was_awake != awake:
changed_units.append(unit)
if changed_units.is_empty():
return
if not bool(action.get("silent", false)):
var text := str(action.get("text", "")).strip_edges()
if text.is_empty():
text = "적 초소병의 움직임이 바뀌었다." if awake else "적 초소병이 다시 경계 위치에 머문다."
_emit_log(text)
for unit in changed_units:
if bool(unit.get("alive", false)) and bool(unit.get("deployed", true)):
_emit_combat_feedback(unit, "경계" if awake else "대기", "priority")
_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"])])
_emit_combat_feedback(unit, "증원", "reinforcement")
_notify_changed()
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:
if not is_inside(cell):
return "전장 밖"
var terrain_name := get_terrain_name(cell)
var zone_name := _map_zone_label(cell)
if terrain_name.is_empty():
return zone_name
if zone_name.is_empty():
return terrain_name
return "%s %s" % [zone_name, terrain_name]
func _map_zone_label(cell: Vector2i) -> String:
if not is_inside(cell):
return "전장 밖"
var width := maxi(1, map_size.x)
var left_cut := int(floor(float(width) / 3.0))
var right_cut := int(ceil(float(width) * 2.0 / 3.0))
if cell.x < left_cut:
return "서측"
if cell.x >= right_cut:
return "동측"
return "중앙"
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()