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:
Moritz Graf
2026-09-23 21:56:34 +02:00
parent d11a84a0d3
commit 5852479c21
53 changed files with 5544 additions and 0 deletions
@@ -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()