extends CharacterBody2D const DEFAULT_SHIP_TEXTURE := "res://assets/Ships/Spaceship_0.png" const IMPACT_EFFECT_SCENE := preload("res://scenes/game/impact_effect.tscn") const DAMAGE_POPUP_SCENE := preload("res://scenes/game/damage_popup.tscn") const COMBAT_ROLLS := preload("res://scripts/game/combat_rolls.gd") const CAPTAIN_BALANCE := preload("res://scripts/captain/captain_balance.gd") const SHIP_CATALOG := preload("res://scripts/game/ship_catalog.gd") const COMBAT_FLASH := preload("res://scripts/game/combat_flash.gd") const MINI_BOOST_ENERGY_PER_SECOND := 20.0 const DESTRUCTION_EFFECT := preload("res://scripts/game/player_destruction_effect.gd") @export var max_speed := 76.0 @export var reverse_max_speed := 210.0 @export var max_strafe_speed := 210.0 @export var thrust_acceleration := 90.0 @export var brake_acceleration := 180.0 @export var space_drag := 16.0 @export var max_turn_speed := 2.8 @export var turn_acceleration := 7.0 @export var turn_damping := 4.0 @export var base_max_hull := 100.0 @export var base_max_shields := 100.0 @export var base_scanner_range := 1000.0 var scanner_range := 1000.0 @export var base_max_energy := 100.0 @export var base_max_speed := 90.0 @export var base_energy_regeneration := 5.0 @export var base_shield_regeneration := 0.0 @export var max_hull := 100.0 @export var max_shields := 100.0 @export var max_energy := 100.0 var hull := 100.0 var shields := 100.0 var energy := 100.0 var energy_regeneration := 5.0 var shield_regeneration := 0.0 var absorption := Vector3.ZERO var damage_multiplier := 1.0 var experience_multiplier := 1.0 var credits_multiplier := 1.0 var luck_percent := 0.0 var shield_on_damage_percent := 0.0 var critical_chance_bonus := 0.0 var critical_damage_bonus := 0.0 @export var solar_charge_radius := 550.0 @export var solar_charge_full_radius := 300.0 @export var solar_charge_fraction_per_second := 0.25 var charging_star: Node2D var angular_velocity := 0.0 var _sprite: Sprite2D var _contact_damage_timer := 0.0 var _is_destroyed := false var is_docked := false var _boost_speed := 0.0 var _boost_target := 0.0 var _boost_acceleration := 0.0 var _boost_velocity_acceleration := 0.0 var _mini_boost_active := false var _mini_boost_exhausted := false var _mini_boost_coast_speed := 0.0 @onready var _shield_visual: Node2D = $ShieldVisual func _enter_tree() -> void: GameState.captain.reset_combat_state() # Set the chassis before inventory children calculate equipment bonuses. var chassis := SHIP_CATALOG.for_path(GameState.selected_ship_path) base_max_hull = float(chassis["hull"]) base_max_shields = float(chassis["shields"]) base_max_energy = float(chassis["energy"]) base_max_speed = float(chassis["speed"]) func apply_equipment_bonuses(bonuses: Dictionary) -> void: absorption = DamageTypes.capped(Vector3(float(bonuses.get("kinetic_absorption", 0.0)), float(bonuses.get("energy_absorption", 0.0)), float(bonuses.get("radiation_absorption", 0.0)))) max_hull = (base_max_hull + float(bonuses.get("max_hull", 0.0))) * (1.0 + float(bonuses.get("hull_percent", 0.0)) / 100.0) max_shields = (base_max_shields + float(bonuses.get("max_shields", 0.0))) * (1.0 + float(bonuses.get("shield_percent", 0.0)) / 100.0) scanner_range = base_scanner_range + float(bonuses.get("scanner_range", 0.0)) max_energy = base_max_energy + float(bonuses.get("max_energy", 0.0)) max_speed = (base_max_speed + float(bonuses.get("max_speed", 0.0))) * (1.0 + float(bonuses.get("speed_percent", 0.0)) / 100.0) damage_multiplier = 1.0 + float(bonuses.get("damage_percent", 0.0)) / 100.0 experience_multiplier = 1.0 + float(bonuses.get("experience_percent", 0.0)) / 100.0 credits_multiplier = 1.0 + float(bonuses.get("credits_percent", 0.0)) / 100.0 luck_percent = maxf(0.0, float(bonuses.get("luck_percent", 0.0))) shield_on_damage_percent = maxf(0.0, float(bonuses.get("shield_on_damage_percent", 0.0))) critical_chance_bonus = float(bonuses.get("critical_chance_percent", 0.0)) / 100.0 critical_damage_bonus = float(bonuses.get("critical_damage_percent", 0.0)) / 100.0 energy_regeneration = base_energy_regeneration + float(bonuses.get("energy_regeneration", 0.0)) shield_regeneration = base_shield_regeneration + float(bonuses.get("shield_regeneration", 0.0)) reverse_max_speed = max_speed * 0.5 max_strafe_speed = max_speed * 0.5 hull = minf(hull, max_hull) shields = minf(shields, max_shields) energy = minf(energy, max_energy) _update_shield_visual() func _ready() -> void: var extension_systems := Node.new() extension_systems.name = "ExtensionSystems" extension_systems.set_script(preload("res://scripts/game/extension_systems.gd")) extension_systems.set("cooldowns", $ShipInventory.extension_cooldowns) add_child(extension_systems) for index in ShipInventory.EXTENSION_SLOT_COUNT: _ensure_input_action(StringName("ability_%d" % (index + 1)), (KEY_1 + index) as Key) _ensure_input_action(&"use_energy_bank", KEY_R) _ensure_input_action(&"ship_thrust", KEY_W) _ensure_input_action(&"ship_mini_boost", KEY_TAB) _ensure_input_action(&"ship_brake", KEY_S) _ensure_input_action(&"ship_turn_left", KEY_A) _ensure_input_action(&"ship_turn_right", KEY_D) _ensure_input_action(&"ship_strafe_left", KEY_Q) _ensure_input_action(&"ship_strafe_right", KEY_E) var scanner_layer := CanvasLayer.new() scanner_layer.name = "ScannerOverlay" scanner_layer.layer = preload("res://scripts/ui/render_layers.gd").SCANNER add_child(scanner_layer) var scanner := Control.new() scanner.set_script(preload("res://scripts/ui/scanner_overlay.gd")) scanner.set("player", self) scanner_layer.add_child(scanner) scanner.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _sprite = $Sprite2D var texture_path := SHIP_CATALOG.texture_path(SHIP_CATALOG.for_path(GameState.selected_ship_path)) if texture_path.is_empty() or not ResourceLoader.exists(texture_path): texture_path = DEFAULT_SHIP_TEXTURE var ship_texture := load(texture_path) as Texture2D if ship_texture: _sprite.texture = ship_texture var largest_dimension := maxf(ship_texture.get_width(), ship_texture.get_height()) _sprite.scale = Vector2.ONE * (64.0 / largest_dimension) preload("res://scripts/game/ship_shadow.gd").attach_to(_sprite) # Inventory children have already applied the restored equipment bonuses. hull = max_hull shields = max_shields energy = max_energy _update_shield_visual() func _ensure_input_action(action_name: StringName, physical_key: Key) -> void: if not InputMap.has_action(action_name): InputMap.add_action(action_name, 0.5) var event := InputEventKey.new() event.physical_keycode = physical_key if not InputMap.action_has_event(action_name, event): InputMap.action_add_event(action_name, event) func take_damage(amount: float, _critical: bool = false, damage_type: int = DamageTypes.Type.KINETIC) -> void: if _is_destroyed or is_docked or amount <= 0.0: return var hit := COMBAT_ROLLS.incoming_hit(amount * (1.0 - absorption[damage_type] / 100.0), get_evasion_chance(), get_damage_reduction()) if hit["evaded"]: var flash := Node2D.new() flash.set_script(COMBAT_FLASH) flash.set("flash_color", Color("77e8bc")) flash.position = position get_parent().add_child(flash) var popup := DAMAGE_POPUP_SCENE.instantiate() as Label popup.text = "UNIK" popup.set("is_evade", true) popup.set("text_color", Color("77e8bc")) popup.position = position + Vector2(-20, -38) get_parent().add_child(popup) return var remaining_damage: float = hit["damage"] var shield_damage := minf(shields, remaining_damage) shields -= shield_damage remaining_damage -= shield_damage hull = maxf(0.0, hull - remaining_damage) if shield_damage > 0.0 and shields > 0.0: _shield_visual.call("flash_hit") _update_shield_visual() if hull <= 0.0: _destroy_ship() func restore_shields_from_damage(actual_damage: float) -> void: if _is_destroyed or actual_damage <= 0.0 or shield_on_damage_percent <= 0.0 or GameState.captain.is_skill_active("power_redirect"): return shields = minf(max_shields, shields + actual_damage * shield_on_damage_percent / 100.0) _update_shield_visual() func get_passive_extension_value(effect: String) -> float: var best := 0.0 for item in $ShipInventory.extension_slots: if item != null and item.is_passive_extension() and item.definition.passive_effect == effect: best = maxf(best, item.get_passive_value()) return best func recycle_asteroid_fragment() -> void: if _is_destroyed: return var fraction := get_passive_extension_value("recycler") / 100.0 if fraction <= 0.0: return energy = minf(max_energy, energy + max_energy * fraction) if not GameState.captain.is_skill_active("power_redirect"): shields = minf(max_shields, shields + max_shields * fraction) _update_shield_visual() func try_control_enemy(enemy: CharacterBody2D) -> bool: var limit := int(get_passive_extension_value("virus")) if limit <= 0 or _is_destroyed: return false var controlled := 0 for ally in get_tree().get_nodes_in_group("virus_allies"): if not ally.is_queued_for_deletion() and float(ally.get("hull")) > 0.0: controlled += 1 if controlled >= limit: return false enemy.call("set_virus_controlled", true) return true func _sync_virus_allies() -> void: var remaining := int(get_passive_extension_value("virus")) if not _is_destroyed else 0 for ally in get_tree().get_nodes_in_group("virus_allies"): if ally.is_queued_for_deletion(): continue if remaining > 0: remaining -= 1 else: ally.call("set_virus_controlled", false) func get_evasion_chance() -> float: return GameState.captain.get_skill_bonus("tactical_maneuver") if GameState.captain.is_skill_active("tactical_maneuver") else 0.0 func get_energy_regeneration() -> float: return (energy_regeneration + get_solar_regeneration()) * GameState.captain.energy_regeneration_multiplier() func get_damage_reduction() -> float: var reduction := GameState.captain.get_skill_bonus("mechanic") var threshold := get_passive_extension_value("titanium_armor") / 100.0 if threshold > 0.0 and hull / maxf(1.0, max_hull) <= threshold: reduction = 1.0 - (1.0 - reduction) * 0.5 return reduction func get_weapon_damage_multiplier() -> float: return damage_multiplier * (1.0 + GameState.captain.get_skill_bonus("gunner")) func get_critical_chance() -> float: return clampf(critical_chance_bonus + GameState.captain.get_skill_bonus("sharp_eye"), 0.0, 1.0) func get_critical_multiplier() -> float: return CAPTAIN_BALANCE.BASE_CRITICAL_MULTIPLIER * (1.0 + maxf(0.0, critical_damage_bonus)) func roll_weapon_hit(item: ItemInstance) -> Dictionary: return COMBAT_ROLLS.weapon_hit(item.roll_damage(), get_weapon_damage_multiplier(), get_critical_chance(), get_critical_multiplier()) func register_enemy_collision(enemy_position: Vector2) -> void: if _is_destroyed or _contact_damage_timer > 0.0: return var push_direction := global_position - enemy_position if push_direction.length_squared() < 0.001: push_direction = -velocity if velocity.length_squared() > 0.001 else Vector2.UP.rotated(rotation) push_direction = push_direction.normalized() velocity = velocity.bounce(push_direction) * 0.55 + push_direction * 110.0 _clamp_velocity_to_max_speed() _contact_damage_timer = 0.8 _spawn_hit_effect(global_position, Color(0.65, 0.9, 1.0)) take_damage(max_hull * 0.01) func _spawn_hit_effect(at_position: Vector2, effect_color: Color) -> void: var effect := IMPACT_EFFECT_SCENE.instantiate() as Node2D effect.global_position = at_position get_parent().add_child(effect) effect.call("configure", false, effect_color) func _update_shield_visual() -> void: if is_instance_valid(_shield_visual): _shield_visual.call("set_shield_values", shields, max_shields) func _destroy_ship() -> void: if _is_destroyed: return _is_destroyed = true $ExtensionSystems.reset_combat_state() GameState.captain.reset_combat_state() var death_velocity := velocity velocity = Vector2.ZERO collision_layer = 0 collision_mask = 0 _mini_boost_active = false var explosion := Node2D.new() explosion.name = "PlayerDestructionEffect" explosion.set_script(DESTRUCTION_EFFECT) explosion.call("configure", _sprite, death_velocity, get_node_or_null("Camera2D")) get_parent().add_child(explosion) explosion.connect("finished", _show_defeat_screen) _sprite.hide() for node_name in ["ShieldVisual", "EngineVisual", "EnergyIndicator", "SolarEnergyEffect"]: var visual := get_node_or_null(node_name) as CanvasItem if visual != null: visual.hide() $EngineAudio.stop() $EngineAudio.set_process(false) $EngineVisual.set_process(false) $ShieldVisual.set_process(false) var radiation := get_node_or_null("RadiationStatus") if radiation != null: radiation.set_physics_process(false) func _show_defeat_screen() -> void: if not is_inside_tree() or get_parent().has_node("DefeatOverlay"): return var overlay := CanvasLayer.new() overlay.set_script(preload("res://scripts/ui/defeat_overlay.gd")) overlay.set("ship", self) get_parent().add_child(overlay) func _restart_level_after_destruction() -> void: if is_inside_tree(): $ShipInventory.preserve_for_restart() GameState.reset_current_system_exploration() get_tree().reload_current_scene.call_deferred() func _physics_process(delta: float) -> void: _sync_virus_allies() if not _is_destroyed: GameState.captain.tick_skills(delta) if GameState.captain.is_skill_active("power_redirect"): shields = 0.0 if _is_destroyed or is_docked: _mini_boost_active = false return _contact_damage_timer = maxf(0.0, _contact_damage_timer - delta) energy = minf(max_energy, energy + get_energy_regeneration() * delta) var was_mini_boosting := _mini_boost_active _update_mini_boost(delta) if _mini_boost_active: _mini_boost_coast_speed = 0.0 else: if was_mini_boosting: _mini_boost_coast_speed = maxf(0.0, velocity.length() - max_speed) _mini_boost_coast_speed = move_toward(_mini_boost_coast_speed, 0.0, space_drag * delta) var was_boosting := _boost_target > 0.0 or _boost_speed > 0.0 if _boost_target > 0.0: _boost_speed = move_toward(_boost_speed, _boost_target, _boost_acceleration * delta) velocity = velocity.move_toward(Vector2.UP.rotated(rotation) * (max_speed + _boost_target), _boost_velocity_acceleration * delta) if _boost_speed >= _boost_target: _boost_target = 0.0 else: _boost_speed = move_toward(_boost_speed, 0.0, space_drag * delta) if was_boosting: velocity = velocity.move_toward(Vector2.ZERO, space_drag * delta) if shield_regeneration > 0.0 and shields < max_shields and not GameState.captain.is_skill_active("power_redirect"): var previous_shields := shields shields = minf(max_shields, shields + shield_regeneration * delta) if shields != previous_shields: _update_shield_visual() var turn_input := Input.get_axis("ship_turn_left", "ship_turn_right") if not is_zero_approx(turn_input): angular_velocity = move_toward( angular_velocity, turn_input * max_turn_speed, turn_acceleration * delta ) else: angular_velocity = move_toward(angular_velocity, 0.0, turn_damping * delta) rotation += angular_velocity * delta var forward := Vector2.UP.rotated(rotation) if Input.is_action_pressed("ship_brake"): var forward_speed := velocity.dot(forward) if forward_speed > 0.0: velocity = velocity.move_toward(Vector2.ZERO, brake_acceleration * delta) else: velocity -= forward * thrust_acceleration * delta var reverse_speed := velocity.dot(forward) if reverse_speed < -reverse_max_speed: velocity += forward * (-reverse_max_speed - reverse_speed) elif Input.is_action_pressed("ship_thrust") or _mini_boost_active: velocity += forward * thrust_acceleration * (2.0 if _mini_boost_active else 1.0) * delta var forward_speed := velocity.dot(forward) var forward_limit := _get_allowed_speed() if forward_speed > forward_limit and not was_boosting: velocity += forward * (forward_limit - forward_speed) var strafe_input := Input.get_axis("ship_strafe_left", "ship_strafe_right") var right := Vector2.RIGHT.rotated(rotation) if not is_zero_approx(strafe_input): velocity += right * strafe_input * thrust_acceleration * delta var strafe_speed := velocity.dot(right) velocity += right * (clampf(strafe_speed, -max_strafe_speed, max_strafe_speed) - strafe_speed) if ( not was_boosting and not _mini_boost_active and not Input.is_action_pressed("ship_brake") and not Input.is_action_pressed("ship_thrust") and is_zero_approx(strafe_input) ): velocity = velocity.move_toward(Vector2.ZERO, space_drag * delta) _clamp_velocity_to_max_speed() move_and_slide() _check_enemy_collisions() _clamp_velocity_to_max_speed() func _clamp_velocity_to_max_speed() -> void: var speed_squared := velocity.length_squared() var allowed_speed := _get_allowed_speed() var max_speed_squared := allowed_speed * allowed_speed if speed_squared > max_speed_squared: velocity = velocity.normalized() * allowed_speed func is_boosting() -> bool: return _boost_target > 0.0 or _boost_speed > 0.0 or _mini_boost_active func is_mini_boosting() -> bool: return _mini_boost_active and not _is_destroyed and not is_docked func _get_allowed_speed() -> float: return maxf(max_speed + _boost_speed, max_speed * 2.0 if _mini_boost_active else max_speed + _mini_boost_coast_speed) func _update_mini_boost(delta: float) -> void: _mini_boost_active = false if not Input.is_action_pressed("ship_mini_boost"): _mini_boost_exhausted = false return if _mini_boost_exhausted or Input.is_action_pressed("ship_brake"): return var cost := MINI_BOOST_ENERGY_PER_SECOND * delta if energy < cost or energy <= 0.0: _mini_boost_exhausted = true return energy = maxf(0.0, energy - cost) _mini_boost_active = true func _input(event: InputEvent) -> void: if event is InputEventKey and event.is_action("ship_mini_boost"): # Tab controls the engine rather than moving focus between HUD buttons. get_viewport().set_input_as_handled() func get_solar_regeneration() -> float: if _is_destroyed or is_docked or not is_instance_valid(charging_star): return 0.0 var distance := global_position.distance_to(charging_star.global_position) var strength := 1.0 - smoothstep(solar_charge_full_radius, solar_charge_radius, distance) return max_energy * solar_charge_fraction_per_second * strength func start_boost(speed_bonus: float, acceleration_time: float) -> void: _boost_target = max_speed * maxf(0.0, speed_bonus) _boost_acceleration = _boost_target / maxf(0.01, acceleration_time) _boost_velocity_acceleration = (max_speed + _boost_target) / maxf(0.01, acceleration_time) func get_active_ability(slot_index: int) -> ActiveAbility: var item: ItemInstance = $ShipInventory.get_item("extension", slot_index) return item.get_active_ability() if item != null and item.definition != null else null func get_luck_chance() -> float: return GameState.captain.get_luck_chance(luck_percent) func is_cloaked() -> bool: var systems := get_node_or_null("ExtensionSystems") return systems != null and float(systems.get("cloak_remaining")) > 0.0 and not _is_destroyed func get_ability_cooldown(slot_index: int) -> float: var ability := get_active_ability(slot_index) if ability == null or ability.ability_id.is_empty(): return 0.0 return float($ExtensionSystems.cooldowns.get(ability.ability_id, 0.0)) func ability_unavailable_reason(slot_index: int) -> String: if _is_destroyed or is_docked or get_tree().paused: return "Statek niedostępny" var remaining := get_ability_cooldown(slot_index) if remaining > 0.0: return "Odnowienie: %d s" % ceili(remaining) var ability := get_active_ability(slot_index) return ability.unavailable_reason(self) if ability != null else "Brak rozszerzenia" func activate_ability(slot_index: int) -> bool: if not ability_unavailable_reason(slot_index).is_empty(): return false var ability := get_active_ability(slot_index) ability.activate(self) if ability.cooldown > 0.0 and not ability.ability_id.is_empty(): $ExtensionSystems.cooldowns[ability.ability_id] = ability.cooldown return true func use_energy_bank() -> void: if $ShipInventory.use_energy_bank(): var popup := DAMAGE_POPUP_SCENE.instantiate() as Label popup.text = "+%d%% ENERGII" % roundi(GameState.captain.get_energy_bank_fraction() * 100.0) popup.set("text_color", Color("70f5df")) popup.position = position + Vector2(-48, -60) get_parent().add_child(popup) func _unhandled_key_input(event: InputEvent) -> void: if event.is_action_pressed("use_energy_bank") and not event.is_echo(): use_energy_bank() get_viewport().set_input_as_handled() return if event is InputEventKey and event.echo: return for index in ShipInventory.EXTENSION_SLOT_COUNT: if event.is_action_pressed("ability_%d" % (index + 1)): activate_ability(index) get_viewport().set_input_as_handled() return func _check_enemy_collisions() -> void: for collision_index in get_slide_collision_count(): var collision := get_slide_collision(collision_index) var collider := collision.get_collider() if collider is Node and collider.is_in_group("enemies"): register_enemy_collision(collider.global_position)