feat(sasch40): add retro birthday game subproject and k8s deployment
- Add Vue 3 + Vite + TypeScript retro game: 'Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft' - Add dynamic GameBoy layout: controls visible only during arcade modes, fullscreen during dialogue and tavern - Add QR code welcome & prequel screen with lore, feature preview, and audio hints - Add competitive party highscore and ranking breakdown on royal certificate - Add dedicated individual 1:1 character portraits for Sascha, Verena, NAT, Hansi, Mop, Lutz, Armend, Amy, Mane, Pü, and Mario - Add Dockerfile, Makefile, and K8s deployment at sasch40.haumdaucher.de - Update openclaw timeout configuration
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
// Procedural 8-bit chiptune & medieval techno sound engine using Web Audio API
|
||||
|
||||
class SoundEngine {
|
||||
private ctx: AudioContext | null = null
|
||||
private isMuted: boolean = false
|
||||
private bgmPlaying: boolean = false
|
||||
private bgmTimer: number | null = null
|
||||
private step: number = 0
|
||||
|
||||
private initCtx() {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
this.ctx = new AudioCtx()
|
||||
}
|
||||
if (this.ctx.state === 'suspended') {
|
||||
this.ctx.resume()
|
||||
}
|
||||
}
|
||||
|
||||
public toggleMute(): boolean {
|
||||
this.isMuted = !this.isMuted
|
||||
if (this.isMuted) {
|
||||
this.stopBGM()
|
||||
} else {
|
||||
this.startBGM()
|
||||
}
|
||||
return this.isMuted
|
||||
}
|
||||
|
||||
public getMuted(): boolean {
|
||||
return this.isMuted
|
||||
}
|
||||
|
||||
// SFX: Kronkorken Collect (bright double ding)
|
||||
public playCoin() {
|
||||
if (this.isMuted) return
|
||||
this.initCtx()
|
||||
if (!this.ctx) return
|
||||
|
||||
const now = this.ctx.currentTime
|
||||
const osc = this.ctx.createOscillator()
|
||||
const gain = this.ctx.createGain()
|
||||
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(987.77, now) // B5
|
||||
osc.frequency.setValueAtTime(1318.51, now + 0.08) // E6
|
||||
|
||||
gain.gain.setValueAtTime(0.2, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(this.ctx.destination)
|
||||
|
||||
osc.start(now)
|
||||
osc.stop(now + 0.35)
|
||||
}
|
||||
|
||||
// SFX: Sprung / Jump (arcade sweep up)
|
||||
public playJump() {
|
||||
if (this.isMuted) return
|
||||
this.initCtx()
|
||||
if (!this.ctx) return
|
||||
|
||||
const now = this.ctx.currentTime
|
||||
const osc = this.ctx.createOscillator()
|
||||
const gain = this.ctx.createGain()
|
||||
|
||||
osc.type = 'square'
|
||||
osc.frequency.setValueAtTime(150, now)
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.15)
|
||||
|
||||
gain.gain.setValueAtTime(0.15, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(this.ctx.destination)
|
||||
|
||||
osc.start(now)
|
||||
osc.stop(now + 0.15)
|
||||
}
|
||||
|
||||
// SFX: Beer Gulp / Power-up
|
||||
public playGulp() {
|
||||
if (this.isMuted) return
|
||||
this.initCtx()
|
||||
if (!this.ctx) return
|
||||
|
||||
const now = this.ctx.currentTime
|
||||
const osc = this.ctx.createOscillator()
|
||||
const gain = this.ctx.createGain()
|
||||
|
||||
osc.type = 'sawtooth'
|
||||
osc.frequency.setValueAtTime(400, now)
|
||||
osc.frequency.exponentialRampToValueAtTime(180, now + 0.25)
|
||||
|
||||
gain.gain.setValueAtTime(0.2, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(this.ctx.destination)
|
||||
|
||||
osc.start(now)
|
||||
osc.stop(now + 0.25)
|
||||
}
|
||||
|
||||
// SFX: Dialog blip (Monkey Island text chatter)
|
||||
public playDialogueBlip() {
|
||||
if (this.isMuted) return
|
||||
this.initCtx()
|
||||
if (!this.ctx) return
|
||||
|
||||
const now = this.ctx.currentTime
|
||||
const osc = this.ctx.createOscillator()
|
||||
const gain = this.ctx.createGain()
|
||||
|
||||
osc.type = 'sine'
|
||||
const freqs = [320, 360, 420, 480]
|
||||
const randomFreq = freqs[Math.floor(Math.random() * freqs.length)]
|
||||
osc.frequency.setValueAtTime(randomFreq, now)
|
||||
|
||||
gain.gain.setValueAtTime(0.08, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05)
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(this.ctx.destination)
|
||||
|
||||
osc.start(now)
|
||||
osc.stop(now + 0.05)
|
||||
}
|
||||
|
||||
// SFX: Fanfare / Royal Crowning
|
||||
public playFanfare() {
|
||||
if (this.isMuted) return
|
||||
this.initCtx()
|
||||
if (!this.ctx) return
|
||||
|
||||
const notes = [261.63, 329.63, 392.00, 523.25] // C, E, G, C
|
||||
notes.forEach((freq, idx) => {
|
||||
if (!this.ctx) return
|
||||
const now = this.ctx.currentTime + idx * 0.12
|
||||
const osc = this.ctx.createOscillator()
|
||||
const gain = this.ctx.createGain()
|
||||
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(freq, now)
|
||||
|
||||
gain.gain.setValueAtTime(0.25, now)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + (idx === 3 ? 0.8 : 0.2))
|
||||
|
||||
osc.connect(gain)
|
||||
gain.connect(this.ctx.destination)
|
||||
|
||||
osc.start(now)
|
||||
osc.stop(now + (idx === 3 ? 0.8 : 0.25))
|
||||
})
|
||||
}
|
||||
|
||||
// 135 BPM Medieval Techno Chiptune Beat
|
||||
public startBGM() {
|
||||
if (this.isMuted || this.bgmPlaying) return
|
||||
this.initCtx()
|
||||
this.bgmPlaying = true
|
||||
|
||||
// 135 BPM = 135 beats/min = 2.25 beats/sec -> 16th note = 60 / (135 * 4) = 0.111s
|
||||
const stepDuration = (60 / 135) / 2 // 8th note = 0.222s
|
||||
this.step = 0
|
||||
|
||||
// Medieval Dorian melody scale (D, E, F, G, A, B, C)
|
||||
const melody = [
|
||||
293.66, 0, 349.23, 392.00, // D4, -, F4, G4
|
||||
440.00, 0, 392.00, 349.23, // A4, -, G4, F4
|
||||
392.00, 440.00, 523.25, 440.00, // G4, A4, C5, A4
|
||||
349.23, 0, 293.66, 0 // F4, -, D4, -
|
||||
]
|
||||
|
||||
const bassline = [
|
||||
146.83, 146.83, 146.83, 146.83, // D3
|
||||
174.61, 174.61, 174.61, 174.61, // F3
|
||||
196.00, 196.00, 196.00, 196.00, // G3
|
||||
146.83, 146.83, 220.00, 146.83 // D3, A3
|
||||
]
|
||||
|
||||
const playLoop = () => {
|
||||
if (!this.bgmPlaying || !this.ctx) return
|
||||
const now = this.ctx.currentTime
|
||||
|
||||
// 4-to-the-floor Techno Kick on every even step (quarter note)
|
||||
if (this.step % 2 === 0) {
|
||||
const kickOsc = this.ctx.createOscillator()
|
||||
const kickGain = this.ctx.createGain()
|
||||
kickOsc.frequency.setValueAtTime(140, now)
|
||||
kickOsc.frequency.exponentialRampToValueAtTime(35, now + 0.1)
|
||||
kickGain.gain.setValueAtTime(0.35, now)
|
||||
kickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
|
||||
kickOsc.connect(kickGain)
|
||||
kickGain.connect(this.ctx.destination)
|
||||
kickOsc.start(now)
|
||||
kickOsc.stop(now + 0.18)
|
||||
}
|
||||
|
||||
// Hi-Hat / Shaker on offbeats
|
||||
if (this.step % 2 === 1) {
|
||||
const hatNode = this.ctx.createBufferSource()
|
||||
const bufferSize = this.ctx.sampleRate * 0.04
|
||||
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate)
|
||||
const data = buffer.getChannelData(0)
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1
|
||||
}
|
||||
hatNode.buffer = buffer
|
||||
|
||||
const hatFilter = this.ctx.createBiquadFilter()
|
||||
hatFilter.type = 'highpass'
|
||||
hatFilter.frequency.value = 7000
|
||||
|
||||
const hatGain = this.ctx.createGain()
|
||||
hatGain.gain.setValueAtTime(0.08, now)
|
||||
hatGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04)
|
||||
|
||||
hatNode.connect(hatFilter)
|
||||
hatFilter.connect(hatGain)
|
||||
hatGain.connect(this.ctx.destination)
|
||||
hatNode.start(now)
|
||||
}
|
||||
|
||||
// Bassline (sawtooth filtered)
|
||||
const bassFreq = bassline[this.step % bassline.length]
|
||||
if (bassFreq > 0) {
|
||||
const bassOsc = this.ctx.createOscillator()
|
||||
const bassGain = this.ctx.createGain()
|
||||
const bassFilter = this.ctx.createBiquadFilter()
|
||||
|
||||
bassOsc.type = 'sawtooth'
|
||||
bassOsc.frequency.setValueAtTime(bassFreq, now)
|
||||
|
||||
bassFilter.type = 'lowpass'
|
||||
bassFilter.frequency.setValueAtTime(450, now)
|
||||
|
||||
bassGain.gain.setValueAtTime(0.12, now)
|
||||
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2)
|
||||
|
||||
bassOsc.connect(bassFilter)
|
||||
bassFilter.connect(bassGain)
|
||||
bassGain.connect(this.ctx.destination)
|
||||
|
||||
bassOsc.start(now)
|
||||
bassOsc.stop(now + 0.2)
|
||||
}
|
||||
|
||||
// Medieval Lute Lead Melody
|
||||
const melFreq = melody[this.step % melody.length]
|
||||
if (melFreq > 0) {
|
||||
const melOsc = this.ctx.createOscillator()
|
||||
const melGain = this.ctx.createGain()
|
||||
|
||||
melOsc.type = 'square'
|
||||
melOsc.frequency.setValueAtTime(melFreq, now)
|
||||
|
||||
melGain.gain.setValueAtTime(0.08, now)
|
||||
melGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
|
||||
|
||||
melOsc.connect(melGain)
|
||||
melGain.connect(this.ctx.destination)
|
||||
|
||||
melOsc.start(now)
|
||||
melOsc.stop(now + 0.18)
|
||||
}
|
||||
|
||||
this.step++
|
||||
this.bgmTimer = window.setTimeout(playLoop, stepDuration * 1000)
|
||||
}
|
||||
|
||||
playLoop()
|
||||
}
|
||||
|
||||
public stopBGM() {
|
||||
this.bgmPlaying = false
|
||||
if (this.bgmTimer) {
|
||||
clearTimeout(this.bgmTimer)
|
||||
this.bgmTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sound = new SoundEngine()
|
||||
Reference in New Issue
Block a user