extends CharacterBody2D const DAMAGE_POPUP_SCENE := preload("res://scenes/game/damage_popup.tscn") const IMPACT_EFFECT_SCENE := preload("res://scenes/game/impact_effect.tscn") const WEAPON_AUDIO_SCRIPT := preload("res://scripts/game/weapon_audio.gd") const ITEM_GENERATOR := preload("res://scripts/items/item_generator.gd") const LOOT_PICKUP := preload("res://scripts/items/loot_pickup.gd") const LOOT_OFFSET := Vector2(0, 36) const LEVEL_BALANCE := preload("res://scripts/levels/level_balance.gd") const LASER_COOLDOWN_MULTIPLIER := 2.0 const LASER_MINIMUM_COOLDOWN := 1.4 const LASER_MISS_CHANCE := 0.45 const LASER_DAMAGE_MULTIPLIER := 0.8 @export_enum("Normal", "Elite", "Boss") var loot_tier: int = 0 @export var max_hull := 50.0 @export var activation_range := 760.0 @export var approach_speed := 62.0 @export var approach_acceleration := 85.0 @export var turn_speed := 1.8 @export var weapon_range := 680.0 @export var weapon_cooldown := 1.5 @export var weapon_damage := 8.0 enum Tactic { RAM, KEEP_DISTANCE } @export_enum("Losowa", "Taran", "Dystans") var tactic_choice := 0 var tactic := Tactic.RAM var _orbit_direction := 1.0 var max_shields := 20.0 var shields := 20.0 var absorption := Vector3.ZERO var weapon_kind := ItemDefinition.WeaponKind.PLASMA var _shield_visual: Node2D var hull := 50.0 var _player: CharacterBody2D var _activated := false var _retreat_mine_rolled := false var _sprite: Sprite2D var _weapon_timer := 0.0 var _slow_remaining := 0.0 var _combat_target: Node2D var virus_controlled := false var _reward_claimed := false func set_virus_controlled(value: bool) -> void: if virus_controlled == value or is_queued_for_deletion(): return virus_controlled = value _combat_target = null _activated = true if value: remove_from_group("enemies") add_to_group("virus_allies") set_deferred("collision_layer", 64) set_deferred("collision_mask", 5) if not _reward_claimed: _reward_claimed = true _reward_captain() _drop_loot() else: remove_from_group("virus_allies") add_to_group("enemies") set_deferred("collision_layer", 4) set_deferred("collision_mask", 2) if is_instance_valid(_sprite): _sprite.modulate = Color("b986ff") if value else get_tier_tint() queue_redraw() func _draw() -> void: if virus_controlled: draw_arc(Vector2.ZERO, get_visual_size() * 0.6, 0.0, TAU, 48, Color("c57dff"), 2.0, true) for index in 3: var marker := Vector2.from_angle(index * TAU / 3.0) * get_visual_size() * 0.67 draw_circle(marker, 2.5, Color("e0baff")) func _ready() -> void: tactic = randi_range(0, 1) if tactic_choice == 0 else tactic_choice - 1 _orbit_direction = -1.0 if randf() < 0.5 else 1.0 hull = max_hull _sprite = $Sprite2D _shield_visual = Node2D.new() _shield_visual.set_script(preload("res://scripts/game/shield_visual.gd")) _shield_visual.scale = Vector2.ONE * get_visual_size() / 64.0 add_child(_shield_visual) _shield_visual.set_shield_values(shields, max_shields) add_to_group("enemies") _weapon_timer = randf_range(0.2, get_fire_interval()) func apply_level_settings(level: int, tier: int) -> void: loot_tier = tier $Sprite2D.modulate = get_tier_tint() var power := LEVEL_BALANCE.power_scale(level) var combat_multiplier := 2.0 if tier == 2 else 1.25 var boss_multiplier := 2.0 if tier == 2 else 1.0 var hull_multiplier := 4.0 if tier == 2 else (2.0 if tier == 1 else 1.0) var damage_bonus := 4.0 if tier == 2 else (1.3 if tier == 1 else 1.0) max_hull = 50.0 * power * hull_multiplier * combat_multiplier hull = max_hull max_shields = max_hull * 0.4 shields = max_shields var profile := DamageTypes.enemy_profile(level, GameState.system_seed) weapon_kind = int(profile["kind"]) absorption = profile["absorption"] weapon_damage = 8.0 * power * damage_bonus * combat_multiplier approach_speed = 62.0 * (1.0 + 0.0005 * (clampi(level, 1, 1000) - 1)) * boss_multiplier approach_acceleration = 85.0 * boss_multiplier turn_speed = 1.8 * boss_multiplier activation_range = 760.0 * boss_multiplier weapon_range = 680.0 * boss_multiplier weapon_cooldown = maxf(0.55, 1.5 / (1.0 + 0.001 * (clampi(level, 1, 1000) - 1))) / boss_multiplier if tier == 2: # Ease the first encounter, returning smoothly to standard bosses at level 6. var intro := clampf(float(level - 1) / 5.0, 0.0, 1.0) max_hull *= lerpf(0.6, 1.0, intro) hull = max_hull max_shields = max_hull * 0.4 shields = max_shields weapon_damage *= lerpf(0.5, 1.0, intro) weapon_cooldown *= lerpf(1.25, 1.0, intro) approach_speed *= lerpf(0.8, 1.0, intro) $CollisionShape2D.scale = Vector2.ONE * boss_multiplier if is_instance_valid(_shield_visual): _shield_visual.scale = Vector2.ONE * get_visual_size() / 64.0 _shield_visual.set_shield_values(shields, max_shields) func configure(player: CharacterBody2D, ship_texture: Texture2D) -> void: _player = player _sprite = $Sprite2D _sprite.texture = ship_texture var largest_dimension := maxf(ship_texture.get_width(), ship_texture.get_height()) var visual_size := get_visual_size() _sprite.scale = Vector2.ONE * (visual_size / largest_dimension) _sprite.modulate = get_tier_tint() preload("res://scripts/game/ship_shadow.gd").attach_to(_sprite) func get_tier_tint() -> Color: return Color("ffb3df") if loot_tier == 2 else (Color("ffc785") if loot_tier == 1 else Color.WHITE) func get_visual_size() -> float: return 112.0 if loot_tier == 2 else (68.0 if loot_tier == 1 else 56.0) func regenerate_defenses(delta: float) -> void: if hull <= 0.0 or is_queued_for_deletion(): return hull = minf(max_hull, hull + max_hull * 0.01 * delta) shields = minf(max_shields, shields + max_shields * 0.01 * delta) if is_instance_valid(_shield_visual): _shield_visual.set_shield_values(shields, max_shields) func _physics_process(delta: float) -> void: regenerate_defenses(delta) _slow_remaining = maxf(0.0, _slow_remaining - delta) _weapon_timer = maxf(0.0, _weapon_timer - delta) if not is_instance_valid(_player) or bool(_player.get("_is_destroyed")): velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta) move_and_slide() return if not virus_controlled and hull > 0.0 and hull < max_hull * 0.1: _player.call("try_control_enemy", self) _combat_target = select_combat_target() if _combat_target == null: velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta) move_and_slide() return var to_player := _combat_target.global_position - global_position if to_player.length() <= activation_range: _activated = true if _activated and to_player.length_squared() > 1.0: var direction := to_player.normalized() var fleeing := is_retreating() if fleeing and not _retreat_mine_rolled: _retreat_mine_rolled = true if randf() < 0.15: _drop_retreat_mine(to_player.normalized()) var movement := combat_movement(to_player) var desired_rotation := (movement if fleeing else direction).angle() + PI * 0.5 rotation = rotate_toward(rotation, desired_rotation, turn_speed * delta) velocity = velocity.move_toward(movement * approach_speed * (0.5 if fleeing else 1.0) * get_slow_multiplier(), approach_acceleration * get_slow_multiplier() * delta) if not fleeing and to_player.length() <= weapon_range and _weapon_timer <= 0.0: _fire_plasma(direction) _weapon_timer = get_fire_interval() else: velocity = velocity.move_toward(Vector2.ZERO, approach_acceleration * delta) move_and_slide() _check_player_collision() func is_retreating() -> bool: return not virus_controlled and hull > 0.0 and hull < max_hull * 0.1 func _drop_retreat_mine(toward_target: Vector2) -> void: var mine := Node2D.new() mine.set_script(preload("res://scripts/game/enemy_mine.gd")) mine.set("player", _player) mine.set("drift", toward_target * 36.0) mine.position = position + toward_target * (get_visual_size() * 0.5 + 16.0) get_parent().add_child.call_deferred(mine) func combat_movement(to_target: Vector2) -> Vector2: var distance := to_target.length() var direction := to_target.normalized() if is_retreating(): var escape := -direction var half := preload("res://scripts/levels/system_generator.gd").WORLD_SIZE * 0.5 - Vector2(120, 120) if absf(global_position.x) > half.x: escape.x = -signf(global_position.x) if absf(global_position.y) > half.y: escape.y = -signf(global_position.y) return escape.normalized() if tactic == Tactic.RAM: return direction var preferred := minf(weapon_range * 0.65, 420.0) if distance < preferred * 0.8: return -direction if distance > preferred * 1.15: return direction return direction.orthogonal() * _orbit_direction * 0.55 func get_slow_multiplier() -> float: return 0.5 if _slow_remaining > 0.0 else 1.0 func select_combat_target() -> Node2D: if virus_controlled: var opponent: Node2D var nearest := INF for enemy in get_tree().get_nodes_in_group("enemies"): if enemy.is_queued_for_deletion() or float(enemy.get("hull")) <= 0.0: continue var distance := global_position.distance_squared_to(enemy.global_position) if distance < nearest: nearest = distance opponent = enemy return opponent var closest: Node2D var closest_distance := INF for flare in get_tree().get_nodes_in_group("player_flares"): if flare.is_queued_for_deletion(): continue var distance := global_position.distance_to(flare.global_position) if distance <= maxf(activation_range, weapon_range) and distance < closest_distance: closest = flare closest_distance = distance if closest != null: return closest if is_instance_valid(_player) and not bool(_player.get("_is_destroyed")) and not bool(_player.call("is_cloaked")): return _player return null func apply_mine_hit() -> void: if is_queued_for_deletion() or hull <= 0.0: return _slow_remaining = 5.0 velocity = velocity.limit_length(approach_speed * 0.5) take_damage(hull * 0.5, false, DamageTypes.Type.KINETIC, true) func _check_player_collision() -> void: if virus_controlled: return for collision_index in get_slide_collision_count(): var collision := get_slide_collision(collision_index) if collision.get_collider() == _player and _player.has_method("register_enemy_collision"): _player.call("register_enemy_collision", global_position) func _fire_plasma(direction: Vector2) -> void: var shot_mask := 37 if virus_controlled else 115 var muzzle := global_position + direction * (get_visual_size() * 0.5 + 8.0) if weapon_kind == ItemDefinition.WeaponKind.LASER: var endpoint := muzzle + _laser_aim_direction(direction) * weapon_range var query := PhysicsRayQueryParameters2D.create(muzzle, endpoint, shot_mask, [get_rid()]) var hit := get_world_2d().direct_space_state.intersect_ray(query) if not hit.is_empty(): endpoint = hit["position"] var target: Node = hit.get("collider") if target is CharacterBody2D and target.has_method("take_damage"): target.take_damage(get_shot_damage(), false, DamageTypes.Type.ENERGY) var beam := Line2D.new() beam.set_script(preload("res://scripts/game/laser_beam_visual.gd")) beam.width = 4.0 beam.default_color = Color(1.0, 0.3, 0.3) beam.add_point(muzzle) beam.add_point(endpoint) get_parent().add_child(beam) get_tree().create_timer(0.1).timeout.connect(beam.queue_free) else: var projectile := preload("res://scenes/game/weapon_projectile.tscn").instantiate() projectile.enemy_shot = true projectile.collision_mask = shot_mask projectile.missile_homing = false var speed := 430.0 if weapon_kind == ItemDefinition.WeaponKind.GAMMA else (950.0 if weapon_kind == ItemDefinition.WeaponKind.MACHINE_GUN else 600.0) projectile.configure(muzzle, direction, weapon_kind, speed, weapon_damage, weapon_range) get_parent().add_child(projectile) WEAPON_AUDIO_SCRIPT.play(get_parent(), global_position, weapon_kind, -4.0) func take_weapon_hit(amount: float, critical: bool, damage_type: int = DamageTypes.Type.KINETIC) -> void: if is_queued_for_deletion() or hull <= 0.0: return var before := hull + shields take_damage(amount, critical, damage_type) if is_instance_valid(_player): _player.call("restore_shields_from_damage", maxf(0.0, before - hull - shields)) func take_damage(amount: float, critical: bool = false, damage_type: int = DamageTypes.Type.KINETIC, bypass_absorption: bool = false) -> void: if is_queued_for_deletion() or hull <= 0.0: return var actual := maxf(0.0, amount) * (1.0 if bypass_absorption else 1.0 - absorption[damage_type] / 100.0) var shield_damage := minf(shields, actual) shields -= shield_damage hull = maxf(0.0, hull - actual + shield_damage) if is_instance_valid(_shield_visual): _shield_visual.set_shield_values(shields, max_shields) if shield_damage > 0.0: _shield_visual.flash_hit() _show_damage_popup(actual, critical) if hull <= 0.0: if not _reward_claimed: _reward_claimed = true _reward_captain() _drop_loot() _spawn_destruction_effect() queue_free() func get_fire_interval() -> float: if weapon_kind == ItemDefinition.WeaponKind.LASER: return maxf(LASER_MINIMUM_COOLDOWN, weapon_cooldown * LASER_COOLDOWN_MULTIPLIER) return weapon_cooldown func _laser_aim_direction(direction: Vector2) -> Vector2: if randf() < LASER_MISS_CHANCE: var side := Vector2(-direction.y, direction.x) var distance := global_position.distance_to(_combat_target.global_position) if is_instance_valid(_combat_target) else weapon_range var offset := randf_range(60.0, 110.0) * (-1.0 if randf() < 0.5 else 1.0) return (direction * maxf(1.0, distance) + side * offset).normalized() return direction.rotated(randf_range(-0.02, 0.02)) func get_shot_damage() -> float: return weapon_damage * (LASER_DAMAGE_MULTIPLIER if weapon_kind == ItemDefinition.WeaponKind.LASER else 1.0) func get_weapon_dps() -> float: var interval := get_fire_interval() return get_shot_damage() / maxf(0.001, interval) * (1.0 + 0.25 * minf(3.0, interval) if weapon_kind == ItemDefinition.WeaponKind.GAMMA else 1.0) func _reward_captain() -> void: if is_instance_valid(_player): var reward := GameState.captain.reward_enemy(GameState.system_level, loot_tier, float(_player.get("experience_multiplier")), float(_player.get("credits_multiplier")), false) if reward.y > 0: var credits := Node2D.new() credits.set_script(preload("res://scripts/items/credit_pickup.gd")) credits.set("amount", reward.y) credits.position = position - LOOT_OFFSET get_parent().call_deferred("add_child", credits) func _drop_loot() -> void: # Roll independently of equipment drops. var bank_chance := 0.1 if randf() < bank_chance: var bank := ItemInstance.new() bank.definition = preload("res://resources/items/energy_bank.tres") var bank_pickup := Node2D.new() bank_pickup.set_script(LOOT_PICKUP) bank_pickup.set("item", bank) bank_pickup.position = position + Vector2(0, -42) get_parent().call_deferred("add_child", bank_pickup) var chance := 0.65 if loot_tier == 0 else (0.9 if loot_tier == 1 else 1.0) if randf() > chance: return var pickup := Node2D.new() pickup.set_script(LOOT_PICKUP) pickup.set("item", ITEM_GENERATOR.generate_loot(GameState.system_level, loot_tier, null, float(_player.get("luck_percent")) if is_instance_valid(_player) else 0.0)) pickup.position = position + LOOT_OFFSET get_parent().call_deferred("add_child", pickup) func _spawn_destruction_effect() -> void: var effect := IMPACT_EFFECT_SCENE.instantiate() as Node2D effect.global_position = global_position get_parent().add_child(effect) effect.call("configure", true, Color(1.0, 0.36, 0.18)) func _show_damage_popup(amount: float, critical: bool = false) -> void: if critical: var flash := Node2D.new() flash.set_script(preload("res://scripts/game/combat_flash.gd")) flash.set("flash_color", Color("ffbc5c")) flash.position = position get_parent().add_child(flash) var popup := DAMAGE_POPUP_SCENE.instantiate() as Label popup.text = "%s-%s DMG" % ["KRYT! " if critical else "", _format_damage(amount)] popup.set("is_critical", critical) popup.global_position = global_position + Vector2(randf_range(-16.0, 16.0), -28.0) get_parent().add_child(popup) func _format_damage(amount: float) -> String: if is_equal_approx(amount, roundf(amount)): return str(int(roundf(amount))) return "%.1f" % amount