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,542 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user