openvibers/openvibe.games Commits

Scraplandia has sound: footsteps by surface, a jump and a landing, the swing and the hit (wood, stone, scrap, people), pickups and level-ups, and wind, rain, birds by day and crickets at night, all synthesised in the browser, with a volume slider in Settings (the game had no sound at all) (#113)

OP OpenVibers committed 24 days ago · f58d3cde17 · 9 files, +565 −2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 74ed835..d09ff62 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -27,6 +27,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
++- **Sound.** Scraplandia had none. Every sound is synthesised in the browser, so there are no files to load:
++ - footsteps on grass, stone and water;
++ - a jump and a landing as hard as the fall;
++ - a swing's whoosh;
++ - the hit: a thunk on wood, a clack on stone, a ring on scrap, a thud on people;
++ - a blip when something goes into the pack, and a chime on a level-up;
++ - wind that gusts and rises in a storm, rain, birds by day and crickets at night.
++
++ Under water everything is muffled. Settings has a volume slider (saved; 0 is off). Audio starts on the first click
++ or key, as browsers require.
- **First steps.** A new scrapper gets a checklist in the corner:
- gather branches and stones (piles just inside every city gate);
- craft a Stone Axe;
ddiff --git a/apps/client/src/audio/sfx.test.ts b/apps/client/src/audio/sfx.test.ts
nnew file mode 100644
iindex 0000000..7c879eb
---- /dev/null
++++ b/apps/client/src/audio/sfx.test.ts
@@@ -0,0 +1,57 @@
++import { describe, expect, it } from 'vitest'
++import { footstepSurface, materialForVisual, readVolume, saveVolume } from './sfx.js'
++
++class MemoryStorage {
++ private map = new Map<string, string>()
++ getItem(k: string): string | null {
++ return this.map.get(k) ?? null
++ }
++ setItem(k: string, v: string): void {
++ this.map.set(k, v)
++ }
++}
++
++describe('footstepSurface', () => {
++ const WATER = -0.42
++ it('is grass on the terrain', () => {
++ expect(footstepSurface(3.02, 3, WATER)).toBe('grass')
++ })
++ it('is stone on anything standing above the terrain (a pavement slab, a floor)', () => {
++ expect(footstepSurface(2.4, 2.2, WATER)).toBe('stone')
++ })
++ it('is water wading where the ground is below the waterline', () => {
++ expect(footstepSurface(-0.5, -0.5, WATER)).toBe('water')
++ })
++ it('is not water on a pier or deck over the water', () => {
++ expect(footstepSurface(0.6, -1.2, WATER)).toBe('stone')
++ })
++})
++
++describe('materialForVisual', () => {
++ it('maps node looks to what they sound like when hit', () => {
++ expect(materialForVisual('tree')).toBe('wood')
++ expect(materialForVisual('branches')).toBe('wood')
++ expect(materialForVisual('rock')).toBe('stone')
++ expect(materialForVisual('stones')).toBe('stone')
++ expect(materialForVisual('scrap')).toBe('metal')
++ expect(materialForVisual(undefined)).toBe('plant')
++ })
++})
++
++describe('volume', () => {
++ it('defaults to 60% and keeps what was saved, clamped to 0..1', () => {
++ const storage = new MemoryStorage()
++ expect(readVolume(storage)).toBe(0.6)
++ saveVolume(0.25, storage)
++ expect(readVolume(storage)).toBe(0.25)
++ saveVolume(4, storage)
++ expect(readVolume(storage)).toBe(1)
++ storage.setItem('scraplandia:volume', 'loud')
++ expect(readVolume(storage)).toBe(0.6)
++ })
++ it('keeps 0 (sound off) rather than falling back to the default', () => {
++ const storage = new MemoryStorage()
++ saveVolume(0, storage)
++ expect(readVolume(storage)).toBe(0)
++ })
++})
ddiff --git a/apps/client/src/audio/sfx.ts b/apps/client/src/audio/sfx.ts
nnew file mode 100644
iindex 0000000..d33c46d
---- /dev/null
++++ b/apps/client/src/audio/sfx.ts
@@@ -0,0 +1,375 @@
++/**
++ * Scraplandia's sounds, synthesised with Web Audio: no files to fetch, so
++ * nothing to wait for and nothing to break. The game had no sound at all —
++ * no footsteps, no thunk when an axe bites, no wind — which is a large part
++ * of why walking around felt dead.
++ *
++ * Everything goes through one master gain (the Settings volume, kept in
++ * localStorage) and a low-pass that closes while the camera is under water.
++ * Browsers only start audio after a gesture, so the context is created on
++ * the first click or key (unlock()).
++ */
++
++export type Surface = 'grass' | 'stone' | 'water'
++export type HitMaterial = 'wood' | 'stone' | 'metal' | 'flesh' | 'plant'
++export type Weather = 'clear' | 'cloudy' | 'rain' | 'storm' | 'fog'
++
++const VOLUME_KEY = 'scraplandia:volume'
++const DEFAULT_VOLUME = 0.6
++
++export function readVolume(storage: Pick<Storage, 'getItem'> | null = safeStorage()): number {
++ const raw = storage?.getItem(VOLUME_KEY)
++ const v = raw === null || raw === undefined ? NaN : Number(raw)
++ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : DEFAULT_VOLUME
++}
++
++export function saveVolume(
++ v: number,
++ storage: Pick<Storage, 'setItem'> | null = safeStorage(),
++): void {
++ try {
++ storage?.setItem(VOLUME_KEY, String(Math.min(1, Math.max(0, v))))
++ } catch {
++ // private mode: the volume lasts this visit
++ }
++}
++
++function safeStorage(): Storage | null {
++ try {
++ return typeof localStorage === 'undefined' ? null : localStorage
++ } catch {
++ return null
++ }
++}
++
++/**
++ * What the player's feet are on: water below the waterline, stone when they
++ * stand above the terrain (a pavement slab, a floor, a rock), grass otherwise.
++ */
++export function footstepSurface(feetY: number, groundY: number, waterLevel: number): Surface {
++ if (groundY < waterLevel - 0.03 && feetY < waterLevel + 0.2) return 'water'
++ return feetY - groundY > 0.12 ? 'stone' : 'grass'
++}
++
++/** The sound a node's look makes when hit (resource node `visual`). */
++export function materialForVisual(visual: string | undefined): HitMaterial {
++ switch (visual) {
++ case 'tree':
++ case 'branches':
++ return 'wood'
++ case 'rock':
++ case 'stones':
++ return 'stone'
++ case 'scrap':
++ return 'metal'
++ default:
++ return 'plant'
++ }
++}
++
++interface Env {
++ attack: number
++ decay: number
++ gain: number
++}
++
++export class Sfx {
++ private ctx: AudioContext | null = null
++ private master: GainNode | null = null
++ private muffle: BiquadFilterNode | null = null
++ private noise: AudioBuffer | null = null
++ private volume = readVolume()
++ private wind: { gain: GainNode; filter: BiquadFilterNode } | null = null
++ private rain: GainNode | null = null
++ private weather: Weather = 'clear'
++ private underwater = false
++ private nextBird = 0
++ private nextCricket = 0
++ private windPhase = 0
++
++ /** Starts audio on the first gesture (browsers refuse earlier); later calls only resume it. */
++ unlock(): void {
++ if (this.ctx) {
++ if (this.ctx.state === 'suspended') void this.ctx.resume()
++ return
++ }
++ const Ctor =
++ window.AudioContext ??
++ (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
++ if (!Ctor) return
++ const ctx = new Ctor()
++ this.ctx = ctx
++ this.muffle = ctx.createBiquadFilter()
++ this.muffle.type = 'lowpass'
++ this.muffle.frequency.value = 20000
++ this.master = ctx.createGain()
++ this.master.gain.value = this.volume
++ this.muffle.connect(this.master).connect(ctx.destination)
++ // Two seconds of white noise feed every hiss, crunch and whoosh.
++ this.noise = ctx.createBuffer(1, ctx.sampleRate * 2, ctx.sampleRate)
++ const data = this.noise.getChannelData(0)
++ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1
++ this.startAmbience(ctx)
++ }
++
++ setVolume(v: number): void {
++ this.volume = Math.min(1, Math.max(0, v))
++ saveVolume(this.volume)
++ if (this.ctx && this.master)
++ this.master.gain.setTargetAtTime(this.volume, this.ctx.currentTime, 0.05)
++ }
++
++ getVolume(): number {
++ return this.volume
++ }
++
++ setWeather(w: Weather): void {
++ this.weather = w
++ }
++
++ setUnderwater(on: boolean): void {
++ if (on === this.underwater) return
++ this.underwater = on
++ if (this.ctx && this.muffle)
++ this.muffle.frequency.setTargetAtTime(on ? 500 : 20000, this.ctx.currentTime, 0.08)
++ }
++
++ footstep(surface: Surface, sprinting: boolean): void {
++ const loud = sprinting ? 1.25 : 1
++ if (surface === 'water') {
++ this.burst(
++ 'bandpass',
++ 900 + Math.random() * 500,
++ 0.9,
++ { attack: 0.01, decay: 0.2, gain: 0.22 * loud },
++ 1.6,
++ )
++ return
++ }
++ if (surface === 'stone') {
++ this.burst('bandpass', 1900 + Math.random() * 600, 1.3, {
++ attack: 0.002,
++ decay: 0.05,
++ gain: 0.2 * loud,
++ })
++ this.tone('sine', 120, 90, { attack: 0.002, decay: 0.04, gain: 0.1 * loud })
++ return
++ }
++ this.burst('lowpass', 700 + Math.random() * 400, 0.8, {
++ attack: 0.005,
++ decay: 0.09,
++ gain: 0.24 * loud,
++ })
++ this.tone('sine', 80, 60, { attack: 0.003, decay: 0.05, gain: 0.08 * loud })
++ }
++
++ jump(): void {
++ this.burst('lowpass', 800, 0.7, { attack: 0.005, decay: 0.06, gain: 0.1 })
++ }
++
++ /** strength 0..1 from the falling speed. */
++ land(strength: number): void {
++ const s = Math.min(1, Math.max(0.2, strength))
++ this.tone('sine', 75, 45, { attack: 0.003, decay: 0.14, gain: 0.35 * s })
++ this.burst('lowpass', 500, 0.7, { attack: 0.003, decay: 0.1, gain: 0.25 * s })
++ }
++
++ swing(): void {
++ if (!this.ctx) return
++ const src = this.noiseSource()
++ const filter = this.ctx.createBiquadFilter()
++ filter.type = 'bandpass'
++ filter.Q.value = 1.4
++ const t = this.ctx.currentTime
++ filter.frequency.setValueAtTime(500, t)
++ filter.frequency.exponentialRampToValueAtTime(2400, t + 0.16)
++ src.connect(filter)
++ this.envelope(filter, { attack: 0.05, decay: 0.14, gain: 0.14 }, src)
++ }
++
++ hit(material: HitMaterial): void {
++ switch (material) {
++ case 'wood':
++ this.tone('triangle', 220, 150, { attack: 0.002, decay: 0.13, gain: 0.45 })
++ this.burst('bandpass', 1300, 2.5, { attack: 0.001, decay: 0.05, gain: 0.35 })
++ break
++ case 'stone':
++ this.burst('bandpass', 3200, 1.8, { attack: 0.001, decay: 0.07, gain: 0.45 })
++ this.tone('square', 950, 620, { attack: 0.001, decay: 0.035, gain: 0.08 })
++ break
++ case 'metal':
++ // Inharmonic partials ring like a struck sheet.
++ this.tone('sine', 523, 520, { attack: 0.001, decay: 0.5, gain: 0.16 })
++ this.tone('sine', 1307, 1300, { attack: 0.001, decay: 0.32, gain: 0.09 })
++ this.tone('sine', 2051, 2040, { attack: 0.001, decay: 0.22, gain: 0.06 })
++ this.burst('highpass', 4000, 0.7, { attack: 0.001, decay: 0.02, gain: 0.2 })
++ break
++ case 'flesh':
++ this.burst('lowpass', 520, 0.8, { attack: 0.002, decay: 0.09, gain: 0.35 })
++ this.tone('sine', 95, 70, { attack: 0.002, decay: 0.08, gain: 0.2 })
++ break
++ case 'plant':
++ this.burst('bandpass', 2600, 0.9, { attack: 0.004, decay: 0.12, gain: 0.18 })
++ break
++ }
++ }
++
++ /** Something went into the pack. */
++ pickup(): void {
++ this.tone('sine', 660, 700, { attack: 0.004, decay: 0.07, gain: 0.12 })
++ this.tone('sine', 990, 1040, { attack: 0.004, decay: 0.09, gain: 0.1 }, 0.06)
++ }
++
++ levelUp(): void {
++ ;[523, 659, 784, 1047].forEach((f, i) =>
++ this.tone('triangle', f, f, { attack: 0.005, decay: 0.22, gain: 0.12 }, i * 0.08),
++ )
++ }
++
++ hurt(): void {
++ this.tone('sawtooth', 210, 110, { attack: 0.003, decay: 0.16, gain: 0.12 })
++ this.burst('lowpass', 600, 0.8, { attack: 0.002, decay: 0.08, gain: 0.25 })
++ }
++
++ click(): void {
++ this.tone('sine', 1700, 1500, { attack: 0.001, decay: 0.025, gain: 0.05 })
++ }
++
++ /**
++ * Ambience, every frame: wind always (stronger in a storm), birds by day,
++ * crickets at night, rain hiss in rain. daylight is 0 at night, 1 at noon.
++ */
++ update(dt: number, daylight: number): void {
++ const ctx = this.ctx
++ if (!ctx || !this.wind || !this.rain) return
++ const t = ctx.currentTime
++ this.windPhase += dt
++ const stormy = this.weather === 'storm' ? 1 : this.weather === 'rain' ? 0.5 : 0
++ const gust = 0.5 + 0.5 * Math.sin(this.windPhase * 0.23) * Math.sin(this.windPhase * 0.071 + 1)
++ this.wind.gain.gain.setTargetAtTime(0.035 + gust * 0.03 + stormy * 0.08, t, 0.5)
++ this.wind.filter.frequency.setTargetAtTime(320 + gust * 500 + stormy * 400, t, 0.5)
++ this.rain.gain.setTargetAtTime(stormy * 0.09, t, 1)
++ if (this.underwater || stormy > 0) return
++ if (daylight > 0.25 && t >= this.nextBird) {
++ this.nextBird = t + 2 + Math.random() * 6
++ this.birdCall()
++ }
++ if (daylight < 0.05 && t >= this.nextCricket) {
++ this.nextCricket = t + 0.5 + Math.random() * 1.2
++ this.cricket()
++ }
++ }
++
++ // ── Building blocks ────────────────────────────────────────────────
++
++ private startAmbience(ctx: AudioContext): void {
++ const windSrc = this.noiseSource(true)
++ const windFilter = ctx.createBiquadFilter()
++ windFilter.type = 'lowpass'
++ windFilter.frequency.value = 400
++ windFilter.Q.value = 0.6
++ const windGain = ctx.createGain()
++ windGain.gain.value = 0
++ windSrc.connect(windFilter).connect(windGain).connect(this.muffle!)
++ windSrc.start()
++ this.wind = { gain: windGain, filter: windFilter }
++ const rainSrc = this.noiseSource(true)
++ const rainFilter = ctx.createBiquadFilter()
++ rainFilter.type = 'highpass'
++ rainFilter.frequency.value = 1200
++ const rainGain = ctx.createGain()
++ rainGain.gain.value = 0
++ rainSrc.connect(rainFilter).connect(rainGain).connect(this.muffle!)
++ rainSrc.start()
++ this.rain = rainGain
++ }
++
++ private birdCall(): void {
++ const ctx = this.ctx!
++ const pan = ctx.createStereoPanner()
++ pan.pan.value = Math.random() * 1.6 - 0.8
++ pan.connect(this.muffle!)
++ const base = 2400 + Math.random() * 1800
++ const notes = 2 + Math.floor(Math.random() * 3)
++ for (let i = 0; i < notes; i++) {
++ const at = ctx.currentTime + i * (0.11 + Math.random() * 0.05)
++ const osc = ctx.createOscillator()
++ osc.type = 'sine'
++ osc.frequency.setValueAtTime(base, at)
++ osc.frequency.exponentialRampToValueAtTime(base * (1.25 + Math.random() * 0.3), at + 0.04)
++ osc.frequency.exponentialRampToValueAtTime(base * 0.9, at + 0.09)
++ const g = ctx.createGain()
++ g.gain.setValueAtTime(0, at)
++ g.gain.linearRampToValueAtTime(0.025, at + 0.01)
++ g.gain.exponentialRampToValueAtTime(0.0001, at + 0.1)
++ osc.connect(g).connect(pan)
++ osc.start(at)
++ osc.stop(at + 0.12)
++ }
++ }
++
++ private cricket(): void {
++ const ctx = this.ctx!
++ const pan = ctx.createStereoPanner()
++ pan.pan.value = Math.random() * 1.4 - 0.7
++ pan.connect(this.muffle!)
++ for (let i = 0; i < 3; i++) {
++ const at = ctx.currentTime + i * 0.045
++ const osc = ctx.createOscillator()
++ osc.frequency.value = 4300
++ const g = ctx.createGain()
++ g.gain.setValueAtTime(0, at)
++ g.gain.linearRampToValueAtTime(0.012, at + 0.008)
++ g.gain.exponentialRampToValueAtTime(0.0001, at + 0.03)
++ osc.connect(g).connect(pan)
++ osc.start(at)
++ osc.stop(at + 0.04)
++ }
++ }
++
++ private noiseSource(loop = false): AudioBufferSourceNode {
++ const src = this.ctx!.createBufferSource()
++ src.buffer = this.noise
++ src.loop = loop
++ if (!loop) src.playbackRate.value = 0.85 + Math.random() * 0.3
++ return src
++ }
++
++ /** A filtered noise hit. `sweep` > 1 glides the filter up by that ratio. */
++ private burst(type: BiquadFilterType, freq: number, q: number, env: Env, sweep = 1): void {
++ if (!this.ctx) return
++ const src = this.noiseSource()
++ const filter = this.ctx.createBiquadFilter()
++ filter.type = type
++ filter.Q.value = q
++ const t = this.ctx.currentTime
++ filter.frequency.setValueAtTime(freq, t)
++ if (sweep !== 1)
++ filter.frequency.exponentialRampToValueAtTime(freq * sweep, t + env.attack + env.decay)
++ src.connect(filter)
++ this.envelope(filter, env, src)
++ }
++
++ /** A pitched blip gliding from `from` to `to` Hz, `delay` seconds from now. */
++ private tone(type: OscillatorType, from: number, to: number, env: Env, delay = 0): void {
++ if (!this.ctx) return
++ const osc = this.ctx.createOscillator()
++ osc.type = type
++ const t = this.ctx.currentTime + delay
++ osc.frequency.setValueAtTime(from, t)
++ if (to !== from) osc.frequency.exponentialRampToValueAtTime(to, t + env.attack + env.decay)
++ this.envelope(osc, env, osc, delay)
++ }
++
++ private envelope(node: AudioNode, env: Env, source: AudioScheduledSourceNode, delay = 0): void {
++ const ctx = this.ctx!
++ const t = ctx.currentTime + delay
++ const g = ctx.createGain()
++ g.gain.setValueAtTime(0, t)
++ g.gain.linearRampToValueAtTime(env.gain, t + env.attack)
++ g.gain.exponentialRampToValueAtTime(0.0001, t + env.attack + env.decay)
++ node.connect(g).connect(this.muffle!)
++ source.start(t)
++ source.stop(t + env.attack + env.decay + 0.02)
++ }
++}
ddiff --git a/apps/client/src/game/interactionController.ts b/apps/client/src/game/interactionController.ts
iindex 1341a9b..70c5829 100644
---- a/apps/client/src/game/interactionController.ts
++++ b/apps/client/src/game/interactionController.ts
@@@ -62,6 +62,8 @@ export class InteractionController {
rotating = false
/** Cosmetic hook: a swing was sent (viewmodel + body animation). */
onSwing: (() => void) | null = null
++ /** Cosmetic hook: a melee swing went out, at what (null: the air). Sounds read it. */
++ onMelee: ((target: AimTarget | null) => void) | null = null
/** True when the last 'use' this client sent targeted another player. */
lastTargetWasPlayer = false
/** Hook: player pressed E on a trading post (entity id passed along). */
@@@ -405,6 +407,7 @@ export class InteractionController {
// crosshair. Resources gather (use); the rest is an attack.
this.onSwing?.()
const target = this.aim()
++ this.onMelee?.(target)
if (!target) return
if (target.kind === 'resource') {
this.lastTargetWasPlayer = false
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex ab57b90..e789267 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -18,7 +18,9 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Appearance } from '@openvibe/protocol'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Scene } from '@babylonjs/core/scene.js'
--import { WATER_LEVEL } from '@openvibe/content'
++import { WATER_LEVEL, terrainHeight } from '@openvibe/content'
++import { DEFAULT_MOVEMENT } from '@openvibe/gameplay'
++import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
@@@ -348,6 +350,41 @@ async function start(): Promise<void> {
fpBody.triggerSwing()
}
++ // Sounds. Browsers start audio only after a gesture: the first click or key.
++ const sfx = new Sfx()
++ const unlockAudio = (): void => sfx.unlock()
++ window.addEventListener('pointerdown', unlockAudio)
++ window.addEventListener('keydown', unlockAudio)
++ hud.volume = () => sfx.getVolume()
++ hud.onVolumeChange = (v) => sfx.setVolume(v)
++ interact.onMelee = (target) => {
++ sfx.swing()
++ if (!target) return
++ const material =
++ target.kind === 'resource'
++ ? materialForVisual(content.nodeType(target.def ?? '')?.visual)
++ : target.kind === 'player' || target.kind === 'npc'
++ ? 'flesh'
++ : target.def && content.item(target.def)?.health
++ ? 'wood'
++ : null
++ // The blow lands mid-swing, after the whoosh starts.
++ if (material) setTimeout(() => sfx.hit(material), 90)
++ }
++ let packCount = -1
++ state.events.on('inventory', () => {
++ const count = (state.inventory?.slots ?? []).reduce((n, s) => n + (s.stack?.count ?? 0), 0)
++ if (packCount >= 0 && count > packCount) sfx.pickup()
++ packCount = count
++ })
++ state.events.on('levelUp', () => sfx.levelUp())
++ state.events.on('fx', ({ kind, id }) => {
++ if (id === state.myEntityId && (kind === 'hurt' || kind === 'death')) sfx.hurt()
++ })
++ let stride = 0
++ let wasGrounded = true
++ let fallSpeed = 0
++
hud.onUiCaptureChange = (captured) => {
input.uiCapture = captured
touchControls.setUiCapture(captured)
@@@ -377,6 +414,7 @@ async function start(): Promise<void> {
}
interact.onHotbarChanged()
hud.renderHotbar()
++ sfx.click()
return
}
default:
@@@ -459,6 +497,7 @@ async function start(): Promise<void> {
if (msg.t === 'time') {
environment.setDayFraction(msg.frac)
environment.setWeather(msg.weather)
++ sfx.setWeather(msg.weather)
}
if (msg.t === 'snap') {
player.onSnapshot(msg)
@@@ -583,6 +622,31 @@ async function start(): Promise<void> {
viewmodel.setItem(state.activeItemDef())
viewmodel.update(elapsed, speed, player.move.grounded, look.dx, look.dy)
++ // Footsteps on the viewmodel's bob (one step per half bob cycle), a jump, a landing.
++ const grounded = player.move.grounded
++ if (grounded) {
++ if (!wasGrounded && fallSpeed > 3) sfx.land((fallSpeed - 3) / 9)
++ stride += speed * elapsed
++ if (speed < 0.3) stride = STEP_METRES * 0.7
++ if (stride >= STEP_METRES) {
++ stride -= STEP_METRES
++ const pos = player.move.pos
++ sfx.footstep(
++ footstepSurface(
++ pos.y - DEFAULT_MOVEMENT.capsuleHeight / 2,
++ terrainHeight(content.world, pos.x, pos.z),
++ WATER_LEVEL,
++ ),
++ speed > DEFAULT_MOVEMENT.maxGroundSpeed + 0.5,
++ )
++ }
++ } else {
++ if (wasGrounded && player.move.vel.y > 1) sfx.jump()
++ fallSpeed = Math.max(0, -player.move.vel.y)
++ }
++ wasGrounded = grounded
++ sfx.update(elapsed, environment.daylight)
++
// Beams (modular: any weapon whose module declares firesBeam gets this
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
// held (dim searching ray → bright latched beam anchored to the exact
@@@ -647,6 +711,7 @@ async function start(): Promise<void> {
// haze yields via environment.underwater).
const submerged = player.camera.position.y < WATER_LEVEL
environment.underwater = submerged
++ sfx.setUnderwater(submerged)
if (submerged !== wasSubmerged) {
wasSubmerged = submerged
if (submerged) {
@@@ -665,6 +730,9 @@ async function start(): Promise<void> {
}
}
++/** Metres walked per footstep: the viewmodel bobs once per step at this stride (phase += speed * 1.6). */
++const STEP_METRES = Math.PI / 1.6
++
const _beamEnd = { x: 0, y: 0, z: 0 }
const _beamEndV = new Vector3()
const _viewFwd = { x: 0, y: 0, z: 0 }
ddiff --git a/apps/client/src/render/environment.ts b/apps/client/src/render/environment.ts
iindex dd404df..97540bd 100644
---- a/apps/client/src/render/environment.ts
++++ b/apps/client/src/render/environment.ts
@@@ -57,6 +57,8 @@ export class Environment {
private readonly moon: Mesh
private readonly moonMat: StandardMaterial
private t = DAY_SECONDS * 0.34
++ /** The sun's height, 0 from sunset to sunrise and 1 at noon (sounds read it: birds by day, crickets at night). */
++ daylight = 1
private targetT: number | null = null
constructor(
@@@ -248,6 +250,7 @@ export class Environment {
// dimmed under weather.
const day = Math.max(0, elevation)
const night = Math.max(0, -elevation)
++ this.daylight = day
const dim = 1 - this.weatherDim * 0.65
this.sun.intensity = (day * 1.25 + night * 0.35 + 0.03) * dim
this.sun.diffuse.set(
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex 001af54..7010160 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -48,6 +48,9 @@ export class Hud {
onUiCaptureChange: ((captured: boolean) => void) | null = null
/** Applies a new graphics quality to the running engine and scene (wired in main.ts). */
onQualityChange: ((override: QualityOverride) => void) | null = null
++ /** The sound volume, 0 to 1, and where a change goes (wired in main.ts to the sounds). */
++ volume: () => number = () => 0.6
++ onVolumeChange: ((v: number) => void) | null = null
constructor(
root: HTMLElement,
@@@ -685,10 +688,42 @@ export class Hud {
}
sync()
row.append(head, seg, hint)
-- wrap.appendChild(row)
++ wrap.append(row, this.volumeRow())
this.menuBodyEl.appendChild(wrap)
}
++ /** Settings: sound volume (footsteps, hits, wind and birds), saved for next time. */
++ private volumeRow(): HTMLElement {
++ const row = document.createElement('div')
++ row.className = 'skill-row'
++ const head = document.createElement('div')
++ head.className = 'skill-head'
++ const title = document.createElement('label')
++ title.htmlFor = 'volume-range'
++ title.textContent = 'Sound'
++ const now = document.createElement('span')
++ now.className = 'skill-level'
++ head.append(title, now)
++ const range = document.createElement('input')
++ range.type = 'range'
++ range.id = 'volume-range'
++ range.className = 'volume-range'
++ range.min = '0'
++ range.max = '100'
++ range.step = '5'
++ range.value = String(Math.round(this.volume() * 100))
++ const show = (): void => {
++ now.textContent = range.value === '0' ? 'Off' : `${range.value}%`
++ }
++ range.addEventListener('input', () => {
++ show()
++ this.onVolumeChange?.(Number(range.value) / 100)
++ })
++ show()
++ row.append(head, range)
++ return row
++ }
++
/** Faction standings: who likes you, who wants you dead. */
private renderStanding(): void {
const wrap = document.createElement('div')
ddiff --git a/apps/client/src/ui/styles.css b/apps/client/src/ui/styles.css
iindex 2735377..1f28e6b 100644
---- a/apps/client/src/ui/styles.css
++++ b/apps/client/src/ui/styles.css
@@@ -737,6 +737,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #f0b040;
}
++/* Settings: the sound volume slider, tall enough to drag with a thumb. */
++.volume-range {
++ display: block;
++ width: 100%;
++ min-height: 40px;
++ margin: 2px 0 6px;
++ accent-color: #f0b040;
++}
++
.segmented-option:focus-visible {
outline: 2px solid #f0b040;
outline-offset: 1px;
ddiff --git a/test/apps__client__src__audio__sfx.test.js b/test/apps__client__src__audio__sfx.test.js
nnew file mode 100644
iindex 0000000..fd45371
---- /dev/null
++++ b/test/apps__client__src__audio__sfx.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/audio/sfx.test.ts'
++// covers: 'apps/client/src/audio'
++// Generated by node test/run.js --index; do not edit.