TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23/**4 * Spinza procedural sound engine. Every sound is synthesised with WebAudio —5 * no sample files — so each game gets an original sonic identity from its6 * `ambience` preset. Volumes come from the player's saved settings.7 */89type Ambience = string;1011interface Levels {12 master: number;13 music: number;14 effects: number;15 enabled: boolean;16}1718const PRESETS: Record<string, { root: number; wave: OscillatorType; detune: number; filter: number; lfo: number; shimmer: boolean }> = {19 neon: { root: 55, wave: "sawtooth", detune: 7, filter: 700, lfo: 0.11, shimmer: true },20 space: { root: 41.2, wave: "sine", detune: 3, filter: 500, lfo: 0.05, shimmer: true },21 royal: { root: 65.4, wave: "triangle", detune: 5, filter: 900, lfo: 0.08, shimmer: false },22 heist: { root: 49, wave: "square", detune: 4, filter: 450, lfo: 0.13, shimmer: false },23 ice: { root: 73.4, wave: "sine", detune: 6, filter: 1200, lfo: 0.07, shimmer: true },24 fire: { root: 43.65, wave: "sawtooth", detune: 9, filter: 600, lfo: 0.16, shimmer: false },25 quantum: { root: 58.3, wave: "square", detune: 12, filter: 800, lfo: 0.21, shimmer: true },26 city: { root: 51.9, wave: "sawtooth", detune: 6, filter: 750, lfo: 0.12, shimmer: true },27 desert: { root: 46.2, wave: "triangle", detune: 4, filter: 650, lfo: 0.06, shimmer: false },28 arcade: { root: 82.4, wave: "square", detune: 8, filter: 1400, lfo: 0.25, shimmer: false },29 mine: { root: 38.9, wave: "sawtooth", detune: 5, filter: 400, lfo: 0.09, shimmer: false },30 race: { root: 61.7, wave: "sawtooth", detune: 10, filter: 900, lfo: 0.18, shimmer: false },31 dragon: { root: 36.7, wave: "triangle", detune: 7, filter: 550, lfo: 0.1, shimmer: false },32 ocean: { root: 43.65, wave: "sine", detune: 4, filter: 480, lfo: 0.05, shimmer: true },33 lunar: { root: 55, wave: "triangle", detune: 3, filter: 700, lfo: 0.06, shimmer: true },34 jungle: { root: 49, wave: "triangle", detune: 6, filter: 850, lfo: 0.14, shimmer: false },35 void: { root: 32.7, wave: "sine", detune: 2, filter: 380, lfo: 0.04, shimmer: true },36 reactor: { root: 58.3, wave: "square", detune: 9, filter: 620, lfo: 0.19, shimmer: false },37 obsidian: { root: 41.2, wave: "sine", detune: 1, filter: 420, lfo: 0.03, shimmer: false },38 spinza: { root: 55, wave: "triangle", detune: 8, filter: 900, lfo: 0.1, shimmer: true },39};4041export class SoundEngine {42 private ctx: AudioContext | null = null;43 private master!: GainNode;44 private musicBus!: GainNode;45 private fxBus!: GainNode;46 private ambienceNodes: AudioNode[] = [];47 private levels: Levels = { master: 0.8, music: 0.6, effects: 0.8, enabled: true };48 private ambience: Ambience = "spinza";49 private started = false;5051 setLevels(l: Partial<Levels>) {52 this.levels = { ...this.levels, ...l };53 if (this.ctx) {54 this.master.gain.setTargetAtTime(this.levels.enabled ? this.levels.master : 0, this.ctx.currentTime, 0.05);55 this.musicBus.gain.setTargetAtTime(this.levels.music * 0.35, this.ctx.currentTime, 0.1);56 this.fxBus.gain.setTargetAtTime(this.levels.effects, this.ctx.currentTime, 0.05);57 }58 }5960 setAmbience(a: Ambience) {61 this.ambience = a;62 if (this.started) this.startAmbience();63 }6465 /** Must be called from a user gesture. */66 unlock() {67 if (this.ctx) {68 if (this.ctx.state === "suspended") void this.ctx.resume();69 return;70 }71 const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;72 if (!AC) return;73 this.ctx = new AC();74 this.master = this.ctx.createGain();75 this.master.gain.value = this.levels.enabled ? this.levels.master : 0;76 const comp = this.ctx.createDynamicsCompressor();77 comp.threshold.value = -18;78 comp.ratio.value = 4;79 this.master.connect(comp).connect(this.ctx.destination);80 this.musicBus = this.ctx.createGain();81 this.musicBus.gain.value = this.levels.music * 0.35;82 this.musicBus.connect(this.master);83 this.fxBus = this.ctx.createGain();84 this.fxBus.gain.value = this.levels.effects;85 this.fxBus.connect(this.master);86 this.started = true;87 this.startAmbience();88 }8990 destroy() {91 this.stopAmbience();92 void this.ctx?.close();93 this.ctx = null;94 this.started = false;95 }9697 /* ------------------------------------------------------------ ambience */9899 private stopAmbience() {100 for (const n of this.ambienceNodes) {101 try {102 (n as OscillatorNode).stop?.();103 } catch {104 /* noop */105 }106 n.disconnect();107 }108 this.ambienceNodes = [];109 }110111 private startAmbience() {112 if (!this.ctx) return;113 this.stopAmbience();114 const p = PRESETS[this.ambience] ?? PRESETS.spinza;115 const ctx = this.ctx;116 const filter = ctx.createBiquadFilter();117 filter.type = "lowpass";118 filter.frequency.value = p.filter;119 filter.Q.value = 0.8;120 const lfo = ctx.createOscillator();121 lfo.frequency.value = p.lfo;122 const lfoGain = ctx.createGain();123 lfoGain.gain.value = p.filter * 0.45;124 lfo.connect(lfoGain).connect(filter.frequency);125 lfo.start();126 const pad = ctx.createGain();127 pad.gain.value = 0;128 pad.gain.setTargetAtTime(1, ctx.currentTime, 2.5);129 filter.connect(pad).connect(this.musicBus);130 const voices = [p.root, p.root * 1.5, p.root * 2, p.root * 2.9966];131 voices.forEach((f, i) => {132 const o = ctx.createOscillator();133 o.type = i === 3 ? "sine" : p.wave;134 o.frequency.value = f;135 o.detune.value = (i % 2 === 0 ? 1 : -1) * p.detune;136 const g = ctx.createGain();137 g.gain.value = i === 0 ? 0.35 : i === 3 ? 0.08 : 0.18;138 o.connect(g).connect(filter);139 o.start();140 this.ambienceNodes.push(o, g);141 });142 if (p.shimmer) {143 const s = ctx.createOscillator();144 s.type = "sine";145 s.frequency.value = p.root * 8;146 const sg = ctx.createGain();147 sg.gain.value = 0.02;148 const trem = ctx.createOscillator();149 trem.frequency.value = 0.3;150 const tg = ctx.createGain();151 tg.gain.value = 0.02;152 trem.connect(tg).connect(sg.gain);153 trem.start();154 s.connect(sg).connect(filter);155 s.start();156 this.ambienceNodes.push(s, sg, trem, tg);157 }158 this.ambienceNodes.push(filter, lfo, lfoGain, pad);159 }160161 /* -------------------------------------------------------------- effects */162163 private blip(freq: number, dur: number, type: OscillatorType = "sine", gain = 0.5, slide = 0) {164 if (!this.ctx) return;165 const ctx = this.ctx;166 const o = ctx.createOscillator();167 o.type = type;168 o.frequency.setValueAtTime(freq, ctx.currentTime);169 if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), ctx.currentTime + dur);170 const g = ctx.createGain();171 g.gain.setValueAtTime(0, ctx.currentTime);172 g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.005);173 g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);174 o.connect(g).connect(this.fxBus);175 o.start();176 o.stop(ctx.currentTime + dur + 0.02);177 }178179 private noise(dur: number, gain = 0.3, filterHz = 1200) {180 if (!this.ctx) return;181 const ctx = this.ctx;182 const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate);183 const d = buf.getChannelData(0);184 for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length); // noise texture only, never game logic185 const src = ctx.createBufferSource();186 src.buffer = buf;187 const f = ctx.createBiquadFilter();188 f.type = "bandpass";189 f.frequency.value = filterHz;190 const g = ctx.createGain();191 g.gain.value = gain;192 src.connect(f).connect(g).connect(this.fxBus);193 src.start();194 }195196 click() {197 this.blip(1800, 0.05, "square", 0.12);198 }199 spinStart() {200 this.noise(0.35, 0.2, 900);201 this.blip(220, 0.25, "triangle", 0.2, 180);202 }203 reelStop(index: number) {204 this.blip(160 + index * 18, 0.09, "triangle", 0.35, -60);205 this.noise(0.06, 0.15, 2200);206 }207 tick() {208 this.blip(2400, 0.02, "square", 0.05);209 }210 win(level: number) {211 // level 0..4 — arpeggio grows with size.212 const base = [523.25, 659.25, 783.99, 1046.5, 1318.5];213 const notes = base.slice(0, 2 + Math.min(3, level));214 notes.forEach((f, i) => setTimeout(() => this.blip(f, 0.35, "triangle", 0.3), i * 70));215 }216 bigWin() {217 [392, 523.25, 659.25, 783.99, 1046.5, 1318.5, 1567.98].forEach((f, i) => setTimeout(() => this.blip(f, 0.6, "sawtooth", 0.22), i * 110));218 setTimeout(() => this.noise(0.8, 0.25, 3000), 300);219 }220 cascade() {221 this.blip(880, 0.12, "sine", 0.25, 440);222 this.noise(0.12, 0.12, 1600);223 }224 coin() {225 this.blip(1567.98, 0.16, "triangle", 0.3, 400);226 }227 bonus() {228 [261.63, 329.63, 392, 523.25].forEach((f, i) => setTimeout(() => this.blip(f, 0.5, "triangle", 0.35), i * 120));229 this.noise(0.6, 0.2, 800);230 }231 jackpot() {232 for (let i = 0; i < 12; i++) setTimeout(() => this.blip(523.25 * Math.pow(1.0595, (i * 5) % 24), 0.4, "square", 0.2), i * 90);233 setTimeout(() => this.noise(1.2, 0.3, 2500), 200);234 }235 error() {236 this.blip(180, 0.25, "square", 0.2, -60);237 }238}239240let engine: SoundEngine | null = null;241export function getSound(): SoundEngine {242 if (!engine) engine = new SoundEngine();243 return engine;244}245