Files
infrapuzzle/projects/sasch40/frontend/src/components/PartyExpressView.vue
T
Moritz Graf 8a0b65c3fa feat(sasch40): major overhaul with doodle jump, party bunker, and guitar hero
- Add global settings & pause modal (gear icon) with mute and reset, removing obsolete bag button
- Extend Prologue with Flo Maier (castle fire game over & Löschzug restart) and Viktor (vampire bite game over & Knoblauch restart)
- Rebuild Akt II as 3-level vertical Doodle Jump climber to rescue sisters Saskia, Maja, and Miriam with custom pixel portraits
- Update Akt III Schankraum with seamless oak floor, double-tap 'Luke zum Party Bunker', duel intro modals, and Hansi's departure in Hinterland
- Rebuild Akt IV as 3-lane medieval Guitar Hero rock concert with live crowd meter, combo multipliers, and Hansi duo
- Update AGENTS.md architectural documentation and playtest test suite
2026-09-24 18:22:28 +02:00

836 lines
20 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)
const showConcertIntro = ref(true)
const concertFinished = ref(false)
let animId: number | null = null
// Canvas dimensions
const W = 360
const H = 480
// Fretboard layout
const boardX = 60
const boardW = 240
const strikeY = 360
const laneCenters = [boardX + 40, boardX + 120, boardX + 200] // 100, 180, 260
const laneColors = ['#ef4444', '#f59e0b', '#38bdf8']
const laneGlow = ['rgba(239, 68, 68, 0.4)', 'rgba(245, 158, 11, 0.4)', 'rgba(56, 189, 248, 0.4)']
// Note structure
interface Note {
id: number
lane: 0 | 1 | 2
targetTime: number // in seconds from concert start
hit: boolean
missed: boolean
}
interface HitPopup {
x: number
y: number
text: string
color: string
life: number
}
interface Particle {
x: number
y: number
vx: number
vy: number
life: number
color: string
size: number
}
// Concert timeline
const SONG_DURATION = 42 // 42 seconds total concert
let startTime = 0
let elapsedTime = 0
let notes: Note[] = []
let hitPopups: HitPopup[] = []
let particles: Particle[] = []
// Score tracking
const score = ref(0)
const streak = ref(0)
const maxStreak = ref(0)
const totalHits = ref(0)
const totalPerfects = ref(0)
const totalMisses = ref(0)
// Active button pressed state for visual feedback
const lanePressed = ref([false, false, false])
// Stage background
let stageImg: HTMLImageElement | null = null
if (typeof Image !== 'undefined') {
stageImg = new Image()
stageImg.src = '/assets/scenes/lute_stage.jpg'
}
// Generate rhythmic 135-BPM note chart (~55 notes across 42 seconds)
function generateNoteChart() {
const chart: Note[] = []
let id = 1
const bpm = 135
const beatInterval = 60 / bpm // ~0.444s
// Intro riff (beats 4 to 16)
for (let b = 4; b < 16; b += 2) {
chart.push({ id: id++, lane: (b % 4 === 0 ? 0 : 1) as 0 | 1 | 2, targetTime: b * beatInterval, hit: false, missed: false })
}
// Main verse (beats 18 to 44) - driving rhythm across 3 lanes
const versePattern: (0 | 1 | 2)[] = [0, 1, 2, 1, 0, 0, 2, 1, 2, 0, 1, 2, 1]
for (let i = 0; i < versePattern.length; i++) {
chart.push({
id: id++,
lane: versePattern[i],
targetTime: (18 + i * 2) * beatInterval,
hit: false,
missed: false
})
}
// Solo riff / Fast rock rhythm (beats 46 to 76)
for (let b = 46; b < 76; b += 1.5) {
const lane = (Math.floor(b * 2) % 3) as 0 | 1 | 2
chart.push({
id: id++,
lane,
targetTime: b * beatInterval,
hit: false,
missed: false
})
}
// Grand finale chords / double hits (beats 78 to 92)
for (let b = 78; b <= 90; b += 2) {
chart.push({
id: id++,
lane: (b % 2 === 0 ? 1 : 2) as 0 | 1 | 2,
targetTime: b * beatInterval,
hit: false,
missed: false
})
if (b % 4 === 0) {
chart.push({
id: id++,
lane: 0,
targetTime: b * beatInterval,
hit: false,
missed: false
})
}
}
return chart
}
function startConcert() {
sound.playDialogueBlip()
showConcertIntro.value = false
notes = generateNoteChart()
startTime = performance.now()
score.value = 0
streak.value = 0
maxStreak.value = 0
totalHits.value = 0
totalPerfects.value = 0
totalMisses.value = 0
concertFinished.value = false
}
// Trigger hitting a lane
function hitLane(lane: 0 | 1 | 2) {
if (showConcertIntro.value || concertFinished.value) return
lanePressed.value[lane] = true
setTimeout(() => {
lanePressed.value[lane] = false
}, 120)
const currentT = (performance.now() - startTime) / 1000
// Find closest unhit note in this lane
let closestNote: Note | null = null
let minDiff = Infinity
for (const n of notes) {
if (!n.hit && !n.missed && n.lane === lane) {
const diff = Math.abs(n.targetTime - currentT)
if (diff < minDiff && diff < 0.35) {
minDiff = diff
closestNote = n
}
}
}
const laneX = laneCenters[lane]
if (closestNote) {
closestNote.hit = true
sound.playLuteNote(lane, true)
streak.value++
if (streak.value > maxStreak.value) maxStreak.value = streak.value
totalHits.value++
if (minDiff < 0.12) {
// Perfect!
totalPerfects.value++
score.value += 150 * Math.min(4, Math.floor(streak.value / 5) + 1)
addPopup(laneX, strikeY - 15, 'PERFEKT! ✨', '#ffd700')
spawnParticles(laneX, strikeY, '#ffd700', 12)
} else {
// Good!
score.value += 80 * Math.min(4, Math.floor(streak.value / 5) + 1)
addPopup(laneX, strikeY - 15, 'GUT! 🎵', '#4ade80')
spawnParticles(laneX, strikeY, '#4ade80', 8)
}
} else {
// Strummed without note -> minor mistwizzle
sound.playLuteNote(lane, false)
if (streak.value > 3) {
addPopup(laneX, strikeY - 10, 'UPPS!', '#f87171')
}
streak.value = 0
}
}
function addPopup(x: number, y: number, text: string, color: string) {
hitPopups.push({ x, y, text, color, life: 25 })
}
function spawnParticles(x: number, y: number, color: string, count = 8) {
for (let i = 0; i < count; i++) {
particles.push({
x,
y,
vx: (Math.random() - 0.5) * 6,
vy: (Math.random() - 0.5) * 6,
life: 20 + Math.random() * 10,
color,
size: 2 + Math.random() * 3
})
}
}
function finishConcert() {
concertFinished.value = true
sound.playFanfare()
store.serveGuest()
store.serveGuest()
store.addKronkorken(10)
store.unlockEasterEgg('lute_hero')
}
function proceedToCertificate() {
store.setAct('finale')
}
// Controls
function handleKeyDown(e: KeyboardEvent) {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') {
hitLane(0)
} else if (e.code === 'ArrowDown' || e.code === 'KeyS' || e.code === 'Space') {
hitLane(1)
} else if (e.code === 'ArrowRight' || e.code === 'KeyD') {
hitLane(2)
}
}
defineExpose({
hitLane
})
function updatePhysics() {
if (store.isPaused || showConcertIntro.value || concertFinished.value) return
elapsedTime = (performance.now() - startTime) / 1000
// Check missed notes
notes.forEach(n => {
if (!n.hit && !n.missed && elapsedTime - n.targetTime > 0.3) {
n.missed = true
totalMisses.value++
streak.value = 0
addPopup(laneCenters[n.lane], strikeY + 15, 'VORBEI! 💨', '#94a3b8')
}
})
// Popups update
hitPopups.forEach(p => {
p.y -= 1
p.life--
})
hitPopups = hitPopups.filter(p => p.life > 0)
// Particles update
particles.forEach(p => {
p.x += p.vx
p.y += p.vy
p.life--
})
particles = particles.filter(p => p.life > 0)
// Song completion
if (elapsedTime >= SONG_DURATION && !concertFinished.value) {
finishConcert()
}
}
function drawGame(ctx: CanvasRenderingContext2D) {
ctx.clearRect(0, 0, W, H)
// 1. Stage Background
if (stageImg && stageImg.complete && stageImg.naturalWidth > 0) {
ctx.drawImage(stageImg, 0, 0, W, H)
} else {
// Atmospheric dark medieval rock concert gradient
const grad = ctx.createLinearGradient(0, 0, 0, H)
grad.addColorStop(0, '#0f172a')
grad.addColorStop(0.5, '#451a03')
grad.addColorStop(1, '#170c06')
ctx.fillStyle = grad
ctx.fillRect(0, 0, W, H)
}
// Dark overlay over stage to keep fretboard high-contrast
ctx.fillStyle = 'rgba(10, 5, 3, 0.45)'
ctx.fillRect(0, 0, W, H)
// 2. Fretboard (Parchment & Eiche Neck)
ctx.fillStyle = 'rgba(25, 12, 6, 0.88)'
ctx.fillRect(boardX, 0, boardW, H)
ctx.strokeStyle = '#d4af37'
ctx.lineWidth = 2
ctx.strokeRect(boardX, 0, boardW, H)
// Frets (horizontal brass bars)
ctx.strokeStyle = 'rgba(212, 175, 55, 0.25)'
ctx.lineWidth = 1.5
for (let y = 60; y < H; y += 45) {
ctx.beginPath()
ctx.moveTo(boardX, y)
ctx.lineTo(boardX + boardW, y)
ctx.stroke()
}
// 3 Strings (vertical glowing lines)
for (let i = 0; i < 3; i++) {
const lx = laneCenters[i]
ctx.strokeStyle = lanePressed.value[i] ? '#ffffff' : laneColors[i]
ctx.lineWidth = lanePressed.value[i] ? 4 : 2
ctx.beginPath()
ctx.moveTo(lx, 0)
ctx.lineTo(lx, H)
ctx.stroke()
// Strike Target Circles at bottom
ctx.fillStyle = lanePressed.value[i] ? laneColors[i] : 'rgba(0, 0, 0, 0.6)'
ctx.beginPath()
ctx.arc(lx, strikeY, 18, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = laneColors[i]
ctx.lineWidth = 3
ctx.stroke()
// Target glyph
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 9px monospace'
ctx.fillText(i === 0 ? 'D' : i === 1 ? 'G' : 'A', lx - 4, strikeY + 3)
}
// 3. Falling Notes
const speed = 260 // pixels per second travel speed
const noteRadius = 14
notes.forEach(n => {
if (n.hit || n.missed) return
const timeDiff = n.targetTime - elapsedTime
const ny = strikeY - timeDiff * speed
if (ny >= -20 && ny <= strikeY + 40) {
const nx = laneCenters[n.lane]
// Outer glow
ctx.fillStyle = laneGlow[n.lane]
ctx.beginPath()
ctx.arc(nx, ny, noteRadius + 5, 0, Math.PI * 2)
ctx.fill()
// Note body (flaming rune / beer cap pick)
ctx.fillStyle = laneColors[n.lane]
ctx.beginPath()
ctx.arc(nx, ny, noteRadius, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 2
ctx.stroke()
// Center music note icon
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 11px sans-serif'
ctx.fillText('♫', nx - 4, ny + 4)
}
})
// 4. Hit Burst Particles
particles.forEach(p => {
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
ctx.fill()
})
// 5. Floating Hit Popups
hitPopups.forEach(p => {
ctx.fillStyle = p.color
ctx.font = 'bold 9px monospace'
ctx.fillText(p.text, p.x - 24, p.y)
})
// 6. HUD (Top Banner)
drawHUD(ctx)
}
function drawHUD(ctx: CanvasRenderingContext2D) {
// Top Header Banner
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)'
ctx.fillRect(0, 0, W, 32)
ctx.strokeStyle = '#d4af37'
ctx.lineWidth = 1
ctx.strokeRect(0, 0, W, 32)
// Score & Streak
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 8px monospace'
ctx.fillText(`PUNKTE: ${score.value}`, 8, 14)
const multiplier = Math.min(4, Math.floor(streak.value / 5) + 1)
ctx.fillStyle = streak.value >= 5 ? '#f59e0b' : '#38bdf8'
ctx.fillText(`COMBO: x${multiplier} (${streak.value})`, 8, 26)
// Progress Bar for Song
const progress = Math.min(1, Math.max(0, elapsedTime / SONG_DURATION))
ctx.fillStyle = '#334155'
ctx.fillRect(W - 100, 10, 90, 12)
ctx.fillStyle = '#10b981'
ctx.fillRect(W - 98, 12, 86 * progress, 8)
ctx.strokeStyle = '#ffd700'
ctx.strokeRect(W - 100, 10, 90, 12)
}
function gameLoop() {
updatePhysics()
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) drawGame(ctx)
}
animId = requestAnimationFrame(gameLoop)
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
animId = requestAnimationFrame(gameLoop)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
if (animId) cancelAnimationFrame(animId)
})
</script>
<template>
<div class="concert-viewport">
<canvas
ref="canvasRef"
:width="W"
:height="H"
class="game-canvas"
></canvas>
<!-- Bottom Thumb Controls (3 Huge Touch Buttons for the 3 Strings) -->
<div v-if="!showConcertIntro && !concertFinished" class="lute-hero-controls">
<button
class="string-btn btn-lane-0"
:class="{ active: lanePressed[0] }"
@touchstart.prevent="hitLane(0)"
@mousedown="hitLane(0)"
>
<span class="string-badge">D</span>
<span class="string-label">🎸 LINKS</span>
</button>
<button
class="string-btn btn-lane-1"
:class="{ active: lanePressed[1] }"
@touchstart.prevent="hitLane(1)"
@mousedown="hitLane(1)"
>
<span class="string-badge">G</span>
<span class="string-label">⚡ MITTE</span>
</button>
<button
class="string-btn btn-lane-2"
:class="{ active: lanePressed[2] }"
@touchstart.prevent="hitLane(2)"
@mousedown="hitLane(2)"
>
<span class="string-badge">A</span>
<span class="string-label">🤘 RECHTS</span>
</button>
</div>
<!-- Act IV Intro Modal -->
<div v-if="showConcertIntro" class="intro-overlay">
<div class="intro-card">
<span class="intro-act-tag">AKT IV: MITTELALTER GUITAR HERO</span>
<h2 class="intro-card-title">HANSIS ABSCHIEDSKONZERT</h2>
<p class="intro-card-story">
Ritter Hansi bricht auf nach Münchanien! Zum großen Abschied rocken König Sascha und Hansi als Barden-Duo die Burgbühne bei 135 BPM vor 12.000 begeisterten Fans!
</p>
<div class="mission-callout">
<span class="callout-icon">🎸</span>
<div class="callout-text">
<strong>Barden-Mission:</strong><br />
Triff die fallenden Noten im Takt auf den 3 Lauten-Saiten (Links, Mitte, Rechts). Halte die Combo oben für den maximalen Rock-Multiplikator!
</div>
</div>
<div class="controls-overview">
<div class="ctrl-item">
<span class="ctrl-badge">TOUCH</span>
<span class="ctrl-label">3 große Saiten-Tasten am unteren Bildschirmrand</span>
</div>
<div class="ctrl-item">
<span class="ctrl-badge">TASTATUR</span>
<span class="ctrl-label">Links: ◀ / A | Mitte: ▼ / S / Leertaste | Rechts: ▶ / D</span>
</div>
</div>
<button class="start-act-btn" @click="startConcert">
🔥 BÜHNE FREI: KONZERT STARTEN!
</button>
</div>
</div>
<!-- Concert Finished Score Modal -->
<div v-if="concertFinished" class="intro-overlay">
<div class="intro-card finish-card">
<span class="intro-act-tag">🎉 ZUGABE! DIE CROWD TOBT!</span>
<h2 class="intro-card-title">LEGENDÄRES ABSCHIEDSKONZERT!</h2>
<p class="intro-card-story">
Was für ein Solo! Hansi und Sascha haben die Burgmauern von Haumdaucher zum Beben gebracht! Hansi verabschiedet sich mit Freudentränen Richtung Münchanien.
</p>
<div class="concert-stats">
<div class="stat-row">
<span class="stat-name">Rock-Punkte:</span>
<span class="stat-val highlight">{{ score }}</span>
</div>
<div class="stat-row">
<span class="stat-name">Perfekte Treffer:</span>
<span class="stat-val">{{ totalPerfects }} ✨</span>
</div>
<div class="stat-row">
<span class="stat-name">Maximale Combo:</span>
<span class="stat-val">{{ maxStreak }}x 🔥</span>
</div>
</div>
<div class="concert-rewards">
<span class="reward-pill">👑 +10 Kronkorken</span>
<span class="reward-pill">🍻 +2 Kaltes Helles</span>
</div>
<button class="start-act-btn victory-btn" @click="proceedToCertificate">
🏆 Zur königlichen Siegerurkunde!
</button>
</div>
</div>
</div>
</template>
<style scoped>
.concert-viewport {
position: relative;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
user-select: none;
touch-action: none;
}
.game-canvas {
width: 100%;
height: 100%;
display: block;
}
/* 3 Large Thumb Buttons at the Bottom */
.lute-hero-controls {
position: absolute;
bottom: 8px;
left: 12px;
right: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
z-index: 30;
}
.string-btn {
height: 52px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.7);
transition: transform 0.08s ease, filter 0.08s ease;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.string-btn:active, .string-btn.active {
transform: scale(0.92);
filter: brightness(1.3);
}
.btn-lane-0 {
background: linear-gradient(180deg, #b91c1c 0%, #7f1d1d 100%);
border: 2px solid #ef4444;
color: #fff;
}
.btn-lane-1 {
background: linear-gradient(180deg, #d97706 0%, #92400e 100%);
border: 2px solid #f59e0b;
color: #fff;
}
.btn-lane-2 {
background: linear-gradient(180deg, #0284c7 0%, #0369a1 100%);
border: 2px solid #38bdf8;
color: #fff;
}
.string-badge {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
background: rgba(0, 0, 0, 0.4);
padding: 1px 6px;
border-radius: 4px;
}
.string-label {
font-family: 'MedievalSharp', serif;
font-size: 13px;
font-weight: bold;
}
/* Modals */
.intro-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 {
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;
}
.finish-card {
border-color: #ffd700;
background: radial-gradient(circle at 50% 20%, #3b1d0c 0%, #1a0c06 100%);
}
.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: 9px;
color: #ffd700;
margin: 0;
line-height: 1.35;
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 #f59e0b;
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: #fef3c7;
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: 11px;
color: #fed7aa;
text-align: right;
}
.start-act-btn {
background: linear-gradient(180deg, #dc2626 0%, #991b1b 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 {
background: linear-gradient(180deg, #ef4444 0%, #b91c1c 100%);
transform: scale(1.02);
}
.start-act-btn:active {
transform: scale(0.96);
}
.victory-btn {
background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
}
.victory-btn:hover {
background: linear-gradient(180deg, #22c55e 0%, #16a34a 100%);
}
.concert-stats {
width: 100%;
background: #1c0a04;
border: 1px solid #b45309;
border-radius: 6px;
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-name {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fed7aa;
}
.stat-val {
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #fff;
}
.stat-val.highlight {
color: #ffd700;
font-size: 9px;
}
.concert-rewards {
display: flex;
gap: 8px;
}
.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;
}
</style>