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,258 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
const canvasRef = ref<HTMLCanvasElement | null>(null)
let animId: number | null = null
let wagonX = 60
let wagonY = 220
let speed = 3.5
let distance = 0
const maxDistance = 2000
interface Guest {
x: number
name: string
served: boolean
avatarColor: string
}
let guests: Guest[] = [
{ x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ x: 800, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ x: 1050, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ x: 1300, name: 'Maier Flo (Ton) 🎧', served: false, avatarColor: '#7c3aed' },
{ x: 1550, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ x: 1800, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' }
]
function throwBeer() {
sound.playGulp()
// Check if any guest is near
guests.forEach(g => {
if (!g.served && Math.abs(distance + wagonX - g.x) < 80) {
g.served = true
store.addBeer()
store.addKronkorken(3)
}
})
}
function updateGame() {
distance += speed
if (distance >= maxDistance) {
store.setAct('finale')
return
}
}
function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.clearRect(0, 0, w, h)
// Sunset Party Sky
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#4a154b')
grad.addColorStop(0.5, '#993355')
grad.addColorStop(1, '#ffaa44')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
// Distant party silhouettes with laser beams
const now = Date.now() * 0.003
ctx.strokeStyle = `hsl(${(now * 50) % 360}, 100%, 70%)`
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.sin(now), h)
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.cos(now), h)
ctx.stroke()
// Cobblestone road
ctx.fillStyle = '#292524'
ctx.fillRect(0, 240, w, 80)
ctx.fillStyle = '#44403c'
for (let r = 0; r < w; r += 30) {
ctx.fillRect((r - (distance % 30)), 240, 26, 4)
}
// Draw Guests along the road
guests.forEach(g => {
const screenX = g.x - distance
if (screenX > -50 && screenX < w + 50) {
// Guest body
ctx.fillStyle = g.avatarColor
ctx.fillRect(screenX, 195, 20, 45)
// Head
ctx.fillStyle = '#fed7aa'
ctx.beginPath()
ctx.arc(screenX + 10, 185, 10, 0, Math.PI * 2)
ctx.fill()
// Name & icon tag
ctx.fillStyle = '#fff'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.textAlign = 'center'
ctx.fillText(g.name, screenX + 10, 168)
if (g.served) {
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 12px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 154)
} else {
ctx.fillStyle = '#fbbf24'
ctx.font = '10px monospace'
ctx.fillText('DURST!', screenX + 10, 154)
}
}
})
// Draw Royal Party Wagon (Bollerwagen)
const wx = wagonX
const wy = wagonY
const bob = Math.sin(Date.now() * 0.015) * 2
// Wagon wooden bed
ctx.fillStyle = '#78350f'
ctx.fillRect(wx - 20, wy + bob, 90, 24)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx - 18, wy + bob + 3, 86, 18)
// Wheels
ctx.fillStyle = '#451a03'
ctx.beginPath()
ctx.arc(wx - 6, wy + 24, 10, 0, Math.PI * 2)
ctx.arc(wx + 54, wy + 24, 10, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 2
ctx.stroke()
// Sascha standing on wagon with crown, sunglasses & beer mug
ctx.fillStyle = '#dc2626' // Cape
ctx.fillRect(wx + 10, wy - 30 + bob, 22, 34)
ctx.fillStyle = '#1d4ed8' // Tunic
ctx.fillRect(wx + 14, wy - 28 + bob, 16, 22)
ctx.fillStyle = '#fcd34d' // Face
ctx.fillRect(wx + 15, wy - 42 + bob, 14, 14)
// Sunglasses
ctx.fillStyle = '#0f172a'
ctx.fillRect(wx + 17, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 23, wy - 38 + bob, 5, 3)
// Crown
ctx.fillStyle = '#facc15'
ctx.fillRect(wx + 14, wy - 48 + bob, 16, 6)
// Giant Bass Speaker on the back of the wagon
ctx.fillStyle = '#18181b'
ctx.fillRect(wx - 16, wy - 34 + bob, 24, 34)
ctx.fillStyle = '#f43f5e'
ctx.beginPath()
ctx.arc(wx - 4, wy - 18 + bob, 8 + Math.abs(Math.sin(Date.now() * 0.02) * 3), 0, Math.PI * 2)
ctx.fill()
// Hof-DJs Banner
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px "Press Start 2P", monospace'
ctx.textAlign = 'left'
ctx.fillText('135 BPM EXPRESS', 10, 25)
// Progress Bar
const progress = Math.min(1, distance / maxDistance)
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(10, 35, w - 20, 8)
ctx.fillStyle = '#22c55e'
ctx.fillRect(10, 35, (w - 20) * progress, 8)
}
function loop() {
updateGame()
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) draw(ctx, canvasRef.value.width, canvasRef.value.height)
}
animId = requestAnimationFrame(loop)
}
onMounted(() => {
animId = requestAnimationFrame(loop)
})
onUnmounted(() => {
if (animId) cancelAnimationFrame(animId)
})
</script>
<template>
<div class="express-container">
<canvas ref="canvasRef" width="360" height="320" class="game-canvas"></canvas>
<div class="express-controls">
<button class="throw-beer-btn" @click="throwBeer">
🍺 BIER AN DIE GÄSTE SCHENKEN!
</button>
<span class="express-hint">Tippe, wenn du an deinen Freunden vorbeifährst!</span>
</div>
</div>
</template>
<style scoped>
.express-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
flex-direction: column;
}
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.express-controls {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 20;
}
.throw-beer-btn {
background: #eab308;
color: #1a0f0a;
border: 2px solid #fff;
border-radius: 8px;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
padding: 10px 14px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.express-hint {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
text-shadow: 1px 1px 2px #000;
}
</style>