Initial Godot tactical RPG prototype
This commit is contained in:
2388
scripts/core/battle_state.gd
Normal file
2388
scripts/core/battle_state.gd
Normal file
File diff suppressed because it is too large
Load Diff
592
scripts/core/campaign_state.gd
Normal file
592
scripts/core/campaign_state.gd
Normal file
@@ -0,0 +1,592 @@
|
||||
extends RefCounted
|
||||
class_name CampaignState
|
||||
|
||||
const SAVE_PATH := "user://campaign_save.json"
|
||||
const SAVE_VERSION := 3
|
||||
|
||||
var save_version := SAVE_VERSION
|
||||
var campaign_id := ""
|
||||
var campaign_title := ""
|
||||
var start_scenario_id := ""
|
||||
var scenario_order: Array[String] = []
|
||||
var scenario_paths := {}
|
||||
var scenario_titles := {}
|
||||
var initial_joined_officers: Array[String] = []
|
||||
var current_scenario_id := ""
|
||||
var completed_scenarios: Array[String] = []
|
||||
var gold := 0
|
||||
var inventory := {}
|
||||
var roster := {}
|
||||
var flags := {}
|
||||
var joined_officers: Array[String] = []
|
||||
var applied_post_battle_choices := {}
|
||||
var pending_post_battle_choice_scenario_id := ""
|
||||
|
||||
|
||||
func load_campaign(path: String) -> bool:
|
||||
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("Campaign file is not valid JSON: %s" % path)
|
||||
return false
|
||||
|
||||
campaign_id = str(parsed.get("id", ""))
|
||||
campaign_title = str(parsed.get("title", "Campaign"))
|
||||
start_scenario_id = str(parsed.get("start_scenario", ""))
|
||||
scenario_order.clear()
|
||||
scenario_paths.clear()
|
||||
scenario_titles.clear()
|
||||
initial_joined_officers.clear()
|
||||
|
||||
for officer_id in parsed.get("initial_joined_officers", []):
|
||||
var normalized := str(officer_id)
|
||||
if normalized.is_empty() or initial_joined_officers.has(normalized):
|
||||
continue
|
||||
initial_joined_officers.append(normalized)
|
||||
|
||||
for scenario in parsed.get("scenarios", []):
|
||||
if typeof(scenario) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var scenario_id := str(scenario.get("id", ""))
|
||||
var scenario_path := str(scenario.get("path", ""))
|
||||
if scenario_id.is_empty() or scenario_path.is_empty():
|
||||
continue
|
||||
scenario_order.append(scenario_id)
|
||||
scenario_paths[scenario_id] = scenario_path
|
||||
scenario_titles[scenario_id] = str(scenario.get("title", scenario_id))
|
||||
|
||||
if start_scenario_id.is_empty() and not scenario_order.is_empty():
|
||||
start_scenario_id = scenario_order[0]
|
||||
return not scenario_order.is_empty()
|
||||
|
||||
|
||||
func load_or_start(scenario_id: String) -> void:
|
||||
var loaded_save := load_game()
|
||||
if not loaded_save:
|
||||
start_new(scenario_id)
|
||||
else:
|
||||
if current_scenario_id.is_empty():
|
||||
current_scenario_id = scenario_id
|
||||
var save_needs_update := false
|
||||
if loaded_save:
|
||||
save_needs_update = _clear_invalid_pending_post_battle_choice() or save_needs_update
|
||||
_normalize_current_scenario()
|
||||
if loaded_save:
|
||||
save_needs_update = _backfill_applied_post_battle_choices() or save_needs_update
|
||||
save_needs_update = _reconcile_completed_officer_transitions() or save_needs_update
|
||||
if save_needs_update and not save_game():
|
||||
push_error("Unable to save reconciled campaign officer state.")
|
||||
|
||||
|
||||
func start_new(scenario_id: String) -> void:
|
||||
current_scenario_id = scenario_id
|
||||
completed_scenarios.clear()
|
||||
gold = 0
|
||||
inventory.clear()
|
||||
roster.clear()
|
||||
flags.clear()
|
||||
joined_officers.clear()
|
||||
applied_post_battle_choices.clear()
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
for officer_id in initial_joined_officers:
|
||||
joined_officers.append(officer_id)
|
||||
|
||||
|
||||
func reset_save(scenario_id: String) -> bool:
|
||||
var save_file := ProjectSettings.globalize_path(SAVE_PATH)
|
||||
if FileAccess.file_exists(SAVE_PATH):
|
||||
var error := DirAccess.remove_absolute(save_file)
|
||||
if error != OK:
|
||||
push_error("Unable to delete campaign save: %s" % save_file)
|
||||
return false
|
||||
start_new(scenario_id)
|
||||
return save_game()
|
||||
|
||||
|
||||
func load_game() -> bool:
|
||||
if not FileAccess.file_exists(SAVE_PATH):
|
||||
return false
|
||||
|
||||
var parsed = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return false
|
||||
|
||||
save_version = int(parsed.get("save_version", 1))
|
||||
current_scenario_id = str(parsed.get("current_scenario_id", ""))
|
||||
completed_scenarios.clear()
|
||||
for scenario_id in parsed.get("completed_scenarios", []):
|
||||
completed_scenarios.append(str(scenario_id))
|
||||
gold = int(parsed.get("gold", 0))
|
||||
inventory = _copy_dictionary(parsed.get("inventory", {}))
|
||||
roster = _copy_dictionary(parsed.get("roster", {}))
|
||||
flags = _copy_dictionary(parsed.get("flags", {}))
|
||||
applied_post_battle_choices = _copy_dictionary(parsed.get("applied_post_battle_choices", {}))
|
||||
pending_post_battle_choice_scenario_id = str(parsed.get("pending_post_battle_choice_scenario_id", ""))
|
||||
joined_officers.clear()
|
||||
var saved_joined = parsed.get("joined_officers", initial_joined_officers)
|
||||
for officer_id in saved_joined:
|
||||
var normalized := str(officer_id)
|
||||
if normalized.is_empty() or joined_officers.has(normalized):
|
||||
continue
|
||||
joined_officers.append(normalized)
|
||||
return true
|
||||
|
||||
|
||||
func save_game() -> bool:
|
||||
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("Unable to save campaign state: %s" % SAVE_PATH)
|
||||
return false
|
||||
file.store_string(JSON.stringify(to_dict()))
|
||||
return true
|
||||
|
||||
|
||||
func apply_battle_result(battle_state) -> Dictionary:
|
||||
if battle_state.battle_status != "victory":
|
||||
return {
|
||||
"saved": false,
|
||||
"rewards_applied": false,
|
||||
"already_completed": false,
|
||||
"gold": 0,
|
||||
"items": [],
|
||||
"joined_officers": [],
|
||||
"left_officers": [],
|
||||
"progression_events": []
|
||||
}
|
||||
|
||||
var already_completed := completed_scenarios.has(battle_state.battle_id)
|
||||
var rewards := battle_state.get_rewards()
|
||||
var post_battle_choices := battle_state.get_post_battle_choices()
|
||||
var previous_completed := completed_scenarios.duplicate()
|
||||
var previous_gold := gold
|
||||
var previous_inventory := inventory.duplicate(true)
|
||||
var previous_roster := roster.duplicate(true)
|
||||
var previous_current_scenario_id := current_scenario_id
|
||||
var previous_joined_officers := joined_officers.duplicate()
|
||||
var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id
|
||||
var rewarded_items: Array = []
|
||||
var joined_reward_officers: Array = []
|
||||
var left_reward_officers: Array = []
|
||||
var reward_gold := 0
|
||||
var next_scenario_id := get_next_scenario_id(battle_state.battle_id)
|
||||
|
||||
if not already_completed:
|
||||
roster = battle_state.get_player_roster_snapshot()
|
||||
inventory = battle_state.get_inventory_snapshot()
|
||||
|
||||
if not already_completed:
|
||||
completed_scenarios.append(battle_state.battle_id)
|
||||
reward_gold = int(rewards.get("gold", 0))
|
||||
gold += reward_gold
|
||||
|
||||
for item_id in rewards.get("items", []):
|
||||
var key := str(item_id)
|
||||
inventory[key] = int(inventory.get(key, 0)) + 1
|
||||
rewarded_items.append(key)
|
||||
joined_reward_officers = _apply_join_officers(rewards.get("join_officers", []))
|
||||
left_reward_officers = _apply_leave_officers(rewards.get("leave_officers", []))
|
||||
pending_post_battle_choice_scenario_id = battle_state.battle_id if not post_battle_choices.is_empty() else ""
|
||||
|
||||
if not next_scenario_id.is_empty():
|
||||
current_scenario_id = next_scenario_id
|
||||
else:
|
||||
current_scenario_id = battle_state.battle_id
|
||||
|
||||
var saved := save_game()
|
||||
if not saved:
|
||||
completed_scenarios.clear()
|
||||
for scenario_id in previous_completed:
|
||||
completed_scenarios.append(str(scenario_id))
|
||||
gold = previous_gold
|
||||
inventory = previous_inventory
|
||||
roster = previous_roster
|
||||
current_scenario_id = previous_current_scenario_id
|
||||
pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id
|
||||
joined_officers.clear()
|
||||
for officer_id in previous_joined_officers:
|
||||
joined_officers.append(str(officer_id))
|
||||
reward_gold = 0
|
||||
rewarded_items.clear()
|
||||
joined_reward_officers.clear()
|
||||
left_reward_officers.clear()
|
||||
|
||||
return {
|
||||
"saved": saved,
|
||||
"rewards_applied": not already_completed and saved,
|
||||
"already_completed": already_completed,
|
||||
"gold": reward_gold,
|
||||
"items": rewarded_items,
|
||||
"joined_officers": joined_reward_officers,
|
||||
"left_officers": left_reward_officers,
|
||||
"progression_events": battle_state.get_progression_events() if saved and not already_completed else [],
|
||||
"next_scenario_id": next_scenario_id,
|
||||
"next_scenario_title": get_scenario_title(next_scenario_id),
|
||||
"scenario_id": battle_state.battle_id,
|
||||
"campaign_complete": next_scenario_id.is_empty(),
|
||||
"post_battle_choices": post_battle_choices if saved and not already_completed else [],
|
||||
"choice_applied": not saved or already_completed or post_battle_choices.is_empty(),
|
||||
"choice_label": ""
|
||||
}
|
||||
|
||||
|
||||
func get_roster_overrides() -> Dictionary:
|
||||
return roster.duplicate(true)
|
||||
|
||||
|
||||
func get_joined_officers_snapshot() -> Array:
|
||||
return joined_officers.duplicate()
|
||||
|
||||
|
||||
func get_inventory_snapshot() -> Dictionary:
|
||||
return inventory.duplicate(true)
|
||||
|
||||
|
||||
func try_buy_item(item_id: String, price: int) -> bool:
|
||||
if item_id.is_empty() or price <= 0 or gold < price:
|
||||
return false
|
||||
var previous_count := int(inventory.get(item_id, 0))
|
||||
gold -= price
|
||||
inventory[item_id] = previous_count + 1
|
||||
if save_game():
|
||||
return true
|
||||
gold += price
|
||||
if previous_count > 0:
|
||||
inventory[item_id] = previous_count
|
||||
else:
|
||||
inventory.erase(item_id)
|
||||
return false
|
||||
|
||||
|
||||
func try_save_prebattle_loadout(roster_snapshot: Dictionary, inventory_snapshot: Dictionary) -> bool:
|
||||
var previous_roster := roster.duplicate(true)
|
||||
var previous_inventory := inventory.duplicate(true)
|
||||
for roster_key in roster_snapshot.keys():
|
||||
var entry = roster_snapshot[roster_key]
|
||||
if typeof(entry) == TYPE_DICTIONARY:
|
||||
roster[str(roster_key)] = entry.duplicate(true)
|
||||
inventory = inventory_snapshot.duplicate(true)
|
||||
if save_game():
|
||||
return true
|
||||
roster = previous_roster
|
||||
inventory = previous_inventory
|
||||
return false
|
||||
|
||||
|
||||
func try_apply_post_battle_choice(choice: Dictionary, scenario_id: String = "") -> bool:
|
||||
var previous_flags := flags.duplicate(true)
|
||||
var previous_gold := gold
|
||||
var previous_inventory := inventory.duplicate(true)
|
||||
var previous_joined_officers := joined_officers.duplicate()
|
||||
var previous_applied_post_battle_choices := applied_post_battle_choices.duplicate(true)
|
||||
var previous_pending_post_battle_choice_scenario_id := pending_post_battle_choice_scenario_id
|
||||
var previous_current_scenario_id := current_scenario_id
|
||||
|
||||
var set_flags: Dictionary = choice.get("set_flags", {})
|
||||
for flag_name in set_flags.keys():
|
||||
flags[str(flag_name)] = set_flags[flag_name]
|
||||
|
||||
var reward_gold := int(choice.get("gold", 0))
|
||||
if reward_gold > 0:
|
||||
gold += reward_gold
|
||||
for item_id in choice.get("items", []):
|
||||
var key := str(item_id)
|
||||
if key.is_empty():
|
||||
continue
|
||||
inventory[key] = int(inventory.get(key, 0)) + 1
|
||||
_apply_join_officers(choice.get("join_officers", []))
|
||||
_apply_leave_officers(choice.get("leave_officers", []))
|
||||
var choice_id := str(choice.get("id", ""))
|
||||
if not scenario_id.is_empty() and not choice_id.is_empty():
|
||||
applied_post_battle_choices[scenario_id] = choice_id
|
||||
if pending_post_battle_choice_scenario_id == scenario_id:
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
var next_scenario_id := get_next_scenario_id(scenario_id)
|
||||
current_scenario_id = next_scenario_id if not next_scenario_id.is_empty() else scenario_id
|
||||
|
||||
if save_game():
|
||||
return true
|
||||
flags = previous_flags
|
||||
gold = previous_gold
|
||||
inventory = previous_inventory
|
||||
applied_post_battle_choices = previous_applied_post_battle_choices
|
||||
pending_post_battle_choice_scenario_id = previous_pending_post_battle_choice_scenario_id
|
||||
current_scenario_id = previous_current_scenario_id
|
||||
joined_officers.clear()
|
||||
for officer_id in previous_joined_officers:
|
||||
joined_officers.append(str(officer_id))
|
||||
return false
|
||||
|
||||
|
||||
func has_pending_post_battle_choice() -> bool:
|
||||
if pending_post_battle_choice_scenario_id.is_empty():
|
||||
return false
|
||||
if not scenario_paths.has(pending_post_battle_choice_scenario_id):
|
||||
return false
|
||||
if not completed_scenarios.has(pending_post_battle_choice_scenario_id):
|
||||
return false
|
||||
if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func get_pending_post_battle_choice_scenario_id() -> String:
|
||||
return pending_post_battle_choice_scenario_id
|
||||
|
||||
|
||||
func get_pending_post_battle_choice_summary() -> Dictionary:
|
||||
if pending_post_battle_choice_scenario_id.is_empty():
|
||||
return {}
|
||||
var scenario_data := _load_scenario_definition(pending_post_battle_choice_scenario_id)
|
||||
if scenario_data.is_empty():
|
||||
return {}
|
||||
var choices = scenario_data.get("post_battle_choices", [])
|
||||
if typeof(choices) != TYPE_ARRAY or choices.is_empty():
|
||||
return {}
|
||||
var next_scenario_id := get_next_scenario_id(pending_post_battle_choice_scenario_id)
|
||||
return {
|
||||
"saved": true,
|
||||
"rewards_applied": false,
|
||||
"already_completed": false,
|
||||
"pending_choice": true,
|
||||
"gold": 0,
|
||||
"items": [],
|
||||
"joined_officers": [],
|
||||
"left_officers": [],
|
||||
"progression_events": [],
|
||||
"next_scenario_id": next_scenario_id,
|
||||
"next_scenario_title": get_scenario_title(next_scenario_id),
|
||||
"scenario_id": pending_post_battle_choice_scenario_id,
|
||||
"campaign_complete": next_scenario_id.is_empty(),
|
||||
"post_battle_choices": choices,
|
||||
"choice_applied": false,
|
||||
"choice_label": ""
|
||||
}
|
||||
|
||||
|
||||
func _clear_invalid_pending_post_battle_choice() -> bool:
|
||||
if pending_post_battle_choice_scenario_id.is_empty():
|
||||
return false
|
||||
if not scenario_paths.has(pending_post_battle_choice_scenario_id):
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
return true
|
||||
if not completed_scenarios.has(pending_post_battle_choice_scenario_id):
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
return true
|
||||
if applied_post_battle_choices.has(pending_post_battle_choice_scenario_id):
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
return true
|
||||
if get_pending_post_battle_choice_summary().is_empty():
|
||||
pending_post_battle_choice_scenario_id = ""
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _apply_join_officers(source) -> Array:
|
||||
var result := []
|
||||
if typeof(source) != TYPE_ARRAY:
|
||||
return result
|
||||
for officer_id in source:
|
||||
var key := str(officer_id)
|
||||
if key.is_empty() or joined_officers.has(key):
|
||||
continue
|
||||
joined_officers.append(key)
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
|
||||
func _apply_leave_officers(source) -> Array:
|
||||
var result := []
|
||||
if typeof(source) != TYPE_ARRAY:
|
||||
return result
|
||||
for officer_id in source:
|
||||
var key := str(officer_id)
|
||||
if key.is_empty() or not joined_officers.has(key):
|
||||
continue
|
||||
joined_officers.erase(key)
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
|
||||
func _reconcile_completed_officer_transitions() -> bool:
|
||||
var changed := false
|
||||
for scenario_id in scenario_order:
|
||||
if not completed_scenarios.has(scenario_id):
|
||||
continue
|
||||
var scenario_data := _load_scenario_definition(scenario_id)
|
||||
if scenario_data.is_empty():
|
||||
continue
|
||||
|
||||
var rewards = scenario_data.get("rewards", {})
|
||||
if typeof(rewards) == TYPE_DICTIONARY:
|
||||
changed = _apply_officer_transitions_from_block(rewards) or changed
|
||||
|
||||
var applied_choice := _get_reconciled_post_battle_choice(scenario_id, scenario_data)
|
||||
if not applied_choice.is_empty():
|
||||
changed = _apply_officer_transitions_from_block(applied_choice) or changed
|
||||
return changed
|
||||
|
||||
|
||||
func _backfill_applied_post_battle_choices() -> bool:
|
||||
var changed := false
|
||||
for scenario_id in scenario_order:
|
||||
if not completed_scenarios.has(scenario_id) or applied_post_battle_choices.has(scenario_id):
|
||||
continue
|
||||
if scenario_id == pending_post_battle_choice_scenario_id:
|
||||
continue
|
||||
var scenario_data := _load_scenario_definition(scenario_id)
|
||||
if scenario_data.is_empty():
|
||||
continue
|
||||
var choices = scenario_data.get("post_battle_choices", [])
|
||||
if typeof(choices) != TYPE_ARRAY:
|
||||
continue
|
||||
var matching_choices := _matching_choices_from_flags(choices)
|
||||
if matching_choices.size() == 1:
|
||||
var choice_id := str(matching_choices[0].get("id", ""))
|
||||
if not choice_id.is_empty():
|
||||
applied_post_battle_choices[scenario_id] = choice_id
|
||||
changed = true
|
||||
return changed
|
||||
|
||||
|
||||
func _get_reconciled_post_battle_choice(scenario_id: String, scenario_data: Dictionary) -> Dictionary:
|
||||
if scenario_id == pending_post_battle_choice_scenario_id:
|
||||
return {}
|
||||
var choices = scenario_data.get("post_battle_choices", [])
|
||||
if typeof(choices) != TYPE_ARRAY:
|
||||
return {}
|
||||
var applied_choice_id := str(applied_post_battle_choices.get(scenario_id, ""))
|
||||
if not applied_choice_id.is_empty():
|
||||
for choice in choices:
|
||||
if typeof(choice) == TYPE_DICTIONARY and str(choice.get("id", "")) == applied_choice_id:
|
||||
return choice
|
||||
return {}
|
||||
var matching_choices := _matching_choices_from_flags(choices)
|
||||
if matching_choices.size() == 1:
|
||||
return matching_choices[0]
|
||||
return {}
|
||||
|
||||
|
||||
func _load_scenario_definition(scenario_id: String) -> Dictionary:
|
||||
var path := get_scenario_path(scenario_id)
|
||||
if path.is_empty():
|
||||
return {}
|
||||
var parsed = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("Scenario file is not valid JSON: %s" % path)
|
||||
return {}
|
||||
return parsed
|
||||
|
||||
|
||||
func _matching_choices_from_flags(choices: Array) -> Array:
|
||||
var result := []
|
||||
for choice in choices:
|
||||
if typeof(choice) != TYPE_DICTIONARY or not _choice_flags_match(choice):
|
||||
continue
|
||||
result.append(choice)
|
||||
return result
|
||||
|
||||
|
||||
func _choice_flags_match(choice: Dictionary) -> bool:
|
||||
var set_flags = choice.get("set_flags", {})
|
||||
if typeof(set_flags) != TYPE_DICTIONARY or set_flags.is_empty():
|
||||
return false
|
||||
for flag_name in set_flags.keys():
|
||||
var key := str(flag_name)
|
||||
if not flags.has(key) or flags[key] != set_flags[flag_name]:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _apply_officer_transitions_from_block(block: Dictionary) -> bool:
|
||||
var joined := _apply_join_officers(block.get("join_officers", []))
|
||||
var left := _apply_leave_officers(block.get("leave_officers", []))
|
||||
return not joined.is_empty() or not left.is_empty()
|
||||
|
||||
|
||||
func get_flags_snapshot() -> Dictionary:
|
||||
return flags.duplicate(true)
|
||||
|
||||
|
||||
func is_scenario_completed(scenario_id: String) -> bool:
|
||||
return completed_scenarios.has(scenario_id)
|
||||
|
||||
|
||||
func get_start_scenario_id() -> String:
|
||||
return start_scenario_id
|
||||
|
||||
|
||||
func get_scenario_path(scenario_id: String) -> String:
|
||||
return str(scenario_paths.get(scenario_id, ""))
|
||||
|
||||
|
||||
func get_current_scenario_path() -> String:
|
||||
if current_scenario_id.is_empty():
|
||||
current_scenario_id = get_start_scenario_id()
|
||||
return get_scenario_path(current_scenario_id)
|
||||
|
||||
|
||||
func get_scenario_title(scenario_id: String) -> String:
|
||||
if scenario_id.is_empty():
|
||||
return ""
|
||||
return str(scenario_titles.get(scenario_id, scenario_id))
|
||||
|
||||
|
||||
func get_next_scenario_id(scenario_id: String) -> String:
|
||||
var index := scenario_order.find(scenario_id)
|
||||
if index == -1 or index + 1 >= scenario_order.size():
|
||||
return ""
|
||||
return scenario_order[index + 1]
|
||||
|
||||
|
||||
func is_campaign_complete() -> bool:
|
||||
if has_pending_post_battle_choice():
|
||||
return false
|
||||
if scenario_order.is_empty():
|
||||
return false
|
||||
for scenario_id in scenario_order:
|
||||
if not completed_scenarios.has(scenario_id):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func get_progress_text() -> String:
|
||||
var current_title := get_scenario_title(current_scenario_id)
|
||||
if current_title.is_empty():
|
||||
current_title = get_scenario_title(get_start_scenario_id())
|
||||
var complete_text := "complete" if is_campaign_complete() else "in progress"
|
||||
return "%s | %s | Gold %d | %d/%d complete" % [
|
||||
campaign_title,
|
||||
complete_text,
|
||||
gold,
|
||||
completed_scenarios.size(),
|
||||
scenario_order.size()
|
||||
]
|
||||
|
||||
|
||||
func _normalize_current_scenario() -> void:
|
||||
if current_scenario_id.is_empty():
|
||||
current_scenario_id = get_start_scenario_id()
|
||||
if completed_scenarios.has(current_scenario_id):
|
||||
var next_id := get_next_scenario_id(current_scenario_id)
|
||||
if not next_id.is_empty():
|
||||
current_scenario_id = next_id
|
||||
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"save_version": SAVE_VERSION,
|
||||
"current_scenario_id": current_scenario_id,
|
||||
"completed_scenarios": completed_scenarios.duplicate(),
|
||||
"gold": gold,
|
||||
"inventory": inventory.duplicate(true),
|
||||
"roster": roster.duplicate(true),
|
||||
"flags": flags.duplicate(true),
|
||||
"joined_officers": joined_officers.duplicate(),
|
||||
"applied_post_battle_choices": applied_post_battle_choices.duplicate(true),
|
||||
"pending_post_battle_choice_scenario_id": pending_post_battle_choice_scenario_id
|
||||
}
|
||||
|
||||
|
||||
func _copy_dictionary(value) -> Dictionary:
|
||||
if typeof(value) == TYPE_DICTIONARY:
|
||||
return value.duplicate(true)
|
||||
return {}
|
||||
234
scripts/core/data_catalog.gd
Normal file
234
scripts/core/data_catalog.gd
Normal file
@@ -0,0 +1,234 @@
|
||||
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 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"] = str(deployment.get("officer_id", ""))
|
||||
unit["name"] = str(deployment.get("name", officer.get("name", unit["id"])))
|
||||
unit["class_id"] = class_id
|
||||
unit["class"] = str(class_def.get("name", class_id.capitalize()))
|
||||
unit["team"] = str(deployment.get("team", "enemy"))
|
||||
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["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 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["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 _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 _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 := max(1, int(value))
|
||||
return _normalized_range_pair(scalar, scalar)
|
||||
|
||||
|
||||
func _normalized_range_pair(min_range: int, max_range: int) -> Dictionary:
|
||||
var safe_min := max(1, min_range)
|
||||
var safe_max := max(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
|
||||
2360
scripts/scenes/battle_scene.gd
Normal file
2360
scripts/scenes/battle_scene.gd
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user