Add scaling Alien Grunt boss waves and native simulation; publish Web release
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user