186 lines
6.0 KiB
GDScript
186 lines
6.0 KiB
GDScript
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)
|