extends Resource class_name CaptainProgress const BALANCE := preload("res://scripts/captain/captain_balance.gd") const SKILLS := preload("res://scripts/captain/captain_skills.gd") const ACTIVE_SKILL_SLOTS := 5 const MAX_LUCK_CHANCE := 0.60 @export var level: int = 1 @export var experience: int = 0 @export var credits: int = 0 @export var parts: float = 0.0 @export var minerals: Dictionary = {"uranium": 0, "titanium": 0, "iron": 0, "aluminium": 0, "sulfur": 0} @export var learning_points: int = 0 @export var skills: Dictionary = {} @export var equipped_skills: Array[String] = ["", "", "", "", ""] var skill_offers: Array[String] = [] var skill_cooldowns: Dictionary = {} var skill_durations: Dictionary = {} var _ammo_credit: Dictionary = {} var _skill_rng := RandomNumberGenerator.new() func _init() -> void: _skill_rng.randomize() func get_xp_required() -> int: return BALANCE.xp_required(level) func get_skill_rank(skill: String) -> int: return clampi(int(skills.get(skill, 0)), 0, SKILLS.MAX_RANK) if SKILLS.DATA.has(skill) else 0 func get_skill_bonus(skill: String) -> float: return SKILLS.value(skill, get_skill_rank(skill)) func get_energy_bank_fraction() -> float: return maxf(0.10, get_skill_bonus("electrician")) func normalize_skill_ranks() -> void: for skill in skills.keys(): if not SKILLS.DATA.has(skill): skills.erase(skill) else: skills[skill] = get_skill_rank(skill) normalize_equipped_skills() func normalize_equipped_skills() -> void: equipped_skills.resize(ACTIVE_SKILL_SLOTS) var seen: Array[String] = [] for index in ACTIVE_SKILL_SLOTS: var skill := equipped_skills[index] if not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0 or skill in seen: equipped_skills[index] = "" else: seen.append(skill) func equip_skill(slot: int, skill: String) -> bool: if slot < 0 or slot >= ACTIVE_SKILL_SLOTS: return false if not skill.is_empty() and (not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0): return false normalize_equipped_skills() # Moving a skill between slots never resets its cooldown or active duration. var previous_slot := equipped_skills.find(skill) if not skill.is_empty() else -1 if previous_slot >= 0: equipped_skills[previous_slot] = "" equipped_skills[slot] = skill emit_changed() return true func get_spent_learning_points() -> int: var spent := 0 for skill in SKILLS.IDS: spent += get_skill_rank(skill) return spent func get_skill_reset_cost() -> int: return BALANCE.SKILL_RESET_COST_PER_LEVEL * maxi(1, level) func can_reset_skills() -> bool: return false # Skills belong to the entire run; choices cannot be rerolled at a shop. func reset_skills() -> bool: return false func can_train(skill: String) -> bool: return learning_points > 0 and skill in skill_offers and get_skill_rank(skill) < SKILLS.MAX_RANK func train_skill(skill: String) -> bool: if not can_train(skill): return false skills[skill] = get_skill_rank(skill) + 1 if get_skill_rank(skill) == 1 and bool(SKILLS.DATA[skill]["active"]): normalize_equipped_skills() var empty := equipped_skills.find("") if empty >= 0: equipped_skills[empty] = skill learning_points -= 1 skill_offers.clear() prepare_skill_offers() emit_changed() return true func prepare_skill_offers() -> void: if learning_points <= 0 or not skill_offers.is_empty(): return var pool: Array[String] = [] for skill in SKILLS.IDS: if get_skill_rank(skill) < SKILLS.MAX_RANK: pool.append(skill) if pool.is_empty(): learning_points = 0 return while skill_offers.size() < 3 and not pool.is_empty(): var index := _skill_rng.randi_range(0, pool.size() - 1) skill_offers.append(pool[index]) pool.remove_at(index) func get_luck_chance(equipment_bonus_percent: float = 0.0) -> float: return clampf(get_skill_bonus("lucky") + equipment_bonus_percent / 100.0, 0.0, MAX_LUCK_CHANCE) func roll_loot_level(base_level: int, roll: float = -1.0, equipment_bonus_percent: float = 0.0) -> int: var level := clampi(base_level, 1, 1000) var chance := get_luck_chance(equipment_bonus_percent) if chance <= 0.0 or level >= 1000: return level var luck_roll := randf() if roll < 0.0 else roll return mini(1000, level + 1) if luck_roll < chance else level func discounted_price(base_price: int) -> int: return maxi(1, roundi(base_price * (1.0 - get_skill_bonus("business_shark")))) func should_consume_ammunition(ammo_kind: String) -> bool: # Accumulate fractions for an exact saving rate instead of a lucky streak. var credit := float(_ammo_credit.get(ammo_kind, 0.0)) + get_skill_bonus("efficient_magazine") var saved := credit >= 1.0 - 0.000001 _ammo_credit[ammo_kind] = maxf(0.0, credit - 1.0) if saved else credit return not saved func reset_combat_state() -> void: skill_cooldowns.clear() skill_durations.clear() emit_changed() func tick_skills(delta: float) -> void: for skill in skill_cooldowns: skill_cooldowns[skill] = maxf(0.0, float(skill_cooldowns[skill]) - delta) for skill in skill_durations: skill_durations[skill] = maxf(0.0, float(skill_durations[skill]) - delta) func is_skill_active(skill: String) -> bool: return float(skill_durations.get(skill, 0.0)) > 0.0 func energy_regeneration_multiplier() -> float: var bonus := 1.0 for skill in ["reactor_overload", "power_redirect"]: if is_skill_active(skill): bonus += get_skill_bonus(skill) return bonus func skill_unavailable_reason(skill: String, ship: CharacterBody2D) -> String: if not SKILLS.DATA.has(skill) or not bool(SKILLS.DATA[skill]["active"]) or get_skill_rank(skill) <= 0: return "Umiejętność nie jest odblokowana" if skill not in equipped_skills: return "Przypisz umiejętność do slotu F1–F5 w panelu kapitana" if bool(ship.get("_is_destroyed")) or bool(ship.get("is_docked")): return "Wymagany aktywny lot" if float(skill_cooldowns.get(skill, 0.0)) > 0.0: return "Odnowienie: %.1f s" % float(skill_cooldowns[skill]) if skill == "power_redirect" and (float(ship.get("max_shields")) <= 0.0 or float(ship.get("shields")) < float(ship.get("max_shields")) * 0.25): return "Wymagane min. 25% tarcz" return "" func activate_skill(skill: String, ship: CharacterBody2D) -> bool: if not skill_unavailable_reason(skill, ship).is_empty(): return false skill_cooldowns[skill] = SKILLS.COOLDOWN skill_durations[skill] = SKILLS.DURATION if skill == "power_redirect": ship.set("shields", 0.0) ship.call("_update_shield_visual") emit_changed() return true func add_rewards(xp_amount: int, credit_amount: int) -> void: experience += maxi(0, xp_amount) credits += maxi(0, credit_amount) while experience >= get_xp_required(): experience -= get_xp_required() level += 1 learning_points += BALANCE.LEARNING_POINTS_PER_LEVEL prepare_skill_offers() emit_changed() func reward_enemy(system_level: int, tier: int, xp_multiplier: float, credit_multiplier: float, grant_credits: bool = true) -> Vector2i: var index := clampi(tier, 0, 2) var scale_factor := 1.0 + 0.05 * (clampi(system_level, 1, 1000) - 1) var xp := roundi(BALANCE.KILL_XP[index] * scale_factor * maxf(0.0, xp_multiplier)) var money := roundi(BALANCE.KILL_CREDITS[index] * scale_factor * maxf(0.0, credit_multiplier)) add_rewards(xp, money if grant_credits else 0) return Vector2i(xp, money)