extends RefCounted class_name DataCatalog const OFFICERS_PATH := "res://data/defs/officers.json" const CLASSES_PATH := "res://data/defs/classes.json" const TERRAIN_PATH := "res://data/defs/terrain.json" const ITEMS_PATH := "res://data/defs/items.json" const SKILLS_PATH := "res://data/defs/skills.json" var officers: Dictionary = {} var classes: Dictionary = {} var terrain: Dictionary = {} var items: Dictionary = {} var skills: Dictionary = {} func load_defaults() -> bool: officers = _load_json_object(OFFICERS_PATH) classes = _load_json_object(CLASSES_PATH) terrain = _load_json_object(TERRAIN_PATH) items = _load_json_object(ITEMS_PATH) skills = _load_json_object(SKILLS_PATH) return not officers.is_empty() and not classes.is_empty() and not terrain.is_empty() func hydrate_deployment(deployment: Dictionary) -> Dictionary: var officer := _get_dict(officers, deployment.get("officer_id", "")) var class_id := str(deployment.get("class_id", officer.get("class_id", "infantry"))) var class_def := _get_dict(classes, class_id) var equipment := _merged_equipment(officer.get("equipment", {}), deployment.get("equipment", {})) var range_pair := _resolve_attack_range(class_def, equipment) var unit_officer_id := str(deployment.get("officer_id", "")) var unit_team := str(deployment.get("team", "enemy")) var sprite_info := _resolved_unit_sprite(deployment, officer, class_def, unit_team, unit_officer_id) var stats := {} _overlay_stats(stats, officer.get("base", {})) _overlay_stats(stats, deployment.get("base", {})) _add_stats(stats, class_def.get("base_bonus", {})) _add_equipment_bonuses(stats, equipment) var unit := {} unit["id"] = str(deployment.get("unit_id", deployment.get("id", deployment.get("officer_id", "")))) unit["officer_id"] = unit_officer_id unit["name"] = str(deployment.get("name", officer.get("name", unit["id"]))) unit["portrait"] = str(deployment.get("portrait", officer.get("portrait", ""))) unit["sprite"] = str(sprite_info.get("path", "")) unit["uses_enemy_sprite"] = bool(sprite_info.get("uses_enemy_sprite", false)) unit["class_id"] = class_id unit["class"] = str(class_def.get("name", class_id.capitalize())) unit["team"] = unit_team unit["pos"] = deployment.get("pos", [0, 0]) unit["requires_joined"] = bool(deployment.get("requires_joined", false)) unit["controllable"] = bool(deployment.get("controllable", true)) unit["persist_progression"] = bool(deployment.get("persist_progression", true)) unit["ai_target_priority"] = int(deployment.get("ai_target_priority", 0)) unit["ai_behavior"] = str(deployment.get("ai_behavior", "")).strip_edges().to_lower() if deployment.has("ai_guard_anchor"): unit["ai_guard_anchor"] = deployment["ai_guard_anchor"] elif unit["ai_behavior"] == "guard": unit["ai_guard_anchor"] = deployment.get("pos", [0, 0]) if deployment.has("ai_guard_radius"): unit["ai_guard_radius"] = int(deployment.get("ai_guard_radius", 0)) elif unit["ai_behavior"] == "guard": unit["ai_guard_radius"] = 2 unit["level"] = int(deployment.get("level", officer.get("level", 1))) unit["exp"] = int(deployment.get("exp", officer.get("exp", 0))) unit["max_hp"] = int(stats.get("hp", stats.get("max_hp", 1))) unit["hp"] = int(deployment.get("hp", unit["max_hp"])) unit["max_mp"] = int(stats.get("mp", 0)) unit["mp"] = int(deployment.get("mp", unit["max_mp"])) unit["atk"] = int(stats.get("atk", 1)) unit["def"] = int(stats.get("def", 0)) unit["int"] = int(stats.get("int", 0)) unit["agi"] = int(stats.get("agi", 0)) unit["move"] = int(deployment.get("move", class_def.get("move", 3))) unit["move_type"] = str(deployment.get("move_type", class_def.get("move_type", "foot"))) if deployment.has("range"): range_pair = _range_pair_from_value(deployment["range"]) unit["min_range"] = int(deployment.get("min_range", range_pair["min"])) unit["range"] = int(range_pair["max"]) unit["growth"] = class_def.get("growth", {}).duplicate(true) unit["growth_bonus"] = officer.get("growth_bonus", {}).duplicate(true) if deployment.has("growth_bonus") and typeof(deployment["growth_bonus"]) == TYPE_DICTIONARY: for stat in deployment["growth_bonus"].keys(): unit["growth_bonus"][stat] = deployment["growth_bonus"][stat] unit["equipment"] = equipment unit["skills"] = _merged_skill_list(class_def.get("skills", []), officer.get("skills", []), deployment.get("skills", [])) return unit func _resolved_unit_sprite(deployment: Dictionary, officer: Dictionary, class_def: Dictionary, team: String, officer_id: String) -> Dictionary: var deployment_sprite := str(deployment.get("sprite", "")).strip_edges() if not deployment_sprite.is_empty(): return {"path": deployment_sprite, "uses_enemy_sprite": false} var officer_sprite := str(officer.get("sprite", "")).strip_edges() if not officer_sprite.is_empty(): return {"path": officer_sprite, "uses_enemy_sprite": false} var enemy_sprite := str(class_def.get("enemy_sprite", "")).strip_edges() if team == "enemy" and officer_id.strip_edges().is_empty() and not enemy_sprite.is_empty(): return {"path": enemy_sprite, "uses_enemy_sprite": true} return {"path": str(class_def.get("sprite", "")).strip_edges(), "uses_enemy_sprite": false} func get_runtime_terrain_defs(default_defs: Dictionary) -> Dictionary: var result := default_defs.duplicate(true) for key in terrain.keys(): var source := _get_dict(terrain, key) var fallback: Dictionary = result.get(key, {}) var runtime := fallback.duplicate(true) runtime["id"] = str(source.get("id", runtime.get("id", key))) runtime["name"] = str(source.get("name", runtime.get("name", key))) runtime["defense"] = int(source.get("defense", runtime.get("defense", 0))) runtime["avoid"] = int(source.get("avoid", runtime.get("avoid", 0))) runtime["heal"] = int(source.get("heal", runtime.get("heal", 0))) runtime["move_cost"] = source.get("move_cost", runtime.get("move_cost", 1)) runtime["color"] = _parse_color(source.get("color", runtime.get("color", Color.WHITE)), runtime.get("color", Color.WHITE)) result[key] = runtime return result func get_skill(skill_id: String) -> Dictionary: return _get_dict(skills, skill_id).duplicate(true) func get_item(item_id: String) -> Dictionary: return _get_dict(items, item_id).duplicate(true) func get_item_ids() -> Array: var result := [] for item_id in items.keys(): result.append(str(item_id)) result.sort() return result func get_class_def(class_id: String) -> Dictionary: return _get_dict(classes, class_id).duplicate(true) func get_portrait_for_speaker(speaker_name: String) -> String: var normalized_speaker := _normalized_speaker_name(speaker_name) if normalized_speaker.is_empty(): return "" for officer_id in officers.keys(): var officer := _get_dict(officers, officer_id) if _normalized_speaker_name(str(officer.get("name", ""))) == normalized_speaker: return str(officer.get("portrait", "")) for alias in officer.get("aliases", []): if _normalized_speaker_name(str(alias)) == normalized_speaker: return str(officer.get("portrait", "")) return "" func get_portrait_for_officer(officer_id: String) -> String: if officer_id.is_empty(): return "" var officer := _get_dict(officers, officer_id) return str(officer.get("portrait", "")) func _load_json_object(path: String) -> Dictionary: if not FileAccess.file_exists(path): push_error("Missing data file: %s" % path) return {} var text := FileAccess.get_file_as_string(path) var parsed = JSON.parse_string(text) if typeof(parsed) != TYPE_DICTIONARY: push_error("Data file is not a JSON object: %s" % path) return {} return parsed func _get_dict(source: Dictionary, key) -> Dictionary: if key == null: return {} var value = source.get(str(key), {}) if typeof(value) == TYPE_DICTIONARY: return value return {} func _normalized_speaker_name(value: String) -> String: return value.strip_edges().to_lower() func _merged_equipment(base_equipment, override_equipment) -> Dictionary: var result := {} if typeof(base_equipment) == TYPE_DICTIONARY: result = base_equipment.duplicate(true) if typeof(override_equipment) == TYPE_DICTIONARY: for slot in override_equipment.keys(): result[slot] = override_equipment[slot] return result func _overlay_stats(target: Dictionary, source) -> void: if typeof(source) != TYPE_DICTIONARY: return for key in source.keys(): target[key] = int(source[key]) func _add_stats(target: Dictionary, source) -> void: if typeof(source) != TYPE_DICTIONARY: return for key in source.keys(): target[key] = int(target.get(key, 0)) + int(source[key]) func _add_equipment_bonuses(target: Dictionary, equipment: Dictionary) -> void: for slot in equipment.keys(): var item_id = equipment[slot] if item_id == null: continue var item := _get_dict(items, item_id) _add_stats(target, item.get("bonuses", {})) func _merged_skill_list(class_skills, officer_skills, deployment_skills) -> Array: var result := [] _append_unique_skills(result, class_skills) _append_unique_skills(result, officer_skills) _append_unique_skills(result, deployment_skills) return result func _append_unique_skills(target: Array, source) -> void: if typeof(source) != TYPE_ARRAY: return for skill_id in source: var normalized := str(skill_id) if normalized.is_empty() or target.has(normalized): continue target.append(normalized) func _resolve_attack_range(class_def: Dictionary, equipment: Dictionary) -> Dictionary: var attack_range := _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_dict(items, item_id) if item.has("range"): var item_range := _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 _range_pair_from_value(value) -> Dictionary: if typeof(value) == TYPE_ARRAY: if value.size() >= 2: return _normalized_range_pair(int(value[0]), int(value[1])) if value.size() == 1: return _normalized_range_pair(int(value[0]), int(value[0])) return _normalized_range_pair(1, 1) if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT: return _normalized_range_pair(1, 1) var scalar: int = maxi(1, int(value)) return _normalized_range_pair(scalar, scalar) func _normalized_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 _parse_color(value, fallback: Color) -> Color: if typeof(value) == TYPE_COLOR: return value if typeof(value) == TYPE_ARRAY and value.size() >= 3: var alpha := 1.0 if value.size() >= 4: alpha = float(value[3]) return Color(float(value[0]), float(value[1]), float(value[2]), alpha) var text := str(value) if Color.html_is_valid(text): return Color.html(text) return fallback