- 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
543 lines
13 KiB
Vue
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>
|