Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
Assets become engine: a mesh builder, a texture painter and a sound device; the game keeps its recipes #8
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/4-assets-become-engine-a-mesh-builder-a-te → main 4 commits fork
16 files
changed +867
−306
README.md +12 −6
| @@ -22,7 +22,10 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 22 | 22 | a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and |
| 23 | 23 | loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`. |
| 24 | 24 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and |
| 25 | weapon models and every sound are produced in code at start-up. There are no | |
| 25 | weapon models and every sound are produced in code at start-up, by three | |
| 26 | generic tools in the engine — a box mesh builder, an atlas painter over hashed | |
| 27 | value noise, and a two-voice sound device — driven by this game's recipes in | |
| 28 | `src/client/{models,materials,audio}.ts`. There are no | |
| 26 | 29 | asset files and nothing from the real game is used — the numbers follow CS's |
| 27 | 30 | published shape so the habits transfer, and the rest is original. |
| 28 | 31 | - **Learned, and measured.** The bots do not aim by rule. A 32-input, two-layer |
| @@ -50,15 +53,18 @@ the rest. | ||
| 50 | 53 | ## Shape |
| 51 | 54 | |
| 52 | 55 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| 53 | imports the game: `math` (vectors, matrices, angles, a seeded rng) and `trace` | |
| 54 | (rays against boxes, and the `Box` they are traced against). `boundary.test.ts` | |
| 56 | imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace` | |
| 57 | (rays against boxes, and the `Box` they are traced against), `mesh` (a box | |
| 58 | builder), `texture` (hashed value noise, fbm and an atlas painter) and `sound` | |
| 59 | (a lazy `AudioContext` with noise bursts and tones). `boundary.test.ts` | |
| 55 | 60 | reads the imports of every file in here and fails if one of them points at |
| 56 | 61 | `src/client/`, at `src/rl/` or at a package. |
| 57 | 62 | - `src/client/` — the game, in modules named for one thing each: `map`, |
| 58 | 63 | `physics`, `weapons`, `combat`, `match` (the rules), `world` (the simulation), |
| 59 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `textures`, | |
| 60 | `renderer`, `hud`, `input`, `audio`, `main`. Everything except the last five is | |
| 61 | headless and tested. | |
| 64 | `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`, | |
| 65 | `renderer`, `hud`, `input`, `audio`, `main`. The last five aside, everything is | |
| 66 | headless and tested; `models`, `materials` and `audio` are this game's recipes | |
| 67 | for the engine's three asset tools. | |
| 62 | 68 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 63 | 69 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 64 | 70 | the algorithm, with its backward pass checked against finite differences. |
src/client/audio.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { createAudio, loudness, shotTone } from "./audio.ts"; | |
| 3 | import type { Burst, SoundDevice, Tone } from "../engine/sound.ts"; | |
| 4 | ||
| 5 | /** A device that plays nothing and writes down everything. */ | |
| 6 | function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } { | |
| 7 | const device = { | |
| 8 | muted: false, | |
| 9 | bursts: [] as Burst[], | |
| 10 | tones: [] as Tone[], | |
| 11 | resumes: 0, | |
| 12 | resume() { device.resumes += 1; }, | |
| 13 | noiseBurst(burst: Burst) { if (!device.muted) device.bursts.push(burst); }, | |
| 14 | tone(tone: Tone) { if (!device.muted) device.tones.push(tone); }, | |
| 15 | }; | |
| 16 | return device; | |
| 17 | } | |
| 18 | ||
| 19 | test("a shot is a burst plus a thump, coloured by the weapon", () => { | |
| 20 | const device = recorder(); | |
| 21 | const audio = createAudio(device); | |
| 22 | audio.shot("ak47", [0, 0, 0], [0, 0, 0]); | |
| 23 | expect(device.bursts.length).toBe(1); | |
| 24 | expect(device.tones.length).toBe(1); | |
| 25 | const burst = device.bursts[0] as Burst; | |
| 26 | const thump = device.tones[0] as Tone; | |
| 27 | expect(burst.frequency).toBe(shotTone("ak47").cut); | |
| 28 | expect(burst.length).toBe(shotTone("ak47").length); | |
| 29 | expect(thump.slideTo).toBeLessThan(thump.frequency); | |
| 30 | ||
| 31 | audio.shot("awp", [0, 0, 0], [0, 0, 0]); | |
| 32 | const sniper = device.bursts[1] as Burst; | |
| 33 | // The sniper is lower and rings longer than the rifle. | |
| 34 | expect(sniper.frequency).toBeLessThan(burst.frequency); | |
| 35 | expect(sniper.length).toBeGreaterThan(burst.length); | |
| 36 | }); | |
| 37 | ||
| 38 | test("a shot across the map is quieter than one at your feet", () => { | |
| 39 | const device = recorder(); | |
| 40 | const audio = createAudio(device); | |
| 41 | audio.shot("ak47", [0, 0, 0], [0, 0, 0]); | |
| 42 | audio.shot("ak47", [4000, 0, 0], [0, 0, 0]); | |
| 43 | const near = device.bursts[0] as Burst, far = device.bursts[1] as Burst; | |
| 44 | expect(far.gain).toBeLessThan(near.gain / 2); | |
| 45 | expect(near.gain).toBeCloseTo(0.35, 6); | |
| 46 | expect(loudness([100, 0, 0], [0, 0, 0])).toBe(1); | |
| 47 | expect(loudness([4000, 0, 0], [0, 0, 0])).toBeLessThan(0.3); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a headshot beeps higher than a body hit", () => { | |
| 51 | const device = recorder(); | |
| 52 | const audio = createAudio(device); | |
| 53 | audio.hit(true); | |
| 54 | audio.hit(false); | |
| 55 | const head = device.tones[0] as Tone, body = device.tones[1] as Tone; | |
| 56 | expect(head.frequency).toBeGreaterThan(body.frequency); | |
| 57 | expect(head.type).toBe("square"); | |
| 58 | }); | |
| 59 | ||
| 60 | test("muting the voice mutes the device under it", () => { | |
| 61 | const device = recorder(); | |
| 62 | const audio = createAudio(device); | |
| 63 | audio.muted = true; | |
| 64 | expect(device.muted).toBe(true); | |
| 65 | audio.shot("ak47", [0, 0, 0], [0, 0, 0]); | |
| 66 | audio.beep(700); | |
| 67 | expect(device.bursts).toEqual([]); | |
| 68 | expect(device.tones).toEqual([]); | |
| 69 | audio.muted = false; | |
| 70 | audio.beep(700); | |
| 71 | expect(device.tones.length).toBe(1); | |
| 72 | }); | |
| 73 | ||
| 74 | test("resuming the voice resumes the device", () => { | |
| 75 | const device = recorder(); | |
| 76 | createAudio(device).resume(); | |
| 77 | expect(device.resumes).toBe(1); | |
| 78 | }); |
src/client/audio.ts +39 −65
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * Sound, synthesised. A gunshot is a noise burst through a resonant filter | |
| 3 | * plus a low thump; the rest are short tones. Nothing is downloaded, and the | |
| 4 | * context is only created once the person has clicked something. | |
| 2 | * The game's voice. A gunshot is a noise burst through a resonant filter plus a | |
| 3 | * low thump, and each weapon kind gets its own colour, length and thump; a hit | |
| 4 | * and a buy are short tones. Nothing is downloaded, and the engine's device | |
| 5 | * only opens a context once the person has clicked something. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { dist, type Vec3 } from "../engine/math.ts"; |
| 9 | import { attenuate, createSoundDevice, type SoundDevice } from "../engine/sound.ts"; | |
| 8 | 10 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 9 | 11 | |
| 10 | 12 | export type Audio = { |
| @@ -23,77 +25,49 @@ const TONE: Record<string, { cut: number; thump: number; length: number }> = { | ||
| 23 | 25 | knife: { cut: 4200, thump: 400, length: 0.08 }, |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | export function createAudio(): Audio { | |
| 27 | let ctx: AudioContext | null = null; | |
| 28 | let noise: AudioBuffer | null = null; | |
| 28 | /** Full volume within 120 units, and audible across the map after that. */ | |
| 29 | const EARSHOT = 900; | |
| 30 | const CLOSE = 120; | |
| 29 | 31 | |
| 30 | const ensure = (): AudioContext | null => { | |
| 31 | if (ctx === null) { | |
| 32 | const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; | |
| 33 | if (Ctor === undefined) return null; | |
| 34 | ctx = new Ctor(); | |
| 35 | noise = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate); | |
| 36 | const data = noise.getChannelData(0); | |
| 37 | for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |
| 38 | } | |
| 39 | if (ctx.state === "suspended") void ctx.resume(); | |
| 40 | return ctx; | |
| 41 | }; | |
| 32 | /** The shot a weapon makes: its filter colour, its thump and how long it rings. */ | |
| 33 | export const shotTone = (weapon: WeaponId): { cut: number; thump: number; length: number } => | |
| 34 | TONE[WEAPONS[weapon]?.kind ?? "rifle"] ?? (TONE.rifle as { cut: number; thump: number; length: number }); | |
| 42 | 35 | |
| 36 | /** How loud a shot fired at `at` is to someone standing at `listener`. */ | |
| 37 | export const loudness = (at: Vec3, listener: Vec3): number => | |
| 38 | attenuate(dist(at, listener), EARSHOT, CLOSE); | |
| 39 | ||
| 40 | export function createAudio(device: SoundDevice = createSoundDevice()): Audio { | |
| 43 | 41 | const audio: Audio = { |
| 44 | muted: false, | |
| 45 | resume: () => void ensure(), | |
| 42 | get muted() { | |
| 43 | return device.muted; | |
| 44 | }, | |
| 45 | set muted(value: boolean) { | |
| 46 | device.muted = value; | |
| 47 | }, | |
| 48 | resume: () => device.resume(), | |
| 46 | 49 | shot(weapon, at, listener) { |
| 47 | if (audio.muted) return; | |
| 48 | const context = ensure(); | |
| 49 | if (context === null || noise === null) return; | |
| 50 | const kind = WEAPONS[weapon]?.kind ?? "rifle"; | |
| 51 | const tone = TONE[kind] ?? TONE.rifle; | |
| 52 | const d = dist(at, listener); | |
| 53 | const volume = Math.min(1, 900 / Math.max(120, d)); | |
| 54 | const now = context.currentTime; | |
| 55 | ||
| 56 | const source = context.createBufferSource(); | |
| 57 | source.buffer = noise; | |
| 58 | source.playbackRate.value = 1 + Math.random() * 0.15; | |
| 59 | const filter = context.createBiquadFilter(); | |
| 60 | filter.type = "bandpass"; | |
| 61 | filter.frequency.value = tone.cut; | |
| 62 | filter.Q.value = 0.9; | |
| 63 | const gain = context.createGain(); | |
| 64 | gain.gain.setValueAtTime(0.35 * volume, now); | |
| 65 | gain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length); | |
| 66 | source.connect(filter).connect(gain).connect(context.destination); | |
| 67 | source.start(now); | |
| 68 | source.stop(now + tone.length); | |
| 69 | ||
| 70 | const thump = context.createOscillator(); | |
| 71 | thump.frequency.setValueAtTime(tone.thump, now); | |
| 72 | thump.frequency.exponentialRampToValueAtTime(tone.thump * 0.4, now + tone.length); | |
| 73 | const thumpGain = context.createGain(); | |
| 74 | thumpGain.gain.setValueAtTime(0.3 * volume, now); | |
| 75 | thumpGain.gain.exponentialRampToValueAtTime(0.0008, now + tone.length); | |
| 76 | thump.connect(thumpGain).connect(context.destination); | |
| 77 | thump.start(now); | |
| 78 | thump.stop(now + tone.length); | |
| 50 | const tone = shotTone(weapon); | |
| 51 | const volume = loudness(at, listener); | |
| 52 | device.noiseBurst({ | |
| 53 | frequency: tone.cut, | |
| 54 | gain: 0.35 * volume, | |
| 55 | length: tone.length, | |
| 56 | q: 0.9, | |
| 57 | rate: 1 + Math.random() * 0.15, | |
| 58 | }); | |
| 59 | device.tone({ | |
| 60 | frequency: tone.thump, | |
| 61 | slideTo: tone.thump * 0.4, | |
| 62 | gain: 0.3 * volume, | |
| 63 | length: tone.length, | |
| 64 | }); | |
| 79 | 65 | }, |
| 80 | 66 | hit(headshot) { |
| 81 | 67 | audio.beep(headshot ? 1400 : 900, 0.08); |
| 82 | 68 | }, |
| 83 | 69 | beep(pitch, gain = 0.06) { |
| 84 | if (audio.muted) return; | |
| 85 | const context = ensure(); | |
| 86 | if (context === null) return; | |
| 87 | const now = context.currentTime; | |
| 88 | const osc = context.createOscillator(); | |
| 89 | osc.type = "square"; | |
| 90 | osc.frequency.value = pitch; | |
| 91 | const volume = context.createGain(); | |
| 92 | volume.gain.setValueAtTime(gain, now); | |
| 93 | volume.gain.exponentialRampToValueAtTime(0.0005, now + 0.09); | |
| 94 | osc.connect(volume).connect(context.destination); | |
| 95 | osc.start(now); | |
| 96 | osc.stop(now + 0.1); | |
| 70 | device.tone({ frequency: pitch, gain, length: 0.09, type: "square", floor: 0.0005, tail: 0.01 }); | |
| 97 | 71 | }, |
| 98 | 72 | }; |
| 99 | 73 | return audio; |
src/client/materials.test.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { LAYERS, MATERIALS, TEX_SIZE, materialTextures } from "./materials.ts"; | |
| 3 | ||
| 4 | /** | |
| 5 | * The atlas the game shipped before the painter moved into the engine. If a | |
| 6 | * refactor, a reordered list or a changed constant repaints the world, this | |
| 7 | * digest says so before anyone has to notice that the sand went green. | |
| 8 | */ | |
| 9 | const ATLAS_SHA256 = "d063c0839f70367067065f31deadbfe291ba95df0973f7d6602e3150846d8b21"; | |
| 10 | ||
| 11 | test("the atlas is byte-for-byte the one the game has always had", () => { | |
| 12 | const bytes = materialTextures(); | |
| 13 | expect(bytes.length).toBe(TEX_SIZE * TEX_SIZE * 4 * LAYERS); | |
| 14 | expect(new Bun.CryptoHasher("sha256").update(bytes).digest("hex")).toBe(ATLAS_SHA256); | |
| 15 | }); | |
| 16 | ||
| 17 | test("LAYERS is the length of the list, counted once", () => { | |
| 18 | expect(LAYERS).toBe(MATERIALS.length); | |
| 19 | expect(LAYERS).toBe(9); | |
| 20 | }); | |
| 21 | ||
| 22 | test("every material paints something visible, and no two are the same", () => { | |
| 23 | const swatches = new Set<string>(); | |
| 24 | for (const shade of MATERIALS) { | |
| 25 | const [r, g, b] = shade(0.3, 0.7); | |
| 26 | for (const channel of [r, g, b]) { | |
| 27 | expect(Number.isFinite(channel)).toBe(true); | |
| 28 | expect(channel).toBeGreaterThan(0); | |
| 29 | } | |
| 30 | swatches.add(`${Math.round(r)},${Math.round(g)},${Math.round(b)}`); | |
| 31 | } | |
| 32 | expect(swatches.size).toBe(MATERIALS.length); | |
| 33 | }); |
src/client/materials.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * The nine surfaces this game is made of, as shaders for the engine's texture | |
| 3 | * painter: sand, plaster, crate, site paint, metal, skin, fatigues, gear and a | |
| 4 | * warning light. Nothing is downloaded; the map and the models refer to a | |
| 5 | * material by its index in this list, which is the array-texture layer it is | |
| 6 | * painted into. | |
| 7 | */ | |
| 8 | ||
| 9 | import { fbm, hash, paintAtlas, type Shader } from "../engine/texture.ts"; | |
| 10 | ||
| 11 | export const TEX_SIZE = 128; | |
| 12 | ||
| 13 | const tint = (base: [number, number, number], k: number): [number, number, number] => | |
| 14 | [base[0] * k, base[1] * k, base[2] * k]; | |
| 15 | ||
| 16 | /** Material 0..8, in the order the map and the models refer to them. */ | |
| 17 | export const MATERIALS: Shader[] = [ | |
| 18 | // 0 sand floor | |
| 19 | (u, v) => { | |
| 20 | const n = fbm(u * 6, v * 6, 6, 1); | |
| 21 | const grit = hash(Math.floor(u * 128), Math.floor(v * 128), 9) * 0.12; | |
| 22 | return tint([196, 174, 132], 0.8 + n * 0.35 + grit); | |
| 23 | }, | |
| 24 | // 1 plaster wall, with a block course every quarter | |
| 25 | (u, v) => { | |
| 26 | const n = fbm(u * 8, v * 8, 8, 2); | |
| 27 | const row = Math.floor(v * 4); | |
| 28 | const line = Math.abs(v * 4 - Math.round(v * 4)) < 0.03 || Math.abs(((u + (row % 2) * 0.5) * 4) % 1 - 0.5) < 0.02; | |
| 29 | return tint([176, 163, 140], (0.82 + n * 0.3) * (line ? 0.78 : 1)); | |
| 30 | }, | |
| 31 | // 2 wooden crate, planks across and a border | |
| 32 | (u, v) => { | |
| 33 | const grain = fbm(u * 3, v * 24, 24, 3); | |
| 34 | const plank = Math.abs((v * 4) % 1 - 0.5) < 0.04 ? 0.7 : 1; | |
| 35 | const edge = u < 0.06 || u > 0.94 || v < 0.06 || v > 0.94 ? 0.82 : 1; | |
| 36 | return tint([158, 112, 60], (0.78 + grain * 0.4) * plank * edge); | |
| 37 | }, | |
| 38 | // 3 painted site floor | |
| 39 | (u, v) => { | |
| 40 | const n = fbm(u * 6, v * 6, 6, 4); | |
| 41 | const stripe = ((u + v) * 6) % 1 < 0.35 ? 1.12 : 0.92; | |
| 42 | return tint([150, 128, 92], (0.85 + n * 0.25) * stripe); | |
| 43 | }, | |
| 44 | // 4 metal | |
| 45 | (u, v) => { | |
| 46 | const brushed = fbm(u * 40, v * 3, 40, 5); | |
| 47 | return tint([132, 138, 146], 0.8 + brushed * 0.4); | |
| 48 | }, | |
| 49 | // 5 skin | |
| 50 | (u, v) => tint([196, 152, 118], 0.9 + fbm(u * 8, v * 8, 8, 6) * 0.2), | |
| 51 | // 6 fatigues | |
| 52 | (u, v) => { | |
| 53 | const n = fbm(u * 10, v * 10, 10, 7); | |
| 54 | return tint([120, 116, 96], 0.75 + n * 0.5); | |
| 55 | }, | |
| 56 | // 7 dark gear | |
| 57 | (u, v) => tint([58, 62, 70], 0.8 + fbm(u * 12, v * 12, 12, 8) * 0.45), | |
| 58 | // 8 warning light | |
| 59 | (u, v) => tint([220, 60, 48], 0.8 + Math.abs(Math.sin((u + v) * 12)) * 0.4), | |
| 60 | ]; | |
| 61 | ||
| 62 | /** How many layers the array texture has: one per material, counted once. */ | |
| 63 | export const LAYERS = MATERIALS.length; | |
| 64 | ||
| 65 | /** All nine layers, tightly packed RGBA, ready for texImage3D. */ | |
| 66 | export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE); |
src/client/models.ts +3 −69
| @@ -1,70 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Every model in the game is boxes, generated here. There are no asset files: |
| 3 | * the level, the players, the guns and the bomb are all built from the same | |
| 4 | * six-quad primitive, which keeps the whole game one download. | |
| 3 | * the level, the players, the guns and the bomb are all recipes for the | |
| 4 | * engine's box builder, which keeps the whole game one download. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { addBox, builder, finish, type Builder, type Mesh } from "../engine/mesh.ts"; | |
| 7 | 8 | import type { Box } from "./map.ts"; |
| 8 | import type { Vec3 } from "../engine/math.ts"; | |
| 9 | ||
| 10 | export type Mesh = { | |
| 11 | position: Float32Array; | |
| 12 | normal: Float32Array; | |
| 13 | uv: Float32Array; | |
| 14 | layer: Float32Array; | |
| 15 | index: Uint32Array; | |
| 16 | }; | |
| 17 | ||
| 18 | type Builder = { | |
| 19 | position: number[]; | |
| 20 | normal: number[]; | |
| 21 | uv: number[]; | |
| 22 | layer: number[]; | |
| 23 | index: number[]; | |
| 24 | }; | |
| 25 | ||
| 26 | export const builder = (): Builder => ({ position: [], normal: [], uv: [], layer: [], index: [] }); | |
| 27 | ||
| 28 | export function finish(b: Builder): Mesh { | |
| 29 | return { | |
| 30 | position: new Float32Array(b.position), | |
| 31 | normal: new Float32Array(b.normal), | |
| 32 | uv: new Float32Array(b.uv), | |
| 33 | layer: new Float32Array(b.layer), | |
| 34 | index: new Uint32Array(b.index), | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | const FACES: [Vec3, number, number, number][] = [ | |
| 39 | // normal, axis of the face, then the two axes across it | |
| 40 | [[1, 0, 0], 0, 1, 2], | |
| 41 | [[-1, 0, 0], 0, 1, 2], | |
| 42 | [[0, 1, 0], 1, 0, 2], | |
| 43 | [[0, -1, 0], 1, 0, 2], | |
| 44 | [[0, 0, 1], 2, 0, 1], | |
| 45 | [[0, 0, -1], 2, 0, 1], | |
| 46 | ]; | |
| 47 | ||
| 48 | /** Six quads with the texture tiling every `scale` units in world space. */ | |
| 49 | export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128): void { | |
| 50 | for (const [normal, axis, u, v] of FACES) { | |
| 51 | const base = b.position.length / 3; | |
| 52 | const at = (normal[axis] as number) > 0 ? max : min; | |
| 53 | for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) { | |
| 54 | const p: Vec3 = [0, 0, 0]; | |
| 55 | p[axis] = at[axis] as number; | |
| 56 | p[u] = (du === 0 ? min[u] : max[u]) as number; | |
| 57 | p[v] = (dv === 0 ? min[v] : max[v]) as number; | |
| 58 | b.position.push(p[0], p[1], p[2]); | |
| 59 | b.normal.push(normal[0], normal[1], normal[2]); | |
| 60 | b.uv.push((p[u] as number) / scale, (p[v] as number) / scale); | |
| 61 | b.layer.push(layer); | |
| 62 | } | |
| 63 | // Wind the two triangles so the outside faces out. | |
| 64 | if ((normal[axis] as number) > 0) b.index.push(base, base + 1, base + 2, base, base + 2, base + 3); | |
| 65 | else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2); | |
| 66 | } | |
| 67 | } | |
| 68 | 9 | |
| 69 | 10 | /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */ |
| 70 | 11 | export function levelMesh(boxes: Box[]): Mesh { |
| @@ -122,10 +63,3 @@ export function viewModelMesh(kind: string): Mesh { | ||
| 122 | 63 | if (kind === "rifle" || kind === "smg") cube(b, 0.55, -0.66, 0.35, 0.2, 0.26, 0.8, GRIP); // stock |
| 123 | 64 | return finish(b); |
| 124 | 65 | } |
| 125 | ||
| 126 | /** A unit quad on the XY plane, for tracers, sprites and decals. */ | |
| 127 | export function quadMesh(): Mesh { | |
| 128 | const b = builder(); | |
| 129 | addBox(b, [-0.5, -0.5, 0], [0.5, 0.5, 0], 0, 1); | |
| 130 | return finish(b); | |
| 131 | } |
src/client/renderer.ts +3 −2
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { multiply, perspective, viewMatrix, modelMatrix, type Vec3 } from "../engine/math.ts"; |
| 9 | import { bombMesh, levelMesh, playerMesh, viewModelMesh, type Mesh } from "./models.ts"; | |
| 10 | import { LAYERS, TEX_SIZE, materialTextures } from "./textures.ts"; | |
| 9 | import type { Mesh } from "../engine/mesh.ts"; | |
| 10 | import { bombMesh, levelMesh, playerMesh, viewModelMesh } from "./models.ts"; | |
| 11 | import { LAYERS, TEX_SIZE, materialTextures } from "./materials.ts"; | |
| 11 | 12 | import type { World } from "./world.ts"; |
| 12 | 13 | import { hullHeight } from "./physics.ts"; |
| 13 | 14 | import { WEAPONS } from "./weapons.ts"; |
src/client/textures.ts deleted −106
| @@ -1,106 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Textures, generated. One 128×128 layer per material, written straight into | |
| 3 | * the bytes a WebGL2 array texture wants — no images to download, no atlas | |
| 4 | * bleeding, and the whole surface set costs about a millisecond to make. | |
| 5 | */ | |
| 6 | ||
| 7 | export const TEX_SIZE = 128; | |
| 8 | export const LAYERS = 9; | |
| 9 | ||
| 10 | const hash = (x: number, y: number, seed: number): number => { | |
| 11 | let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0; | |
| 12 | h = (h ^ (h >>> 13)) * 1274126177; | |
| 13 | return ((h ^ (h >>> 16)) >>> 0) / 4294967295; | |
| 14 | }; | |
| 15 | ||
| 16 | /** Value noise that wraps at `period`, so a tiled surface has no seam. */ | |
| 17 | function noise(x: number, y: number, period: number, seed: number): number { | |
| 18 | const xi = Math.floor(x), yi = Math.floor(y); | |
| 19 | const xf = x - xi, yf = y - yi; | |
| 20 | const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf); | |
| 21 | const at = (i: number, j: number): number => | |
| 22 | hash(((i % period) + period) % period, ((j % period) + period) % period, seed); | |
| 23 | const a = at(xi, yi), b = at(xi + 1, yi), c = at(xi, yi + 1), d = at(xi + 1, yi + 1); | |
| 24 | return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; | |
| 25 | } | |
| 26 | ||
| 27 | const fbm = (x: number, y: number, period: number, seed: number, octaves = 4): number => { | |
| 28 | let sum = 0, amp = 0.5, freq = 1; | |
| 29 | for (let o = 0; o < octaves; o++) { | |
| 30 | sum += noise(x * freq, y * freq, period * freq, seed + o) * amp; | |
| 31 | amp *= 0.5; | |
| 32 | freq *= 2; | |
| 33 | } | |
| 34 | return sum; | |
| 35 | }; | |
| 36 | ||
| 37 | type Shader = (u: number, v: number) => [number, number, number]; | |
| 38 | ||
| 39 | const tint = (base: [number, number, number], k: number): [number, number, number] => | |
| 40 | [base[0] * k, base[1] * k, base[2] * k]; | |
| 41 | ||
| 42 | /** Material 0..8, in the order the map and the models refer to them. */ | |
| 43 | const SHADERS: Shader[] = [ | |
| 44 | // 0 sand floor | |
| 45 | (u, v) => { | |
| 46 | const n = fbm(u * 6, v * 6, 6, 1); | |
| 47 | const grit = hash(Math.floor(u * 128), Math.floor(v * 128), 9) * 0.12; | |
| 48 | return tint([196, 174, 132], 0.8 + n * 0.35 + grit); | |
| 49 | }, | |
| 50 | // 1 plaster wall, with a block course every quarter | |
| 51 | (u, v) => { | |
| 52 | const n = fbm(u * 8, v * 8, 8, 2); | |
| 53 | const row = Math.floor(v * 4); | |
| 54 | const line = Math.abs(v * 4 - Math.round(v * 4)) < 0.03 || Math.abs(((u + (row % 2) * 0.5) * 4) % 1 - 0.5) < 0.02; | |
| 55 | return tint([176, 163, 140], (0.82 + n * 0.3) * (line ? 0.78 : 1)); | |
| 56 | }, | |
| 57 | // 2 wooden crate, planks across and a border | |
| 58 | (u, v) => { | |
| 59 | const grain = fbm(u * 3, v * 24, 24, 3); | |
| 60 | const plank = Math.abs((v * 4) % 1 - 0.5) < 0.04 ? 0.7 : 1; | |
| 61 | const edge = u < 0.06 || u > 0.94 || v < 0.06 || v > 0.94 ? 0.82 : 1; | |
| 62 | return tint([158, 112, 60], (0.78 + grain * 0.4) * plank * edge); | |
| 63 | }, | |
| 64 | // 3 painted site floor | |
| 65 | (u, v) => { | |
| 66 | const n = fbm(u * 6, v * 6, 6, 4); | |
| 67 | const stripe = ((u + v) * 6) % 1 < 0.35 ? 1.12 : 0.92; | |
| 68 | return tint([150, 128, 92], (0.85 + n * 0.25) * stripe); | |
| 69 | }, | |
| 70 | // 4 metal | |
| 71 | (u, v) => { | |
| 72 | const brushed = fbm(u * 40, v * 3, 40, 5); | |
| 73 | return tint([132, 138, 146], 0.8 + brushed * 0.4); | |
| 74 | }, | |
| 75 | // 5 skin | |
| 76 | (u, v) => tint([196, 152, 118], 0.9 + fbm(u * 8, v * 8, 8, 6) * 0.2), | |
| 77 | // 6 fatigues | |
| 78 | (u, v) => { | |
| 79 | const n = fbm(u * 10, v * 10, 10, 7); | |
| 80 | return tint([120, 116, 96], 0.75 + n * 0.5); | |
| 81 | }, | |
| 82 | // 7 dark gear | |
| 83 | (u, v) => tint([58, 62, 70], 0.8 + fbm(u * 12, v * 12, 12, 8) * 0.45), | |
| 84 | // 8 warning light | |
| 85 | (u, v) => tint([220, 60, 48], 0.8 + Math.abs(Math.sin((u + v) * 12)) * 0.4), | |
| 86 | ]; | |
| 87 | ||
| 88 | /** All nine layers, tightly packed RGBA, ready for texSubImage3D. */ | |
| 89 | export function materialTextures(): Uint8Array { | |
| 90 | const size = TEX_SIZE; | |
| 91 | const out = new Uint8Array(size * size * 4 * LAYERS); | |
| 92 | for (let layer = 0; layer < LAYERS; layer++) { | |
| 93 | const shade = SHADERS[layer] ?? SHADERS[1]; | |
| 94 | for (let y = 0; y < size; y++) { | |
| 95 | for (let x = 0; x < size; x++) { | |
| 96 | const [r, g, b] = shade(x / size, y / size); | |
| 97 | const i = (layer * size * size + y * size + x) * 4; | |
| 98 | out[i] = Math.max(0, Math.min(255, r)); | |
| 99 | out[i + 1] = Math.max(0, Math.min(255, g)); | |
| 100 | out[i + 2] = Math.max(0, Math.min(255, b)); | |
| 101 | out[i + 3] = 255; | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | return out; | |
| 106 | } |
src/engine/mesh.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { addBox, builder, finish, quadMesh, type Mesh } from "./mesh.ts"; | |
| 3 | import { cross, dot, normalize, sub, type Vec3 } from "./math.ts"; | |
| 4 | ||
| 5 | const at = (a: Float32Array, i: number): Vec3 => | |
| 6 | [a[i * 3] as number, a[i * 3 + 1] as number, a[i * 3 + 2] as number]; | |
| 7 | const vertex = (mesh: Mesh, i: number): Vec3 => at(mesh.position, i); | |
| 8 | ||
| 9 | const box = (min: Vec3, max: Vec3, layer = 3, scale = 128): Mesh => { | |
| 10 | const b = builder(); | |
| 11 | addBox(b, min, max, layer, scale); | |
| 12 | return finish(b); | |
| 13 | }; | |
| 14 | ||
| 15 | test("a box is six quads: 24 vertices, 12 triangles, one layer", () => { | |
| 16 | const mesh = box([0, 0, 0], [64, 64, 64], 3); | |
| 17 | expect(mesh.position.length).toBe(24 * 3); | |
| 18 | expect(mesh.normal.length).toBe(24 * 3); | |
| 19 | expect(mesh.uv.length).toBe(24 * 2); | |
| 20 | expect(mesh.index.length).toBe(12 * 3); | |
| 21 | expect([...mesh.layer]).toEqual(Array(24).fill(3)); | |
| 22 | expect(Math.max(...mesh.index)).toBe(23); | |
| 23 | }); | |
| 24 | ||
| 25 | test("every triangle winds so its face points out of the box", () => { | |
| 26 | const min: Vec3 = [-10, -20, -30], max: Vec3 = [40, 50, 60]; | |
| 27 | const mesh = box(min, max); | |
| 28 | const centre: Vec3 = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; | |
| 29 | for (let t = 0; t < mesh.index.length / 3; t++) { | |
| 30 | const a = vertex(mesh, mesh.index[t * 3] as number); | |
| 31 | const b = vertex(mesh, mesh.index[t * 3 + 1] as number); | |
| 32 | const c = vertex(mesh, mesh.index[t * 3 + 2] as number); | |
| 33 | const face = normalize(cross(sub(b, a), sub(c, a))); | |
| 34 | // The winding normal agrees with the stored normal, and both lead away | |
| 35 | // from the middle of the box. | |
| 36 | const stored = at(mesh.normal, mesh.index[t * 3] as number); | |
| 37 | expect(dot(face, stored)).toBeCloseTo(1, 5); | |
| 38 | expect(dot(face, sub(a, centre))).toBeGreaterThan(0); | |
| 39 | } | |
| 40 | }); | |
| 41 | ||
| 42 | test("uv tiles once every `scale` units, whatever the face", () => { | |
| 43 | const mesh = box([0, 0, 0], [256, 128, 384], 0, 128); | |
| 44 | const spans = new Set<string>(); | |
| 45 | for (let q = 0; q < 6; q++) { | |
| 46 | const us: number[] = [], vs: number[] = []; | |
| 47 | for (let i = q * 4; i < q * 4 + 4; i++) { | |
| 48 | us.push(mesh.uv[i * 2] as number); | |
| 49 | vs.push(mesh.uv[i * 2 + 1] as number); | |
| 50 | } | |
| 51 | spans.add(`${Math.max(...us) - Math.min(...us)}x${Math.max(...vs) - Math.min(...vs)}`); | |
| 52 | } | |
| 53 | // 256/128 = 2 across X, 1 across Y, 3 across Z: the three face pairs. | |
| 54 | expect([...spans].sort()).toEqual(["1x3", "2x1", "2x3"]); | |
| 55 | ||
| 56 | const half = box([0, 0, 0], [64, 64, 64], 0, 64); | |
| 57 | expect(Math.max(...half.uv)).toBeCloseTo(1, 6); | |
| 58 | const quarter = box([0, 0, 0], [64, 64, 64], 0, 256); | |
| 59 | expect(Math.max(...quarter.uv)).toBeCloseTo(0.25, 6); | |
| 60 | }); | |
| 61 | ||
| 62 | test("boxes accumulate in one builder with indices that stay in range", () => { | |
| 63 | const b = builder(); | |
| 64 | addBox(b, [0, 0, 0], [1, 1, 1], 0); | |
| 65 | addBox(b, [2, 2, 2], [3, 3, 3], 1); | |
| 66 | const mesh = finish(b); | |
| 67 | expect(mesh.position.length).toBe(48 * 3); | |
| 68 | expect(Math.max(...mesh.index)).toBe(47); | |
| 69 | expect(mesh.index.slice(36, 39)).toEqual(new Uint32Array([24, 25, 26])); | |
| 70 | }); | |
| 71 | ||
| 72 | test("the quad is a unit square on the XY plane", () => { | |
| 73 | const mesh = quadMesh(); | |
| 74 | for (let i = 0; i < mesh.position.length / 3; i++) { | |
| 75 | const [x, y, z] = vertex(mesh, i); | |
| 76 | expect(Math.abs(x)).toBeCloseTo(0.5, 6); | |
| 77 | expect(Math.abs(y)).toBeCloseTo(0.5, 6); | |
| 78 | expect(z).toBe(0); | |
| 79 | } | |
| 80 | expect(Math.max(...mesh.uv)).toBeCloseTo(0.5, 6); | |
| 81 | }); |
src/engine/mesh.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * Meshes, built from boxes. One interleaved-by-attribute buffer set — position, | |
| 3 | * normal, uv, layer and an index — grown as plain arrays and frozen into typed | |
| 4 | * ones at the end. The only primitive is a box, because a box with a texture | |
| 5 | * layer per face is enough to build a level, a player and a gun, and because | |
| 6 | * six quads upload as one draw. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Vec3 } from "./math.ts"; | |
| 10 | ||
| 11 | export type Mesh = { | |
| 12 | position: Float32Array; | |
| 13 | normal: Float32Array; | |
| 14 | uv: Float32Array; | |
| 15 | layer: Float32Array; | |
| 16 | index: Uint32Array; | |
| 17 | }; | |
| 18 | ||
| 19 | export type Builder = { | |
| 20 | position: number[]; | |
| 21 | normal: number[]; | |
| 22 | uv: number[]; | |
| 23 | layer: number[]; | |
| 24 | index: number[]; | |
| 25 | }; | |
| 26 | ||
| 27 | export const builder = (): Builder => ({ position: [], normal: [], uv: [], layer: [], index: [] }); | |
| 28 | ||
| 29 | export function finish(b: Builder): Mesh { | |
| 30 | return { | |
| 31 | position: new Float32Array(b.position), | |
| 32 | normal: new Float32Array(b.normal), | |
| 33 | uv: new Float32Array(b.uv), | |
| 34 | layer: new Float32Array(b.layer), | |
| 35 | index: new Uint32Array(b.index), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | const FACES: [Vec3, number, number, number][] = [ | |
| 40 | // normal, axis of the face, then the two axes across it | |
| 41 | [[1, 0, 0], 0, 1, 2], | |
| 42 | [[-1, 0, 0], 0, 1, 2], | |
| 43 | [[0, 1, 0], 1, 0, 2], | |
| 44 | [[0, -1, 0], 1, 0, 2], | |
| 45 | [[0, 0, 1], 2, 0, 1], | |
| 46 | [[0, 0, -1], 2, 0, 1], | |
| 47 | ]; | |
| 48 | ||
| 49 | /** | |
| 50 | * Is (axis, u, v) a right-handed frame? It is for the X and Z faces, whose | |
| 51 | * across-axes come round in order, and is not for the Y faces, whose u is X and | |
| 52 | * whose v is Z. The winding has to account for it or the north and south face | |
| 53 | * of every box comes out inside-out and the back-face cull eats it. | |
| 54 | */ | |
| 55 | const handed = (axis: number, u: number, v: number): number => | |
| 56 | (u - axis + 3) % 3 === 1 && (v - u + 3) % 3 === 1 ? 1 : -1; | |
| 57 | ||
| 58 | /** Six quads with the texture tiling every `scale` units in world space. */ | |
| 59 | export function addBox(b: Builder, min: Vec3, max: Vec3, layer: number, scale = 128): void { | |
| 60 | for (const [normal, axis, u, v] of FACES) { | |
| 61 | const base = b.position.length / 3; | |
| 62 | const at = (normal[axis] as number) > 0 ? max : min; | |
| 63 | for (const [du, dv] of [[0, 0], [1, 0], [1, 1], [0, 1]]) { | |
| 64 | const p: Vec3 = [0, 0, 0]; | |
| 65 | p[axis] = at[axis] as number; | |
| 66 | p[u] = (du === 0 ? min[u] : max[u]) as number; | |
| 67 | p[v] = (dv === 0 ? min[v] : max[v]) as number; | |
| 68 | b.position.push(p[0], p[1], p[2]); | |
| 69 | b.normal.push(normal[0], normal[1], normal[2]); | |
| 70 | b.uv.push((p[u] as number) / scale, (p[v] as number) / scale); | |
| 71 | b.layer.push(layer); | |
| 72 | } | |
| 73 | // Wind the two triangles so the outside faces out. | |
| 74 | if ((normal[axis] as number) * handed(axis, u, v) > 0) | |
| 75 | b.index.push(base, base + 1, base + 2, base, base + 2, base + 3); | |
| 76 | else b.index.push(base, base + 2, base + 1, base, base + 3, base + 2); | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | /** A unit quad on the XY plane, for tracers, sprites and decals. */ | |
| 81 | export function quadMesh(): Mesh { | |
| 82 | const b = builder(); | |
| 83 | addBox(b, [-0.5, -0.5, 0], [0.5, 0.5, 0], 0, 1); | |
| 84 | return finish(b); | |
| 85 | } |
src/engine/sound.test.ts added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { attenuate, createSoundDevice, type Ctor } from "./sound.ts"; | |
| 3 | ||
| 4 | // --- the arithmetic ------------------------------------------------------- | |
| 5 | ||
| 6 | test("attenuation is full up close, falls off with distance, and never exceeds 1", () => { | |
| 7 | expect(attenuate(0, 900, 120)).toBe(1); | |
| 8 | expect(attenuate(120, 900, 120)).toBe(1); | |
| 9 | expect(attenuate(900, 900, 120)).toBe(1); | |
| 10 | expect(attenuate(1800, 900, 120)).toBeCloseTo(0.5, 12); | |
| 11 | let last = 1; | |
| 12 | for (let d = 900; d < 6000; d += 100) { | |
| 13 | const now = attenuate(d, 900, 120); | |
| 14 | expect(now).toBeLessThanOrEqual(last); | |
| 15 | expect(now).toBeGreaterThan(0); | |
| 16 | last = now; | |
| 17 | } | |
| 18 | expect(attenuate(6000, 900, 120)).toBeLessThan(0.2); | |
| 19 | }); | |
| 20 | ||
| 21 | // --- the wiring, against a context that only writes down what it was asked -- | |
| 22 | ||
| 23 | type Event = { kind: string; at: number; value: number; time: number }; | |
| 24 | ||
| 25 | /** The smallest thing that answers like an AudioContext, and remembers. */ | |
| 26 | function fakeContext(): { | |
| 27 | Ctor: Ctor; log: Event[]; buffers: Float32Array[]; contexts: number; resumes: number; | |
| 28 | } { | |
| 29 | const log: Event[] = []; | |
| 30 | const buffers: Float32Array[] = []; | |
| 31 | const state = { contexts: 0, resumes: 0 }; | |
| 32 | const param = (kind: string) => ({ | |
| 33 | value: 0, | |
| 34 | setValueAtTime: (value: number, time: number) => log.push({ kind: `${kind}.set`, at: 0, value, time }), | |
| 35 | exponentialRampToValueAtTime: (value: number, time: number) => | |
| 36 | log.push({ kind: `${kind}.ramp`, at: 0, value, time }), | |
| 37 | }); | |
| 38 | const node = (kind: string) => ({ | |
| 39 | kind, | |
| 40 | frequency: param(`${kind}.frequency`), | |
| 41 | playbackRate: param(`${kind}.rate`), | |
| 42 | gain: param(`${kind}.gain`), | |
| 43 | Q: param(`${kind}.q`), | |
| 44 | type: "", | |
| 45 | buffer: null as unknown, | |
| 46 | connect(next: unknown) { | |
| 47 | log.push({ kind: `${kind}.connect`, at: 0, value: 0, time: 0 }); | |
| 48 | return next; | |
| 49 | }, | |
| 50 | start: (time: number) => log.push({ kind: `${kind}.start`, at: 0, value: 0, time }), | |
| 51 | stop: (time: number) => log.push({ kind: `${kind}.stop`, at: 0, value: 0, time }), | |
| 52 | }); | |
| 53 | class Fake { | |
| 54 | sampleRate = 48000; | |
| 55 | currentTime = 2; | |
| 56 | state = "suspended"; | |
| 57 | destination = { kind: "destination" }; | |
| 58 | constructor() { | |
| 59 | state.contexts += 1; | |
| 60 | } | |
| 61 | resume(): void { | |
| 62 | state.resumes += 1; | |
| 63 | this.state = "running"; | |
| 64 | } | |
| 65 | createBuffer(_channels: number, length: number) { | |
| 66 | const data = new Float32Array(length); | |
| 67 | buffers.push(data); | |
| 68 | return { length, getChannelData: () => data }; | |
| 69 | } | |
| 70 | createBufferSource() { return node("source"); } | |
| 71 | createBiquadFilter() { return node("filter"); } | |
| 72 | createGain() { return node("gain"); } | |
| 73 | createOscillator() { return node("osc"); } | |
| 74 | } | |
| 75 | return { | |
| 76 | Ctor: Fake as unknown as Ctor, | |
| 77 | log, | |
| 78 | buffers, | |
| 79 | get contexts() { return state.contexts; }, | |
| 80 | get resumes() { return state.resumes; }, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | const find = (log: Event[], kind: string): Event | undefined => log.find((e) => e.kind === kind); | |
| 85 | ||
| 86 | test("a device with no AudioContext at all stays quiet instead of throwing", () => { | |
| 87 | const device = createSoundDevice(undefined); | |
| 88 | device.resume(); | |
| 89 | device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 }); | |
| 90 | device.tone({ frequency: 440, gain: 0.06, length: 0.09 }); | |
| 91 | expect(device.muted).toBe(false); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the context is opened once, lazily, and resumed when it is suspended", () => { | |
| 95 | const fake = fakeContext(); | |
| 96 | const device = createSoundDevice(fake.Ctor); | |
| 97 | expect(fake.contexts).toBe(0); | |
| 98 | device.tone({ frequency: 440, gain: 0.06, length: 0.09 }); | |
| 99 | device.tone({ frequency: 880, gain: 0.06, length: 0.09 }); | |
| 100 | expect(fake.contexts).toBe(1); | |
| 101 | expect(fake.resumes).toBe(1); // the second call found it already running | |
| 102 | }); | |
| 103 | ||
| 104 | test("the noise buffer is half a second of noise, filled once and reused", () => { | |
| 105 | const fake = fakeContext(); | |
| 106 | const device = createSoundDevice(fake.Ctor); | |
| 107 | device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2, rate: 1.1 }); | |
| 108 | device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 }); | |
| 109 | expect(fake.buffers.length).toBe(1); | |
| 110 | const data = fake.buffers[0] as Float32Array; | |
| 111 | expect(data.length).toBe(48000 * 0.5); | |
| 112 | expect(Math.max(...data)).toBeGreaterThan(0.9); | |
| 113 | expect(Math.min(...data)).toBeLessThan(-0.9); | |
| 114 | expect(find(fake.log, "source.start")?.time).toBe(2); | |
| 115 | expect(find(fake.log, "source.stop")?.time).toBeCloseTo(2.2, 6); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a burst decays from the gain asked to silence across its length", () => { | |
| 119 | const fake = fakeContext(); | |
| 120 | createSoundDevice(fake.Ctor).noiseBurst({ frequency: 1500, gain: 0.35, length: 0.24 }); | |
| 121 | expect(find(fake.log, "gain.gain.set")?.value).toBeCloseTo(0.35, 6); | |
| 122 | const end = find(fake.log, "gain.gain.ramp"); | |
| 123 | expect(end?.value).toBeGreaterThan(0); // exponential ramps cannot reach zero | |
| 124 | expect(end?.time).toBeCloseTo(2.24, 6); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a tone slides only when asked, and holds its node open for the tail", () => { | |
| 128 | const plain = fakeContext(); | |
| 129 | createSoundDevice(plain.Ctor).tone({ frequency: 440, gain: 0.06, length: 0.09, tail: 0.01 }); | |
| 130 | expect(find(plain.log, "osc.frequency.ramp")).toBeUndefined(); | |
| 131 | expect(find(plain.log, "osc.stop")?.time).toBeCloseTo(2.1, 6); | |
| 132 | ||
| 133 | const slid = fakeContext(); | |
| 134 | createSoundDevice(slid.Ctor).tone({ frequency: 150, slideTo: 60, gain: 0.3, length: 0.16 }); | |
| 135 | expect(find(slid.log, "osc.frequency.set")?.value).toBe(150); | |
| 136 | expect(find(slid.log, "osc.frequency.ramp")?.value).toBe(60); | |
| 137 | expect(find(slid.log, "osc.stop")?.time).toBeCloseTo(2.16, 6); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a muted device builds no nodes at all", () => { | |
| 141 | const fake = fakeContext(); | |
| 142 | const device = createSoundDevice(fake.Ctor); | |
| 143 | device.muted = true; | |
| 144 | device.noiseBurst({ frequency: 1500, gain: 0.35, length: 0.2 }); | |
| 145 | device.tone({ frequency: 440, gain: 0.06, length: 0.09 }); | |
| 146 | expect(fake.log).toEqual([]); | |
| 147 | device.muted = false; | |
| 148 | device.tone({ frequency: 440, gain: 0.06, length: 0.09 }); | |
| 149 | expect(fake.log.length).toBeGreaterThan(0); | |
| 150 | }); |
src/engine/sound.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * Sound, synthesised. Two voices are enough for a game: a burst of noise | |
| 3 | * through a resonant filter, which is anything that hits or explodes, and a | |
| 4 | * tone that can slide, which is anything that beeps or thumps. Both are shaped | |
| 5 | * by one decaying envelope and go straight to the destination. | |
| 6 | * | |
| 7 | * The `AudioContext` is opened on the first sound, not on load, because a | |
| 8 | * browser will not start one until the person has clicked something — and | |
| 9 | * because a page that never makes a sound should never pay for the device. The | |
| 10 | * constructor is a parameter so a test can hand in its own. | |
| 11 | */ | |
| 12 | ||
| 13 | export type Ctor = new () => AudioContext; | |
| 14 | ||
| 15 | /** A burst of band-passed noise: a gunshot, an impact, a footstep. */ | |
| 16 | export type Burst = { | |
| 17 | /** Centre of the bandpass, in Hz — the "colour" of the hit. */ | |
| 18 | frequency: number; | |
| 19 | /** Peak gain, 0..1, before the envelope decays it to silence. */ | |
| 20 | gain: number; | |
| 21 | /** Seconds from peak to silence. */ | |
| 22 | length: number; | |
| 23 | q?: number; | |
| 24 | /** Playback rate of the noise buffer; vary it so repeats do not phase. */ | |
| 25 | rate?: number; | |
| 26 | }; | |
| 27 | ||
| 28 | /** A tone, optionally sliding to a second frequency across its length. */ | |
| 29 | export type Tone = { | |
| 30 | frequency: number; | |
| 31 | gain: number; | |
| 32 | length: number; | |
| 33 | slideTo?: number; | |
| 34 | type?: OscillatorType; | |
| 35 | /** Where the exponential envelope lands; it cannot ramp to zero. */ | |
| 36 | floor?: number; | |
| 37 | /** Extra seconds to hold the node open after the envelope has landed. */ | |
| 38 | tail?: number; | |
| 39 | }; | |
| 40 | ||
| 41 | export type SoundDevice = { | |
| 42 | /** Open the context, or resume one the browser suspended. Safe to call often. */ | |
| 43 | resume(): void; | |
| 44 | noiseBurst(burst: Burst): void; | |
| 45 | tone(tone: Tone): void; | |
| 46 | /** True while the device makes no sound at all. */ | |
| 47 | muted: boolean; | |
| 48 | }; | |
| 49 | ||
| 50 | /** | |
| 51 | * How loud a sound at `distance` is, 0..1: full inside `near`, and falling off | |
| 52 | * with distance after it. Not inverse-square — a game wants to hear the fight | |
| 53 | * across the map, and the ear cares about the ratio, not the physics. | |
| 54 | */ | |
| 55 | export const attenuate = (distance: number, reference: number, near: number): number => | |
| 56 | Math.min(1, reference / Math.max(near, Math.max(0, distance))); | |
| 57 | ||
| 58 | const NOISE_SECONDS = 0.5; | |
| 59 | ||
| 60 | const defaultCtor = (): Ctor | undefined => { | |
| 61 | const g = globalThis as unknown as { AudioContext?: Ctor; webkitAudioContext?: Ctor }; | |
| 62 | return g.AudioContext ?? g.webkitAudioContext; | |
| 63 | }; | |
| 64 | ||
| 65 | export function createSoundDevice(Ctor: Ctor | undefined = defaultCtor()): SoundDevice { | |
| 66 | let ctx: AudioContext | null = null; | |
| 67 | let noise: AudioBuffer | null = null; | |
| 68 | ||
| 69 | /** The context, opened on first use, or null where there is no audio at all. */ | |
| 70 | const ensure = (): AudioContext | null => { | |
| 71 | if (ctx === null) { | |
| 72 | if (Ctor === undefined) return null; | |
| 73 | ctx = new Ctor(); | |
| 74 | noise = ctx.createBuffer(1, Math.round(ctx.sampleRate * NOISE_SECONDS), ctx.sampleRate); | |
| 75 | const data = noise.getChannelData(0); | |
| 76 | for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; | |
| 77 | } | |
| 78 | if (ctx.state === "suspended") void ctx.resume(); | |
| 79 | return ctx; | |
| 80 | }; | |
| 81 | ||
| 82 | /** A gain node that decays from `gain` to `floor` over `length` seconds. */ | |
| 83 | const decay = (context: AudioContext, now: number, gain: number, length: number, floor: number): GainNode => { | |
| 84 | const node = context.createGain(); | |
| 85 | node.gain.setValueAtTime(gain, now); | |
| 86 | node.gain.exponentialRampToValueAtTime(floor, now + length); | |
| 87 | return node; | |
| 88 | }; | |
| 89 | ||
| 90 | const device: SoundDevice = { | |
| 91 | muted: false, | |
| 92 | resume: () => void ensure(), | |
| 93 | ||
| 94 | noiseBurst({ frequency, gain, length, q = 0.9, rate = 1 }) { | |
| 95 | if (device.muted) return; | |
| 96 | const context = ensure(); | |
| 97 | if (context === null || noise === null) return; | |
| 98 | const now = context.currentTime; | |
| 99 | const source = context.createBufferSource(); | |
| 100 | source.buffer = noise; | |
| 101 | source.playbackRate.value = rate; | |
| 102 | const filter = context.createBiquadFilter(); | |
| 103 | filter.type = "bandpass"; | |
| 104 | filter.frequency.value = frequency; | |
| 105 | filter.Q.value = q; | |
| 106 | source.connect(filter).connect(decay(context, now, gain, length, 0.0008)).connect(context.destination); | |
| 107 | source.start(now); | |
| 108 | source.stop(now + length); | |
| 109 | }, | |
| 110 | ||
| 111 | tone({ frequency, gain, length, slideTo, type = "sine", floor = 0.0008, tail = 0 }) { | |
| 112 | if (device.muted) return; | |
| 113 | const context = ensure(); | |
| 114 | if (context === null) return; | |
| 115 | const now = context.currentTime; | |
| 116 | const osc = context.createOscillator(); | |
| 117 | osc.type = type; | |
| 118 | if (slideTo === undefined) osc.frequency.value = frequency; | |
| 119 | else { | |
| 120 | osc.frequency.setValueAtTime(frequency, now); | |
| 121 | osc.frequency.exponentialRampToValueAtTime(slideTo, now + length); | |
| 122 | } | |
| 123 | osc.connect(decay(context, now, gain, length, floor)).connect(context.destination); | |
| 124 | osc.start(now); | |
| 125 | osc.stop(now + length + tail); | |
| 126 | }, | |
| 127 | }; | |
| 128 | return device; | |
| 129 | } |
src/engine/texture.test.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { fbm, hash, noise, paintAtlas, type Shader } from "./texture.ts"; | |
| 3 | ||
| 4 | const flat = (r: number, g: number, b: number): Shader => () => [r, g, b]; | |
| 5 | ||
| 6 | test("the hash is stable, spread over 0..1, and moves with the seed", () => { | |
| 7 | expect(hash(3, 7, 1)).toBe(hash(3, 7, 1)); | |
| 8 | expect(hash(3, 7, 1)).not.toBe(hash(3, 7, 2)); | |
| 9 | let lo = 1, hi = 0, sum = 0; | |
| 10 | for (let x = 0; x < 64; x++) { | |
| 11 | for (let y = 0; y < 64; y++) { | |
| 12 | const h = hash(x, y, 5); | |
| 13 | lo = Math.min(lo, h); | |
| 14 | hi = Math.max(hi, h); | |
| 15 | sum += h; | |
| 16 | } | |
| 17 | } | |
| 18 | expect(lo).toBeGreaterThanOrEqual(0); | |
| 19 | expect(hi).toBeLessThanOrEqual(1); | |
| 20 | expect(sum / 4096).toBeCloseTo(0.5, 1); | |
| 21 | }); | |
| 22 | ||
| 23 | test("noise is smooth, hits the lattice exactly, and wraps at its period", () => { | |
| 24 | expect(noise(4, 9, 16, 3)).toBeCloseTo(hash(4, 9, 3), 12); | |
| 25 | expect(noise(0.5, 0.25, 8, 3)).toBeCloseTo(noise(8.5, 8.25, 8, 3), 12); | |
| 26 | const a = noise(2.4, 1.1, 8, 3), b = noise(2.401, 1.1, 8, 3); | |
| 27 | expect(Math.abs(a - b)).toBeLessThan(0.01); | |
| 28 | }); | |
| 29 | ||
| 30 | test("fbm stays inside 0..1 and adds detail with every octave", () => { | |
| 31 | for (let i = 0; i < 200; i++) { | |
| 32 | const value = fbm(i * 0.37, i * 0.11, 8, 2); | |
| 33 | expect(value).toBeGreaterThanOrEqual(0); | |
| 34 | expect(value).toBeLessThanOrEqual(1); | |
| 35 | } | |
| 36 | expect(fbm(1.3, 2.7, 8, 2, 1)).not.toBeCloseTo(fbm(1.3, 2.7, 8, 2, 4), 6); | |
| 37 | }); | |
| 38 | ||
| 39 | test("an atlas is size*size*4*layers opaque bytes", () => { | |
| 40 | const atlas = paintAtlas([flat(10, 20, 30), flat(40, 50, 60)], 8); | |
| 41 | expect(atlas.length).toBe(8 * 8 * 4 * 2); | |
| 42 | for (let i = 3; i < atlas.length; i += 4) expect(atlas[i]).toBe(255); | |
| 43 | expect([...atlas.slice(0, 4)]).toEqual([10, 20, 30, 255]); | |
| 44 | // Layers are stacked, not interleaved: the second starts halfway through. | |
| 45 | expect([...atlas.slice(8 * 8 * 4, 8 * 8 * 4 + 4)]).toEqual([40, 50, 60, 255]); | |
| 46 | }); | |
| 47 | ||
| 48 | test("painting the same shaders twice paints the same bytes", () => { | |
| 49 | const shaders: Shader[] = [(u, v) => [fbm(u * 6, v * 6, 6, 1) * 255, u * 255, v * 255]]; | |
| 50 | expect(paintAtlas(shaders, 16)).toEqual(paintAtlas(shaders, 16)); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a shader that returns out-of-range values is clamped, not wrapped", () => { | |
| 54 | const atlas = paintAtlas([flat(300, -40, Number.NaN)], 4); | |
| 55 | expect([...atlas.slice(0, 4)]).toEqual([255, 0, 0, 255]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("a shader sees uv across the layer, one texel at a time", () => { | |
| 59 | const seen: [number, number][] = []; | |
| 60 | paintAtlas([(u, v) => { seen.push([u, v]); return [0, 0, 0]; }], 4); | |
| 61 | expect(seen.length).toBe(16); | |
| 62 | expect(seen[0]).toEqual([0, 0]); | |
| 63 | expect(seen[5]).toEqual([0.25, 0.25]); | |
| 64 | expect(seen[15]).toEqual([0.75, 0.75]); | |
| 65 | }); |
src/engine/texture.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * Textures, painted. A shader is a function of (u, v) returning RGB in 0..255, | |
| 3 | * and an atlas is a stack of them written straight into the bytes a WebGL2 | |
| 4 | * array texture wants — no images to download, no atlas bleeding, and a set of | |
| 5 | * nine 128×128 layers costs about a millisecond to make. | |
| 6 | * | |
| 7 | * The noise is value noise that wraps at a period, so a surface tiles without a | |
| 8 | * seam, and it is hashed rather than tabled, so no state has to be carried and | |
| 9 | * the same (x, y, seed) is the same byte on every machine. | |
| 10 | */ | |
| 11 | ||
| 12 | /** A stable hash of a lattice point to 0..1. No tables, no state, no drift. */ | |
| 13 | export const hash = (x: number, y: number, seed: number): number => { | |
| 14 | let h = (x * 374761393 + y * 668265263 + seed * 1274126177) | 0; | |
| 15 | h = (h ^ (h >>> 13)) * 1274126177; | |
| 16 | return ((h ^ (h >>> 16)) >>> 0) / 4294967295; | |
| 17 | }; | |
| 18 | ||
| 19 | /** Value noise that wraps at `period`, so a tiled surface has no seam. */ | |
| 20 | export function noise(x: number, y: number, period: number, seed: number): number { | |
| 21 | const xi = Math.floor(x), yi = Math.floor(y); | |
| 22 | const xf = x - xi, yf = y - yi; | |
| 23 | const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf); | |
| 24 | const at = (i: number, j: number): number => | |
| 25 | hash(((i % period) + period) % period, ((j % period) + period) % period, seed); | |
| 26 | const a = at(xi, yi), b = at(xi + 1, yi), c = at(xi, yi + 1), d = at(xi + 1, yi + 1); | |
| 27 | return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v; | |
| 28 | } | |
| 29 | ||
| 30 | /** Octaves of `noise`, halving in amplitude and doubling in frequency. */ | |
| 31 | export const fbm = (x: number, y: number, period: number, seed: number, octaves = 4): number => { | |
| 32 | let sum = 0, amp = 0.5, freq = 1; | |
| 33 | for (let o = 0; o < octaves; o++) { | |
| 34 | sum += noise(x * freq, y * freq, period * freq, seed + o) * amp; | |
| 35 | amp *= 0.5; | |
| 36 | freq *= 2; | |
| 37 | } | |
| 38 | return sum; | |
| 39 | }; | |
| 40 | ||
| 41 | /** One layer's surface: uv in 0..1 in, RGB in 0..255 out. */ | |
| 42 | export type Shader = (u: number, v: number) => [number, number, number]; | |
| 43 | ||
| 44 | /** | |
| 45 | * One `size`×`size` layer per shader, tightly packed RGBA and fully opaque, | |
| 46 | * ready for texImage3D. Anything a shader returns outside 0..255 is clamped, | |
| 47 | * because a surface that is a little too bright should not wrap to black. | |
| 48 | */ | |
| 49 | export function paintAtlas(shaders: Shader[], size: number): Uint8Array { | |
| 50 | const out = new Uint8Array(size * size * 4 * shaders.length); | |
| 51 | for (let layer = 0; layer < shaders.length; layer++) { | |
| 52 | const shade = shaders[layer] as Shader; | |
| 53 | for (let y = 0; y < size; y++) { | |
| 54 | for (let x = 0; x < size; x++) { | |
| 55 | const [r, g, b] = shade(x / size, y / size); | |
| 56 | const i = (layer * size * size + y * size + x) * 4; | |
| 57 | out[i] = Math.max(0, Math.min(255, r)); | |
| 58 | out[i + 1] = Math.max(0, Math.min(255, g)); | |
| 59 | out[i + 2] = Math.max(0, Math.min(255, b)); | |
| 60 | out[i + 3] = 255; | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | return out; | |
| 65 | } |
static/app.6864d577a4.js added +58
Diff not shown — a line 56,044 characters wide (limit 2,000). +58
View filestatic/app.e8ca965610.js deleted −58
Diff not shown — a line 55,651 characters wide (limit 2,000). −58
View file
new above
offline