sound effects

This commit is contained in:
2025-12-27 20:59:48 +02:00
parent 542b3eb6b3
commit 2a977ddec4
2 changed files with 140 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react'
import { useStore } from '../store'
import { playKeyClick, playDelete, playComplete, playHint, playWordComplete } from '../sounds'
export function useSounds() {
const prevEntries = useRef<string[][] | null>(null)
const prevSolved = useRef(false)
const prevHints = useRef(0)
useEffect(() => {
const unsub = useStore.subscribe((state) => {
if (!state.puzzle) return
// letter entry or deletion
if (prevEntries.current && state.entries !== prevEntries.current) {
for (let r = 0; r < state.entries.length; r++) {
for (let c = 0; c < state.entries[r].length; c++) {
const cur = state.entries[r][c]
const old = prevEntries.current[r]?.[c] || ''
if (cur && !old) {
playKeyClick()
// word completion check
const span = state.wordSpans.find(s =>
s.cells.some(([sr, sc]) => sr === r && sc === c)
)
if (span) {
const allFilled = span.cells.every(([wr, wc]) => state.entries[wr]?.[wc])
if (allFilled) {
const wasFilled = span.cells.every(([wr, wc]) => prevEntries.current?.[wr]?.[wc])
if (!wasFilled) setTimeout(() => playWordComplete(), 50)
}
}
break
}
if (!cur && old) {
playDelete()
break
}
}
}
}
prevEntries.current = state.entries
// solved
if (state.solved && !prevSolved.current) {
playComplete()
}
prevSolved.current = state.solved
// hints
if (state.hintsUsed > prevHints.current) {
playHint()
}
prevHints.current = state.hintsUsed
})
return unsub
}, [])
}
+81
View File
@@ -0,0 +1,81 @@
import { useStore } from './store'
let ctx: AudioContext | null = null
function getCtx(): AudioContext | null {
if (ctx && ctx.state !== 'closed') return ctx
try { ctx = new AudioContext() } catch { return null }
return ctx
}
function isSoundEnabled(): boolean {
return useStore.getState().narratorSettings.sounds
}
function tone(freq: number, duration: number, type: OscillatorType = 'sine', gain = 0.15) {
if (!isSoundEnabled()) return
const ac = getCtx()
if (!ac) return
if (ac.state === 'suspended') ac.resume()
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = type
osc.frequency.value = freq
g.gain.value = gain
g.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + duration / 1000)
osc.connect(g).connect(ac.destination)
osc.start()
osc.stop(ac.currentTime + duration / 1000)
}
export function playKeyClick() {
tone(1200, 50, 'sine', 0.08)
}
export function playDelete() {
tone(400, 50, 'sine', 0.08)
}
export function playComplete() {
if (!isSoundEnabled()) return
const ac = getCtx()
if (!ac) return
if (ac.state === 'suspended') ac.resume()
const notes = [523, 659, 784]
notes.forEach((freq, i) => {
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'sine'
osc.frequency.value = freq
g.gain.value = 0.15
const start = ac.currentTime + i * 0.1
g.gain.exponentialRampToValueAtTime(0.001, start + 0.15)
osc.connect(g).connect(ac.destination)
osc.start(start)
osc.stop(start + 0.15)
})
}
export function playHint() {
tone(660, 100, 'sine', 0.1)
}
export function playWordComplete() {
if (!isSoundEnabled()) return
const ac = getCtx()
if (!ac) return
if (ac.state === 'suspended') ac.resume()
const notes = [587, 784]
notes.forEach((freq, i) => {
const osc = ac.createOscillator()
const g = ac.createGain()
osc.type = 'sine'
osc.frequency.value = freq
g.gain.value = 0.12
const start = ac.currentTime + i * 0.1
g.gain.exponentialRampToValueAtTime(0.001, start + 0.12)
osc.connect(g).connect(ac.destination)
osc.start(start)
osc.stop(start + 0.12)
})
}