Add industrial metal soundtrack with seamless loop

- assets/audio/music/zombie_industrial_loop.ogg: 57.60 s Ogg Vorbis loop
  generated locally with YuE2-3B on the Tesla V100 (score written for this
  track; vocals removed with HTDemucs). The loop is spliced at the track's
  structural repeat with a 0.6 s crossfade: measured seam dip <= 1.6 dB.
- scripts/main.gd: "Music" AudioStreamPlayer at -11 dB, started at boot on
  desktop and on the start click on Web (browser autoplay policy). It forces
  stream.loop at runtime because the Web staging step re-imports the OGG with
  loop=false.
- tools/test_music.gd: soundtrack test (loop, length, volume, playback,
  still playing after player death).
- docs/MUSIC.md: source, loop construction, verification, licence.
- docs/WEB-DEPLOY.md: release ab6054111f5919a8 notes.

Initial commit of the project.
This commit is contained in:
enne2
2026-10-10 23:11:13 +02:00
commit 956a15a525
199 changed files with 4563 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
extends RefCounted
const MAX_STAINS := 160
static func impact(world: Node3D, point: Vector3, direction: Vector3) -> void:
# CPU particles work in both Forward+ and WebGL Compatibility.
var particles := CPUParticles3D.new()
particles.name = "BloodSpray"
particles.amount = 28
particles.lifetime = 0.65
particles.one_shot = true
particles.explosiveness = 1.0
particles.direction = direction.normalized()
particles.spread = 65
particles.initial_velocity_min = 1.3
particles.initial_velocity_max = 3.5
particles.gravity = Vector3(0, -9.8, 0)
particles.scale_amount_min = 0.5
particles.scale_amount_max = 1.5
var mesh := SphereMesh.new()
mesh.radius = 0.018
mesh.height = 0.036
var material := StandardMaterial3D.new()
material.albedo_color = Color(0.45, 0.015, 0.025)
material.roughness = 0.35
mesh.material = material
particles.mesh = mesh
world.add_child(particles)
particles.global_position = point
particles.emitting = true
world.get_tree().create_timer(1.3).timeout.connect(particles.queue_free)
var query := PhysicsRayQueryParameters3D.create(point + Vector3.UP * 0.05, point + Vector3.DOWN * 8, 1)
var hit := world.get_world_3d().direct_space_state.intersect_ray(query)
if hit.is_empty() or hit.normal.dot(Vector3.UP) < 0.7:
return
var width := randf_range(0.55, 1.15)
var stain: Node3D
if RenderingServer.get_current_rendering_method() == "gl_compatibility":
# WebGL has no projected decals: textured horizontal patch on floor.
var patch := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(width, width)
var mat := StandardMaterial3D.new()
mat.albedo_texture = load("res://assets/effects/blood.png")
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.roughness = 0.9
plane.material = mat
patch.mesh = plane
patch.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
stain = patch
else:
var decal := Decal.new()
decal.texture_albedo = load("res://assets/effects/blood.png")
decal.size = Vector3(width, 0.25, width)
decal.cull_mask = 1
decal.upper_fade = 0
decal.lower_fade = 0
stain = decal
stain.name = "BloodStain"
world.add_child(stain)
stain.global_position = hit.position + Vector3.UP * 0.012
stain.rotation.y = randf_range(0, TAU)
stain.add_to_group("blood_stains")
var stains := world.get_tree().get_nodes_in_group("blood_stains")
if stains.size() > MAX_STAINS:
stains[0].queue_free()
+1
View File
@@ -0,0 +1 @@
uid://dwi0onjbbv86u
+90
View File
@@ -0,0 +1,90 @@
extends CanvasLayer
## Local-only trauma pivot: never modifies the player's aim angles.
const SHAKE_SECONDS := 0.42
const BLOOD_SECONDS := 1.15
const SPLAT_COUNT := 7
var pivot: Node3D
var shake_left := 0.0
var blood_left := 0.0
var intensity := 0.0
var phase := 0.0
var active := true
var overlay: Control
var vignette: ColorRect
var shader_material: ShaderMaterial
var splats: Array[TextureRect] = []
func _ready() -> void:
layer = 2
overlay = Control.new()
overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(overlay)
overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vignette = ColorRect.new()
vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
shader_material = ShaderMaterial.new()
shader_material.shader = load("res://assets/effects/damage_vignette.gdshader")
vignette.material = shader_material
overlay.add_child(vignette)
vignette.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for i in SPLAT_COUNT:
var splat := TextureRect.new()
splat.texture = load("res://assets/effects/blood.png")
splat.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
splat.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
splat.mouse_filter = Control.MOUSE_FILTER_IGNORE
splat.modulate.a = 0
overlay.add_child(splat)
splats.append(splat)
func hit(amount: float) -> void:
if not active or amount <= 0:
return
# Refresh instead of stacking tweens/particles: bounded cost and shake.
intensity = minf(1.0, maxf(intensity, 0.55 + amount / 100.0) + 0.12)
shake_left = SHAKE_SECONDS
blood_left = BLOOD_SECONDS
phase = randf_range(0, TAU)
var viewport_size := get_viewport().get_visible_rect().size
for i in splats.size():
var splat := splats[i]
var size := minf(viewport_size.x, viewport_size.y) * randf_range(0.22, 0.38)
splat.size = Vector2.ONE * size
splat.pivot_offset = splat.size * 0.5
# Keep the central reticle and HUD readable; splats lie near the edges.
var center := Vector2.ZERO
match i % 4:
0: center = viewport_size * Vector2(randf_range(0.06, 0.17), randf_range(0.25, 0.90))
1: center = viewport_size * Vector2(randf_range(0.83, 0.95), randf_range(0.25, 0.90))
2: center = viewport_size * Vector2(randf_range(0.25, 0.75), randf_range(0.82, 0.98))
3: center = viewport_size * Vector2(randf_range(0.3, 0.8), randf_range(0.04, 0.13))
splat.position = center - splat.size * 0.5
splat.rotation = randf_range(-PI, PI)
splat.modulate = Color(0.85, 0.1, 0.12, 0.85 * intensity)
shader_material.set_shader_parameter("strength", intensity)
func _process(delta: float) -> void:
if not active:
return
phase += delta * 42.0
shake_left = maxf(0, shake_left - delta)
blood_left = maxf(0, blood_left - delta)
var shake := pow(shake_left / SHAKE_SECONDS, 2.0) * intensity
if is_instance_valid(pivot):
pivot.position = Vector3(sin(phase) * 0.025, cos(phase * 1.3) * 0.018, sin(phase * 0.8) * 0.012) * shake
pivot.rotation = Vector3(sin(phase * 0.9) * 0.024, cos(phase) * 0.013, sin(phase * 1.1) * 0.035) * shake
var alpha := blood_left / BLOOD_SECONDS
for splat in splats:
splat.modulate.a = alpha * 0.85 * intensity
shader_material.set_shader_parameter("strength", minf(1, alpha * 1.4) * intensity)
if blood_left <= 0:
intensity = 0
func stop() -> void:
active = false
shake_left = 0
blood_left = 0
intensity = 0
if is_instance_valid(pivot):
pivot.transform = Transform3D.IDENTITY
overlay.hide()
+1
View File
@@ -0,0 +1 @@
uid://75qn70d6fklx
+113
View File
@@ -0,0 +1,113 @@
extends RigidBody3D
signal detonated
const FUSE_SECONDS := 4.0
const BLAST_RADIUS := 5.0
const MAX_DAMAGE := 160.0
const Audio = preload("res://scripts/hl_audio.gd")
const Blood = preload("res://scripts/blood_effects.gd")
var age := 0.0
var exploded := false
var thrower: CharacterBody3D
func _ready() -> void:
name = "Grenade"
add_to_group("grenades")
collision_layer = 16
collision_mask = 1 | 4
mass = 0.4
continuous_cd = true
linear_damp = 0.15
angular_damp = 0.6
var physics := PhysicsMaterial.new()
physics.bounce = 0.45
physics.friction = 0.65
physics_material_override = physics
var collider := CollisionShape3D.new()
var sphere := SphereShape3D.new()
sphere.radius = 0.12
collider.shape = sphere
add_child(collider)
var model: Node3D = load("res://assets/weapons/grenade.scn").instantiate()
add_child(model)
for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
for mesh in model.find_children("*", "MeshInstance3D", true, false):
mesh.layers = 2
if is_instance_valid(thrower):
add_collision_exception_with(thrower)
func _physics_process(delta: float) -> void:
age += delta
if age >= FUSE_SECONDS and not exploded:
exploded = true
call_deferred("_explode")
func _explode() -> void:
freeze = true
collision_layer = 0
collision_mask = 0
hide()
var world := get_parent() as Node3D
var point := global_position
var targets := get_tree().get_nodes_in_group("live_zombies")
if is_instance_valid(thrower) and not thrower.death_started:
targets.append(thrower)
for target in targets:
if not is_instance_valid(target):
continue
var center: Vector3 = target.global_position + Vector3.UP * 0.9
var distance := point.distance_to(center)
if distance > BLAST_RADIUS:
continue
var query := PhysicsRayQueryParameters3D.create(point, center, 1)
if not get_world_3d().direct_space_state.intersect_ray(query).is_empty():
continue
var damage := MAX_DAMAGE * (1.0 - distance / BLAST_RADIUS)
if target == thrower:
target.take_damage(damage)
else:
Blood.impact(world, center, (center - point).normalized())
target.take_damage(damage, center.y)
_effect(world, point)
detonated.emit()
queue_free()
func _effect(world: Node3D, point: Vector3) -> void:
var effect := Node3D.new()
effect.name = "GrenadeExplosion"
world.add_child(effect)
effect.global_position = point
Audio.play(effect, "weapons/explode%d.wav" % randi_range(3, 5), true, -7)
var light := OmniLight3D.new()
light.light_color = Color(1, 0.45, 0.08)
light.light_energy = 8
light.omni_range = 10
effect.add_child(light)
var tween := effect.create_tween()
tween.tween_property(light, "light_energy", 0.0, 0.3)
var particles := CPUParticles3D.new()
particles.amount = 60
particles.lifetime = 0.85
particles.one_shot = true
particles.explosiveness = 1
particles.direction = Vector3.UP
particles.spread = 180
particles.initial_velocity_min = 2
particles.initial_velocity_max = 6
particles.gravity = Vector3(0, -3, 0)
var mesh := SphereMesh.new()
mesh.radius = 0.06
mesh.height = 0.12
var material := StandardMaterial3D.new()
material.albedo_color = Color(1, 0.3, 0.05)
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mesh.material = material
particles.mesh = mesh
var colors := Gradient.new()
colors.set_color(0, Color(1, 0.65, 0.1, 1))
colors.set_color(1, Color(0.15, 0.12, 0.1, 0))
particles.color_ramp = colors
effect.add_child(particles)
particles.emitting = true
world.get_tree().create_timer(4).timeout.connect(effect.queue_free)
+1
View File
@@ -0,0 +1 @@
uid://6681xr24ymu0
+22
View File
@@ -0,0 +1,22 @@
extends RefCounted
## Original WAVs; overlapping shots allowed, positional creature/environment audio.
static func play(parent: Node, path: String, positional: bool = false, volume: float = -10.0) -> void:
var stream := load("res://assets/audio/" + path) as AudioStream
if stream == null:
return
if positional:
var speaker := AudioStreamPlayer3D.new()
speaker.stream = stream
speaker.volume_db = volume
speaker.max_distance = 30
speaker.unit_size = 4
parent.add_child(speaker)
speaker.finished.connect(speaker.queue_free)
speaker.play()
else:
var speaker := AudioStreamPlayer.new()
speaker.stream = stream
speaker.volume_db = volume
parent.add_child(speaker)
speaker.finished.connect(speaker.queue_free)
speaker.play()
+1
View File
@@ -0,0 +1 @@
uid://clxd1ronsgnm6
+158
View File
@@ -0,0 +1,158 @@
extends Node3D
const PlayerScript = preload("res://scripts/player.gd")
const ZombieScript = preload("res://scripts/zombie.gd")
const RoomScript = preload("res://scripts/room.gd")
const MUSIC_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg"
const MUSIC_VOLUME_DB := -11.0
var player: CharacterBody3D
var room: Node3D
var music: AudioStreamPlayer
var music_started := false
var hud: Label
var score := 0
var wave := 0
var alive := 0
var spawning := false
var wave_pending := false
var smoke := false
var awaiting_web_start := false
var start_label: Label
func _ready() -> void:
smoke = "--smoke" in OS.get_cmdline_user_args()
room = Node3D.new()
room.set_script(RoomScript)
add_child(room)
player = CharacterBody3D.new()
player.set_script(PlayerScript)
player.position = Vector3(0, 0.1, 0)
add_child(player)
var envnode := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.08, 0.12, 0.19)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.65, 0.73, 0.85)
env.ambient_light_energy = 0.65
envnode.environment = env
add_child(envnode)
var light := OmniLight3D.new()
light.position = Vector3(0, 4.5, 0)
light.omni_range = 24
light.light_energy = 3
light.shadow_enabled = true
add_child(light)
var canvas := CanvasLayer.new()
add_child(canvas)
hud = Label.new()
hud.position = Vector2(24, 20)
hud.add_theme_font_size_override("font_size", 24)
canvas.add_child(hud)
var cross := Label.new()
cross.text = "+"
cross.add_theme_font_size_override("font_size", 30)
canvas.add_child(cross)
cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_setup_music()
if OS.has_feature("web"):
awaiting_web_start = true
start_label = Label.new()
start_label.text = "CLICCA PER INIZIARE\nWASD: movimento · clic sinistro: spara · destro: granata · R: ricarica"
start_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
start_label.add_theme_font_size_override("font_size", 24)
start_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
canvas.add_child(start_label)
start_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
else:
_start_music()
_next_wave()
if smoke:
get_tree().create_timer(12).timeout.connect(func():
print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp)
get_tree().quit())
func _unhandled_input(event: InputEvent) -> void:
if awaiting_web_start and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
awaiting_web_start = false
start_label.queue_free()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# Il browser concede l'audio solo dopo un gesto dell'utente: qui il click.
_start_music()
_next_wave()
## Colonna sonora: industrial metal strumentale su loop senza giunzione udibile.
func _setup_music() -> void:
music = AudioStreamPlayer.new()
music.name = "Music"
music.stream = load(MUSIC_PATH)
music.volume_db = MUSIC_VOLUME_DB
if music.stream is AudioStreamOggVorbis:
music.stream.loop = true
add_child(music)
music.add_to_group("music")
# Rete di sicurezza: se il flag di loop mancasse, il brano riparte a fine coda.
music.finished.connect(_restart_music)
func _start_music() -> void:
if music_started or music == null:
return
music_started = true
music.play()
func _restart_music() -> void:
if music_started and is_instance_valid(music):
music.play()
func _process(_dt: float) -> void:
if hud:
hud.text = "VITA %d MUNIZIONI %d/30 ONDATA %d PUNTI %d\nWASD · mouse · sinistro: spara · destro: granata · R: ricarica · spazio: salto · Esc: mouse" % [player.hp, player.ammo, wave, score]
hud.text += "\nGRANATA: " + ("PRONTA" if player.grenade_cooldown <= 0 else "%d s" % ceili(player.grenade_cooldown))
if player.reloading:
hud.text += "\nRICARICA..."
if player.hp <= 0:
hud.text += "\nSEI MORTO — premi R per ricominciare"
func zombies_for_wave(number: int) -> int:
return 4 + number * 2
func _next_wave() -> void:
wave_pending = false
if player.hp <= 0 or spawning:
return
wave += 1
spawning = true
for i in zombies_for_wave(wave):
if player.hp <= 0:
break
_spawn(i % 4)
await get_tree().create_timer(0.8).timeout
spawning = false
_check_wave()
func _spawn(index: int) -> void:
var centers := [Vector3(0, 0, -9), Vector3(0, 0, 9), Vector3(-9, 0, 0), Vector3(9, 0, 0)]
var inward: Vector3 = -centers[index].normalized()
var zombie := CharacterBody3D.new()
zombie.set_script(ZombieScript)
zombie.player = player
zombie.position = centers[index] - inward * 1.2
add_child(zombie)
alive += 1
zombie.killed.connect(func(headshot: bool):
score += 150 if headshot else 100
alive -= 1
_check_wave())
var tween := zombie.create_tween()
zombie.entry_tween = tween
tween.tween_property(zombie, "position", centers[index] + Vector3(0, 1.55, 0), 1.2)
tween.tween_callback(func():
for glass in room.glass_panes.duplicate():
if is_instance_valid(glass) and Vector2(glass.position.x - centers[index].x, glass.position.z - centers[index].z).length() < 2:
room.shatter_glass(glass))
tween.tween_property(zombie, "position", centers[index] + inward * 1.4 + Vector3(0, 0.1, 0), 0.8)
tween.tween_callback(func(): zombie.entering = false)
func _check_wave() -> void:
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
wave_pending = true
get_tree().create_timer(3).timeout.connect(_next_wave)
+1
View File
@@ -0,0 +1 @@
uid://bc8p55iv7cox6
+198
View File
@@ -0,0 +1,198 @@
extends CharacterBody3D
var camera: Camera3D
var damage_pivot: Node3D
var damage_feedback: CanvasLayer
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
var hp := 100.0
var ammo := 30
var cooldown := 0.0
var grenade_cooldown := 0.0
const GRENADE_COOLDOWN := 15.0
const Grenade = preload("res://scripts/grenade.gd")
var reloading := false
var gun: Node3D
var flash: OmniLight3D
var weapon_anim: AnimationPlayer
var step_timer := 0.0
var death_started := false
var death_overlay: ColorRect
const Blood = preload("res://scripts/blood_effects.gd")
const Audio = preload("res://scripts/hl_audio.gd")
func _ready() -> void:
collision_layer = 2
collision_mask = 1 | 4
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.height = 1.8
cap.radius = 0.35
cs.shape = cap
cs.position.y = 0.9
add_child(cs)
camera = Camera3D.new()
camera.position.y = 1.65
camera.current = true
camera.fov = 80
damage_pivot = Node3D.new()
damage_pivot.name = "DamagePivot"
add_child(damage_pivot)
damage_pivot.add_child(camera)
damage_feedback = CanvasLayer.new()
damage_feedback.set_script(DamageFeedback)
damage_feedback.pivot = damage_pivot
add_child(damage_feedback)
gun = Node3D.new()
camera.add_child(gun)
var viewmodel: Node3D = load("res://assets/weapons/v_9mmar.scn").instantiate()
gun.add_child(viewmodel)
for area in viewmodel.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
for mesh in viewmodel.find_children("*", "MeshInstance3D", true, false):
mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mesh.layers = 2
weapon_anim = viewmodel.get_node("AnimationPlayer")
weapon_anim.animation_finished.connect(func(_name: StringName):
if not reloading and not death_started:
weapon_anim.play("idle1"))
weapon_anim.play("deploy")
flash = OmniLight3D.new()
flash.position = Vector3(0.28, -0.2, -1.1)
flash.light_color = Color(1, 0.7, 0.25)
flash.omni_range = 7
flash.light_energy = 0
camera.add_child(flash)
if DisplayServer.get_name() != "headless" and not OS.has_feature("web"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
if OS.has_feature("web") and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and hp > 0:
rotate_y(-event.relative.x * 0.0025)
camera.rotation.x = clampf(camera.rotation.x - event.relative.y * 0.0025, -1.4, 1.4)
if event.is_action_pressed("pause") and not death_started:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
if event.is_action_pressed("grenade") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and not death_started:
throw_grenade()
if event.is_action_pressed("reload"):
if hp <= 0:
get_tree().reload_current_scene()
elif not reloading and ammo < 30:
reloading = true
weapon_anim.play("reload")
Audio.play(self, "items/cliprelease1.wav")
get_tree().create_timer(0.8).timeout.connect(func():
if not death_started:
Audio.play(self, "items/clipinsert1.wav"))
get_tree().create_timer(1.57).timeout.connect(func():
if not death_started:
ammo = 30
reloading = false
weapon_anim.play("idle1"))
func _physics_process(dt: float) -> void:
cooldown -= dt
grenade_cooldown = maxf(0, grenade_cooldown - dt)
flash.light_energy = maxf(0, flash.light_energy - dt * 60)
gun.position.z = lerpf(gun.position.z, 0, dt * 15)
if hp <= 0:
return
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := transform.basis * Vector3(input.x, 0, input.y)
velocity.x = direction.x * 5
velocity.z = direction.z * 5
velocity.y -= 20 * dt
if is_on_floor() and Input.is_action_just_pressed("jump"):
velocity.y = 7
move_and_slide()
step_timer -= dt
if is_on_floor() and direction.length() > 0.1 and step_timer <= 0:
step_timer = 0.42
Audio.play(self, "player/pl_metal%d.wav" % randi_range(1, 4), false, -18)
if Input.is_action_just_pressed("shoot") and ammo <= 0 and not reloading:
Audio.play(self, "weapons/dryfire1.wav")
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
shoot()
func shoot() -> void:
if ammo <= 0 or reloading or hp <= 0:
return
weapon_anim.stop()
weapon_anim.play("shoot")
Audio.play(self, "weapons/hks%d.wav" % randi_range(1, 3), false, -13)
ammo -= 1
cooldown = 0.13
flash.light_energy = 3
gun.position.z = 0.06
var start := camera.global_position
var query := PhysicsRayQueryParameters3D.create(start, start - camera.global_basis.z * 80, 1 | 4)
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if hit and hit.collider.has_method("take_damage"):
Blood.impact(get_parent(), hit.position, -camera.global_basis.z)
hit.collider.take_damage(35, hit.position.y)
func throw_grenade() -> RigidBody3D:
if death_started or hp <= 0 or grenade_cooldown > 0:
return null
var world := get_parent() as Node3D
var forward := -camera.global_basis.z
var start := camera.global_position
var destination := start + forward * 0.55
# Do not spawn through the wall when the camera is close to it.
var query := PhysicsRayQueryParameters3D.create(start, destination, 1)
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
destination = start + forward * maxf(0, start.distance_to(hit.position) - 0.16)
var grenade := RigidBody3D.new()
grenade.set_script(Grenade)
grenade.thrower = self
grenade.position = world.to_local(destination)
world.add_child(grenade)
grenade.linear_velocity = forward * 12 + Vector3.UP * 3 + velocity
grenade.angular_velocity = Vector3(5, 3, 2)
grenade_cooldown = GRENADE_COOLDOWN
return grenade
func take_damage(amount: float) -> void:
if death_started or amount <= 0:
return
hp = maxf(0, hp - amount)
if hp <= 0:
_die()
else:
damage_feedback.hit(amount)
func _die() -> void:
death_started = true
damage_feedback.stop()
velocity = Vector3.ZERO
reloading = false
gun.hide()
flash.light_energy = 0
weapon_anim.stop()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Original Half-Life player pain sample used as the death event vocal.
Audio.play(self, "player/pl_pain7.wav", false, -8)
var layer := CanvasLayer.new()
layer.layer = 5
add_child(layer)
death_overlay = ColorRect.new()
death_overlay.color = Color(0, 0, 0, 0)
death_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(death_overlay)
death_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(camera, "position:y", 0.22, 0.9).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tween.tween_property(camera, "rotation:z", deg_to_rad(-75), 0.9)
tween.tween_property(camera, "rotation:x", 0.08, 0.9)
tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7)
var message := Label.new()
message.text = "SEI MORTO\nPremi R per ricominciare"
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.add_theme_font_size_override("font_size", 36)
message.modulate.a = 0
layer.add_child(message)
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2)
+1
View File
@@ -0,0 +1 @@
uid://dfflxt06enx5
+198
View File
@@ -0,0 +1,198 @@
## Stanza 18x18x5 con 4 finestre (una per muro) e nessuna porta.
## Geometria costruita in codice per determinismo; vetro = Area3D rompibile.
class_name Room
extends Node3D
const W: float = 18.0 # larghezza (x)
const D: float = 18.0 # profondita (z)
const H: float = 5.0 # altezza (y)
const WALL_T: float = 0.3
# Finestra: 4 m x 2 m, bordo basso a 1.5 m
const WIN_W: float = 4.0
const WIN_H: float = 2.0
const WIN_Y0: float = 1.5
var glass_panes: Array[Area3D] = []
## Punti di spawn esterni, uno per finestra (4 finestre x 4 slot)
var window_spawn_points: Array[Vector3] = []
var window_normals: Array[Vector3] = [] # direzione "dentro" la stanza per ogni finestra
func _ready() -> void:
var wall_mat := _surface_material("wall", 0.9)
var floor_mat := _surface_material("floor", 0.95)
var ceil_mat := _surface_material("ceiling", 0.85)
# Pavimento
_box(Vector3(W, 0.2, D), Vector3(0, -0.1, 0), floor_mat, true)
# Soffitto
_box(Vector3(W, 0.2, D), Vector3(0, H + 0.1, 0), ceil_mat, false)
# 4 muri con apertura finestra al centro
# nord (z = -D/2), sud (z = +D/2), ovest (x = -W/2), est (x = +W/2)
var walls := [
{"center": Vector3(0, H / 2, -D / 2 - WALL_T / 2), "size": Vector3(W, H, WALL_T), "axis": "x"},
{"center": Vector3(0, H / 2, D / 2 + WALL_T / 2), "size": Vector3(W, H, WALL_T), "axis": "x"},
{"center": Vector3(-W / 2 - WALL_T / 2, H / 2, 0), "size": Vector3(WALL_T, H, D), "axis": "z"},
{"center": Vector3(W / 2 + WALL_T / 2, H / 2, 0), "size": Vector3(WALL_T, H, D), "axis": "z"},
]
for w in walls:
_build_wall_with_window(w.center, w.size, w.axis, wall_mat)
# Vetrata per ogni finestra
_add_glass(Vector3(0, WIN_Y0 + WIN_H / 2, -D / 2), Vector3(WIN_W, WIN_H, 0.05), Vector3(1, 0, 0))
_add_glass(Vector3(0, WIN_Y0 + WIN_H / 2, D / 2), Vector3(WIN_W, WIN_H, 0.05), Vector3(1, 0, 0))
_add_glass(Vector3(-W / 2, WIN_Y0 + WIN_H / 2, 0), Vector3(0.05, WIN_H, WIN_W), Vector3(0, 0, 1))
_add_glass(Vector3(W / 2, WIN_Y0 + WIN_H / 2, 0), Vector3(0.05, WIN_H, WIN_W), Vector3(0, 0, 1))
# Punti di spawn: 4 slot per finestra, fuori dal muro
var win_centers := [
{"c": Vector3(0, 0, -D / 2), "n": Vector3(0, 0, 1)},
{"c": Vector3(0, 0, D / 2), "n": Vector3(0, 0, -1)},
{"c": Vector3(-W / 2, 0, 0), "n": Vector3(1, 0, 0)},
{"c": Vector3(W / 2, 0, 0), "n": Vector3(-1, 0, 0)},
]
for wc in win_centers:
var side: Vector3 = wc.n.cross(Vector3(0, 1, 0)) # asse laterale della finestra
for i in 4:
var off := (i - 1.5) * (WIN_W / 3.0)
var p: Vector3 = wc.c + side * off + wc.n * (-1.2) # 1.2 m fuori dalla finestra
window_spawn_points.append(p)
window_normals.append(wc.n)
func _build_wall_with_window(center: Vector3, size: Vector3, axis: String, mat: StandardMaterial3D) -> void:
# Segments around the window opening (window is centered on the wall face)
var wall := StaticBody3D.new()
wall.name = "Wall"
add_child(wall)
var half_win_x := WIN_W / 2.0
var y0 := WIN_Y0
var y1 := WIN_Y0 + WIN_H
if axis == "x": # muro lungo x
var L := size.x
var segs := [
{"s": Vector3(L / 2 - half_win_x, size.y, size.z), "c": Vector3(-half_win_x - (L / 2 - half_win_x) / 2.0, 0, 0)},
{"s": Vector3(L / 2 - half_win_x, size.y, size.z), "c": Vector3(half_win_x + (L / 2 - half_win_x) / 2.0, 0, 0)},
{"s": Vector3(WIN_W, y0, size.z), "c": Vector3(0, -size.y / 2 + y0 / 2.0, 0)},
{"s": Vector3(WIN_W, size.y - y1, size.z), "c": Vector3(0, size.y / 2 - (size.y - y1) / 2.0, 0)},
]
for seg in segs:
_add_box_to(wall, seg.s, center + seg.c, mat)
else: # muro lungo z
var L := size.z
var segs := [
{"s": Vector3(size.x, size.y, L / 2 - half_win_x), "c": Vector3(0, 0, -half_win_x - (L / 2 - half_win_x) / 2.0)},
{"s": Vector3(size.x, size.y, L / 2 - half_win_x), "c": Vector3(0, 0, half_win_x + (L / 2 - half_win_x) / 2.0)},
{"s": Vector3(size.x, y0, WIN_W), "c": Vector3(0, -size.y / 2 + y0 / 2.0, 0)},
{"s": Vector3(size.x, size.y - y1, WIN_W), "c": Vector3(0, size.y / 2 - (size.y - y1) / 2.0, 0)},
]
for seg in segs:
_add_box_to(wall, seg.s, center + seg.c, mat)
func _add_glass(center: Vector3, size: Vector3, rot_axis: Vector3) -> void:
var glass := Area3D.new()
glass.name = "Glass"
glass.collision_layer = 8
glass.collision_mask = 0
add_child(glass)
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
glass.add_child(shape)
glass.global_position = center
var mesh := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = size
var gm := StandardMaterial3D.new()
gm.albedo_color = Color(0.7, 0.85, 0.9, 0.16)
gm.cull_mode = BaseMaterial3D.CULL_DISABLED
gm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
gm.shading_mode = BaseMaterial3D.SHADING_MODE_PER_PIXEL
gm.roughness = 0.05
gm.metallic = 0.1
bm.material = gm
mesh.mesh = bm
glass.add_child(mesh)
glass_panes.append(glass)
func shatter_glass(glass: Area3D) -> void:
var sound_origin := Node3D.new()
add_child(sound_origin)
sound_origin.global_position = glass.global_position
preload("res://scripts/hl_audio.gd").play(sound_origin, "debris/bustglass%d.wav" % randi_range(1, 3), true, -12)
get_tree().create_timer(4).timeout.connect(sound_origin.queue_free)
for i in glass_panes.size():
if glass_panes[i] == glass:
glass_panes.remove_at(i)
break
# Schegge di vetro (particelle semplici)
var sh := CPUParticles3D.new()
sh.direction = Vector3(0, -0.3, 0)
sh.spread = 180
sh.initial_velocity_min = 1.5
sh.initial_velocity_max = 4
sh.gravity = Vector3(0, -9.8, 0)
sh.scale_amount_min = 0.4
sh.scale_amount_max = 1.2
sh.color = Color(0.7, 0.85, 1.0, 0.8)
sh.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
sh.emission_sphere_radius = 1.2
var shard := BoxMesh.new()
shard.size = Vector3(0.05, 0.02, 0.08)
var shard_mat := StandardMaterial3D.new()
shard_mat.albedo_color = Color(0.6, 0.8, 0.9)
shard.material = shard_mat
sh.mesh = shard
sh.amount = 40
sh.lifetime = 0.9
sh.one_shot = true
sh.explosiveness = 1.0
sh.position = glass.global_position
add_child(sh)
sh.emitting = true
get_tree().create_timer(2).timeout.connect(sh.queue_free)
glass.queue_free()
func _box(s: Vector3, c: Vector3, m: StandardMaterial3D, collide: bool) -> void:
var body := StaticBody3D.new()
add_child(body)
_add_box_to(body, s, c, m)
if not collide:
body.get_node("shape").set_deferred("disabled", true)
func _add_box_to(parent: Node3D, s: Vector3, c: Vector3, m: StandardMaterial3D) -> void:
var mi := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = s
bm.material = m
mi.mesh = bm
parent.add_child(mi)
var cs := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = s
cs.shape = bs
cs.position = c - parent.position if parent is StaticBody3D else c
cs.name = "shape"
parent.add_child(cs)
# il mesh va centrato sullo stesso punto della collision
mi.position = c - parent.position if parent is StaticBody3D else c
func _surface_material(texture_name: String, rough: float) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_texture = load("res://assets/textures/" + texture_name + ".png")
material.albedo_color = Color.WHITE
material.roughness = rough
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
material.texture_repeat = true
material.uv1_triplanar = true
material.uv1_world_triplanar = true
material.uv1_scale = Vector3.ONE * 0.5
return material
func _mat(color: Color, rough: float) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = color
m.roughness = rough
return m
+1
View File
@@ -0,0 +1 @@
uid://biqsa3jp3253l
+93
View File
@@ -0,0 +1,93 @@
extends CharacterBody3D
signal killed(headshot: bool)
var player: CharacterBody3D
var hp := 100.0
var speed := 2.0
var cooldown := 0.0
var dead := false
var entering := true
var anim: AnimationPlayer
var voice_timer := 4.0
var entry_tween: Tween
const Audio = preload("res://scripts/hl_audio.gd")
func _ready() -> void:
add_to_group("live_zombies")
collision_layer = 4
collision_mask = 1 | 4
var shape := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.35
cap.height = 1.8
shape.shape = cap
shape.position.y = 0.9
add_child(shape)
var model: Node3D = load("res://assets/zombies/zombie_textured.scn").instantiate()
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
speed = randf_range(1.6, 2.8)
anim.play("walk")
Audio.play(self, "zombie/zo_alert%d0.wav" % randi_range(1, 3), true, -15)
func _physics_process(dt: float) -> void:
if dead or entering or not is_instance_valid(player) or player.hp <= 0:
return
cooldown -= dt
voice_timer -= dt
if voice_timer <= 0:
voice_timer = randf_range(5, 10)
Audio.play(self, "zombie/zo_idle%d.wav" % randi_range(1, 4), true, -19)
var dir := player.global_position - global_position
dir.y = 0
var distance := dir.length()
if distance > 0.01:
rotation.y = atan2(-dir.x, -dir.z)
var walking := distance > 1.7
var motion := dir.normalized() * speed if walking else Vector3.ZERO
velocity.x = motion.x
velocity.z = motion.z
velocity.y = -1.0 if is_on_floor() else velocity.y - 20.0 * dt
move_and_slide()
if walking and cooldown <= 0 and anim.current_animation != "walk":
anim.play("walk", 0.15)
if not walking and cooldown <= 0:
cooldown = 1.5
anim.play("attack2", 0.1)
Audio.play(self, "zombie/zo_attack%d.wav" % randi_range(1, 2), true, -15)
get_tree().create_timer(0.5).timeout.connect(_attack)
func _attack() -> void:
if not dead and is_instance_valid(player) and player.hp > 0 and global_position.distance_to(player.global_position) < 2.3:
player.take_damage(12)
Audio.play(self, "zombie/claw_strike%d.wav" % randi_range(1, 3), true, -12)
func take_damage(amount: float, hit_y: float) -> void:
if dead:
return
var headshot := hit_y - global_position.y > 1.45
hp -= amount * (2.5 if headshot else 1.0)
if hp <= 0:
dead = true
remove_from_group("live_zombies")
velocity = Vector3.ZERO
if entry_tween and entry_tween.is_running():
entry_tween.kill()
collision_layer = 0
collision_mask = 0
for speaker in get_children():
if speaker is AudioStreamPlayer3D:
speaker.stop()
speaker.queue_free()
# The original zombie has no dedicated death WAV: use its pain grunt only at death.
Audio.play(self, "zombie/zo_pain2.wav", true, -11)
anim.play("dieheadshot" if headshot else "diesimple", 0.1)
var settle := create_tween()
settle.tween_property(self, "position:y", 0.0, 0.45)
add_to_group("zombie_corpses")
killed.emit(headshot)
else:
Audio.play(self, "zombie/zo_pain1.wav", true, -15)
+1
View File
@@ -0,0 +1 @@
uid://jriohka6tdgj