Hearing for stealth gameplay
A guard should hear a footstep in the next room through the open door, hear it muffled through a shut one, and not hear it through a metre of rock - and should go and look where the sound came from, the doorway, not at the player behind the wall. hammerite-audio gives a game that from the same graph of rooms and openings it plays sound through.
The split is the usual one: Hammerite measures how a sound travels; your game says which sounds exist, how loud each is, and what a guard does about one.
1. Set up
Hand each HammeriteMap3D to the audio addon before giving it a map, so the map's audio manager exists
before its entities are built:
HammeriteAudio.sound_types = load("res://audio/sound_types.tres")
HammeriteAudio.integrate(map_3d)
map_3d.map = map
A map saved without an audio graph is baked as it loads. One that has a graph keeps it - see Rebaking.
2. Sound types: what exists and how loud
A HammeriteAudioSoundType is a kind of noise. Its loudness
lives here, not on whatever plays it, so a footstep is as loud to a guard whatever the mixer does with
it:
var footstep := HammeriteAudioSoundType.new()
footstep.type_name = &"footstep"
footstep.loudness_db = 45.0
footstep.max_range = 700.0
var table := HammeriteAudioSoundTypes.new()
table.types = [footstep]
HammeriteAudio.sound_types = table
loudness_dbis on a scale of your choosing; it is compared with a listener's threshold after the route has taken its share.max_rangeis the distance a sound can travel, along its route round corners, and is the main control on how far it carries: the model's own falloff with distance is capped at 12 dB, so distance alone rarely silences anything.- A type the table does not list is still heard, at the table's defaults.
Normally the table is a .tres resource edited in the inspector. Appending to types in place needs
rebuild_index() afterwards; assigning the array does it for you.
3. Making a noise
Every route ends in the same report: a type, a position, and who made it.
A sound the player hears too - an HammeriteAudioEmitter3D
with sound_type set reports each time it plays, from where it stands, with its parent as the one
who made it:
@onready var _steps: HammeriteAudioEmitter3D = $Footsteps # sound_type = &"footstep"
func _on_foot_down() -> void:
_steps.play()
An emitter with no sound_type is heard by the player and by nobody in the game.
A one-shot where nothing will stay to own an emitter - a bottle that shatters and is freed:
HammeriteAudio.play_one_shot("glass_break.wav", bottle.global_position, &"glass_break", 0.0, true, self)
A noise with no sound of its own - played by another system, or heard only by the game:
HammeriteAudio.report_sound(&"landing", global_position, self)
An emitter that cannot play - no file, no map loaded - reports nothing. For a noise that must reach the guards whatever the mixer is doing, report it.
A report costs nothing when no listener hears its type, little when the source is out of range in a straight line, and a route through the graph only after both.
4. A guard who listens
Give the guard a HammeriteAudioReceiver3D at head height. It
registers itself, and emits sound_heard for every sound of a type it listens for that arrives at or
above its threshold:
@onready var _ears: HammeriteAudioReceiver3D = $Head/Ears
func _ready() -> void:
_ears.heard_types = [&"footstep", &"glass_break"]
_ears.threshold_db = 10.0
_ears.own_sounds_node = self
_ears.sound_heard.connect(_on_sound_heard)
func _on_sound_heard(event: HammeriteAudioSoundEvent) -> void:
var muffled: bool = event.cutoff_hz < 2000.0
_investigate(event.apparent_position, event.level_db, muffled)
A receiver with no heard_types is deaf, so an unconfigured guard is never omniscient. Assign the
array whole, or use add_heard_type() - appending to it in place does not reach the manager.
What the HammeriteAudioSoundEvent says:
apparent_position |
the last opening the sound came through, or the source when nothing stood between: where to go and look |
source_position |
where it really was - what the guard should not know |
is_direct |
a clear run, so the apparent position is the real one |
level_db |
how loud it arrived, on the sound types' scale |
cutoff_hz |
what the route left of it: a shut door two rooms away leaves a few hundred hertz, so muffled can be told from far |
distance |
the length of the route, round corners |
emitter |
who made it, or null for a bare position |
Hearing is pushed, never polled: the signal is emitted inside the report, once per listener that hears it. Keep the handler short - note the sound and decide in your own update.
Things that save a guard from looking foolish:
- Set
own_sounds_nodeto the character when the receiver is not its direct child, or the guard hears their own footsteps. - Keep the ears off the floor. A point exactly on a room's floor is in no room, and a sound traced room to room never reaches it.
- Turn towards a new sound only when it is louder than the one being followed by a few decibels, so a trail of footsteps does not make the guard shuffle in place.
apparent_positionis often mid-air in a doorway. Snap it to the navmesh before walking to it (see Navigation for guards).
enabled = false takes a receiver out of the routing altogether - a sleeping guard costs nothing.
5. Doors
A sound travels through the openings between rooms and not through walls. A door shuts an opening by name:
func _set_open(amount: float) -> void: # 0 shut .. 1 open
HammeriteAudio.set_portal_occlusion(_portal_name, 1.0 - amount, self)
0 is open, 1 costs the opening its full authored attenuation, and anything between is ajar. It
returns false when no opening has that name - worth a warning, because a door whose opening was never
named is a door that never muffles anything.
Openings are named, and their closed attenuation set, in the editor's Audio Portals tool. A name
is stored on the faces the opening is cut from, so it survives every edit of the level; hammerite-nav
reads the same name, so one call can shut both the sound and the route. From code the per-opening
values are HammeriteAudioPortalProperties, and the name is
HammeritePortal.set_portal_name().
A player leaning on a door to listen hears through it better. Do it for gameplay with the receiver and for what is played with the autoload:
_ears.press_ear_to_portal("vault_door", 12.0)
HammeriteAudio.press_ear_to_portal("vault_door", 12.0, self)
6. Rebaking
The graph is baked from the compiled world and saved with the map. A map that already has one is not rebaked behind the author's back when the level changes: a stale graph still works, following the old layout, and the editor and the log say it is out of date. Rebuild it from the Audio Portals tool, or from code when your game changes the level itself:
var report: HammeriteAudioBakeReport = HammeriteAudio.bake(map_3d)
print(report.summary())
A rebake keeps the names and attenuations already authored. Headless, in a build step, use
HammeriteAudioRuntime.bake(map), which names no autoload; see Baking from a script.
Without any graph, hearing falls back to a straight line: a guard hears only what they could see.
See also
- Rooms, portals and line of sight - the rooms and openings sound travels through.
- Sampling the light level - the other half of being noticed.
HammeriteAudio,HammeriteAudioHearing, and the hammerite-audio README.