extends "res://scripts/zombie.gd" ## One Alien Grunt replaces every tenth wave. Uses the shared office navigation. const Hornet = preload("res://scripts/boss_hornet.gd") var rank := 1 var max_hp := 800.0 var melee_damage := 28.0 var ranged_damage := 8.0 var attack_interval := 2.8 var reward := 1000 var attack_lock := 0.0 var pain_cooldown := 0.0 var model: Node3D static func stats_for_wave(number: int) -> Dictionary: var grade := maxi(1, floori(number / 10.0)) return { "rank": grade, "hp": 800.0 + 400.0 * (grade - 1), "speed": minf(3.4, 2.0 + 0.12 * (grade - 1)), "melee": 28.0 + 6.0 * (grade - 1), "ranged": 8.0 + 2.0 * (grade - 1), "interval": maxf(1.3, 2.8 - 0.18 * (grade - 1)), "reward": grade * 1000, } func configure(number: int) -> void: var stats := stats_for_wave(number) rank = stats.rank max_hp = stats.hp hp = max_hp speed = stats.speed melee_damage = stats.melee ranged_damage = stats.ranged attack_interval = stats.interval reward = stats.reward func _ready() -> void: name = "AlienGruntBoss" add_to_group("live_zombies") # bullets, blast, target-counting use the same interface add_to_group("bosses") collision_layer = 0 # no hits or obstruction during materialization collision_mask = 1 | 4 var shape := CollisionShape3D.new() var cap := CapsuleShape3D.new() cap.radius = 0.45 cap.height = 2.4 shape.shape = cap shape.position.y = 1.2 add_child(shape) model = preload("res://assets/boss/agrunt_textured.scn").instantiate() model.scale = Vector3.ONE * 1.1 add_child(model) anim = model.get_node("AnimationPlayer") for mesh in model.find_children("*", "MeshInstance3D", true, false): mesh.layers = 2 for area in model.find_children("*", "Area3D", true, false): area.collision_layer = 0 area.collision_mask = 0 model.hide() # Residual Xen glow keeps the boss readable after the portal flash has faded. var glow := OmniLight3D.new() glow.name = "XenGlow" glow.light_color = Color(0.5, 1.0, 0.68) glow.light_energy = 0.65 glow.light_specular = 0.3 glow.omni_range = 3.5 glow.shadow_enabled = false glow.position = Vector3(0, 1.8, -1.1) glow.visible = false add_child(glow) cooldown = 0.8 _repath = 0 func materialize() -> void: if dead or not is_instance_valid(player) or player.hp <= 0: return model.show() get_node("XenGlow").show() collision_layer = 4 entering = false anim.play("threat", 0.1) attack_lock = 0.7 Audio.play(self, "agrunt/ag_alert1.wav", true, -8) func _physics_process(dt: float) -> void: if dead or entering or not is_instance_valid(player) or player.hp <= 0: return cooldown -= dt attack_lock -= dt pain_cooldown -= dt voice_timer -= dt if voice_timer <= 0: voice_timer = 7.0 Audio.play(self, "agrunt/ag_idle1.wav", true, -14) var delta := player.global_position - global_position delta.y = 0 var distance := delta.length() if distance > 0.01: rotation.y = atan2(-delta.x, -delta.z) var motion := Vector3.ZERO if attack_lock <= 0: if distance <= 2.2 and _attack_clear() and cooldown <= 0: _start_melee() elif distance > 2.2 and distance < 14 and cooldown <= 0 and _ranged_clear(): _start_burst() elif distance > 2.0 or not _attack_clear(): motion = _pursuit_direction(dt) * speed if motion.length_squared() > 0.01: rotation.y = atan2(-motion.x, -motion.z) if anim.current_animation != "walk": anim.play("walk", 0.12) elif anim.current_animation != "idle1": anim.play("idle1", 0.12) velocity.x = motion.x velocity.z = motion.z velocity.y = -1.0 if is_on_floor() else velocity.y - 20 * dt move_and_slide() func _ranged_clear() -> bool: var from := global_position + Vector3.UP * 1.5 var to := player.global_position + Vector3.UP * 1.0 var query := PhysicsRayQueryParameters3D.create(from, to, 1) return get_world_3d().direct_space_state.intersect_ray(query).is_empty() func _start_melee() -> void: cooldown = attack_interval attack_lock = 0.85 anim.play("mattack2", 0.1) Audio.play(self, "agrunt/ag_attack1.wav", true, -9) get_tree().create_timer(0.45).timeout.connect(_attack) func _attack() -> void: if not dead and not entering and is_instance_valid(player) and player.hp > 0 \ and global_position.distance_to(player.global_position) < 2.7 and _attack_clear(): player.take_damage(melee_damage) func _start_burst() -> void: cooldown = attack_interval attack_lock = 0.9 anim.play("hornetattack", 0.08) # Telegraph the attack, then fire three visible dodgeable hornets. for i in range(3): get_tree().create_timer(0.3 + i * 0.18).timeout.connect(_fire_hornet) func _fire_hornet() -> void: if dead or entering or not is_instance_valid(player) or player.hp <= 0: return var from := global_position + Vector3.UP * 1.5 var direction := (player.global_position + Vector3.UP - from).normalized() var projectile := Hornet.new() projectile.player = player projectile.shooter = self projectile.damage = ranged_damage projectile.direction = direction get_parent().add_child(projectile) # Begin at the chest, not beyond it: an offset could skip a nearby thin wall. projectile.global_position = from projectile.look_at(projectile.global_position + direction) Audio.play(self, "agrunt/ag_fire1.wav", true, -13) func take_damage(amount: float, hit_y: float) -> void: if dead or entering or amount <= 0: return var headshot := hit_y - global_position.y > 1.95 hp = maxf(0, hp - amount * (1.5 if headshot else 1.0)) if hp > 0: if pain_cooldown <= 0: pain_cooldown = 0.7 Audio.play(self, "agrunt/ag_pain1.wav", true, -12) return dead = true get_node("XenGlow").hide() remove_from_group("live_zombies") remove_from_group("bosses") add_to_group("boss_corpses") velocity = Vector3.ZERO collision_layer = 0 collision_mask = 0 for speaker in get_children(): if speaker is AudioStreamPlayer3D: speaker.stop() speaker.queue_free() for projectile in get_tree().get_nodes_in_group("boss_projectiles"): if projectile.shooter == self: projectile.queue_free() Audio.play(self, "agrunt/ag_die1.wav", true, -8) anim.play("diehead" if headshot else "diesimple", 0.1) killed.emit(headshot)