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)
Changes
@@@ -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;nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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)++ }++}@@@ -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) returnif (target.kind === 'resource') {this.lastTargetWasPlayer = false@@@ -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 = capturedtouchControls.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_LEVELenvironment.underwater = submerged++ sfx.setUnderwater(submerged)if (submerged !== wasSubmerged) {wasSubmerged = submergedif (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 }@@@ -57,6 +57,8 @@ export class Environment {private readonly moon: Meshprivate readonly moonMat: StandardMaterialprivate 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 = 1private targetT: number | null = nullconstructor(@@@ -248,6 +250,7 @@ export class Environment {// dimmed under weather.const day = Math.max(0, elevation)const night = Math.max(0, -elevation)++ this.daylight = dayconst dim = 1 - this.weatherDim * 0.65this.sun.intensity = (day * 1.25 + night * 0.35 + 0.03) * dimthis.sun.diffuse.set(@@@ -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 = nullconstructor(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')@@@ -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;nnew file mode 100644@@@ -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.
@@ -27,6 +27,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -27,6 +27,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
### 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:
- **First steps.** A new scrapper gets a checklist in the corner:
- gather branches and stones (piles just inside every city gate);
- gather branches and stones (piles just inside every city gate);
- craft a Stone Axe;
- craft a Stone Axe;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,375 @@
@@ -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)
+ }
+}
@@ -62,6 +62,8 @@ export class InteractionController {
@@ -62,6 +62,8 @@ export class InteractionController {
rotating = false
rotating = false
/** Cosmetic hook: a swing was sent (viewmodel + body animation). */
/** Cosmetic hook: a swing was sent (viewmodel + body animation). */
onSwing: (() => void) | null = null
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. */
/** True when the last 'use' this client sent targeted another player. */
lastTargetWasPlayer = false
lastTargetWasPlayer = false
/** Hook: player pressed E on a trading post (entity id passed along). */
/** Hook: player pressed E on a trading post (entity id passed along). */
@@ -405,6 +407,7 @@ export class InteractionController {
@@ -405,6 +407,7 @@ export class InteractionController {
// crosshair. Resources gather (use); the rest is an attack.
// crosshair. Resources gather (use); the rest is an attack.
this.onSwing?.()
this.onSwing?.()
const target = this.aim()
const target = this.aim()
+ this.onMelee?.(target)
if (!target) return
if (!target) return
if (target.kind === 'resource') {
if (target.kind === 'resource') {
this.lastTargetWasPlayer = false
this.lastTargetWasPlayer = false
@@ -18,7 +18,9 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
@@ -18,7 +18,9 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Appearance } from '@openvibe/protocol'
import type { Appearance } from '@openvibe/protocol'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Scene } from '@babylonjs/core/scene.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 { InteractionController } from './game/interactionController.js'
import { LocalPlayer } from './game/localPlayer.js'
import { LocalPlayer } from './game/localPlayer.js'
import { InputTracker } from './input/inputTracker.js'
import { InputTracker } from './input/inputTracker.js'
@@ -348,6 +350,41 @@ async function start(): Promise<void> {
@@ -348,6 +350,41 @@ async function start(): Promise<void> {
fpBody.triggerSwing()
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) => {
hud.onUiCaptureChange = (captured) => {
input.uiCapture = captured
input.uiCapture = captured
touchControls.setUiCapture(captured)
touchControls.setUiCapture(captured)
@@ -377,6 +414,7 @@ async function start(): Promise<void> {
@@ -377,6 +414,7 @@ async function start(): Promise<void> {
}
}
interact.onHotbarChanged()
interact.onHotbarChanged()
hud.renderHotbar()
hud.renderHotbar()
+ sfx.click()
return
return
}
}
default:
default:
@@ -459,6 +497,7 @@ async function start(): Promise<void> {
@@ -459,6 +497,7 @@ async function start(): Promise<void> {
if (msg.t === 'time') {
if (msg.t === 'time') {
environment.setDayFraction(msg.frac)
environment.setDayFraction(msg.frac)
environment.setWeather(msg.weather)
environment.setWeather(msg.weather)
+ sfx.setWeather(msg.weather)
}
}
if (msg.t === 'snap') {
if (msg.t === 'snap') {
player.onSnapshot(msg)
player.onSnapshot(msg)
@@ -583,6 +622,31 @@ async function start(): Promise<void> {
@@ -583,6 +622,31 @@ async function start(): Promise<void> {
viewmodel.setItem(state.activeItemDef())
viewmodel.setItem(state.activeItemDef())
viewmodel.update(elapsed, speed, player.move.grounded, look.dx, look.dy)
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
// Beams (modular: any weapon whose module declares firesBeam gets this
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
// muzzle-anchored beam pipeline). Mine fires whenever the trigger is
// held (dim searching ray → bright latched beam anchored to the exact
// held (dim searching ray → bright latched beam anchored to the exact
@@ -647,6 +711,7 @@ async function start(): Promise<void> {
@@ -647,6 +711,7 @@ async function start(): Promise<void> {
// haze yields via environment.underwater).
// haze yields via environment.underwater).
const submerged = player.camera.position.y < WATER_LEVEL
const submerged = player.camera.position.y < WATER_LEVEL
environment.underwater = submerged
environment.underwater = submerged
+ sfx.setUnderwater(submerged)
if (submerged !== wasSubmerged) {
if (submerged !== wasSubmerged) {
wasSubmerged = submerged
wasSubmerged = submerged
if (submerged) {
if (submerged) {
@@ -665,6 +730,9 @@ async function start(): Promise<void> {
@@ -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 _beamEnd = { x: 0, y: 0, z: 0 }
const _beamEndV = new Vector3()
const _beamEndV = new Vector3()
const _viewFwd = { x: 0, y: 0, z: 0 }
const _viewFwd = { x: 0, y: 0, z: 0 }
@@ -57,6 +57,8 @@ export class Environment {
@@ -57,6 +57,8 @@ export class Environment {
private readonly moon: Mesh
private readonly moon: Mesh
private readonly moonMat: StandardMaterial
private readonly moonMat: StandardMaterial
private t = DAY_SECONDS * 0.34
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
private targetT: number | null = null
constructor(
constructor(
@@ -248,6 +250,7 @@ export class Environment {
@@ -248,6 +250,7 @@ export class Environment {
// dimmed under weather.
// dimmed under weather.
const day = Math.max(0, elevation)
const day = Math.max(0, elevation)
const night = Math.max(0, -elevation)
const night = Math.max(0, -elevation)
+ this.daylight = day
const dim = 1 - this.weatherDim * 0.65
const dim = 1 - this.weatherDim * 0.65
this.sun.intensity = (day * 1.25 + night * 0.35 + 0.03) * dim
this.sun.intensity = (day * 1.25 + night * 0.35 + 0.03) * dim
this.sun.diffuse.set(
this.sun.diffuse.set(
@@ -48,6 +48,9 @@ export class Hud {
@@ -48,6 +48,9 @@ export class Hud {
onUiCaptureChange: ((captured: boolean) => void) | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
/** Applies a new graphics quality to the running engine and scene (wired in main.ts). */
/** Applies a new graphics quality to the running engine and scene (wired in main.ts). */
onQualityChange: ((override: QualityOverride) => void) | null = null
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(
constructor(
root: HTMLElement,
root: HTMLElement,
@@ -685,10 +688,42 @@ export class Hud {
@@ -685,10 +688,42 @@ export class Hud {
}
}
sync()
sync()
row.append(head, seg, hint)
row.append(head, seg, hint)
- wrap.appendChild(row)
+ wrap.append(row, this.volumeRow())
this.menuBodyEl.appendChild(wrap)
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. */
/** Faction standings: who likes you, who wants you dead. */
private renderStanding(): void {
private renderStanding(): void {
const wrap = document.createElement('div')
const wrap = document.createElement('div')
@@ -737,6 +737,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
@@ -737,6 +737,15 @@ body:has(#ovg-frame-toggle:not([hidden])) .status {
color: #f0b040;
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 {
.segmented-option:focus-visible {
outline: 2px solid #f0b040;
outline: 2px solid #f0b040;
outline-offset: 1px;
outline-offset: 1px;
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.