feat(sasch40): polish retro game layout, clean tavern scene, and improve mobile HUD

- Hide GameBoy D-Pad and action buttons during RPG and dialogue modes
- Add prequel intro landing screen for QR code arrivals with Haumdaucher lore
- Clean baked-in text from tavern scene background and streamline navigation bar
- Add safe-zone margins and HTML HUD in platformer and party express acts
- Optimize Certificate modal layout and scoring breakdown for mobile screens
- Add automated end-to-end browser playtest script with Puppeteer
- Accelerate Docker build via native pre-compilation
This commit is contained in:
Moritz Graf
2026-09-24 07:55:42 +02:00
parent 5852479c21
commit f6d39f01da
13 changed files with 998 additions and 202 deletions
@@ -8,12 +8,13 @@ const canvasRef = ref<HTMLCanvasElement | null>(null)
let animId: number | null = null
let wagonX = 60
let wagonY = 220
let wagonY = 330
let speed = 3.5
let distance = 0
const maxDistance = 2000
interface Guest {
id: string
x: number
name: string
served: boolean
@@ -21,23 +22,22 @@ interface Guest {
}
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' }
{ id: 'hansi', x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ id: 'viktor', x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ id: 'albin', x: 800, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ id: 'fabian', x: 1050, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ id: 'maierflo', x: 1300, name: 'Maier Flo (Ton) 🎧', served: false, avatarColor: '#7c3aed' },
{ id: 'guate', x: 1550, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ id: 'markus_nat', 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) {
if (!g.served && Math.abs(distance + wagonX - g.x) < 90) {
g.served = true
store.addBeer()
store.addKronkorken(3)
store.serveGuest()
}
})
}
@@ -56,58 +56,111 @@ function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
// Sunset Party Sky
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#4a154b')
grad.addColorStop(0.5, '#993355')
grad.addColorStop(1, '#ffaa44')
grad.addColorStop(0, '#31103f')
grad.addColorStop(0.5, '#7a2048')
grad.addColorStop(0.8, '#c2410c')
grad.addColorStop(1, '#f59e0b')
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.strokeStyle = `hsl(${(now * 60) % 360}, 100%, 75%)`
ctx.lineWidth = 2
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.moveTo(w / 2, 100)
ctx.lineTo(w * ((Math.sin(now) + 1) / 2), 350)
ctx.moveTo(w / 2, 100)
ctx.lineTo(w * ((Math.cos(now) + 1) / 2), 350)
ctx.stroke()
// Cobblestone road
ctx.fillStyle = '#292524'
ctx.fillRect(0, 240, w, 80)
ctx.fillRect(0, 350, w, 130)
ctx.fillStyle = '#44403c'
for (let r = 0; r < w; r += 30) {
ctx.fillRect((r - (distance % 30)), 240, 26, 4)
for (let r = 0; r < w + 30; r += 30) {
ctx.fillRect((r - (distance % 30)), 350, 24, 6)
}
// Draw Guests along the road
guests.forEach(g => {
const screenX = g.x - distance
if (screenX > -50 && screenX < w + 50) {
if (screenX > -60 && screenX < w + 60) {
// Custom sprite details per guest
if (g.id === 'viktor') {
// Vampire cape
ctx.fillStyle = '#4c0519'
ctx.fillRect(screenX - 4, 305, 28, 42)
}
// Guest body
ctx.fillStyle = g.avatarColor
ctx.fillRect(screenX, 195, 20, 45)
ctx.fillRect(screenX, 305, 20, 45)
// Head
ctx.fillStyle = '#fed7aa'
ctx.beginPath()
ctx.arc(screenX + 10, 185, 10, 0, Math.PI * 2)
ctx.arc(screenX + 10, 295, 10, 0, Math.PI * 2)
ctx.fill()
// Character specifics
if (g.id === 'hansi') {
// Moustache
ctx.fillStyle = '#451a03'
ctx.fillRect(screenX + 6, 296, 8, 2.5)
} else if (g.id === 'viktor') {
// Fangs
ctx.fillStyle = '#ffffff'
ctx.fillRect(screenX + 8, 297, 2, 3)
ctx.fillRect(screenX + 12, 297, 2, 3)
} else if (g.id === 'albin') {
// Gym Headband
ctx.fillStyle = '#38bdf8'
ctx.fillRect(screenX + 2, 288, 16, 3)
} else if (g.id === 'fabian') {
// Beer mug in hand
ctx.fillStyle = '#f59e0b'
ctx.fillRect(screenX + 20, 312, 8, 10)
ctx.fillStyle = '#ffffff'
ctx.fillRect(screenX + 19, 309, 10, 3)
} else if (g.id === 'maierflo') {
// Headphones
ctx.strokeStyle = '#c084fc'
ctx.lineWidth = 2.5
ctx.beginPath()
ctx.arc(screenX + 10, 295, 12, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = '#9333ea'
ctx.fillRect(screenX - 3, 293, 4, 6)
ctx.fillRect(screenX + 19, 293, 4, 6)
} else if (g.id === 'guate') {
// Bushy dark beard
ctx.fillStyle = '#1c1917'
ctx.fillRect(screenX + 4, 297, 12, 9)
} else if (g.id === 'markus_nat') {
// Second head for couple
ctx.fillStyle = '#fbcfe8'
ctx.beginPath()
ctx.arc(screenX + 24, 296, 9, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#db2777'
ctx.fillRect(screenX + 16, 307, 16, 43)
}
// Name & icon tag
ctx.fillStyle = '#fff'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.textAlign = 'center'
ctx.fillText(g.name, screenX + 10, 168)
ctx.fillText(g.name, screenX + 10, 278)
if (g.served) {
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 12px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 154)
ctx.font = 'bold 11px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 264)
} else {
ctx.fillStyle = '#fbbf24'
ctx.font = '10px monospace'
ctx.fillText('DURST!', screenX + 10, 154)
ctx.font = '9px "Press Start 2P", monospace'
ctx.fillText('DURST!', screenX + 10, 264)
}
}
})
@@ -119,55 +172,85 @@ function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
// Wagon wooden bed
ctx.fillStyle = '#78350f'
ctx.fillRect(wx - 20, wy + bob, 90, 24)
ctx.fillRect(wx - 20, wy + bob, 95, 24)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx - 18, wy + bob + 3, 86, 18)
ctx.fillRect(wx - 18, wy + bob + 3, 91, 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()
// Wooden spoke wheels
const wheelSpin = (distance * 0.1) % (Math.PI * 2)
;[-6, 60].forEach(wheelOffset => {
ctx.save()
ctx.translate(wx + wheelOffset, wy + 24)
ctx.rotate(wheelSpin)
ctx.fillStyle = '#451a03'
ctx.beginPath()
ctx.arc(0, 0, 11, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 2.5
ctx.stroke()
// Spokes
ctx.strokeStyle = '#b45309'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(-11, 0); ctx.lineTo(11, 0)
ctx.moveTo(0, -11); ctx.lineTo(0, 11)
ctx.stroke()
ctx.restore()
})
// Sascha standing on wagon with crown, sunglasses & beer mug
ctx.fillStyle = '#dc2626' // Cape
ctx.fillRect(wx + 10, wy - 30 + bob, 22, 34)
ctx.fillRect(wx + 12, wy - 30 + bob, 22, 34)
ctx.fillStyle = '#1d4ed8' // Tunic
ctx.fillRect(wx + 14, wy - 28 + bob, 16, 22)
ctx.fillRect(wx + 16, wy - 28 + bob, 16, 22)
ctx.fillStyle = '#fcd34d' // Face
ctx.fillRect(wx + 15, wy - 42 + bob, 14, 14)
ctx.fillRect(wx + 17, 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)
ctx.fillRect(wx + 19, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 25, wy - 38 + bob, 5, 3)
// Crown
ctx.fillStyle = '#facc15'
ctx.fillRect(wx + 14, wy - 48 + bob, 16, 6)
ctx.fillRect(wx + 16, 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)
const bassPulse = Math.abs(Math.sin(Date.now() * 0.02) * 4)
ctx.arc(wx - 4, wy - 18 + bob, 8 + bassPulse, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 1.5
ctx.stroke()
// DJ Console in front
ctx.fillStyle = '#27272a'
ctx.fillRect(wx + 44, wy - 14 + bob, 26, 16)
// Turntable
ctx.fillStyle = '#09090b'
ctx.beginPath()
ctx.arc(wx + 57, wy - 6 + bob, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ef4444'
ctx.fillRect(wx + 56, wy - 7 + bob, 2, 2)
// Hof-DJs Banner
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px "Press Start 2P", monospace'
ctx.textAlign = 'left'
ctx.fillText('135 BPM EXPRESS', 10, 25)
ctx.font = 'bold 8.5px "Press Start 2P", monospace'
ctx.textAlign = 'center'
ctx.fillText('🎧 135 BPM PARTY EXPRESS 🎧', w / 2, 24)
// Progress Bar
const progress = Math.min(1, distance / maxDistance)
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(10, 35, w - 20, 8)
const barMargin = 24
const barWidth = w - barMargin * 2
ctx.fillStyle = 'rgba(0,0,0,0.6)'
ctx.fillRect(barMargin, 34, barWidth, 8)
ctx.fillStyle = '#22c55e'
ctx.fillRect(10, 35, (w - 20) * progress, 8)
ctx.fillRect(barMargin, 34, barWidth * progress, 8)
}
function loop() {
@@ -190,7 +273,7 @@ onUnmounted(() => {
<template>
<div class="express-container">
<canvas ref="canvasRef" width="360" height="320" class="game-canvas"></canvas>
<canvas ref="canvasRef" width="360" height="480" class="game-canvas"></canvas>
<div class="express-controls">
<button class="throw-beer-btn" @click="throwBeer">
@@ -214,7 +297,7 @@ onUnmounted(() => {
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}