spaceship/vessels/VesselWeaponComponent.gd
Loïc Wisniewski 940540d60c #146: shots, deaths and collapses are heard
Three events get a sound: a shot leaving a gun, a vessel dying, a structure
coming down.

The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py
generates all three from scratch, and is committed alongside them. The
ticket asked for free sources and the good ones are in its comments
(Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0),
but a generated sound has no licence to track, no attribution to keep, and
no binary in the repository whose provenance nobody can check a year from
now. It also makes the sounds DATA: too sharp a shot is a number in that
file and a re-run, not a hunt through a library. Dropping in a pack file
later means replacing a .wav and nothing else.

A voice POOL, not a player, because these overlap constantly - a fleet
firing is several shots inside one shot's length. Twelve voices, and when
they are all busy the one furthest through its own sound is recycled, which
is the one with the least left to lose.

Parented to the tree root, for the reason that matters: every one of these
sounds is started by something that is being freed on the same frame. A
dying ship cannot own the sound of its own death.

Levels are per sound rather than shared - a shot fires constantly and a
death happens once, so equal volumes are not proportionate ones - and shots
get a little pitch spread so a stream of them does not flatten into one
machine-gun note. The deaths get none: a wobbling explosion sounds broken
rather than varied.

The test goes at the failures a play-through would hide for weeks: firing
three times the pool size must leave exactly the pool's worth sounding and
no leaked voices, and the ship-death case is checked from a node that really
has been freed on that frame.
2026-08-29 13:05:47 +02:00

115 lines
4.9 KiB
GDScript

class_name VesselWeaponComponent
extends Node
# The fleet's gun. It does NOT pick its own targets: the player aims it with
# the fire stick, and it shoots along whatever heading that stick reports for
# as long as it is held.
#
# It used to seek the nearest enemy in range and fire on its own, which meant
# combat happened whether or not the player did anything - there was nothing
# to aim, and nothing to decide. Firing is now an action like any other, and
# the right-hand stick is what performs it.
#
# `fire_direction` is set every frame by FireStickController from the stick's
# own reading, and cleared the moment it is released. Zero means "not firing",
# which is the resting state.
var capability_id: String = "weapon"
var fire_range: float
var fire_cooldown: float
var projectile_damage: int
var fire_timer: float = 0.0
var fire_direction: Vector2 = Vector2.ZERO
var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn")
# By path, not by class name - see the note at the top of MuzzleSparkVFX.
const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd")
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
fire_range = state.vessel_stat(ship.vessel_type, "fire_range")
fire_cooldown = state.vessel_stat(ship.vessel_type, "fire_cooldown")
projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage"))
# Forgejo #89: how far off the aimed heading a target may sit and still be
# picked up. 28 degrees is wide enough that pointing roughly at something hits
# it, and narrow enough that the shot still goes where the player pointed -
# past about 45 the gun starts choosing targets on its own, which is the
# behaviour #80 deliberately took away.
const ASSIST_CONE_DEGREES: float = 28.0
# The heading a shot fired from `origin` along `direction` should actually
# take: straight, unless an enemy sits inside the assist cone and within
# reach, in which case it points at the nearest one.
#
# Static, and used by BOTH the gun and the aim guide (see
# FireStickController._draw). If the guide drew the raw stick heading while
# the shot took an assisted one, the line would be telling the player
# something the gun does not do - the same class of mismatch #86 removed
# between the guide's length and a bolt's actual reach.
#
# Enemies only, never asteroids. Snapping onto a rock while the player is
# trying to hit the thing shooting at them would be the assist working
# against them; rocks are large, stationary enough, and easy to hit on
# purpose.
static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector2,
reach: float) -> Vector2:
var heading: Vector2 = direction.normalized()
if heading == Vector2.ZERO or tree == null:
return heading
var cone: float = cos(deg_to_rad(ASSIST_CONE_DEGREES))
var nearest: Vector2 = Vector2.ZERO
var nearest_distance: float = INF
for enemy in tree.get_nodes_in_group("enemies"):
if not is_instance_valid(enemy) or not (enemy is Node2D):
continue
var to_enemy: Vector2 = (enemy as Node2D).global_position - origin
var distance: float = to_enemy.length()
if distance > reach or distance < 0.001 or distance >= nearest_distance:
continue
if to_enemy.normalized().dot(heading) < cone:
continue
nearest_distance = distance
nearest = to_enemy
return heading if nearest == Vector2.ZERO else nearest.normalized()
# Called by FireStickController. A zero vector stops the gun.
func set_fire_direction(direction: Vector2) -> void:
fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO
func is_firing() -> bool:
return fire_direction != Vector2.ZERO
func tick(delta: float) -> void:
# The cooldown runs down whether or not the trigger is held, so tapping
# the stick in a new direction is not punished by a timer that only
# started when the stick moved.
fire_timer -= delta
if fire_direction == Vector2.ZERO or fire_timer > 0.0:
return
var ship: Node2D = get_parent()
var projectile: Node2D = projectile_scene.instantiate()
projectile.position = ship.position
projectile.damage = projectile_damage
# The bolt stops where the gun's range ends, and the aim guide draws that
# same range - one number, one meaning.
projectile.set_range(fire_range)
ship.get_tree().root.add_child(projectile)
# Aimed by DIRECTION, not at a target: launch() takes a displacement, so
# the gun's own range decides how far the shot is sent.
var heading: Vector2 = assisted_heading(ship.get_tree(), ship.global_position,
fire_direction, fire_range)
projectile.launch(heading * fire_range)
# #87: sparks off the hull that fired, so a shot is visibly attached to
# the vessel it came from.
MUZZLE_SPARK.spawn(ship.get_tree().root, ship.global_position, heading)
# #146: and heard. Beside the spark rather than inside it, because the
# spark is about this hull and the sound is about the shot.
Sfx.play(ship.get_tree(), Sfx.SHOT)
fire_timer = fire_cooldown