Files
infrapuzzle/projects/sasch40/frontend/src/components/PlatformerView.vue
T
Moritz Graf 5852479c21 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
2026-09-23 21:56:34 +02:00

543 lines
13 KiB
Vue

<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 animationFrameId: number | null = null
// Game state
interface Platform {
x: number
y: number
w: number
h: number
type: 'roof' | 'barrel' | 'wood'
}
interface Item {
x: number
y: number
type: 'cap' | 'beer'
collected: boolean
bobOffset: number
}
interface Particle {
x: number
y: number
vx: number
vy: number
life: number
color: string
}
let player = {
x: 60,
y: 200,
w: 32,
h: 48,
vx: 0,
vy: 0,
isGrounded: false,
jumpCount: 0,
frame: 0
}
let cameraX = 0
let levelWidth = 2800
let platforms: Platform[] = []
let items: Item[] = []
let particles: Particle[] = []
// Input state
let keys = {
left: false,
right: false,
jump: false
}
// Generate level
function initLevel() {
platforms = [
// Ground base
{ x: 0, y: 280, w: 700, h: 50, type: 'roof' },
{ x: 780, y: 280, w: 600, h: 50, type: 'roof' },
{ x: 1460, y: 280, w: 700, h: 50, type: 'roof' },
{ x: 2240, y: 280, w: 600, h: 50, type: 'roof' },
// Platforms
{ x: 180, y: 210, w: 100, h: 18, type: 'wood' },
{ x: 340, y: 160, w: 120, h: 18, type: 'wood' },
{ x: 520, y: 210, w: 90, h: 18, type: 'barrel' },
{ x: 860, y: 200, w: 110, h: 18, type: 'wood' },
{ x: 1040, y: 150, w: 130, h: 18, type: 'wood' },
{ x: 1220, y: 210, w: 80, h: 18, type: 'barrel' },
{ x: 1560, y: 190, w: 120, h: 18, type: 'wood' },
{ x: 1740, y: 140, w: 140, h: 18, type: 'wood' },
{ x: 1950, y: 200, w: 90, h: 18, type: 'barrel' },
{ x: 2320, y: 190, w: 130, h: 18, type: 'wood' },
{ x: 2500, y: 140, w: 160, h: 18, type: 'wood' }
]
items = []
// Scatter 35 bottle caps and 5 beers
for (let i = 0; i < 35; i++) {
const px = 120 + i * 72
const py = 120 + (i % 3) * 45
items.push({
x: px,
y: py,
type: 'cap',
collected: false,
bobOffset: Math.random() * Math.PI * 2
})
}
// A few Helles beers for big boost
;[450, 1100, 1800, 2450].forEach(bx => {
items.push({
x: bx,
y: 110,
type: 'beer',
collected: false,
bobOffset: 0
})
})
}
// Controller hooks for GameBoy buttons
function onJump() {
if (player.isGrounded || player.jumpCount < 2) {
player.vy = -9.2
player.jumpCount++
player.isGrounded = false
sound.playJump()
// Jump dust particles
for (let i = 0; i < 6; i++) {
particles.push({
x: player.x + player.w / 2,
y: player.y + player.h,
vx: (Math.random() - 0.5) * 3,
vy: (Math.random() - 0.5) * 2,
life: 20,
color: '#d4af37'
})
}
}
}
function onLeft(pressed: boolean) {
keys.left = pressed
}
function onRight(pressed: boolean) {
keys.right = pressed
}
// Expose to parent GameBoy console
defineExpose({
onJump,
onLeft,
onRight
})
function updatePhysics() {
// Horizontal movement (auto slight forward jog + manual control)
const baseSpeed = 2.2
if (keys.right) {
player.vx = baseSpeed + 2.5
} else if (keys.left) {
player.vx = -baseSpeed - 1.5
} else {
// gentle casual auto-scroll jog
player.vx = baseSpeed
}
player.x += player.vx
// Gravity
player.vy += 0.42
if (player.vy > 10) player.vy = 10
player.y += player.vy
player.isGrounded = false
// Platform collision
for (const plat of platforms) {
if (
player.x + player.w > plat.x &&
player.x < plat.x + plat.w &&
player.y + player.h >= plat.y &&
player.y + player.h <= plat.y + 14 &&
player.vy >= 0
) {
player.y = plat.y - player.h
player.vy = 0
player.isGrounded = true
player.jumpCount = 0
}
}
// Prevent falling to oblivion (respawn smoothly with cheer)
if (player.y > 360) {
player.y = 150
player.vy = 0
player.x = Math.max(60, player.x - 120)
}
// Camera tracking
cameraX = player.x - 80
// Item pickup
items.forEach(item => {
if (item.collected) return
const dx = (player.x + player.w / 2) - item.x
const dy = (player.y + player.h / 2) - item.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 26) {
item.collected = true
if (item.type === 'cap') {
store.addKronkorken(1)
} else {
store.addBeer()
}
// Sparkles
for (let p = 0; p < 8; p++) {
particles.push({
x: item.x,
y: item.y,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
life: 25,
color: item.type === 'cap' ? '#ffd700' : '#f59e0b'
})
}
}
})
// Particles update
particles.forEach(p => {
p.x += p.vx
p.y += p.vy
p.life--
})
particles = particles.filter(p => p.life > 0)
// Level completion check (reaching the Taverne gate)
if (player.x >= levelWidth - 100) {
store.setAct('tavern')
}
player.frame++
}
function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number) {
ctx.clearRect(0, 0, width, height)
// Sky gradient
const skyGrad = ctx.createLinearGradient(0, 0, 0, height)
skyGrad.addColorStop(0, '#3a7bd5')
skyGrad.addColorStop(0.65, '#f5af19')
skyGrad.addColorStop(1, '#e65c00')
ctx.fillStyle = skyGrad
ctx.fillRect(0, 0, width, height)
// Parallax mountains & castle silhouette
ctx.fillStyle = 'rgba(74, 35, 90, 0.45)'
for (let m = -1; m < 5; m++) {
const mx = m * 240 - (cameraX * 0.2) % 240
ctx.beginPath()
ctx.moveTo(mx, 260)
ctx.lineTo(mx + 120, 110)
ctx.lineTo(mx + 240, 260)
ctx.fill()
}
// Castle towers in background
ctx.fillStyle = 'rgba(44, 24, 16, 0.65)'
for (let c = -1; c < 4; c++) {
const cx = c * 320 - (cameraX * 0.35) % 320
ctx.fillRect(cx + 60, 120, 50, 140)
ctx.beginPath()
ctx.moveTo(cx + 50, 120)
ctx.lineTo(cx + 85, 80)
ctx.lineTo(cx + 120, 120)
ctx.fill()
}
ctx.save()
ctx.translate(-cameraX, 0)
// Draw platforms
for (const plat of platforms) {
if (plat.type === 'roof') {
ctx.fillStyle = '#b91c1c' // Red roof tiles
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
// Roof trim
ctx.fillStyle = '#7f1d1d'
ctx.fillRect(plat.x, plat.y, plat.w, 4)
} else if (plat.type === 'wood') {
ctx.fillStyle = '#854d0e' // Timber wood platform
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
ctx.fillStyle = '#713f12'
ctx.fillRect(plat.x, plat.y, plat.w, 3)
// Support beams
ctx.fillRect(plat.x + 8, plat.y + plat.h, 6, 40)
ctx.fillRect(plat.x + plat.w - 14, plat.y + plat.h, 6, 40)
} else {
// Beer barrel
ctx.fillStyle = '#92400e'
ctx.beginPath()
ctx.ellipse(plat.x + plat.w / 2, plat.y + plat.h / 2, plat.w / 2, plat.h / 2, 0, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#fef08a'
ctx.lineWidth = 2
ctx.stroke()
}
}
// Draw Items (Kronkorken & Beer)
const time = Date.now() * 0.005
for (const item of items) {
if (item.collected) continue
const bob = Math.sin(time + item.bobOffset) * 5
if (item.type === 'cap') {
// Golden Kronkorken with serrated edge & crown
ctx.save()
ctx.translate(item.x, item.y + bob)
ctx.fillStyle = '#ffd700'
ctx.beginPath()
ctx.arc(0, 0, 9, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#b45309'
ctx.lineWidth = 2
ctx.stroke()
// Star / 40 embossing
ctx.fillStyle = '#78350f'
ctx.font = 'bold 8px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('40', 0, 0)
ctx.restore()
} else {
// Helles Beer Mug with white foam
ctx.save()
ctx.translate(item.x, item.y + bob)
ctx.fillStyle = '#f59e0b'
ctx.fillRect(-6, -6, 12, 14)
ctx.fillStyle = '#ffffff'
ctx.fillRect(-7, -9, 14, 4) // Foam
// Handle
ctx.strokeStyle = '#d97706'
ctx.lineWidth = 2
ctx.strokeRect(6, -3, 4, 8)
ctx.restore()
}
}
// Draw Particles
particles.forEach(p => {
ctx.fillStyle = p.color
ctx.fillRect(p.x, p.y, 3, 3)
})
// Draw Goal Gate: Taverne 'Zum wummernden Ritter'
ctx.fillStyle = '#3e2723'
ctx.fillRect(levelWidth - 140, 160, 110, 120)
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.fillText('ZUM WUMMERNDEN RITTER', levelWidth - 135, 175)
// Glowing door
ctx.fillStyle = '#f59e0b'
ctx.fillRect(levelWidth - 105, 200, 40, 80)
// Draw Player: König Sascha (Pixel King with sunglasses, crown & ermine coat)
const px = player.x
const py = player.y
const legOffset = Math.sin(player.frame * 0.3) * 4
// Red Royal Ermine Cape (waving behind)
ctx.fillStyle = '#dc2626'
ctx.beginPath()
ctx.moveTo(px + 6, py + 16)
ctx.lineTo(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 42)
ctx.lineTo(px + 14, py + 42)
ctx.fill()
// White ermine fur trim
ctx.fillStyle = '#f8fafc'
ctx.fillRect(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 38, 24, 4)
// Royal Blue Tunic & Body
ctx.fillStyle = '#1d4ed8'
ctx.fillRect(px + 6, py + 16, 20, 22)
ctx.fillStyle = '#eab308' // Gold Belt
ctx.fillRect(px + 6, py + 30, 20, 3)
// Legs & Boots
ctx.fillStyle = '#451a03'
ctx.fillRect(px + 8, py + 38, 6, 10 + (player.isGrounded ? legOffset : 0))
ctx.fillRect(px + 18, py + 38, 6, 10 - (player.isGrounded ? legOffset : 0))
// Face
ctx.fillStyle = '#fcd34d'
ctx.fillRect(px + 8, py + 4, 16, 14)
// Iconic Black Sunglasses 🕶️
ctx.fillStyle = '#0f172a'
ctx.fillRect(px + 9, py + 8, 7, 4)
ctx.fillRect(px + 17, py + 8, 7, 4)
ctx.fillRect(px + 15, py + 9, 3, 2) // Bridge
// Golden Crown 👑
ctx.fillStyle = '#facc15'
ctx.beginPath()
ctx.moveTo(px + 6, py + 4)
ctx.lineTo(px + 6, py - 4)
ctx.lineTo(px + 11, py - 1)
ctx.lineTo(px + 16, py - 6)
ctx.lineTo(px + 21, py - 1)
ctx.lineTo(px + 26, py - 4)
ctx.lineTo(px + 26, py + 4)
ctx.fill()
ctx.fillStyle = '#dc2626'
ctx.fillRect(px + 15, py - 2, 2, 2) // Ruby
// Scepter in Hand
ctx.strokeStyle = '#facc15'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(px + 24, py + 18)
ctx.lineTo(px + 28, py + 34)
ctx.stroke()
// Kronkorken on top of scepter
ctx.fillStyle = '#ffd700'
ctx.beginPath()
ctx.arc(px + 24, py + 16, 4, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
// On-screen HUD
ctx.fillStyle = 'rgba(26, 15, 10, 0.75)'
ctx.fillRect(8, 8, 160, 28)
ctx.strokeStyle = '#d4af37'
ctx.lineWidth = 1.5
ctx.strokeRect(8, 8, 160, 28)
ctx.fillStyle = '#ffd700'
ctx.font = '10px "Press Start 2P", monospace'
ctx.fillText(`KRONEN: ${store.kronkorken}`, 16, 26)
}
function gameLoop() {
updatePhysics()
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
drawGame(ctx, canvasRef.value.width, canvasRef.value.height)
}
}
animationFrameId = requestAnimationFrame(gameLoop)
}
// Keyboard controls
function handleKeyDown(e: KeyboardEvent) {
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = true
if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
onJump()
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = false
if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = false
}
onMounted(() => {
initLevel()
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
animationFrameId = requestAnimationFrame(gameLoop)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
})
</script>
<template>
<div class="platformer-container">
<canvas
ref="canvasRef"
width="360"
height="320"
class="game-canvas"
@touchstart.prevent="onJump"
></canvas>
<div class="level-overlay">
<span class="level-title">AKTE II: DIE HOPFEN-PFADE</span>
<span class="level-hint">Tippe oder drücke SPRUNG zum Hüpfen</span>
</div>
</div>
</template>
<style scoped>
.platformer-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.level-overlay {
position: absolute;
top: 6px;
right: 8px;
display: flex;
flex-direction: column;
align-items: flex-end;
pointer-events: none;
}
.level-title {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
text-shadow: 1px 1px 0 #000;
}
.level-hint {
font-family: 'VT323', monospace;
font-size: 14px;
color: #fef08a;
text-shadow: 1px 1px 0 #000;
}
</style>