extends CharacterBody3D signal killed(headshot: bool) var player: CharacterBody3D var navigation: Node var _path := PackedVector3Array() var _path_index := 0 var _repath := 0.0 var _direct := false var _nav_started := false 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) _repath = randf_range(0, 0.5) 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 or not _attack_clear() var motion := _pursuit_direction(dt) * speed if walking else Vector3.ZERO if motion.length_squared() > 0.01: rotation.y = atan2(-motion.x, -motion.z) 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 _pursuit_direction(dt: float) -> Vector3: var target := player.global_position _repath -= dt if navigation != null and (_repath <= 0 or not _nav_started): _nav_started = true _repath = 0.55 + float(get_instance_id() % 7) * 0.02 _direct = navigation.direct_clear(global_position, target) if not _direct: _path = navigation.route(global_position, target) _path_index = 0 if _path.size() > 1 and navigation.direct_clear(global_position, _path[1]): _path_index = 1 if navigation == null or _direct: var dir := target - global_position dir.y = 0 return dir.normalized() while _path_index < _path.size(): var dir := _path[_path_index] - global_position dir.y = 0 if dir.length() > 0.22: return dir.normalized() _path_index += 1 return Vector3.ZERO func _attack_clear() -> bool: # Check below desk-top height: a chest-high ray can pass over a desk # even though the bodies cannot approach each other through it. var start := global_position + Vector3.UP * 0.6 var end := player.global_position + Vector3.UP * 0.6 var query := PhysicsRayQueryParameters3D.create(start, end, 1) query.exclude = [get_rid(), player.get_rid()] return get_world_3d().direct_space_state.intersect_ray(query).is_empty() 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 and _attack_clear(): 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)