Add configurable procedural sounds
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
updateHud,
|
||||
checkWinCondition,
|
||||
ui,
|
||||
sound,
|
||||
}) => {
|
||||
const setHighlight = (body, on) => {
|
||||
const lineWidth = on ? 4 : 2;
|
||||
@@ -37,6 +38,7 @@
|
||||
chain.bodies = [];
|
||||
chain.constraints = [];
|
||||
chain.pointer = null;
|
||||
sound?.stopDrag?.();
|
||||
updateHud();
|
||||
};
|
||||
|
||||
@@ -76,6 +78,7 @@
|
||||
chain.bodies.push(body);
|
||||
setHighlight(body, true);
|
||||
World.add(world, constraint);
|
||||
sound?.playClick?.();
|
||||
updateHud();
|
||||
};
|
||||
|
||||
@@ -240,15 +243,18 @@
|
||||
chain.bodies = [];
|
||||
chain.constraints = [];
|
||||
chain.pointer = null;
|
||||
sound?.stopDrag?.();
|
||||
updateHud();
|
||||
checkWinCondition();
|
||||
};
|
||||
|
||||
const finishChain = (releasePoint) => {
|
||||
if (!chain.active || state.gameOver || state.paused) return;
|
||||
sound?.stopDrag?.();
|
||||
const chainLength = chain.bodies.length;
|
||||
const currentScene = getCurrentScene();
|
||||
if (chainLength >= config.minChain) {
|
||||
sound?.playPop?.();
|
||||
updateLongestChain(chainLength);
|
||||
const { gain, isNegativeProgress } = getChainScoreState();
|
||||
state.score += gain;
|
||||
|
||||
@@ -71,6 +71,30 @@
|
||||
blur: 24,
|
||||
speedSec: 30,
|
||||
},
|
||||
sounds: {
|
||||
enabled: true,
|
||||
basePath: "./assets/sfx",
|
||||
channels: {
|
||||
buzz: {
|
||||
mode: "off", // "procedural" / "sample" / "off"
|
||||
procedural: "buzz",
|
||||
file: "drag-buzz.ogg",
|
||||
volume: 0.06,
|
||||
},
|
||||
click: {
|
||||
mode: "procedural",
|
||||
procedural: "click",
|
||||
file: "link-click.ogg",
|
||||
volume: 0.18,
|
||||
},
|
||||
pop: {
|
||||
mode: "procedural",
|
||||
procedural: "pop",
|
||||
file: "clear-pop.ogg",
|
||||
volume: 0.22,
|
||||
},
|
||||
},
|
||||
},
|
||||
showFps: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +104,10 @@
|
||||
...config,
|
||||
link: { ...config.link },
|
||||
messages: { ...config.messages },
|
||||
sounds: {
|
||||
...config.sounds,
|
||||
channels: { ...config.sounds.channels },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
addToChain,
|
||||
finishChain,
|
||||
updateHud,
|
||||
sound,
|
||||
}) => {
|
||||
let dragConstraint = null;
|
||||
|
||||
@@ -71,6 +72,7 @@
|
||||
|
||||
const handlePointerDown = (evt) => {
|
||||
if (isGameOver() || isPaused() || isLevelWon()) return;
|
||||
sound?.unlock?.();
|
||||
const point = getPointerPosition(evt);
|
||||
const dragTarget = getDraggableBody(point);
|
||||
if (dragTarget) {
|
||||
@@ -90,6 +92,7 @@
|
||||
chain.constraints = [];
|
||||
chain.pointer = point;
|
||||
setHighlight(body, true);
|
||||
sound?.startDrag?.();
|
||||
updateHud();
|
||||
};
|
||||
|
||||
@@ -123,6 +126,7 @@
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
sound?.stopDrag?.();
|
||||
if (dragConstraint) {
|
||||
endDrag();
|
||||
return;
|
||||
|
||||
28
src/main.js
28
src/main.js
@@ -86,12 +86,30 @@
|
||||
: null,
|
||||
});
|
||||
|
||||
const normalizeSounds = (sounds = {}, defaults = baseConfig.sounds || {}) => {
|
||||
const defaultChannels = defaults.channels || {};
|
||||
const overrideChannels = sounds.channels || {};
|
||||
const mergedChannels = { ...defaultChannels };
|
||||
Object.keys(overrideChannels).forEach((key) => {
|
||||
mergedChannels[key] = {
|
||||
...(defaultChannels[key] || {}),
|
||||
...(overrideChannels[key] || {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...defaults,
|
||||
...sounds,
|
||||
channels: mergedChannels,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => {
|
||||
const {
|
||||
link = {},
|
||||
messages = {},
|
||||
goalEffects = {},
|
||||
backdrop = {},
|
||||
sounds = {},
|
||||
...rest
|
||||
} = sceneConfig;
|
||||
const base = defaults || {};
|
||||
@@ -105,6 +123,7 @@
|
||||
),
|
||||
goalEffects: normalizeGoalEffects(goalEffects, base.goalEffects),
|
||||
backdrop: normalizeBackdrop(backdrop, base.backdrop),
|
||||
sounds: normalizeSounds(sounds, base.sounds),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -124,6 +143,12 @@
|
||||
const ui = createUI();
|
||||
const { sceneEl } = ui;
|
||||
|
||||
const createSound = load("PhysilinksSound", {
|
||||
create: "create",
|
||||
fallback: () => ({}),
|
||||
});
|
||||
const sound = createSound({ sounds: config.sounds });
|
||||
|
||||
const state = {
|
||||
width: sceneEl.clientWidth,
|
||||
height: sceneEl.clientHeight,
|
||||
@@ -246,6 +271,7 @@
|
||||
setSceneIdInUrl(next.id);
|
||||
const prevRadius = config.ballRadius;
|
||||
setConfigForScene(next.config);
|
||||
sound?.setConfig?.(config.sounds);
|
||||
ui.setBackdrop(config.backdrop, config.palette);
|
||||
ui.setFpsVisibility(config.showFps);
|
||||
ui.setMessageDefaults(config.messages);
|
||||
@@ -613,6 +639,7 @@
|
||||
updateHud,
|
||||
checkWinCondition,
|
||||
ui,
|
||||
sound,
|
||||
});
|
||||
|
||||
const createInput = load("PhysilinksInput", { create: "create" });
|
||||
@@ -633,6 +660,7 @@
|
||||
addToChain,
|
||||
finishChain,
|
||||
updateHud,
|
||||
sound,
|
||||
});
|
||||
|
||||
const buildLegend = () => {
|
||||
|
||||
354
src/sounds.js
Normal file
354
src/sounds.js
Normal file
@@ -0,0 +1,354 @@
|
||||
(() => {
|
||||
const supportsAudio = () => typeof Audio !== "undefined";
|
||||
const supportsWebAudio = () =>
|
||||
typeof AudioContext !== "undefined" ||
|
||||
typeof webkitAudioContext !== "undefined";
|
||||
|
||||
const DEFAULT_SOUND_CONFIG = {
|
||||
enabled: true,
|
||||
basePath: "./assets/sfx",
|
||||
channels: {
|
||||
buzz: {
|
||||
mode: "procedural",
|
||||
procedural: "buzz",
|
||||
file: "drag-buzz.ogg",
|
||||
volume: 0.06,
|
||||
},
|
||||
click: {
|
||||
mode: "procedural",
|
||||
procedural: "click",
|
||||
file: "link-click.ogg",
|
||||
volume: 0.18,
|
||||
},
|
||||
pop: {
|
||||
mode: "procedural",
|
||||
procedural: "pop",
|
||||
file: "clear-pop.ogg",
|
||||
volume: 0.22,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createAudio = (src, { volume = 1, loop = false, rate = 1 } = {}) => {
|
||||
const audio = new Audio(src);
|
||||
audio.preload = "auto";
|
||||
audio.loop = loop;
|
||||
audio.volume = volume;
|
||||
audio.playbackRate = rate;
|
||||
return audio;
|
||||
};
|
||||
|
||||
const createPoolPlayer = (src, { volume = 1, rate = 1, max = 6 } = {}) => {
|
||||
const pool = [];
|
||||
return () => {
|
||||
if (!supportsAudio()) return;
|
||||
let audio = pool.find((item) => item.paused || item.ended);
|
||||
if (!audio) {
|
||||
if (pool.length >= max) return;
|
||||
audio = createAudio(src, { volume, rate });
|
||||
pool.push(audio);
|
||||
}
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(() => {});
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSoundConfig = (sounds = {}) => {
|
||||
const defaultChannels = DEFAULT_SOUND_CONFIG.channels;
|
||||
const overrideChannels = sounds.channels || {};
|
||||
const mergedChannels = { ...defaultChannels };
|
||||
Object.keys(overrideChannels).forEach((key) => {
|
||||
mergedChannels[key] = {
|
||||
...(defaultChannels[key] || {}),
|
||||
...(overrideChannels[key] || {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...DEFAULT_SOUND_CONFIG,
|
||||
...sounds,
|
||||
channels: mergedChannels,
|
||||
};
|
||||
};
|
||||
|
||||
const create = ({ sounds = {} } = {}) => {
|
||||
let unlocked = false;
|
||||
let buzzing = false;
|
||||
let buzzLoop = null;
|
||||
let audioCtx = null;
|
||||
let masterGain = null;
|
||||
let buzzNodes = null;
|
||||
let soundConfig = normalizeSoundConfig(sounds);
|
||||
const samplePlayers = new Map();
|
||||
let sampleBuzzKey = null;
|
||||
|
||||
const ensureContext = () => {
|
||||
if (!supportsWebAudio()) return false;
|
||||
if (audioCtx) return true;
|
||||
const Ctx =
|
||||
typeof AudioContext !== "undefined" ? AudioContext : webkitAudioContext;
|
||||
audioCtx = new Ctx();
|
||||
masterGain = audioCtx.createGain();
|
||||
masterGain.gain.value = 0.35;
|
||||
masterGain.connect(audioCtx.destination);
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveSampleSrc = (file) => {
|
||||
if (!file) return null;
|
||||
if (file.includes("/") || file.startsWith("http")) {
|
||||
return file;
|
||||
}
|
||||
return `${soundConfig.basePath}/${file}`;
|
||||
};
|
||||
|
||||
const getSamplePlayer = (
|
||||
channelKey,
|
||||
{ file, volume = 1, rate = 1, max = 6 } = {},
|
||||
) => {
|
||||
const src = resolveSampleSrc(file);
|
||||
if (!src) return null;
|
||||
const key = `${channelKey}|${src}|${volume}|${rate}|${max}`;
|
||||
if (!samplePlayers.has(key)) {
|
||||
samplePlayers.set(
|
||||
key,
|
||||
createPoolPlayer(src, { volume, rate, max }),
|
||||
);
|
||||
}
|
||||
return samplePlayers.get(key);
|
||||
};
|
||||
|
||||
const ensureBuzzLoop = ({ file, volume = 0.06, rate = 1 } = {}) => {
|
||||
if (!supportsAudio()) return;
|
||||
const src = resolveSampleSrc(file);
|
||||
if (!src) return;
|
||||
const key = `${src}|${volume}|${rate}`;
|
||||
if (buzzLoop && sampleBuzzKey === key) return;
|
||||
if (buzzLoop) {
|
||||
buzzLoop.pause();
|
||||
}
|
||||
buzzLoop = createAudio(src, {
|
||||
volume,
|
||||
loop: true,
|
||||
rate,
|
||||
});
|
||||
sampleBuzzKey = key;
|
||||
};
|
||||
|
||||
const resumeContext = () => {
|
||||
if (!audioCtx || audioCtx.state !== "suspended") return;
|
||||
audioCtx.resume().catch(() => {});
|
||||
};
|
||||
|
||||
const createNoiseBuffer = (durationSec) => {
|
||||
const buffer = audioCtx.createBuffer(
|
||||
1,
|
||||
durationSec * audioCtx.sampleRate,
|
||||
audioCtx.sampleRate,
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
data[i] = (Math.random() * 2 - 1) * 0.9;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const playClickProcedural = (volume = 0.12) => {
|
||||
if (!ensureContext()) return;
|
||||
resumeContext();
|
||||
const now = audioCtx.currentTime;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(860, now);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, now + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08);
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.1);
|
||||
};
|
||||
|
||||
const playPopProcedural = (volume = 0.2) => {
|
||||
if (!ensureContext()) return;
|
||||
resumeContext();
|
||||
const now = audioCtx.currentTime;
|
||||
const duration = 0.18;
|
||||
const buffer = createNoiseBuffer(duration);
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const filter = audioCtx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(1200, now);
|
||||
const gain = audioCtx.createGain();
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, now + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
source.start(now);
|
||||
source.stop(now + duration);
|
||||
};
|
||||
|
||||
const startBuzzProcedural = (volume = 0.02) => {
|
||||
if (!ensureContext()) return;
|
||||
resumeContext();
|
||||
const now = audioCtx.currentTime;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const lfo = audioCtx.createOscillator();
|
||||
const lfoGain = audioCtx.createGain();
|
||||
const gate = audioCtx.createGain();
|
||||
const gateLfo = audioCtx.createOscillator();
|
||||
const gateLfoGain = audioCtx.createGain();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = "sawtooth";
|
||||
osc.frequency.setValueAtTime(120, now);
|
||||
lfo.type = "sine";
|
||||
lfo.frequency.setValueAtTime(0.8, now);
|
||||
lfoGain.gain.setValueAtTime(0.015, now);
|
||||
gate.gain.setValueAtTime(0.0001, now);
|
||||
gateLfo.type = "sine";
|
||||
gateLfo.frequency.setValueAtTime(0.22, now);
|
||||
gateLfoGain.gain.setValueAtTime(0.5, now);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, now + 0.2);
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(gain.gain);
|
||||
gateLfo.connect(gateLfoGain);
|
||||
gateLfoGain.connect(gate.gain);
|
||||
osc.connect(gain);
|
||||
gain.connect(gate);
|
||||
gate.connect(masterGain);
|
||||
osc.start(now);
|
||||
lfo.start(now);
|
||||
gateLfo.start(now);
|
||||
buzzNodes = { osc, lfo, gain, gate, gateLfo };
|
||||
};
|
||||
|
||||
const stopBuzzProcedural = () => {
|
||||
if (!buzzNodes) return;
|
||||
const now = audioCtx.currentTime;
|
||||
buzzNodes.gain.gain.cancelScheduledValues(now);
|
||||
buzzNodes.gain.gain.setValueAtTime(buzzNodes.gain.gain.value, now);
|
||||
buzzNodes.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
buzzNodes.gate.gain.cancelScheduledValues(now);
|
||||
buzzNodes.gate.gain.setValueAtTime(buzzNodes.gate.gain.value, now);
|
||||
buzzNodes.gate.gain.exponentialRampToValueAtTime(0.0001, now + 0.05);
|
||||
buzzNodes.osc.stop(now + 0.06);
|
||||
buzzNodes.lfo.stop(now + 0.06);
|
||||
buzzNodes.gateLfo.stop(now + 0.06);
|
||||
buzzNodes = null;
|
||||
};
|
||||
|
||||
const unlock = () => {
|
||||
unlocked = true;
|
||||
if (!soundConfig.enabled) return;
|
||||
if (!ensureContext()) return;
|
||||
resumeContext();
|
||||
};
|
||||
|
||||
const playClick = () => {
|
||||
const channel = soundConfig.channels.click || {};
|
||||
if (!soundConfig.enabled || !unlocked || channel.mode === "off") return;
|
||||
if (channel.mode === "procedural" && supportsWebAudio()) {
|
||||
const procKey = channel.procedural || "click";
|
||||
if (procKey === "click") {
|
||||
playClickProcedural(channel.volume ?? 0.12);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (channel.mode === "sample") {
|
||||
const player = getSamplePlayer("click", {
|
||||
file: channel.file,
|
||||
volume: channel.volume ?? 0.18,
|
||||
max: 8,
|
||||
});
|
||||
player?.();
|
||||
}
|
||||
};
|
||||
|
||||
const playPop = () => {
|
||||
const channel = soundConfig.channels.pop || {};
|
||||
if (!soundConfig.enabled || !unlocked || channel.mode === "off") return;
|
||||
if (channel.mode === "procedural" && supportsWebAudio()) {
|
||||
const procKey = channel.procedural || "pop";
|
||||
if (procKey === "pop") {
|
||||
playPopProcedural(channel.volume ?? 0.2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (channel.mode === "sample") {
|
||||
const player = getSamplePlayer("pop", {
|
||||
file: channel.file,
|
||||
volume: channel.volume ?? 0.22,
|
||||
max: 8,
|
||||
});
|
||||
player?.();
|
||||
}
|
||||
};
|
||||
|
||||
const startDrag = () => {
|
||||
const channel = soundConfig.channels.buzz || {};
|
||||
if (!soundConfig.enabled || !unlocked || buzzing) return;
|
||||
if (channel.mode === "off") return;
|
||||
buzzing = true;
|
||||
if (channel.mode === "procedural" && supportsWebAudio()) {
|
||||
const procKey = channel.procedural || "buzz";
|
||||
if (procKey === "buzz") {
|
||||
startBuzzProcedural(channel.volume ?? 0.05);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (channel.mode === "sample") {
|
||||
ensureBuzzLoop({
|
||||
file: channel.file,
|
||||
volume: channel.volume ?? 0.06,
|
||||
});
|
||||
if (!buzzLoop) {
|
||||
buzzing = false;
|
||||
return;
|
||||
}
|
||||
buzzLoop.currentTime = 0;
|
||||
buzzLoop.play().catch(() => {
|
||||
buzzing = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopDrag = () => {
|
||||
if (!buzzing) return;
|
||||
buzzing = false;
|
||||
const channel = soundConfig.channels.buzz || {};
|
||||
if (channel.mode === "procedural" && supportsWebAudio()) {
|
||||
stopBuzzProcedural();
|
||||
return;
|
||||
}
|
||||
if (!buzzLoop) return;
|
||||
buzzLoop.pause();
|
||||
buzzLoop.currentTime = 0;
|
||||
};
|
||||
|
||||
const setConfig = (nextSounds = {}) => {
|
||||
soundConfig = normalizeSoundConfig(nextSounds);
|
||||
samplePlayers.clear();
|
||||
sampleBuzzKey = null;
|
||||
if (buzzLoop) {
|
||||
buzzLoop.pause();
|
||||
buzzLoop = null;
|
||||
}
|
||||
if (!soundConfig.enabled) stopDrag();
|
||||
};
|
||||
|
||||
return {
|
||||
unlock,
|
||||
playClick,
|
||||
playPop,
|
||||
startDrag,
|
||||
stopDrag,
|
||||
setConfig,
|
||||
};
|
||||
};
|
||||
|
||||
window.PhysilinksSound = { create };
|
||||
})();
|
||||
Reference in New Issue
Block a user