extends RefCounted class_name BattleState const DataCatalogScript := preload("res://scripts/core/data_catalog.gd") signal changed signal log_added(message: String) signal dialogue_requested(lines: Array) signal combat_feedback_requested(unit_id: String, text: String, kind: String) signal unit_motion_requested(unit_id: String, from_cell: Vector2i, to_cell: Vector2i) const TEAM_PLAYER := "player" const TEAM_ENEMY := "enemy" const STATUS_ACTIVE := "active" const STATUS_VICTORY := "victory" const STATUS_DEFEAT := "defeat" const EXP_ATTACK := 10 const EXP_COUNTER := 5 const EXP_MISS := 2 const EXP_SKILL := 12 const EXP_HEAL := 8 const EXP_SUPPORT := 8 const EXP_DEFEAT_BONUS := 30 const EXP_LEVEL := 100 const BASE_HIT_CHANCE := 90 const MIN_HIT_CHANCE := 25 const CARDINAL_DIRECTIONS := [ Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1) ] const GROWTH_GRADE_GAINS := { "hp": {"A": 5, "B": 4, "C": 3, "D": 2, "E": 1}, "mp": {"A": 3, "B": 2, "C": 1, "D": 1, "E": 0}, "atk": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "def": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "int": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0}, "agi": {"A": 2, "B": 1, "C": 1, "D": 0, "E": 0} } const DEFAULT_TERRAIN_DEFS := { "G": { "name": "Plain", "move_cost": 1, "defense": 0, "avoid": 0, "color": Color(0.43, 0.62, 0.31) }, "F": { "name": "Forest", "move_cost": 2, "defense": 2, "avoid": 10, "color": Color(0.20, 0.45, 0.23) }, "H": { "name": "Hill", "move_cost": 2, "defense": 1, "avoid": 5, "color": Color(0.55, 0.50, 0.34) }, "R": { "name": "Road", "move_cost": 1, "defense": 0, "avoid": 0, "color": Color(0.64, 0.55, 0.42) }, "W": { "name": "Water", "move_cost": 99, "defense": 0, "avoid": 0, "color": Color(0.18, 0.36, 0.62) } } 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 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 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 := []) -> 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) _emit_log("Battle loaded: %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() 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))) 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.erase("conditional_lines") 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} 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} 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 if not bool(unit.get("requires_joined", false)): return true var officer_id := str(unit.get("officer_id", "")) return not officer_id.is_empty() and campaign_joined_officers.has(officer_id) func _apply_deployment_rules() -> void: var player_units := get_player_units(true) var max_units := get_deployment_max_units() var required_officers: Array = deployment_rules.get("required_officers", []) var required_units: Array = deployment_rules.get("required_units", []) var deployed_count := 0 for unit in player_units: var required := required_units.has(str(unit.get("id", ""))) or required_officers.has(str(unit.get("officer_id", ""))) unit["required_deployment"] = required unit["deployed"] = required if required: deployed_count += 1 for unit in player_units: if unit.get("deployed", false): continue var deploy := max_units <= 0 or deployed_count < max_units unit["deployed"] = deploy if deploy: deployed_count += 1 func _prepare_unit(source_unit: Dictionary) -> Dictionary: if source_unit.is_empty(): return {} var unit: Dictionary = source_unit.duplicate(true) if not unit.has("id") and unit.has("unit_id"): unit["id"] = unit["unit_id"] if str(unit.get("id", "")).is_empty(): push_error("Unit is missing id: %s" % str(source_unit)) return {} var pos_array: Array = unit.get("pos", [0, 0]) if pos_array.size() < 2: push_error("Unit has malformed position: %s" % str(source_unit)) return {} unit["pos"] = Vector2i(int(pos_array[0]), int(pos_array[1])) unit["class"] = str(unit.get("class", unit.get("class_id", "Unit"))).capitalize() unit["hp"] = int(unit.get("hp", unit.get("max_hp", 1))) unit["max_hp"] = int(unit.get("max_hp", unit["hp"])) unit["max_mp"] = int(unit.get("max_mp", unit.get("mp", 0))) unit["mp"] = int(unit.get("mp", unit["max_mp"])) unit["atk"] = int(unit.get("atk", 1)) unit["def"] = int(unit.get("def", 0)) unit["int"] = int(unit.get("int", 0)) unit["agi"] = int(unit.get("agi", 0)) unit["move"] = int(unit.get("move", 3)) unit["move_type"] = str(unit.get("move_type", "foot")) if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: unit["skills"] = [] else: unit["skills"] = unit["skills"].duplicate(true) if typeof(unit.get("range", 1)) == TYPE_ARRAY: var range_array: Array = unit["range"] if range_array.size() >= 2: unit["min_range"] = max(1, int(unit.get("min_range", range_array[0]))) unit["range"] = max(int(unit["min_range"]), int(range_array[1])) elif range_array.size() == 1: unit["range"] = max(1, int(range_array[0])) unit["min_range"] = int(unit["range"]) else: unit["range"] = 1 unit["min_range"] = 1 else: unit["range"] = max(1, int(unit.get("range", 1))) unit["min_range"] = clampi(int(unit.get("min_range", min(1, int(unit["range"])))), 1, int(unit["range"])) unit["level"] = int(unit.get("level", 1)) unit["exp"] = int(unit.get("exp", 0)) if not unit.has("growth") or typeof(unit["growth"]) != TYPE_DICTIONARY: unit["growth"] = {} if not unit.has("growth_bonus") or typeof(unit["growth_bonus"]) != TYPE_DICTIONARY: unit["growth_bonus"] = {} unit["alive"] = bool(unit.get("alive", true)) unit["deployed"] = bool(unit.get("deployed", true)) unit["required_deployment"] = bool(unit.get("required_deployment", false)) unit["controllable"] = bool(unit.get("controllable", true)) unit["persist_progression"] = bool(unit.get("persist_progression", true)) unit["ai_target_priority"] = max(0, int(unit.get("ai_target_priority", 0))) unit["status_effects"] = [] unit["acted"] = false unit["moved"] = false return unit func _apply_roster_overlay(unit: Dictionary, roster_overrides) -> void: if typeof(roster_overrides) != TYPE_DICTIONARY: return if unit.get("team", "") != TEAM_PLAYER: return var overlay := _find_roster_overlay(unit, roster_overrides) if overlay.is_empty(): return for key in ["class_id", "class", "level", "exp", "max_hp", "max_mp", "atk", "def", "int", "agi"]: if overlay.has(key): unit[key] = overlay[key] if overlay.has("equipment") and typeof(overlay["equipment"]) == TYPE_DICTIONARY: unit["equipment"] = overlay["equipment"].duplicate(true) if overlay.has("skills") and typeof(overlay["skills"]) == TYPE_ARRAY: unit["skills"] = overlay["skills"].duplicate(true) if overlay.has("class_id"): _apply_class_runtime_fields(unit, str(unit.get("class_id", ""))) else: _refresh_attack_range_from_equipment(unit) unit["hp"] = int(unit.get("max_hp", unit.get("hp", 1))) unit["mp"] = int(unit.get("max_mp", unit.get("mp", 0))) unit["loaded_from_roster"] = true func _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 has already acted." % unit["name"]) return false selected_unit_id = unit_id _emit_log("Selected %s." % unit["name"]) _notify_changed() return true func clear_selection() -> void: selected_unit_id = "" _notify_changed() func try_move_selected(cell: Vector2i) -> 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 not get_movement_range(unit["id"]).has(cell): return false if not get_unit_at(cell).is_empty(): return false var from_cell: Vector2i = unit["pos"] unit["pos"] = cell unit["moved"] = true unit_motion_requested.emit(str(unit.get("id", "")), from_cell, cell) _emit_log("%s moved to %s." % [unit["name"], _format_cell(cell)]) _run_events("unit_reaches_tile", str(unit.get("team", "")), turn_number, unit) _check_battle_status() _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 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 not _unit_has_skill(caster, skill_id): return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): _emit_log("%s does not have enough MP." % caster["name"]) return false if not _skill_cells_from(caster, skill).has(target_cell): return false var target := get_unit_at(target_cell) if not _skill_target_valid(caster, target, skill): return false var skill_kind := str(skill.get("kind", "damage")) if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: _emit_log("%s is already at full HP." % target["name"]) return false if skill_kind == "support" and not _skill_has_support_effects(skill): return false caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var healed := _resolve_skill_heal(caster, target, skill) if healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": if _resolve_skill_support(caster, target, skill_id, skill): _grant_experience(caster, EXP_SUPPORT) else: var defeated := _resolve_skill_damage(caster, target, skill) _grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0)) 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("No %s remains." % item.get("name", item_id)) return false if str(item.get("kind", "")) != "consumable": return false if not _item_target_cells_from(user, item).has(target_cell): return false var target := get_unit_at(target_cell) if not _item_target_valid(user, target, item): return false var applied := _apply_item_effects(user, target, item) if not applied: return false battle_inventory[item_id] = max(0, int(battle_inventory.get(item_id, 0)) - 1) user["acted"] = true user["moved"] = true selected_unit_id = "" _emit_log("%s uses %s on %s." % [user["name"], item.get("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 damage := calculate_damage(attacker, target) var target_hp_after: int = maxi(0, int(target["hp"]) - damage) var counter_damage := 0 var attacker_hp_after := int(attacker["hp"]) var hit_chance := calculate_hit_chance(attacker, target) var counter_hit_chance := 0 var counter_in_range := false if target_hp_after > 0 or hit_chance < 100: counter_in_range = _is_in_attack_range(target, attacker["pos"]) if counter_in_range: counter_damage = calculate_damage(target, attacker) counter_hit_chance = calculate_hit_chance(target, attacker) attacker_hp_after = maxi(0, int(attacker["hp"]) - counter_damage) return { "attacker_id": attacker_id, "target_id": target_id, "damage": damage, "hit_chance": hit_chance, "target_hp_after": target_hp_after, "would_defeat": int(target["hp"]) - damage <= 0, "in_range": _is_in_attack_range(attacker, target["pos"]), "counter_in_range": counter_in_range, "counter_damage": counter_damage, "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 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": _skill_cells_from(caster, skill).has(target_cell), "valid_target": _skill_target_valid(caster, target, skill) } if target.is_empty(): return preview preview["target_id"] = target.get("id", "") if skill_kind == "heal": var heal_amount := calculate_skill_heal(caster, target, skill) preview["heal"] = heal_amount preview["target_hp_after"] = min(int(target.get("max_hp", 1)), int(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(target, skill_id) else: var damage := calculate_skill_damage(caster, target, skill) preview["damage"] = damage preview["target_hp_after"] = max(0, int(target.get("hp", 0)) - damage) preview["would_defeat"] = int(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) 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"]) return max(1, _effective_stat(attacker, "atk") - _effective_stat(target, "def") - terrain_defense) func calculate_hit_chance(attacker: Dictionary, target: Dictionary) -> int: var terrain_avoid := get_terrain_avoid(target["pos"]) var agility_delta := _effective_stat(attacker, "agi") - _effective_stat(target, "agi") return clampi(BASE_HIT_CHANCE + agility_delta - terrain_avoid, MIN_HIT_CHANCE, 100) func calculate_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var terrain_defense := get_terrain_defense(target["pos"]) var stat_name := str(skill.get("stat", "int")) var stat_value := _effective_stat(caster, stat_name) return max(1, int(skill.get("power", 1)) + stat_value - _effective_stat(target, "def") - terrain_defense) func calculate_skill_heal(caster: Dictionary, target: Dictionary, skill: Dictionary) -> int: var stat_name := str(skill.get("stat", "int")) var raw_heal := int(skill.get("power", 1)) + _effective_stat(caster, stat_name) var missing_hp := int(target.get("max_hp", 1)) - int(target.get("hp", 0)) return max(0, min(raw_heal, missing_hp)) func 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 _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 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))) }) target["status_effects"] = status_effects _emit_log("%s casts %s on %s. %s" % [ caster["name"], skill.get("name", "Skill"), 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 := "next phase" if duration == 1 else "%d phases" % 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 until %s" % [stat.to_upper(), 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 HP for %s" % [status_name, damage, duration_text]) if parts.is_empty(): return "No support effect." 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" % [stat.to_upper(), 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"))).to_upper()) if parts.is_empty(): return "Support" 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", "")) == "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)) if effect_type == "damage_over_time": if amount > 0: parts.append("%s -%d HP" % [_status_display_name(str(effect.get("status", "poison"))), amount]) 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" % [stat.to_upper(), sign, amount]) return _join_strings(parts, ", ") func _status_display_name(status: String) -> String: var normalized := status.strip_edges() if normalized.is_empty(): return "Status" return normalized.replace("_", " ").capitalize() func _is_supported_status_stat(stat: String) -> bool: return stat == "atk" or stat == "def" or stat == "int" or stat == "agi" 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 waits." % 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("Enemy phase begins.") _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 [] 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 [] 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 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_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_objective_cells() -> Array[Vector2i]: var result: Array[Vector2i] = [] _collect_objective_cells(battle_conditions.get("victory", {}), result) return result 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 "" 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 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_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 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 must deploy." % 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("Deployment limit reached.") return false var target_cell := _formation_cell_for_deployment(unit) if not is_inside(target_cell): _emit_log("No open formation cell for %s." % unit["name"]) return false unit["pos"] = target_cell unit["alive"] = true unit["deployed"] = true _emit_log("%s joins the sortie." % unit["name"]) else: unit["deployed"] = false _emit_log("%s moves to reserve." % 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 takes formation at %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 can change equipment before moving or acting." % 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("No %s is in inventory." % item.get("name", item_id)) return false if not _unit_can_equip_item(unit, item): _emit_log("%s cannot equip %s." % [unit["name"], item.get("name", item_id)]) return false var equipment: Dictionary = unit.get("equipment", {}).duplicate(true) var old_item_id := str(equipment.get(slot, "")) if old_item_id == item_id: _emit_log("%s already has %s equipped." % [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 equips %s." % [unit["name"], item.get("name", 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 str(equipment.get(slot, "")) == normalized: continue if not _unit_can_equip_item(unit, item): continue result.append(normalized) result.sort() 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_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_terrain_name(cell: Vector2i) -> String: return String(terrain_defs.get(get_terrain_key(cell), DEFAULT_TERRAIN_DEFS["G"]).get("name", "Plain")) 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_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), "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 "Victory" if battle_status == STATUS_DEFEAT: return "Defeat" var turn_limit := get_turn_limit() if turn_limit > 0: return "Turn %d/%d - %s" % [turn_number, turn_limit, current_team.capitalize()] return "Turn %d - %s" % [turn_number, current_team.capitalize()] func get_turn_limit() -> int: return _find_turn_limit(battle_conditions.get("defeat", {})) func _movement_range_for_unit(unit: Dictionary) -> Array[Vector2i]: 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 > int(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 _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: return _attack_cells_from(attacker, attacker["pos"]).has(target_cell) func _skill_cells_from(unit: Dictionary, skill: Dictionary) -> 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"]) 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 _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 recovers %d HP." % [target["name"], healed]) _emit_combat_feedback(target, "+%d HP" % 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 recovers %d MP." % [target["name"], restored]) _emit_combat_feedback(target, "+%d MP" % 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 is cured of %s." % [target["name"], _join_strings(cured_statuses, ", ")]) _emit_combat_feedback(target, "CURE", "support") if not applied: _emit_log("%s has no effect on %s." % [item.get("name", "Item"), 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 effect_type := str(effect.get("type", "")) var should_cure := ( effect_type == "damage_over_time" 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 _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): if str(active_effect.get("type", "")) != "damage_over_time": continue var active_status := str(active_effect.get("status", "")).strip_edges().to_lower() 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 _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 _unit_can_equip_item(unit: Dictionary, item: Dictionary) -> bool: var slot := _equipment_slot_for_item(item) if slot.is_empty(): return false var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) var equipment_slots: Dictionary = class_def.get("equipment_slots", {}) var allowed = equipment_slots.get(slot, []) if typeof(allowed) != TYPE_ARRAY: return false if slot == "weapon": return allowed.has(str(item.get("weapon_type", ""))) if slot == "armor": return allowed.has(str(item.get("armor_type", ""))) if slot == "accessory": var accessory_type := str(item.get("accessory_type", "accessory")) return allowed.has(accessory_type) return false func _apply_equipment_stat_delta(unit: Dictionary, old_item: Dictionary, new_item: Dictionary) -> void: var old_bonuses: Dictionary = old_item.get("bonuses", {}) if not old_item.is_empty() else {} var new_bonuses: Dictionary = new_item.get("bonuses", {}) if not new_item.is_empty() else {} _apply_stat_bonus_delta(unit, old_bonuses, new_bonuses) func _apply_stat_bonus_delta(unit: Dictionary, old_bonuses: Dictionary, new_bonuses: Dictionary) -> void: for stat in ["hp", "mp", "atk", "def", "int", "agi"]: var delta := int(new_bonuses.get(stat, 0)) - int(old_bonuses.get(stat, 0)) if delta == 0: continue if stat == "hp": unit["max_hp"] = max(1, int(unit.get("max_hp", 1)) + delta) unit["hp"] = clampi(int(unit.get("hp", 1)) + delta, 1, int(unit["max_hp"])) elif stat == "mp": unit["max_mp"] = max(0, int(unit.get("max_mp", 0)) + delta) unit["mp"] = clampi(int(unit.get("mp", 0)) + delta, 0, int(unit["max_mp"])) elif stat == "atk": unit[stat] = max(1, int(unit.get(stat, 1)) + delta) else: unit[stat] = max(0, int(unit.get(stat, 0)) + delta) func _apply_class_runtime_fields(unit: Dictionary, class_id: String) -> void: var class_def := data_catalog.get_class_def(class_id) if class_def.is_empty(): return unit["class"] = str(class_def.get("name", class_id.capitalize())) unit["move"] = int(class_def.get("move", unit.get("move", 3))) unit["move_type"] = str(class_def.get("move_type", unit.get("move_type", "foot"))) unit["growth"] = class_def.get("growth", {}).duplicate(true) _add_missing_skills(unit, class_def.get("skills", [])) _refresh_attack_range_from_equipment(unit) func _refresh_attack_range_from_equipment(unit: Dictionary) -> void: var class_def := data_catalog.get_class_def(str(unit.get("class_id", ""))) var range_pair := _equipment_attack_range_pair(class_def, unit.get("equipment", {})) unit["min_range"] = int(range_pair["min"]) unit["range"] = int(range_pair["max"]) func _equipment_attack_range_pair(class_def: Dictionary, equipment: Dictionary) -> Dictionary: var attack_range := _attack_range_pair_from_value(class_def.get("attack_range", 1)) for slot in equipment.keys(): var item_id = equipment[slot] if item_id == null: continue var item := get_item_def(str(item_id)) if item.has("range"): var item_range := _attack_range_pair_from_value(item["range"]) attack_range["min"] = min(int(attack_range["min"]), int(item_range["min"])) attack_range["max"] = max(int(attack_range["max"]), int(item_range["max"])) return attack_range func _attack_range_pair_from_value(value) -> Dictionary: if typeof(value) == TYPE_ARRAY: if value.size() >= 2: return _normalized_attack_range_pair(int(value[0]), int(value[1])) if value.size() == 1: return _normalized_attack_range_pair(int(value[0]), int(value[0])) return _normalized_attack_range_pair(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_attack_range_pair(1, 1) var scalar: int = maxi(1, int(value)) return _normalized_attack_range_pair(scalar, scalar) func _normalized_attack_range_pair(min_range: int, max_range: int) -> Dictionary: var safe_min: int = maxi(1, min_range) var safe_max: int = maxi(safe_min, max_range) return {"min": safe_min, "max": safe_max} func _run_enemy_turn() -> void: for enemy in get_living_units(TEAM_ENEMY): if battle_status != STATUS_ACTIVE: break if enemy.get("acted", false): continue _enemy_take_action(enemy) enemy["acted"] = true enemy["moved"] = true _check_battle_status() if battle_status == STATUS_ACTIVE: turn_number += 1 current_team = TEAM_PLAYER _emit_log("Player phase begins.") _reset_team_actions(TEAM_PLAYER) if battle_status == STATUS_ACTIVE: _run_events("turn_start", current_team, turn_number) _check_battle_status() _notify_changed() func _enemy_take_action(enemy: Dictionary) -> void: if _try_enemy_skill_action(enemy): return var target := _find_nearest_target(enemy, TEAM_PLAYER) if target.is_empty(): return if _is_in_attack_range(enemy, target["pos"]): _resolve_combat(enemy, target) return var best_cell: Vector2i = enemy["pos"] var best_distance := _manhattan(best_cell, target["pos"]) for cell in _movement_range_for_unit(enemy): var distance := _manhattan(cell, target["pos"]) if distance < best_distance: best_distance = distance best_cell = cell if best_cell != enemy["pos"]: var from_cell: Vector2i = enemy["pos"] enemy["pos"] = best_cell enemy["moved"] = true unit_motion_requested.emit(str(enemy.get("id", "")), from_cell, best_cell) _emit_log("%s advances to %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 target = _find_nearest_target(enemy, TEAM_PLAYER) if not target.is_empty() and _is_in_attack_range(enemy, target["pos"]): _resolve_combat(enemy, target) 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 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 ally in get_living_units(str(caster.get("team", ""))): if not _skill_cells_from(caster, skill).has(ally["pos"]): continue if not _skill_target_valid(caster, ally, skill): continue var heal_amount := calculate_skill_heal(caster, ally, skill) if heal_amount <= 0: continue 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)))) if hp_ratio > 0.5 and missing_hp < 10: continue var score := heal_amount * 10 + missing_hp if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": ally } 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 in _support_ai_targets_for_skill(caster, skill): if not _skill_cells_from(caster, skill).has(target["pos"]): continue if not _skill_target_valid(caster, target, skill): continue if _has_status_effect_from_skill(target, normalized_skill_id): continue var score := _support_ai_score(caster, target, skill) if score <= 0: continue score -= _manhattan(caster["pos"], target["pos"]) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": target } 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) return score func _support_ai_stat_weight(stat: String) -> int: if stat == "atk": return 9 if stat == "def": return 7 if stat == "int": return 6 if stat == "agi": return 4 return 1 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 in get_living_units(target_team): if not _skill_cells_from(caster, skill).has(target["pos"]): continue if not _skill_target_valid(caster, target, skill): continue var damage := calculate_skill_damage(caster, target, skill) var would_defeat := int(target.get("hp", 0)) - damage <= 0 if defeating_only and not would_defeat: continue var score := damage + _ai_target_priority_score(target) if would_defeat: score += 1000 score -= _manhattan(caster["pos"], target["pos"]) if score > best_score: best_score = score best_action = { "skill_id": normalized_skill_id, "skill": skill, "target": target } 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() or target.is_empty(): return false if not _unit_has_skill(caster, skill_id): return false if int(caster.get("mp", 0)) < int(skill.get("mp_cost", 0)): return false if not target.get("alive", false): return false if not _skill_cells_from(caster, skill).has(target["pos"]): return false if not _skill_target_valid(caster, target, skill): return false var skill_kind := str(skill.get("kind", "damage")) if skill_kind == "heal" and calculate_skill_heal(caster, target, skill) <= 0: return false if skill_kind == "support" and (not _skill_has_support_effects(skill) or _has_status_effect_from_skill(target, skill_id)): return false caster["mp"] = max(0, int(caster.get("mp", 0)) - int(skill.get("mp_cost", 0))) if skill_kind == "heal": var healed := _resolve_skill_heal(caster, target, skill) if healed > 0: _grant_experience(caster, EXP_HEAL) elif skill_kind == "support": if _resolve_skill_support(caster, target, skill_id, skill): _grant_experience(caster, EXP_SUPPORT) else: var defeated := _resolve_skill_damage(caster, target, skill) _grant_experience(caster, EXP_SKILL + (EXP_DEFEAT_BONUS if defeated else 0)) _emit_log("%s spends %d MP." % [caster["name"], int(skill.get("mp_cost", 0))]) return true func _find_nearest_target(unit: Dictionary, target_team: String) -> Dictionary: var best_target := {} var best_score := 9999 for target in get_living_units(target_team): var distance := _manhattan(unit["pos"], target["pos"]) var score := distance - int(target.get("ai_target_priority", 0)) if score < best_score: best_score = score best_target = target return best_target func _ai_target_priority_score(target: Dictionary) -> int: return max(0, int(target.get("ai_target_priority", 0))) * 8 func _opposing_team(team: String) -> String: if team == TEAM_PLAYER: return TEAM_ENEMY if team == TEAM_ENEMY: return TEAM_PLAYER return "" func _resolve_combat(attacker: Dictionary, target: Dictionary) -> void: var attack_result := _resolve_attack(attacker, target, false) var attack_exp := EXP_ATTACK if bool(attack_result.get("hit", false)) else EXP_MISS if bool(attack_result.get("defeated", false)) or battle_status != STATUS_ACTIVE: _grant_experience(attacker, attack_exp + (EXP_DEFEAT_BONUS if bool(attack_result.get("defeated", false)) else 0)) return var counter_exp := 0 if bool(target.get("controllable", true)) and _is_in_attack_range(target, attacker["pos"]): var counter_result := _resolve_attack(target, attacker, true) counter_exp = (EXP_COUNTER if bool(counter_result.get("hit", false)) else EXP_MISS) + (EXP_DEFEAT_BONUS if bool(counter_result.get("defeated", false)) else 0) _grant_experience(attacker, attack_exp) _grant_experience(target, counter_exp) func _resolve_attack(attacker: Dictionary, target: Dictionary, is_counter := false) -> Dictionary: var hit_chance := calculate_hit_chance(attacker, target) var verb := "counterattacks" if is_counter else "attacks" if rng.randi_range(1, 100) > hit_chance: _emit_log("%s %s %s but misses. Hit %d%%." % [attacker["name"], verb, target["name"], hit_chance]) _emit_combat_feedback(target, "MISS", "miss") return {"hit": false, "defeated": false} var damage := calculate_damage(attacker, target) target["hp"] = max(0, int(target["hp"]) - damage) _emit_log("%s %s %s for %d damage. Hit %d%%." % [attacker["name"], verb, target["name"], damage, hit_chance]) _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s is defeated." % target["name"]) _emit_combat_feedback(target, "DOWN", "defeat") return {"hit": true, "defeated": true} return {"hit": true, "defeated": false} func _resolve_skill_damage(caster: Dictionary, target: Dictionary, skill: Dictionary) -> bool: var damage := calculate_skill_damage(caster, target, skill) target["hp"] = max(0, int(target["hp"]) - damage) _emit_log("%s casts %s on %s for %d damage." % [ caster["name"], skill.get("name", "Skill"), target["name"], damage ]) _emit_combat_feedback(target, "-%d" % damage, "damage") if int(target["hp"]) <= 0: target["alive"] = false _emit_log("%s is defeated." % target["name"]) _emit_combat_feedback(target, "DOWN", "defeat") return true return false 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 casts %s on %s, restoring %d HP." % [ caster["name"], skill.get("name", "Skill"), target["name"], healed ]) _emit_combat_feedback(target, "+%d HP" % healed, "heal") return healed func _grant_experience(unit: Dictionary, amount: int) -> void: if unit.is_empty() or not unit.get("alive", false) or amount <= 0: return unit["exp"] = int(unit.get("exp", 0)) + amount _emit_log("%s gains %d EXP." % [unit["name"], amount]) while int(unit["exp"]) >= EXP_LEVEL: unit["exp"] = int(unit["exp"]) - EXP_LEVEL _level_up(unit) func _level_up(unit: Dictionary) -> void: var previous_level := int(unit.get("level", 1)) unit["level"] = int(unit.get("level", 1)) + 1 var hp_gain := _growth_gain(unit, "hp", 3) var mp_gain := _growth_gain(unit, "mp", 1) unit["max_hp"] = int(unit.get("max_hp", 1)) + hp_gain unit["hp"] = min(int(unit["max_hp"]), int(unit.get("hp", 1)) + hp_gain) unit["max_mp"] = int(unit.get("max_mp", 0)) + mp_gain unit["mp"] = min(int(unit["max_mp"]), int(unit.get("mp", 0)) + mp_gain) for stat in ["atk", "def", "int", "agi"]: unit[stat] = int(unit.get(stat, 0)) + _growth_gain(unit, stat, 1) _emit_log("%s reaches Lv.%d." % [unit["name"], unit["level"]]) _emit_combat_feedback(unit, "Lv.%d" % int(unit["level"]), "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "level_up", "unit_id": str(unit.get("id", "")), "officer_id": str(unit.get("officer_id", "")), "name": str(unit.get("name", "Unit")), "from_level": previous_level, "to_level": int(unit.get("level", previous_level + 1)), "class_id": str(unit.get("class_id", "")), "class": str(unit.get("class", "")) }) _try_promote_unit(unit) func _try_promote_unit(unit: Dictionary) -> void: var class_id := str(unit.get("class_id", "")) var class_def := data_catalog.get_class_def(class_id) if class_def.is_empty(): return var promotion = class_def.get("promotion", {}) if typeof(promotion) != TYPE_DICTIONARY: return var required_level := int(promotion.get("level", 0)) var next_class_id := str(promotion.get("to", "")) if required_level <= 0 or next_class_id.is_empty() or int(unit.get("level", 1)) < required_level: return var next_class_def := data_catalog.get_class_def(next_class_id) if next_class_def.is_empty(): return var previous_class_id := class_id var previous_class_name := str(class_def.get("name", class_id.capitalize())) _apply_stat_bonus_delta(unit, class_def.get("base_bonus", {}), next_class_def.get("base_bonus", {})) unit["class_id"] = next_class_id _apply_class_runtime_fields(unit, next_class_id) _emit_log("%s promotes to %s." % [unit["name"], unit["class"]]) _emit_combat_feedback(unit, "PROMOTE", "progress") if str(unit.get("team", "")) == TEAM_PLAYER: _record_progression_event({ "type": "promotion", "unit_id": str(unit.get("id", "")), "officer_id": str(unit.get("officer_id", "")), "name": str(unit.get("name", "Unit")), "level": int(unit.get("level", 1)), "from_class_id": previous_class_id, "from_class": previous_class_name, "to_class_id": next_class_id, "to_class": str(unit.get("class", next_class_id.capitalize())) }) func _add_missing_skills(unit: Dictionary, source) -> void: if typeof(source) != TYPE_ARRAY: return if not unit.has("skills") or typeof(unit["skills"]) != TYPE_ARRAY: unit["skills"] = [] for skill_id in source: var normalized := str(skill_id) if normalized.is_empty() or unit["skills"].has(normalized): continue unit["skills"].append(normalized) func _record_progression_event(event: Dictionary) -> void: if str(event.get("name", "")).is_empty() or str(event.get("type", "")).is_empty(): return progression_events.append(event.duplicate(true)) func _growth_gain(unit: Dictionary, stat: String, fallback: int) -> int: var growth: Dictionary = unit.get("growth", {}) var grade := str(growth.get(stat, "C")) var table: Dictionary = GROWTH_GRADE_GAINS.get(stat, {}) var gain := int(table.get(grade, fallback)) var bonus: Dictionary = unit.get("growth_bonus", {}) return max(0, gain + int(bonus.get(stat, 0))) func _check_battle_status() -> void: if _is_condition_group_met(battle_conditions.get("defeat", {})): battle_status = STATUS_DEFEAT current_team = TEAM_PLAYER selected_unit_id = "" _emit_log("Defeat condition met.") elif _is_condition_group_met(battle_conditions.get("victory", {})): battle_status = STATUS_VICTORY current_team = TEAM_PLAYER selected_unit_id = "" _emit_log("Victory condition met.") 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 _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 target := _condition_cell(condition.get("pos", [])) if not is_inside(target): 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 unit.get("pos", Vector2i(-9999, -9999)) == target: 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_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): 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 var cell := _condition_cell(condition_group.get("pos", [])) if is_inside(cell) and not result.has(cell): result.append(cell) 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 not _campaign_flags_match(when.get("campaign_flags", {})): continue _execute_event_actions(event.get("actions", [])) 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 target := _condition_cell(when.get("pos", [])) if not is_inside(target): return false if unit.get("pos", Vector2i(-9999, -9999)) != target: return false return _unit_matches_condition_ids(unit, when.get("unit_ids", []), when.get("officer_ids", [])) 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) -> void: for action in actions: if typeof(action) != TYPE_DICTIONARY: continue _execute_event_action(action) func _execute_event_action(action: 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 == "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) func _apply_objective_event(action: Dictionary) -> void: if action.has("victory"): objectives["victory"] = str(action["victory"]) if action.has("defeat"): objectives["defeat"] = str(action["defeat"]) _emit_log("Objective updated.") 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 portrait := str(line.get("portrait", "")) if portrait.is_empty(): portrait = data_catalog.get_portrait_for_speaker(speaker) return { "speaker": speaker, "text": text, "portrait": portrait, "side": _normalize_dialogue_side(line.get("side", "left")) } 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("Reinforcement %s is not available." % 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("Reinforcement %s could not deploy outside the battlefield." % unit["name"]) return false if get_move_cost(unit["pos"], str(unit.get("move_type", "foot"))) >= 99: _emit_log("Reinforcement %s could not deploy on impassable terrain." % unit["name"]) return false if not get_unit(unit["id"]).is_empty(): _emit_log("Reinforcement %s already exists." % unit["name"]) return false if not get_unit_at(unit["pos"]).is_empty(): _emit_log("Reinforcement %s could not deploy at %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 arrives at %s." % [unit["name"], _format_cell(unit["pos"])]) 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 _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")) _emit_log("%s suffers %d %s damage." % [unit["name"], damage, status.replace("_", " ")]) _emit_combat_feedback(unit, "-%d" % damage, status) if int(unit.get("hp", 0)) <= 0: unit["alive"] = false _emit_log("%s is defeated by %s." % [unit["name"], status.replace("_", " ")]) _emit_combat_feedback(unit, "DOWN", "defeat") break 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", "Support")) 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 %s fades." % [unit["name"], expired_effect_name]) _emit_combat_feedback(unit, "FADES", "fade") func _manhattan(a: Vector2i, b: Vector2i) -> int: return absi(a.x - b.x) + absi(a.y - b.y) func _format_cell(cell: Vector2i) -> String: return "(%d, %d)" % [cell.x + 1, cell.y + 1] func _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()