Add scaling Alien Grunt boss waves and native simulation; publish Web release

This commit is contained in:
enne2
2026-10-15 15:08:52 +02:00
parent 5761e160c1
commit 2606fc5cc2
44 changed files with 1464 additions and 9 deletions
+185
View File
@@ -0,0 +1,185 @@
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)