extends RigidBody3D signal detonated # No timed fuse: the rifle grenade detonates on its first physical contact. const MAX_LIFETIME := 15.0 const Explosion = preload("res://scripts/grenade_explosion.gd") 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 var previous_position := Vector3.ZERO var impact_position := Vector3.ZERO func _ready() -> void: name = "Grenade" add_to_group("grenades") previous_position = global_position collision_layer = 16 collision_mask = 1 | 4 mass = 0.4 continuous_cd = true contact_monitor = true max_contacts_reported = 8 body_entered.connect(_on_body_entered) linear_damp = 0.15 angular_damp = 0.6 var physics := PhysicsMaterial.new() physics.bounce = 0.0 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 # Window panes are Area3D on layer 8, not PhysicsBody3D. A swept ray catches # those too, even if a fast grenade crosses the whole pane in one tick. if not exploded and not previous_position.is_equal_approx(global_position): var query := PhysicsRayQueryParameters3D.create(previous_position, global_position, 8) query.collide_with_areas = true query.collide_with_bodies = false var hit := get_world_3d().direct_space_state.intersect_ray(query) if not hit.is_empty(): _request_detonation(hit.position + hit.normal * 0.13) previous_position = global_position # Safety cleanup only: a projectile lost outside the map must not explode in air. if age >= MAX_LIFETIME and not exploded: queue_free() func _integrate_forces(state: PhysicsDirectBodyState3D) -> void: if not exploded and state.get_contact_count() > 0: # Save the physics position before deferring scene/shape changes. The sphere # centre stays on the free side of the surface, also for damage LOS rays. _request_detonation(state.transform.origin) func _on_body_entered(body: Node) -> void: if body != thrower: _request_detonation(global_position) func _request_detonation(point: Vector3) -> void: if exploded: return exploded = true impact_position = point call_deferred("_explode") func _explode() -> void: freeze = true collision_layer = 0 collision_mask = 0 hide() var world := get_parent() as Node3D var point := impact_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 := Explosion.new() world.add_child(effect) effect.global_position = point Audio.play(effect, "weapons/explode%d.wav" % randi_range(3, 5), true, -7) # Secondary embers only: the original fireball is now the main visual. var particles := CPUParticles3D.new() particles.amount = 18 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.018 mesh.height = 0.036 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