- Recreate 16-bit LucasArts portraits for Saskia, Maja, and Miriam matching real-life source photos - Add 3 sisters showcase in Act II intro modal and golden portrait badges on goal platforms - Fix Act III background scaling: lock scene to 9:16 aspect ratio with medieval ornamental side borders and corner brackets - Ensure 100% full background visibility with zero cropping and lock hotspot buttons to image features across all screen sizes - Fix cache issue by referencing schankraum_v5.jpg with cache buster query - Re-position NAT button to feet (top: 70%) and Mario button to knees (top: 79%) to eliminate face occlusion
1101 lines
27 KiB
Vue
1101 lines
27 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
import { useGameStore } from '../stores/gameStore'
|
|
import { sound } from '../audio/synthAudio'
|
|
import { SISTER_RESCUE_DIALOGUES } from '../config/dialogues'
|
|
import { CHARACTERS } from '../config/characters'
|
|
|
|
const store = useGameStore()
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
const showLevelIntro = ref(true)
|
|
const rescuedSister = ref<'saskia' | 'maja' | 'miriam' | null>(null)
|
|
const currentLevel = ref<1 | 2 | 3>(1)
|
|
const fallNotification = ref(false)
|
|
|
|
let animationFrameId: number | null = null
|
|
|
|
// Canvas dimensions
|
|
const W = 360
|
|
const H = 480
|
|
|
|
// Game entities
|
|
interface Platform {
|
|
x: number
|
|
y: number
|
|
w: number
|
|
h: number
|
|
type: 'normal' | 'spring' | 'cloud'
|
|
hasItem?: 'cap' | 'beer' | null
|
|
itemCollected?: boolean
|
|
}
|
|
|
|
interface Particle {
|
|
x: number
|
|
y: number
|
|
vx: number
|
|
vy: number
|
|
life: number
|
|
maxLife: number
|
|
color: string
|
|
size: number
|
|
}
|
|
|
|
// Player state
|
|
const player = {
|
|
x: W / 2 - 16,
|
|
y: 0,
|
|
w: 32,
|
|
h: 40,
|
|
vx: 0,
|
|
vy: 0,
|
|
facing: 'right' as 'left' | 'right',
|
|
squash: 1, // Visual bounce stretch/squash
|
|
jumpCount: 0
|
|
}
|
|
|
|
let cameraY = 0
|
|
let levelHeight = 1800 // Total vertical height of climb
|
|
let platforms: Platform[] = []
|
|
let particles: Particle[] = []
|
|
|
|
// Input state
|
|
const keys = {
|
|
left: false,
|
|
right: false
|
|
}
|
|
|
|
// Background image loading
|
|
let bgImg: HTMLImageElement | null = null
|
|
if (typeof Image !== 'undefined') {
|
|
bgImg = new Image()
|
|
bgImg.src = '/assets/scenes/doodle_sky.jpg'
|
|
}
|
|
|
|
function startLevel() {
|
|
sound.playDialogueBlip()
|
|
showLevelIntro.value = false
|
|
initCurrentLevel()
|
|
}
|
|
|
|
function initCurrentLevel() {
|
|
// Height increases slightly per level
|
|
levelHeight = currentLevel.value === 1 ? 1600 : currentLevel.value === 2 ? 1850 : 2100
|
|
|
|
// Reset player to ground
|
|
player.x = W / 2 - player.w / 2
|
|
player.y = levelHeight - 70
|
|
player.vx = 0
|
|
player.vy = -10.5
|
|
player.facing = 'right'
|
|
cameraY = levelHeight - H
|
|
|
|
platforms = []
|
|
particles = []
|
|
|
|
// Base platform at bottom
|
|
platforms.push({
|
|
x: 20,
|
|
y: levelHeight - 30,
|
|
w: W - 40,
|
|
h: 18,
|
|
type: 'normal'
|
|
})
|
|
|
|
// Generate platforms climbing up to the top
|
|
const platformSpacing = 68
|
|
const totalPlatforms = Math.floor((levelHeight - 120) / platformSpacing)
|
|
|
|
for (let i = 1; i <= totalPlatforms; i++) {
|
|
const py = levelHeight - 30 - i * platformSpacing
|
|
const pw = Math.max(54, 76 - (i % 3) * 6)
|
|
const px = 20 + Math.random() * (W - pw - 40)
|
|
|
|
// Platform type
|
|
const isSpring = i % 7 === 4
|
|
const isCloud = i % 5 === 2
|
|
|
|
// Collectibles
|
|
let hasItem: 'cap' | 'beer' | null = null
|
|
if (i % 2 === 1) {
|
|
hasItem = 'cap'
|
|
} else if (i % 6 === 0) {
|
|
hasItem = 'beer'
|
|
}
|
|
|
|
platforms.push({
|
|
x: px,
|
|
y: py,
|
|
w: pw,
|
|
h: 14,
|
|
type: isSpring ? 'spring' : isCloud ? 'cloud' : 'normal',
|
|
hasItem,
|
|
itemCollected: false
|
|
})
|
|
}
|
|
|
|
// Goal platform at the very top for the sister
|
|
platforms.push({
|
|
x: W / 2 - 60,
|
|
y: 70,
|
|
w: 120,
|
|
h: 22,
|
|
type: 'normal'
|
|
})
|
|
}
|
|
|
|
// Exposed controls for GameBoy bezel buttons
|
|
function onJump() {
|
|
// Manual extra hopfen boost
|
|
if (player.vy > -4) {
|
|
player.vy = -11.5
|
|
sound.playJump()
|
|
spawnParticles(player.x + player.w / 2, player.y + player.h, '#ffd700', 8)
|
|
}
|
|
}
|
|
|
|
function onLeft(pressed: boolean) {
|
|
keys.left = pressed
|
|
if (pressed) player.facing = 'left'
|
|
}
|
|
|
|
function onRight(pressed: boolean) {
|
|
keys.right = pressed
|
|
if (pressed) player.facing = 'right'
|
|
}
|
|
|
|
defineExpose({
|
|
onJump,
|
|
onLeft,
|
|
onRight,
|
|
triggerSisterRescue
|
|
})
|
|
|
|
// Touch controls on screen halves
|
|
function handleTouchStart(e: TouchEvent) {
|
|
const touch = e.touches[0]
|
|
if (!touch || !canvasRef.value) return
|
|
const rect = canvasRef.value.getBoundingClientRect()
|
|
const touchX = touch.clientX - rect.left
|
|
if (touchX < rect.width / 2) {
|
|
onLeft(true)
|
|
onRight(false)
|
|
} else {
|
|
onRight(true)
|
|
onLeft(false)
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd() {
|
|
onLeft(false)
|
|
onRight(false)
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.code === 'ArrowLeft' || e.code === 'KeyA') onLeft(true)
|
|
if (e.code === 'ArrowRight' || e.code === 'KeyD') onRight(true)
|
|
if (e.code === 'ArrowUp' || e.code === 'Space' || e.code === 'KeyW') onJump()
|
|
}
|
|
|
|
function handleKeyUp(e: KeyboardEvent) {
|
|
if (e.code === 'ArrowLeft' || e.code === 'KeyA') onLeft(false)
|
|
if (e.code === 'ArrowRight' || e.code === 'KeyD') onRight(false)
|
|
}
|
|
|
|
function spawnParticles(x: number, y: number, color: string, count = 6) {
|
|
for (let i = 0; i < count; i++) {
|
|
particles.push({
|
|
x,
|
|
y,
|
|
vx: (Math.random() - 0.5) * 4,
|
|
vy: (Math.random() - 0.5) * 4,
|
|
life: 20 + Math.random() * 10,
|
|
maxLife: 30,
|
|
color,
|
|
size: 2 + Math.random() * 3
|
|
})
|
|
}
|
|
}
|
|
|
|
function triggerSisterRescue(sisterKey: 'saskia' | 'maja' | 'miriam') {
|
|
sound.playRescueFanfare()
|
|
rescuedSister.value = sisterKey
|
|
store.addKronkorken(3)
|
|
store.addBeer()
|
|
}
|
|
|
|
function proceedNextStage() {
|
|
rescuedSister.value = null
|
|
if (currentLevel.value === 1) {
|
|
currentLevel.value = 2
|
|
initCurrentLevel()
|
|
} else if (currentLevel.value === 2) {
|
|
currentLevel.value = 3
|
|
initCurrentLevel()
|
|
} else {
|
|
// All 3 sisters rescued! Proceed to Akt III Taverne
|
|
store.setAct('tavern')
|
|
}
|
|
}
|
|
|
|
function updatePhysics() {
|
|
if (store.isPaused || showLevelIntro.value || rescuedSister.value) return
|
|
|
|
// Horizontal motion
|
|
const speed = 4.6
|
|
if (keys.left) {
|
|
player.vx = -speed
|
|
player.facing = 'left'
|
|
} else if (keys.right) {
|
|
player.vx = speed
|
|
player.facing = 'right'
|
|
} else {
|
|
player.vx *= 0.78
|
|
if (Math.abs(player.vx) < 0.1) player.vx = 0
|
|
}
|
|
player.x += player.vx
|
|
|
|
// Screen wrap-around
|
|
if (player.x + player.w < 0) {
|
|
player.x = W
|
|
} else if (player.x > W) {
|
|
player.x = -player.w
|
|
}
|
|
|
|
// Gravity
|
|
player.vy += 0.36
|
|
if (player.vy > 9.5) player.vy = 9.5
|
|
player.y += player.vy
|
|
|
|
// Visual squash/stretch recovery
|
|
player.squash += (1 - player.squash) * 0.15
|
|
|
|
// Platform collision (only when falling downwards)
|
|
if (player.vy > 0) {
|
|
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
|
|
) {
|
|
// Goal platform collision check
|
|
if (plat.y <= 80) {
|
|
const sister = currentLevel.value === 1 ? 'saskia' : currentLevel.value === 2 ? 'maja' : 'miriam'
|
|
triggerSisterRescue(sister)
|
|
return
|
|
}
|
|
|
|
// Bounce!
|
|
player.squash = 0.65
|
|
if (plat.type === 'spring') {
|
|
player.vy = -16.0
|
|
sound.playSpringBounce()
|
|
spawnParticles(player.x + player.w / 2, plat.y, '#f59e0b', 12)
|
|
} else {
|
|
player.vy = -10.8
|
|
sound.playJump()
|
|
spawnParticles(player.x + player.w / 2, plat.y, '#d4af37', 5)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect item pickup
|
|
for (const plat of platforms) {
|
|
if (plat.hasItem && !plat.itemCollected) {
|
|
const itemX = plat.x + plat.w / 2
|
|
const itemY = plat.y - 14
|
|
const dx = (player.x + player.w / 2) - itemX
|
|
const dy = (player.y + player.h / 2) - itemY
|
|
if (Math.sqrt(dx * dx + dy * dy) < 24) {
|
|
plat.itemCollected = true
|
|
if (plat.hasItem === 'cap') {
|
|
store.addKronkorken(1)
|
|
sound.playCoin()
|
|
spawnParticles(itemX, itemY, '#ffd700', 8)
|
|
} else {
|
|
store.addBeer()
|
|
sound.playGulp()
|
|
spawnParticles(itemX, itemY, '#f59e0b', 10)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Camera upward scrolling
|
|
const targetCameraY = player.y - H * 0.45
|
|
if (targetCameraY < cameraY) {
|
|
cameraY = targetCameraY
|
|
}
|
|
|
|
// Fall off bottom check
|
|
if (player.y > cameraY + H + 30) {
|
|
sound.playBoarHit()
|
|
fallNotification.value = true
|
|
setTimeout(() => {
|
|
fallNotification.value = false
|
|
}, 1800)
|
|
|
|
// Reset player to bottom of level gently
|
|
player.x = W / 2 - player.w / 2
|
|
player.y = levelHeight - 80
|
|
player.vy = -10.5
|
|
cameraY = levelHeight - H
|
|
}
|
|
|
|
// Particles
|
|
particles.forEach(p => {
|
|
p.x += p.vx
|
|
p.y += p.vy
|
|
p.life--
|
|
})
|
|
particles = particles.filter(p => p.life > 0)
|
|
}
|
|
|
|
function drawGame(ctx: CanvasRenderingContext2D) {
|
|
ctx.clearRect(0, 0, W, H)
|
|
|
|
// 1. Background sky parallax
|
|
if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) {
|
|
const drawnH = bgImg.naturalHeight * (W / bgImg.naturalWidth)
|
|
const maxScroll = Math.max(0, drawnH - H)
|
|
const scrollFactor = Math.max(0, Math.min(1, cameraY / (levelHeight - H)))
|
|
const bgY = -scrollFactor * maxScroll
|
|
ctx.drawImage(bgImg, 0, bgY, W, drawnH)
|
|
} else {
|
|
// Fallback sky gradient
|
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, H)
|
|
skyGrad.addColorStop(0, '#0284c7')
|
|
skyGrad.addColorStop(0.5, '#38bdf8')
|
|
skyGrad.addColorStop(1, '#93c5fd')
|
|
ctx.fillStyle = skyGrad
|
|
ctx.fillRect(0, 0, W, H)
|
|
}
|
|
|
|
ctx.save()
|
|
ctx.translate(0, -cameraY)
|
|
|
|
// 2. Platforms
|
|
for (const plat of platforms) {
|
|
if (plat.y < cameraY - 40 || plat.y > cameraY + H + 40) continue
|
|
|
|
if (plat.y <= 80) {
|
|
// Golden Castle Goal Platform
|
|
ctx.fillStyle = '#b45309'
|
|
ctx.fillRect(plat.x - 4, plat.y, plat.w + 8, plat.h)
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fillRect(plat.x, plat.y + 2, plat.w, plat.h - 4)
|
|
|
|
// Sister sprite standing on top
|
|
const sisterX = plat.x + plat.w / 2 - 12
|
|
const sisterY = plat.y - 36
|
|
drawSisterSprite(ctx, sisterX, sisterY, currentLevel.value)
|
|
|
|
// Speech bubble "Hilfe!"
|
|
ctx.fillStyle = '#ffffff'
|
|
ctx.beginPath()
|
|
ctx.roundRect(sisterX - 14, sisterY - 18, 52, 16, 4)
|
|
ctx.fill()
|
|
ctx.fillStyle = '#b91c1c'
|
|
ctx.font = 'bold 8px monospace'
|
|
ctx.fillText('👑 HILFE!', sisterX - 8, sisterY - 6)
|
|
continue
|
|
}
|
|
|
|
if (plat.type === 'spring') {
|
|
// Platform body
|
|
ctx.fillStyle = '#78350f'
|
|
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
|
|
ctx.fillStyle = '#92400e'
|
|
ctx.fillRect(plat.x + 2, plat.y + 2, plat.w - 4, plat.h - 4)
|
|
|
|
// Spring coil on top
|
|
const springX = plat.x + plat.w / 2 - 8
|
|
ctx.fillStyle = '#fbbf24'
|
|
ctx.fillRect(springX, plat.y - 7, 16, 7)
|
|
ctx.fillStyle = '#d97706'
|
|
ctx.fillRect(springX + 2, plat.y - 5, 12, 3)
|
|
} else if (plat.type === 'cloud') {
|
|
// Cloud platform
|
|
ctx.fillStyle = '#e0f2fe'
|
|
ctx.beginPath()
|
|
ctx.roundRect(plat.x, plat.y, plat.w, plat.h, 7)
|
|
ctx.fill()
|
|
ctx.fillStyle = '#bae6fd'
|
|
ctx.fillRect(plat.x + 4, plat.y + plat.h - 3, plat.w - 8, 2)
|
|
} else {
|
|
// Wooden platform
|
|
ctx.fillStyle = '#451a03'
|
|
ctx.fillRect(plat.x, plat.y, plat.w, plat.h)
|
|
ctx.fillStyle = '#78350f'
|
|
ctx.fillRect(plat.x + 2, plat.y + 2, plat.w - 4, plat.h - 4)
|
|
// Hopfen/grass trim
|
|
ctx.fillStyle = '#15803d'
|
|
ctx.fillRect(plat.x, plat.y, plat.w, 3)
|
|
}
|
|
|
|
// Collectible Item on platform
|
|
if (plat.hasItem && !plat.itemCollected) {
|
|
const itemX = plat.x + plat.w / 2
|
|
const itemY = plat.y - 12
|
|
if (plat.hasItem === 'cap') {
|
|
// Golden Kronkorken
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.beginPath()
|
|
ctx.arc(itemX, itemY, 6, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
ctx.strokeStyle = '#b45309'
|
|
ctx.lineWidth = 1.5
|
|
ctx.stroke()
|
|
ctx.fillStyle = '#b45309'
|
|
ctx.font = 'bold 6px monospace'
|
|
ctx.fillText('40', itemX - 4, itemY + 2)
|
|
} else {
|
|
// Helles Beer Mug
|
|
ctx.fillStyle = '#f59e0b'
|
|
ctx.fillRect(itemX - 5, itemY - 6, 10, 12)
|
|
// Foam
|
|
ctx.fillStyle = '#ffffff'
|
|
ctx.fillRect(itemX - 6, itemY - 9, 12, 4)
|
|
// Handle
|
|
ctx.strokeStyle = '#d97706'
|
|
ctx.lineWidth = 1.5
|
|
ctx.strokeRect(itemX + 5, itemY - 3, 3, 7)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Particles
|
|
particles.forEach(p => {
|
|
ctx.fillStyle = p.color
|
|
ctx.globalAlpha = p.life / p.maxLife
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
})
|
|
ctx.globalAlpha = 1.0
|
|
|
|
// 4. Pixel King Sascha
|
|
drawPlayer(ctx)
|
|
|
|
ctx.restore()
|
|
|
|
// 5. HUD Progress Bar (Climb Altitude)
|
|
drawHUD(ctx)
|
|
}
|
|
|
|
function drawPlayer(ctx: CanvasRenderingContext2D) {
|
|
ctx.save()
|
|
ctx.translate(player.x + player.w / 2, player.y + player.h / 2)
|
|
|
|
// Stretch / Squash
|
|
const sy = player.squash
|
|
const sx = 2 - sy
|
|
ctx.scale(sx, sy)
|
|
|
|
// Cape
|
|
ctx.fillStyle = '#b91c1c'
|
|
const capeWave = Math.sin(Date.now() / 120) * 4
|
|
ctx.fillRect(-12, -8, 24, 26 + (player.vy < 0 ? -4 : capeWave))
|
|
|
|
// Body / Bavarian royal doublet
|
|
ctx.fillStyle = '#1e3a8a'
|
|
ctx.fillRect(-9, -12, 18, 22)
|
|
|
|
// Gold trim
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fillRect(-9, 0, 18, 3)
|
|
|
|
// Head
|
|
ctx.fillStyle = '#fbcfe8'
|
|
ctx.fillRect(-7, -26, 14, 14)
|
|
|
|
// Brown beard & hair
|
|
ctx.fillStyle = '#78350f'
|
|
ctx.fillRect(-8, -18, 16, 7)
|
|
ctx.fillRect(-8, -26, 16, 4)
|
|
|
|
// Sunglasses (iconic Sascha style)
|
|
ctx.fillStyle = '#0f172a'
|
|
const eyeX = player.facing === 'right' ? -2 : -6
|
|
ctx.fillRect(eyeX, -22, 9, 4)
|
|
ctx.fillStyle = '#38bdf8'
|
|
ctx.fillRect(eyeX + 1, -22, 2, 2)
|
|
|
|
// Golden Crown
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fillRect(-8, -31, 16, 6)
|
|
// Crown points
|
|
ctx.fillRect(-8, -34, 3, 3)
|
|
ctx.fillRect(-2, -35, 4, 4)
|
|
ctx.fillRect(5, -34, 3, 3)
|
|
|
|
// Legs / boots
|
|
ctx.fillStyle = '#3b82f6'
|
|
ctx.fillRect(-8, 10, 6, 8)
|
|
ctx.fillRect(2, 10, 6, 8)
|
|
ctx.fillStyle = '#78350f'
|
|
ctx.fillRect(-9, 16, 7, 4)
|
|
ctx.fillRect(2, 16, 7, 4)
|
|
|
|
ctx.restore()
|
|
}
|
|
|
|
const sisterImages: Record<number, HTMLImageElement> = {}
|
|
|
|
function preloadSisterImages() {
|
|
const sources: Record<number, string> = {
|
|
1: CHARACTERS.saskia.portrait,
|
|
2: CHARACTERS.maja.portrait,
|
|
3: CHARACTERS.miriam.portrait
|
|
}
|
|
for (const [lvl, src] of Object.entries(sources)) {
|
|
const img = new Image()
|
|
img.src = src
|
|
sisterImages[Number(lvl)] = img
|
|
}
|
|
}
|
|
|
|
function drawSisterSprite(ctx: CanvasRenderingContext2D, x: number, y: number, level: number) {
|
|
const img = sisterImages[level]
|
|
const wave = Math.sin(Date.now() / 160) * 3
|
|
|
|
if (img && img.complete && img.naturalWidth > 0) {
|
|
const size = 32
|
|
const drawX = x - 4
|
|
const drawY = y - 4 + wave
|
|
|
|
ctx.save()
|
|
// Golden border
|
|
ctx.beginPath()
|
|
ctx.arc(drawX + size / 2, drawY + size / 2, size / 2 + 2, 0, Math.PI * 2)
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fill()
|
|
ctx.lineWidth = 2
|
|
ctx.strokeStyle = '#b45309'
|
|
ctx.stroke()
|
|
|
|
// Circular portrait
|
|
ctx.beginPath()
|
|
ctx.arc(drawX + size / 2, drawY + size / 2, size / 2, 0, Math.PI * 2)
|
|
ctx.clip()
|
|
ctx.drawImage(img, drawX, drawY, size, size)
|
|
ctx.restore()
|
|
|
|
// Little waving hand
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.font = '10px monospace'
|
|
ctx.fillText('👋', drawX + size - 2, drawY + 8 + wave)
|
|
} else {
|
|
// Custom pixel-art styling per sister matching real photos
|
|
const dirndlColor = level === 1 ? '#f43f5e' : level === 2 ? '#8b5cf6' : '#06b6d4'
|
|
const hairColor = level === 1 ? '#3d2314' : level === 2 ? '#6b4226' : '#18181b'
|
|
|
|
// Body
|
|
ctx.fillStyle = dirndlColor
|
|
ctx.fillRect(x + 2, y + 14, 18, 18)
|
|
// White apron
|
|
ctx.fillStyle = '#ffffff'
|
|
ctx.fillRect(x + 6, y + 18, 10, 14)
|
|
|
|
// Head
|
|
ctx.fillStyle = '#fce7f3'
|
|
ctx.fillRect(x + 4, y + 4, 14, 12)
|
|
|
|
// Hair
|
|
ctx.fillStyle = hairColor
|
|
if (level === 1) {
|
|
// Saskia: elegant bun updo with side strands
|
|
ctx.fillRect(x + 2, y, 18, 6)
|
|
ctx.fillRect(x + 5, y - 5, 12, 6)
|
|
ctx.fillRect(x + 1, y + 5, 2, 6)
|
|
ctx.fillRect(x + 19, y + 5, 2, 6)
|
|
} else if (level === 2) {
|
|
// Maja: short pixie cut with side bangs
|
|
ctx.fillRect(x + 2, y, 18, 6)
|
|
ctx.fillRect(x + 1, y + 3, 5, 8)
|
|
// Gold hoop earring
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fillRect(x + 1, y + 9, 2, 2)
|
|
ctx.fillStyle = hairColor
|
|
} else {
|
|
// Miriam: long flowing dark hair with sunglasses on head
|
|
ctx.fillRect(x + 2, y, 18, 6)
|
|
ctx.fillRect(x + 1, y + 4, 4, 16)
|
|
ctx.fillRect(x + 17, y + 4, 4, 16)
|
|
// Sunglasses
|
|
ctx.fillStyle = '#0f172a'
|
|
ctx.fillRect(x + 5, y - 3, 12, 3)
|
|
}
|
|
|
|
// Tiara
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.fillRect(x + 6, y - 2, 10, 3)
|
|
|
|
// Waving arm
|
|
ctx.fillStyle = '#fce7f3'
|
|
ctx.fillRect(x - 3, y + 6 + wave, 5, 5)
|
|
}
|
|
}
|
|
|
|
function drawHUD(ctx: CanvasRenderingContext2D) {
|
|
// Top Banner
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)'
|
|
ctx.fillRect(0, 0, W, 28)
|
|
|
|
ctx.fillStyle = '#ffd700'
|
|
ctx.font = 'bold 8px monospace'
|
|
const sisterName = currentLevel.value === 1 ? 'SASKIA' : currentLevel.value === 2 ? 'MAJA' : 'MIRIAM'
|
|
ctx.fillText(`ETAPPE ${currentLevel.value}/3: RETTE ${sisterName}!`, 10, 17)
|
|
|
|
// Climb progress meter
|
|
const progress = Math.min(1, Math.max(0, 1 - cameraY / (levelHeight - H)))
|
|
ctx.fillStyle = '#334155'
|
|
ctx.fillRect(W - 84, 8, 74, 12)
|
|
ctx.fillStyle = '#22c55e'
|
|
ctx.fillRect(W - 82, 10, 70 * progress, 8)
|
|
ctx.strokeStyle = '#ffd700'
|
|
ctx.strokeRect(W - 84, 8, 74, 12)
|
|
}
|
|
|
|
function gameLoop() {
|
|
updatePhysics()
|
|
if (canvasRef.value) {
|
|
const ctx = canvasRef.value.getContext('2d')
|
|
if (ctx) drawGame(ctx)
|
|
}
|
|
animationFrameId = requestAnimationFrame(gameLoop)
|
|
}
|
|
|
|
onMounted(() => {
|
|
preloadSisterImages()
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
window.addEventListener('keyup', handleKeyUp)
|
|
;(window as any).__platformer = {
|
|
triggerSisterRescue,
|
|
setRescuedSister: (s: any) => { rescuedSister.value = s },
|
|
startLevel
|
|
}
|
|
initCurrentLevel()
|
|
animationFrameId = requestAnimationFrame(gameLoop)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
window.removeEventListener('keyup', handleKeyUp)
|
|
delete (window as any).__platformer
|
|
if (animationFrameId) cancelAnimationFrame(animationFrameId)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="platformer-viewport"
|
|
@touchstart="handleTouchStart"
|
|
@touchend="handleTouchEnd"
|
|
>
|
|
<canvas
|
|
ref="canvasRef"
|
|
:width="W"
|
|
:height="H"
|
|
class="game-canvas"
|
|
></canvas>
|
|
|
|
<!-- Fall respawn notification -->
|
|
<transition name="fade">
|
|
<div v-if="fallNotification" class="fall-toast">
|
|
<span>☁️ Schwung holen und nochmal nach oben!</span>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Act II Intro Modal -->
|
|
<div v-if="showLevelIntro" class="intro-overlay">
|
|
<div class="intro-card">
|
|
<span class="intro-act-tag">AKT II: DOODLE JUMP ADVENTURE</span>
|
|
<h2 class="intro-card-title">DIE SCHWESTERNRETTUNG</h2>
|
|
<p class="intro-card-story">
|
|
Gierige Kronkorken-Kobolde haben Saschas drei Schwestern <strong>Saskia</strong>, <strong>Maja</strong> und <strong>Miriam</strong> auf luftige Himmelszinnen entführt!
|
|
</p>
|
|
|
|
<!-- 3 Sisters Preview Showcase -->
|
|
<div class="sisters-showcase">
|
|
<div class="sister-mini-card">
|
|
<div class="sister-mini-portrait">
|
|
<img :src="CHARACTERS.saskia.portrait" alt="Saskia" />
|
|
</div>
|
|
<span class="sister-mini-label">1. Saskia 👑</span>
|
|
</div>
|
|
<div class="sister-mini-card">
|
|
<div class="sister-mini-portrait">
|
|
<img :src="CHARACTERS.maja.portrait" alt="Maja" />
|
|
</div>
|
|
<span class="sister-mini-label">2. Maja 🌸</span>
|
|
</div>
|
|
<div class="sister-mini-card">
|
|
<div class="sister-mini-portrait">
|
|
<img :src="CHARACTERS.miriam.portrait" alt="Miriam" />
|
|
</div>
|
|
<span class="sister-mini-label">3. Miriam ✨</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mission-callout">
|
|
<span class="callout-icon">☁️</span>
|
|
<div class="callout-text">
|
|
<strong>Königliche Mission:</strong><br />
|
|
Springe von Plattform zu Plattform hinauf. Sammle Kronkorken & kühles Helles. Rette am Ende jeder Etappe eine Schwester!
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls-overview">
|
|
<div class="ctrl-item">
|
|
<span class="ctrl-badge">STEUERUNG</span>
|
|
<span class="ctrl-label">D-Pad ◀ / ▶ oder Bildschirmhälften halten</span>
|
|
</div>
|
|
<div class="ctrl-item">
|
|
<span class="ctrl-badge">SPRUNG</span>
|
|
<span class="ctrl-label">Auto-Bounce bei Landung | (A) Extra-Schwung</span>
|
|
</div>
|
|
<div class="ctrl-item">
|
|
<span class="ctrl-badge">WRAP-AROUND</span>
|
|
<span class="ctrl-label">Am linken Rand raus = rechts wieder rein!</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="start-act-btn" @click="startLevel">
|
|
🚀 Auf geht's: Saskia retten!
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sister Rescued Dialogue Modal -->
|
|
<div v-if="rescuedSister" class="rescue-overlay">
|
|
<div class="rescue-card">
|
|
<div class="rescue-header">
|
|
<div class="sister-portrait-box">
|
|
<img
|
|
:src="CHARACTERS[rescuedSister].portrait"
|
|
:alt="CHARACTERS[rescuedSister].name"
|
|
class="sister-img"
|
|
/>
|
|
</div>
|
|
<div class="sister-info">
|
|
<span class="rescue-congrats">🎉 SCHWESTER GERETTET!</span>
|
|
<h3 class="sister-name">{{ SISTER_RESCUE_DIALOGUES[rescuedSister].name }}</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rescue-dialogue-body">
|
|
<p class="rescue-text">„{{ SISTER_RESCUE_DIALOGUES[rescuedSister].text }}“</p>
|
|
</div>
|
|
|
|
<div class="rescue-rewards">
|
|
<span class="reward-pill">👑 +3 Kronkorken</span>
|
|
<span class="reward-pill">🍻 +1 Helles</span>
|
|
</div>
|
|
|
|
<button class="proceed-btn" @click="proceedNextStage">
|
|
{{ SISTER_RESCUE_DIALOGUES[rescuedSister].btnText }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.platformer-viewport {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: #0284c7;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
|
|
.game-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
image-rendering: pixelated;
|
|
}
|
|
|
|
/* Fall Respawn Toast */
|
|
.fall-toast {
|
|
position: absolute;
|
|
bottom: 24px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(15, 23, 42, 0.9);
|
|
border: 1.5px solid #38bdf8;
|
|
border-radius: 20px;
|
|
padding: 6px 14px;
|
|
color: #fef08a;
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 13px;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
|
|
z-index: 40;
|
|
}
|
|
|
|
/* Intro Modal */
|
|
.intro-overlay, .rescue-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.88);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 12px;
|
|
z-index: 50;
|
|
backdrop-filter: blur(2px);
|
|
}
|
|
|
|
.intro-card, .rescue-card {
|
|
background: #231209;
|
|
border: 3px double #d4af37;
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
max-width: 320px;
|
|
width: 100%;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.95);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
text-align: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.intro-act-tag {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 6.5px;
|
|
color: #f59e0b;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.intro-card-title {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 9.5px;
|
|
color: #ffd700;
|
|
margin: 0;
|
|
text-shadow: 1px 1px 0 #000;
|
|
}
|
|
|
|
.intro-card-story {
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 12.5px;
|
|
color: #fef08a;
|
|
line-height: 1.35;
|
|
margin: 0;
|
|
}
|
|
|
|
.mission-callout {
|
|
background: #451a03;
|
|
border-left: 3px solid #22c55e;
|
|
border-radius: 4px;
|
|
padding: 6px 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
text-align: left;
|
|
}
|
|
|
|
.callout-icon {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.callout-text {
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 12px;
|
|
color: #bbf7d0;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.controls-overview {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
width: 100%;
|
|
background: #170c06;
|
|
border: 1px solid #78350f;
|
|
border-radius: 6px;
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.ctrl-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
}
|
|
|
|
.ctrl-badge {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 6px;
|
|
color: #ffd700;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.ctrl-label {
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 11.5px;
|
|
color: #fed7aa;
|
|
text-align: right;
|
|
}
|
|
|
|
.start-act-btn, .proceed-btn {
|
|
background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
|
|
color: #fff;
|
|
border: 2px solid #ffd700;
|
|
border-radius: 8px;
|
|
padding: 9px 14px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 7.5px;
|
|
cursor: pointer;
|
|
margin-top: 4px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
|
|
transition: transform 0.1s;
|
|
}
|
|
|
|
.start-act-btn:hover, .proceed-btn:hover {
|
|
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.start-act-btn:active, .proceed-btn:active {
|
|
transform: scale(0.96);
|
|
}
|
|
|
|
/* Sister Rescue Card */
|
|
.rescue-card {
|
|
border-color: #22c55e;
|
|
}
|
|
|
|
.rescue-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.sister-portrait-box {
|
|
width: 68px;
|
|
height: 68px;
|
|
border-radius: 8px;
|
|
border: 2px solid #ffd700;
|
|
overflow: hidden;
|
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.4);
|
|
flex-shrink: 0;
|
|
background: #1e1b4b;
|
|
}
|
|
|
|
.sister-img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
image-rendering: crisp-edges;
|
|
}
|
|
|
|
.sister-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.rescue-congrats {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 6px;
|
|
color: #4ade80;
|
|
}
|
|
|
|
.sister-name {
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 16px;
|
|
color: #ffd700;
|
|
margin: 2px 0 0 0;
|
|
}
|
|
|
|
.rescue-dialogue-body {
|
|
background: rgba(0, 0, 0, 0.45);
|
|
border-left: 3px solid #22c55e;
|
|
border-radius: 4px;
|
|
padding: 8px 10px;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.rescue-text {
|
|
margin: 0;
|
|
font-family: 'MedievalSharp', serif;
|
|
font-size: 13.5px;
|
|
line-height: 1.35;
|
|
color: #fef3c7;
|
|
}
|
|
|
|
.rescue-rewards {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin: 2px 0;
|
|
}
|
|
|
|
.reward-pill {
|
|
background: #78350f;
|
|
color: #ffd700;
|
|
border: 1px solid #b45309;
|
|
border-radius: 12px;
|
|
padding: 4px 10px;
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 6.5px;
|
|
}
|
|
|
|
/* Sisters Showcase in Act II Intro */
|
|
.sisters-showcase {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
margin: 10px 0 12px 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.sister-mini-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.sister-mini-portrait {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: 8px;
|
|
border: 2px solid #ffd700;
|
|
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
|
|
overflow: hidden;
|
|
background: #1e1b4b;
|
|
}
|
|
|
|
.sister-mini-portrait img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
image-rendering: crisp-edges;
|
|
}
|
|
|
|
.sister-mini-label {
|
|
font-family: 'Press Start 2P', monospace;
|
|
font-size: 5.5px;
|
|
color: #ffd700;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Animations */
|
|
.fade-enter-active, .fade-leave-active {
|
|
transition: opacity 0.3s;
|
|
}
|
|
.fade-enter-from, .fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|