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)
+1
View File
@@ -0,0 +1 @@
uid://dgjgrwojm7qea
+55
View File
@@ -0,0 +1,55 @@
extends Node3D
## Visible, dodgeable Alien Grunt projectile. Swept ray prevents tunnelling.
const SPEED := 8.0
const LIFETIME := 3.0
var direction := Vector3.FORWARD
var damage := 8.0
var shooter: CharacterBody3D
var player: CharacterBody3D
var age := 0.0
var spent := false
func _ready() -> void:
add_to_group("boss_projectiles")
var model: Node3D = preload("res://assets/boss/hornet_textured.scn").instantiate()
add_child(model)
for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
var anim := model.get_node_or_null("AnimationPlayer") as AnimationPlayer
if anim != null:
# The exported native scene already marks idle as looping.
anim.play("idle")
var mesh := SphereMesh.new()
mesh.radius = 0.055
mesh.height = 0.11
var glow := StandardMaterial3D.new()
glow.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
glow.albedo_color = Color(1, 0.7, 0.1)
mesh.material = glow
var marker := MeshInstance3D.new()
marker.mesh = mesh
marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(marker)
if direction.length_squared() > 0:
look_at(global_position + direction)
func _physics_process(dt: float) -> void:
if spent:
return
age += dt
if age >= LIFETIME or not is_instance_valid(shooter) or shooter.dead \
or not is_instance_valid(player) or player.hp <= 0:
queue_free()
return
var next := global_position + direction * SPEED * dt
var query := PhysicsRayQueryParameters3D.create(global_position, next, 1 | 2)
query.exclude = [shooter.get_rid()]
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
spent = true
if hit.collider == player:
player.take_damage(damage)
queue_free()
else:
global_position = next
+1
View File
@@ -0,0 +1 @@
uid://0yeh2nqrpj3o
+83
View File
@@ -0,0 +1,83 @@
extends Node3D
## Cheap Compatibility-safe Xen arrival: rotating rings, CPU wisps, cyan flash.
signal materialized
const Audio = preload("res://scripts/hl_audio.gd")
const ARRIVAL_TIME := 1.5
var elapsed := 0.0
var announced := false
var rings: Array[MeshInstance3D] = []
var light: OmniLight3D
func _ready() -> void:
name = "BossTeleport"
Audio.play(self, "ambience/port_suckin1.wav", true, -9)
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_color = Color(0.12, 1.0, 0.65, 0.8)
for i in range(3):
var ring := MeshInstance3D.new()
ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var mesh := TorusMesh.new()
mesh.inner_radius = 0.91
mesh.outer_radius = 1.0
mesh.rings = 32
mesh.ring_segments = 8
mesh.material = material
ring.mesh = mesh
ring.position.y = 0.10 + i * 0.72
add_child(ring)
rings.append(ring)
light = OmniLight3D.new()
light.light_color = Color(0.12, 1.0, 0.65)
light.light_energy = 3
light.omni_range = 6
light.shadow_enabled = false
light.position.y = 1.1
add_child(light)
var particles := CPUParticles3D.new()
particles.amount = 40
particles.lifetime = 1.0
particles.emitting = true
particles.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
particles.emission_sphere_radius = 0.9
particles.position.y = 0.5
particles.direction = Vector3.UP
particles.spread = 20
particles.initial_velocity_min = 1
particles.initial_velocity_max = 2.2
particles.gravity = Vector3.ZERO
var quad := QuadMesh.new()
quad.size = Vector2(0.22, 0.4)
var mist := material.duplicate() as StandardMaterial3D
mist.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mist.albedo_texture = preload("res://assets/effects/flame.png")
quad.material = mist
particles.mesh = quad
var colors := Gradient.new()
colors.set_color(0, Color(0.1, 1, 0.65, 0.8))
colors.set_color(1, Color(0.05, 0.3, 0.25, 0))
particles.color_ramp = colors
add_child(particles)
get_tree().create_timer(ARRIVAL_TIME).timeout.connect(func():
particles.emitting = false)
func _process(dt: float) -> void:
elapsed += dt
for i in rings.size():
var size := maxf(0.05, sin(minf(elapsed / 2.2, 1.0) * PI))
rings[i].scale = Vector3.ONE * size * (1.0 + 0.12 * sin(elapsed * 11 + i))
rings[i].rotation = Vector3(sin(elapsed * 4 + i) * 0.2, elapsed * 3, 0)
light.light_energy = 3 * maxf(0, 1.0 - maxf(0, elapsed - ARRIVAL_TIME) / 0.7)
if elapsed >= ARRIVAL_TIME and not announced:
announced = true
Audio.play(self, "ambience/port_suckout1.wav", true, -9)
materialized.emit()
if elapsed >= 2.2:
# Sound players belong to this node; leave them time to finish.
for ring in rings:
ring.hide()
light.hide()
set_process(false)
get_tree().create_timer(3).timeout.connect(queue_free)
+1
View File
@@ -0,0 +1 @@
uid://h5wtakci76b2
+37
View File
@@ -9,6 +9,9 @@ var wave_value: Label
var grenade_value: Label
var help: Label
var root: Control
var boss_panel: VBoxContainer
var boss_name: Label
var boss_health: ProgressBar
func _ready() -> void:
layer = 2
@@ -44,6 +47,31 @@ func _ready() -> void:
help.offset_right = 420
help.offset_top = -30
help.offset_bottom = -8
boss_panel = VBoxContainer.new()
boss_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
root.add_child(boss_panel)
boss_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
boss_panel.offset_left = -180
boss_panel.offset_right = 180
boss_panel.offset_top = 76
boss_name = _label(boss_panel, "ALIEN GRUNT", 17, Vector2.ZERO)
boss_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
boss_health = ProgressBar.new()
boss_health.mouse_filter = Control.MOUSE_FILTER_IGNORE
boss_health.custom_minimum_size = Vector2(360, 18)
boss_health.show_percentage = false
boss_health.step = 0.0 # explosion falloff produces fractional health values
boss_health.rounded = false
var fill := StyleBoxFlat.new()
fill.bg_color = Color(0.14, 0.85, 0.5)
boss_health.add_theme_stylebox_override("fill", fill)
var background := StyleBoxFlat.new()
background.bg_color = Color(0.02, 0.05, 0.03, 0.9)
background.border_color = AMBER
background.set_border_width_all(1)
boss_health.add_theme_stylebox_override("background", background)
boss_panel.add_child(boss_health)
boss_panel.hide()
func _panel(label: String, right: bool) -> Panel:
var panel := Panel.new()
@@ -76,6 +104,15 @@ func _label(parent: Control, text: String, size: int, pos: Vector2) -> Label:
parent.add_child(label)
return label
func update_boss(boss: Node) -> void:
boss_panel.visible = is_instance_valid(boss) and not boss.dead
if not boss_panel.visible:
return
boss_name.text = "ALIEN GRUNT — GRADO %d%s" % [boss.rank,
" — ARRIVO" if boss.entering else " | %d / %d" % [ceili(boss.hp), ceili(boss.max_hp)]]
boss_health.max_value = boss.max_hp
boss_health.value = boss.hp
func update_status(player: Node, wave: int, score: int) -> void:
health_value.text = str(maxi(0, ceili(player.hp)))
health_value.add_theme_color_override("font_color", DANGER if player.hp <= 25 else AMBER)
+145 -3
View File
@@ -1,6 +1,8 @@
extends Node3D
const PlayerScript = preload("res://scripts/player.gd")
const ZombieScript = preload("res://scripts/zombie.gd")
const BossScript = preload("res://scripts/alien_grunt.gd")
const BossTeleport = preload("res://scripts/boss_teleport.gd")
const RoomScript = preload("res://scripts/room.gd")
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
const StartScreenScript = preload("res://scripts/start_screen.gd")
@@ -26,15 +28,28 @@ var leaderboard: Node
var start_screen: CanvasLayer
var profile := {}
var score_submitted := false
var boss: CharacterBody3D
var boss_simulation := false
var simulation_wave := 0
var simulation_god := false
var simulation_finished := false
var simulation_restarting := false
var simulation_label: Label
var simulation_retry: Button
func _ready() -> void:
smoke = "--smoke" in OS.get_cmdline_user_args()
profile = Profile.load_profile()
if not _configure_simulation():
return
# A simulation must never read/write the real player's profile or public scores.
profile = {"name": "SIMULAZIONE", "player_id": "boss-simulation", "best": 0} \
if boss_simulation else Profile.load_profile()
room = Node3D.new()
room.set_script(RoomScript)
add_child(room)
player = CharacterBody3D.new()
player.set_script(PlayerScript)
player.invulnerable = boss_simulation and simulation_god
player.position = Vector3(0, 0.1, 0)
add_child(player)
var envnode := WorldEnvironment.new()
@@ -89,6 +104,12 @@ func _ready() -> void:
leaderboard.score_submitted.connect(_on_score_submitted)
leaderboard.submit_failed.connect(_on_submit_failed)
player.died.connect(_on_player_died)
if boss_simulation:
leaderboard.disabled = true
leaderboard.base_url = ""
_setup_simulation_ui()
call_deferred("_start_simulation")
return
start_screen = CanvasLayer.new()
start_screen.set_script(StartScreenScript)
start_screen.player = player
@@ -112,7 +133,7 @@ func _on_start_pressed(player_name: String) -> void:
func start_game(player_name: String, save_profile := true) -> void:
profile["name"] = player_name
if save_profile:
if save_profile and not boss_simulation:
Profile.save_profile(profile)
if not player.touch_mode:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
@@ -126,6 +147,11 @@ func start_game(player_name: String, save_profile := true) -> void:
## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore.
func _on_player_died() -> void:
if boss_simulation:
simulation_finished = false
simulation_retry.hide()
player.set_death_note("SIMULAZIONE — R / RICOMINCIA per riprovare; nessun punteggio inviato")
return
if score_submitted:
return
score_submitted = true
@@ -199,9 +225,13 @@ func _restart_music() -> void:
func _process(_dt: float) -> void:
if hud:
hud.update_status(player, wave, score)
hud.update_boss(boss)
static func is_boss_wave(number: int) -> bool:
return number > 0 and number % 10 == 0
func zombies_for_wave(number: int) -> int:
return 4 + number * 2
return 0 if is_boss_wave(number) else 4 + number * 2
func _next_wave() -> void:
wave_pending = false
@@ -209,6 +239,12 @@ func _next_wave() -> void:
return
wave += 1
spawning = true
if is_boss_wave(wave):
_spawn_boss()
spawning = false
_check_wave()
return
boss = null
for i in zombies_for_wave(wave):
if player.hp <= 0:
break
@@ -265,4 +301,110 @@ func _spawn() -> void:
func _check_wave() -> void:
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
wave_pending = true
if boss_simulation:
simulation_finished = true
simulation_label.text = "BOSS SCONFITTO — ondata %d | R o RIPROVA per ricominciare" % wave
simulation_retry.show()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
return
get_tree().create_timer(3).timeout.connect(_next_wave)
func _boss_spawn_position() -> Vector3:
var candidates := [Vector3(0, 0.05, -6), Vector3(6, 0.05, 0),
Vector3(0, 0.05, 6), Vector3(-6, 0.05, 0), Vector3(6, 0.05, 6),
Vector3(-6, 0.05, -6), Vector3(6, 0.05, -6), Vector3(-6, 0.05, 6)]
candidates.shuffle()
var capsule := CapsuleShape3D.new()
capsule.radius = 0.5
capsule.height = 2.5
for candidate: Vector3 in candidates:
if candidate.distance_to(player.global_position) < 5:
continue
var query := PhysicsShapeQueryParameters3D.new()
query.shape = capsule
query.transform = Transform3D(Basis.IDENTITY, candidate + Vector3.UP * 1.25)
query.collision_mask = 1 | 2 | 4
if get_world_3d().direct_space_state.intersect_shape(query, 1).is_empty() \
and navigation.route(candidate, player.global_position).size() > 1:
return candidate
# Fixed office layout has clear candidates; never silently spawn inside a prop.
return Vector3.INF
func _spawn_boss() -> void:
var at := _boss_spawn_position()
if not at.is_finite():
push_error("Nessun punto libero per il boss")
return
boss = BossScript.new()
boss.configure(wave)
boss.player = player
boss.navigation = navigation
boss.position = at
var instance := boss
alive += 1
boss.killed.connect(func(_headshot: bool):
score += instance.reward
alive -= 1
_check_wave())
add_child(boss)
var portal := BossTeleport.new()
portal.position = at
portal.materialized.connect(func():
if is_instance_valid(instance):
instance.materialize())
add_child(portal)
func _configure_simulation() -> bool:
if OS.has_feature("web"):
return true
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--boss-wave="):
var value := argument.substr(12)
if not value.is_valid_int() or not is_boss_wave(int(value)):
push_error("--boss-wave richiede un multiplo positivo di 10: 10, 20, 30...")
get_tree().quit(2)
return false
simulation_wave = int(value)
boss_simulation = true
simulation_god = "--boss-god" in OS.get_cmdline_user_args()
return true
func _start_simulation() -> void:
# Wait for static physics shapes/navigation to be registered before choosing a portal.
await get_tree().physics_frame
wave = simulation_wave - 1
start_game("SIMULAZIONE", false)
print("BOSS SIMULATION: wave=", simulation_wave, " god=", simulation_god,
" | public scores/profile writes disabled")
func _setup_simulation_ui() -> void:
var canvas := CanvasLayer.new()
canvas.layer = 6
add_child(canvas)
simulation_label = Label.new()
simulation_label.text = "SIMULAZIONE BOSS — ONDATA %d%s | nessun punteggio online" % [
simulation_wave, " | INVULNERABILE" if simulation_god else ""]
simulation_label.position = Vector2(18, 125)
simulation_label.add_theme_font_size_override("font_size", 18)
simulation_label.add_theme_color_override("font_color", Color(0.2, 1, 0.7))
simulation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
canvas.add_child(simulation_label)
simulation_retry = Button.new()
simulation_retry.text = "RIPROVA IL BOSS"
simulation_retry.position = Vector2(510, 520)
simulation_retry.size = Vector2(260, 60)
simulation_retry.pressed.connect(_restart_simulation)
simulation_retry.hide()
canvas.add_child(simulation_retry)
func _input(event: InputEvent) -> void:
# Only the completed native simulation has this shortcut. No menu click-to-start.
if boss_simulation and simulation_finished and event.is_action_pressed("reload"):
get_viewport().set_input_as_handled()
_restart_simulation()
func _restart_simulation() -> void:
if simulation_restarting:
return
simulation_restarting = true
get_tree().reload_current_scene()
+2 -1
View File
@@ -6,6 +6,7 @@ var damage_feedback: CanvasLayer
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
const MAX_HP := 100.0
var hp := MAX_HP
var invulnerable := false # only enabled by the explicit native boss simulation flag
var _regen_elapsed := 0.0
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
@@ -215,7 +216,7 @@ func throw_grenade() -> RigidBody3D:
return grenade
func take_damage(amount: float) -> void:
if death_started or amount <= 0:
if death_started or invulnerable or amount <= 0:
return
hp = maxf(0, hp - amount)
if hp <= 0: