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:
Moritz Graf
2026-09-23 21:56:34 +02:00
parent d11a84a0d3
commit 5852479c21
53 changed files with 5544 additions and 0 deletions
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sasch40
labels:
app: sasch40
namespace: web
spec:
selector:
matchLabels:
app: sasch40
replicas: 1
template:
metadata:
labels:
app: sasch40
spec:
imagePullSecrets:
- name: "registry-haumdaucher-de"
containers:
- image: registry.haumdaucher.de/sasch40:latest
imagePullPolicy: Always
name: sasch40
ports:
- containerPort: 80
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: sasch40
namespace: web
spec:
ports:
- port: 80
targetPort: 80
protocol: TCP
selector:
app: sasch40
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sasch40
namespace: web
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- "sasch40.haumdaucher.de"
secretName: sasch40-haumdaucher-de-tls
rules:
- host: sasch40.haumdaucher.de
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sasch40
port:
number: 80
+60
View File
@@ -0,0 +1,60 @@
# Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft 👑🍺🔊
Ein mobiles 2.5D Pixel-Art Web-Game zum 40. Geburtstag von König Sascha I.
Gehostet unter: `https://sasch40.haumdaucher.de`
---
## 🌟 Spielinhalt & Akte
1. **Prolog (Schloss Haumdaucher Balkon)**: NAT berichtet als Hof-Chronist (mit Bridgerton-Referenzen) vom Verlust der 40 Kronkorken des Reiches. Mop der Erschaffer schaltet die Sprungfedern frei.
2. **Level 1 (Die Hopfen-Pfade)**: 60fps Casual-Jump&Run auf Dächern und Fässern zum Sammeln von Kronkorken und kühlem Hellen.
3. **Die Taverne 'Zum wummernden Ritter'**: Point & Click Adventure mit interaktiven Charakteren:
- Verena (*„Ich putze nach der Party fei ned!“*)
- Armend (schmiedet Kronkorken zum goldenen Subwoofer)
- Lutz im ZTUL-Shirt (Schornsteinfeger)
- Amy in Latzhose (*„Rave the Kingdom!“*)
- Mane (Wander- und Spieletipps)
- Pü (Lichtmeister mit Stroboskop-Fackeln)
- Der Zuluman-Zauberspiegel (Urlaubs-Easter-Egg)
- Bayerisches Beleidigungs-Gstanzl-Fechten
4. **Level 2 (Der Fest-Konvoi)**: Speed-Runner auf dem Bollerwagen mit 135-BPM-Techno und dem jubelnden Haumdaucher Gefolge (Hansi, Viktor, Albin, Fabian, Maier Flo, Guatemalteke, Markus & Nathalie).
5. **Finale & Krönung**: Thronsaal mit Konfetti-Explosion, Auswertung und druck-/screenshotbarer königlicher Urkunde.
---
## 🖼️ Hot-Swappable Asset-Pipeline (Bilder austauschen)
Alle Charakter-Bilder und Sprites sind vollständig vom Code entkoppelt:
* **Bilder-Ordner**: `projects/sasch40/frontend/public/assets/characters/`
* `sascha/portrait.jpg` (oder `.png`)
* `nat/portrait.jpg`
* `verena/portrait.jpg`
* `mop/portrait.jpg`
* `lutz/portrait.jpg`
* `armend/portrait.jpg`
* `amy/portrait.jpg`
* **Originalfotos**: Liegen in `projects/sasch40/source_photos/`
* **Zentrales Register**: Falls ein Dateipfad oder Name angepasst werden soll, geschieht das einfach in `projects/sasch40/frontend/src/config/characters.ts`.
---
## 🚀 Lokale Entwicklung
```bash
cd projects/sasch40/frontend
npm install
npm run dev
# Browser öffnen unter: http://localhost:3000
```
---
## 🐳 Docker & Kubernetes Deployment
```bash
cd projects/sasch40/frontend
make all # Baut und pusht registry.haumdaucher.de/sasch40:latest
# Ingress & Deployment im Cluster anwenden:
kubectl apply -f ../../../k8s/web/sasch40.yaml
```
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.local
+20
View File
@@ -0,0 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+22
View File
@@ -0,0 +1,22 @@
REGISTRY ?= registry.haumdaucher.de
IMAGE_NAME ?= sasch40
TAG ?= latest
PLATFORM ?= linux/amd64
IMAGE ?= $(REGISTRY)/$(IMAGE_NAME):$(TAG)
.PHONY: build tag push all dev
all: build tag push
dev:
npm run dev
build:
docker build --platform $(PLATFORM) -t $(IMAGE_NAME):$(TAG) .
tag:
docker tag $(IMAGE_NAME):$(TAG) $(IMAGE)
push:
docker push $(IMAGE)
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/ui/crown.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#2b1810" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft - Das epische Geburtstagsspiel" />
<link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<title>Saschas Kronkorken Kreuzzug – 40 Jahre Bass & Gerstensaft</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "sasch40-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^0.468.0",
"pinia": "^2.3.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="20" fill="#2b1810"/>
<path d="M20 75 L80 75 L85 40 L65 55 L50 25 L35 55 L15 40 Z" fill="#ffd700" stroke="#b8860b" stroke-width="3"/>
<circle cx="50" cy="25" r="5" fill="#e63946"/>
<circle cx="15" cy="40" r="4" fill="#457b9d"/>
<circle cx="85" cy="40" r="4" fill="#457b9d"/>
<!-- Sunglasses -->
<rect x="32" y="60" width="16" height="10" rx="3" fill="#111"/>
<rect x="52" y="60" width="16" height="10" rx="3" fill="#111"/>
<rect x="48" y="63" width="4" height="3" fill="#111"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

@@ -0,0 +1,18 @@
{
"name": "Saschas Kronkorken Kreuzzug",
"short_name": "Sasch40",
"description": "40 Jahre Bass & Gerstensaft – Das epische Thronjubiläum",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#1a0f0a",
"theme_color": "#d4af37",
"icons": [
{
"src": "/assets/ui/crown.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import GameBoyFrame from './components/GameBoyFrame.vue'
</script>
<template>
<main class="app-root">
<GameBoyFrame />
</main>
</template>
<style scoped>
.app-root {
width: 100vw;
height: 100dvh;
display: flex;
justify-content: center;
align-items: center;
background: #0f0a07;
overflow: hidden;
}
</style>
@@ -0,0 +1,285 @@
// Procedural 8-bit chiptune & medieval techno sound engine using Web Audio API
class SoundEngine {
private ctx: AudioContext | null = null
private isMuted: boolean = false
private bgmPlaying: boolean = false
private bgmTimer: number | null = null
private step: number = 0
private initCtx() {
if (!this.ctx) {
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
this.ctx = new AudioCtx()
}
if (this.ctx.state === 'suspended') {
this.ctx.resume()
}
}
public toggleMute(): boolean {
this.isMuted = !this.isMuted
if (this.isMuted) {
this.stopBGM()
} else {
this.startBGM()
}
return this.isMuted
}
public getMuted(): boolean {
return this.isMuted
}
// SFX: Kronkorken Collect (bright double ding)
public playCoin() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(987.77, now) // B5
osc.frequency.setValueAtTime(1318.51, now + 0.08) // E6
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.35)
}
// SFX: Sprung / Jump (arcade sweep up)
public playJump() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(150, now)
osc.frequency.exponentialRampToValueAtTime(600, now + 0.15)
gain.gain.setValueAtTime(0.15, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.15)
}
// SFX: Beer Gulp / Power-up
public playGulp() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(400, now)
osc.frequency.exponentialRampToValueAtTime(180, now + 0.25)
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.25)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.25)
}
// SFX: Dialog blip (Monkey Island text chatter)
public playDialogueBlip() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sine'
const freqs = [320, 360, 420, 480]
const randomFreq = freqs[Math.floor(Math.random() * freqs.length)]
osc.frequency.setValueAtTime(randomFreq, now)
gain.gain.setValueAtTime(0.08, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.05)
}
// SFX: Fanfare / Royal Crowning
public playFanfare() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const notes = [261.63, 329.63, 392.00, 523.25] // C, E, G, C
notes.forEach((freq, idx) => {
if (!this.ctx) return
const now = this.ctx.currentTime + idx * 0.12
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(freq, now)
gain.gain.setValueAtTime(0.25, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + (idx === 3 ? 0.8 : 0.2))
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + (idx === 3 ? 0.8 : 0.25))
})
}
// 135 BPM Medieval Techno Chiptune Beat
public startBGM() {
if (this.isMuted || this.bgmPlaying) return
this.initCtx()
this.bgmPlaying = true
// 135 BPM = 135 beats/min = 2.25 beats/sec -> 16th note = 60 / (135 * 4) = 0.111s
const stepDuration = (60 / 135) / 2 // 8th note = 0.222s
this.step = 0
// Medieval Dorian melody scale (D, E, F, G, A, B, C)
const melody = [
293.66, 0, 349.23, 392.00, // D4, -, F4, G4
440.00, 0, 392.00, 349.23, // A4, -, G4, F4
392.00, 440.00, 523.25, 440.00, // G4, A4, C5, A4
349.23, 0, 293.66, 0 // F4, -, D4, -
]
const bassline = [
146.83, 146.83, 146.83, 146.83, // D3
174.61, 174.61, 174.61, 174.61, // F3
196.00, 196.00, 196.00, 196.00, // G3
146.83, 146.83, 220.00, 146.83 // D3, A3
]
const playLoop = () => {
if (!this.bgmPlaying || !this.ctx) return
const now = this.ctx.currentTime
// 4-to-the-floor Techno Kick on every even step (quarter note)
if (this.step % 2 === 0) {
const kickOsc = this.ctx.createOscillator()
const kickGain = this.ctx.createGain()
kickOsc.frequency.setValueAtTime(140, now)
kickOsc.frequency.exponentialRampToValueAtTime(35, now + 0.1)
kickGain.gain.setValueAtTime(0.35, now)
kickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
kickOsc.connect(kickGain)
kickGain.connect(this.ctx.destination)
kickOsc.start(now)
kickOsc.stop(now + 0.18)
}
// Hi-Hat / Shaker on offbeats
if (this.step % 2 === 1) {
const hatNode = this.ctx.createBufferSource()
const bufferSize = this.ctx.sampleRate * 0.04
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1
}
hatNode.buffer = buffer
const hatFilter = this.ctx.createBiquadFilter()
hatFilter.type = 'highpass'
hatFilter.frequency.value = 7000
const hatGain = this.ctx.createGain()
hatGain.gain.setValueAtTime(0.08, now)
hatGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04)
hatNode.connect(hatFilter)
hatFilter.connect(hatGain)
hatGain.connect(this.ctx.destination)
hatNode.start(now)
}
// Bassline (sawtooth filtered)
const bassFreq = bassline[this.step % bassline.length]
if (bassFreq > 0) {
const bassOsc = this.ctx.createOscillator()
const bassGain = this.ctx.createGain()
const bassFilter = this.ctx.createBiquadFilter()
bassOsc.type = 'sawtooth'
bassOsc.frequency.setValueAtTime(bassFreq, now)
bassFilter.type = 'lowpass'
bassFilter.frequency.setValueAtTime(450, now)
bassGain.gain.setValueAtTime(0.12, now)
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2)
bassOsc.connect(bassFilter)
bassFilter.connect(bassGain)
bassGain.connect(this.ctx.destination)
bassOsc.start(now)
bassOsc.stop(now + 0.2)
}
// Medieval Lute Lead Melody
const melFreq = melody[this.step % melody.length]
if (melFreq > 0) {
const melOsc = this.ctx.createOscillator()
const melGain = this.ctx.createGain()
melOsc.type = 'square'
melOsc.frequency.setValueAtTime(melFreq, now)
melGain.gain.setValueAtTime(0.08, now)
melGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
melOsc.connect(melGain)
melGain.connect(this.ctx.destination)
melOsc.start(now)
melOsc.stop(now + 0.18)
}
this.step++
this.bgmTimer = window.setTimeout(playLoop, stepDuration * 1000)
}
playLoop()
}
public stopBGM() {
this.bgmPlaying = false
if (this.bgmTimer) {
clearTimeout(this.bgmTimer)
this.bgmTimer = null
}
}
}
export const sound = new SoundEngine()
@@ -0,0 +1,434 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import confetti from 'canvas-confetti'
const store = useGameStore()
const certificateRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
// Fire celebratory party confetti
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
})
})
function restart() {
store.resetGame()
}
function printOrSave() {
window.print()
}
</script>
<template>
<div class="certificate-container">
<div ref="certificateRef" class="certificate-paper">
<div class="royal-seal">👑 40 👑</div>
<h1 class="cert-title">KÖNIGLICHE THRON-URKUNDE</h1>
<h2 class="cert-subtitle">Gratulation zu 40 Jahren Regentschaft!</h2>
<div class="king-portrait-wrapper">
<img
src="/assets/characters/sascha/portrait.jpg"
alt="König Sascha I."
class="king-portrait"
/>
</div>
<!-- Party Competition Highscore Banner -->
<div class="score-banner" :style="{ borderColor: store.scoreRank.color }">
<div class="score-rank-title" :style="{ color: store.scoreRank.color }">
★ {{ store.scoreRank.rank }} ★
</div>
<div class="score-number">
{{ store.totalScore.toLocaleString('de-DE') }} PUNKTE
</div>
<div class="score-subtitle">
„{{ store.scoreRank.title }}“
</div>
</div>
<div class="cert-body">
<p class="decree-text">
Hiermit bekundet das gesamte Herzogtum Haumdaucher, dass
</p>
<h3 class="honoree-name">KÖNIG SASCHA I.</h3>
<p class="decree-details">
die 40 Kronkorken des Reiches glorreich vereint und den Bass auf unsterbliche 135 BPM gehoben hat!
</p>
</div>
<!-- Stats Grid -->
<div class="cert-stats">
<div class="stat-card">
<span class="stat-val">👑 {{ store.kronkorken }}</span>
<span class="stat-lbl">KRONKORKEN</span>
</div>
<div class="stat-card">
<span class="stat-val">🍻 {{ store.beers }}</span>
<span class="stat-lbl">KRÜGE HELLES</span>
</div>
<div class="stat-card">
<span class="stat-val">👥 {{ store.guestsServed }}</span>
<span class="stat-lbl">GEFOLGE VERSORGT</span>
</div>
<div class="stat-card">
<span class="stat-val">🔊 135</span>
<span class="stat-lbl">TECHNO BPM</span>
</div>
</div>
<!-- Point Breakdown Table -->
<div class="breakdown-card">
<div class="breakdown-header">📊 SCORE-AUFSCHLÜSSELUNG</div>
<div class="breakdown-row">
<span>Kronkorken ({{ store.kronkorken }}x)</span>
<span class="breakdown-pts">+{{ store.kronkorken * 100 }}</span>
</div>
<div class="breakdown-row">
<span>Helles gezapft ({{ store.beers }}x)</span>
<span class="breakdown-pts">+{{ store.beers * 250 }}</span>
</div>
<div class="breakdown-row">
<span>Gefolge glücklich ({{ store.guestsServed }}x)</span>
<span class="breakdown-pts">+{{ store.guestsServed * 300 }}</span>
</div>
<div class="breakdown-row">
<span>Gstanzl-Battle Sieg</span>
<span class="breakdown-pts">{{ store.gstanzlWon ? '+1500' : '0' }}</span>
</div>
<div class="breakdown-row">
<span>Geheimnisse ({{ store.unlockedEasterEggs.length }}x)</span>
<span class="breakdown-pts">+{{ store.unlockedEasterEggs.length * 500 }}</span>
</div>
<div class="breakdown-row total-row">
<span>GESAMT-ERGEBNIS</span>
<span class="total-pts">{{ store.totalScore.toLocaleString('de-DE') }}</span>
</div>
<div class="compare-callout">
📱 Zeig deinen Score an der Bar & vergleiche mit anderen Gästen!
</div>
</div>
<!-- Royal Postscript from Verena -->
<div class="queen-ps">
<span class="queen-signature">Königin Verena bemerkt:</span>
<p>„Ich hab dich unendlich lieb mein Schatz... aber geputzt wird nach der Party fei trotzdem ned!“ 🧹</p>
</div>
<!-- Easter Eggs Unlocked -->
<div v-if="store.unlockedEasterEggs.length" class="eggs-list">
<span>Gefundene Geheimnisse:</span>
<div class="egg-tags">
<span v-for="egg in store.unlockedEasterEggs" :key="egg" class="egg-badge">
🌟 {{ egg.toUpperCase() }}
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="cert-actions">
<button class="save-btn" @click="printOrSave">
📸 URKUNDE DRUCKEN / SCREENSHOT
</button>
<button class="restart-btn" @click="restart">
🔄 NOCHMAL SPIELEN
</button>
</div>
</div>
</template>
<style scoped>
.certificate-container {
width: 100%;
height: 100%;
background: #1a0f0a;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.certificate-paper {
background: #fdf6e2;
border: 6px double #854d0e;
border-radius: 12px;
padding: 16px 14px;
width: 100%;
max-width: 330px;
text-align: center;
color: #451a03;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
position: relative;
}
.royal-seal {
font-size: 16px;
font-weight: bold;
color: #b45309;
margin-bottom: 4px;
}
.cert-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #78350f;
margin: 0 0 6px 0;
}
.cert-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 17px;
font-weight: bold;
color: #b91c1c;
margin: 0 0 10px 0;
}
.king-portrait-wrapper {
margin: 0 auto 10px auto;
width: 90px;
height: 90px;
border: 3px solid #d4af37;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
.king-portrait {
width: 100%;
height: 100%;
object-fit: cover;
}
.decree-text {
font-family: 'MedievalSharp', serif;
font-size: 13px;
margin: 4px 0;
}
.honoree-name {
font-family: 'Press Start 2P', monospace;
font-size: 12px;
color: #991b1b;
margin: 6px 0 2px 0;
}
.honoree-title {
font-family: 'MedievalSharp', serif;
font-size: 14px;
font-style: italic;
font-weight: bold;
color: #b45309;
margin: 0 0 6px 0;
}
.decree-details {
font-family: 'MedievalSharp', serif;
font-size: 13px;
line-height: 1.3;
margin: 0 0 10px 0;
}
.cert-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
}
.stat-card {
background: rgba(217, 119, 6, 0.15);
border: 1px solid #d97706;
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
}
.stat-val {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #92400e;
}
.stat-lbl {
font-size: 9px;
color: #78350f;
font-weight: bold;
}
.score-banner {
background: #1e1b18;
border: 3px solid #ffd700;
border-radius: 8px;
padding: 10px 8px;
margin-bottom: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.score-rank-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
margin-bottom: 4px;
text-shadow: 0 0 8px currentColor;
}
.score-number {
font-family: 'Press Start 2P', monospace;
font-size: 14px;
color: #fff;
letter-spacing: 1px;
margin: 4px 0;
}
.score-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 13px;
font-style: italic;
color: #fef08a;
}
.breakdown-card {
background: rgba(254, 243, 199, 0.7);
border: 1px solid #d97706;
border-radius: 6px;
padding: 8px;
margin-bottom: 10px;
font-family: 'MedievalSharp', serif;
font-size: 12px;
text-align: left;
}
.breakdown-header {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #78350f;
margin-bottom: 6px;
text-align: center;
border-bottom: 1px dashed #b45309;
padding-bottom: 4px;
}
.breakdown-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
color: #451a03;
}
.breakdown-pts {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #15803d;
font-weight: bold;
}
.total-row {
border-top: 1px solid #b45309;
margin-top: 4px;
padding-top: 4px;
font-weight: bold;
}
.total-pts {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #b45309;
}
.compare-callout {
margin-top: 6px;
padding: 4px;
background: #fef08a;
border: 1px solid #eab308;
border-radius: 4px;
font-size: 10px;
color: #713f12;
text-align: center;
font-weight: bold;
}
.queen-ps {
background: #fef3c7;
border-left: 3px solid #ec4899;
padding: 6px 8px;
border-radius: 4px;
margin-bottom: 8px;
font-family: 'MedievalSharp', serif;
text-align: left;
}
.queen-signature {
font-size: 11px;
font-weight: bold;
color: #be185d;
}
.queen-ps p {
margin: 2px 0 0 0;
font-size: 12px;
color: #831843;
}
.eggs-list {
font-size: 11px;
color: #78350f;
margin-top: 6px;
}
.egg-tags {
display: flex;
justify-content: center;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.egg-badge {
background: #f59e0b;
color: #fff;
font-size: 8px;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Press Start 2P', monospace;
}
.cert-actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 330px;
}
.save-btn {
background: #15803d;
color: #fff;
border: 2px solid #86efac;
border-radius: 8px;
padding: 10px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
}
.restart-btn {
background: #78350f;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 8px;
padding: 8px;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
cursor: pointer;
}
</style>
@@ -0,0 +1,558 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
import IntroView from './IntroView.vue'
import PrologueView from './PrologueView.vue'
import PlatformerView from './PlatformerView.vue'
import TavernView from './TavernView.vue'
import PartyExpressView from './PartyExpressView.vue'
import CertificateModal from './CertificateModal.vue'
const store = useGameStore()
const platformerRef = ref<InstanceType<typeof PlatformerView> | null>(null)
const inventoryOpen = ref(false)
function handleJump() {
if (platformerRef.value) {
platformerRef.value.onJump()
}
}
function handleDpad(dir: 'left' | 'right', pressed: boolean) {
if (platformerRef.value) {
if (dir === 'left') platformerRef.value.onLeft(pressed)
if (dir === 'right') platformerRef.value.onRight(pressed)
}
}
function toggleAudio() {
store.toggleMute()
}
function toggleInventory() {
inventoryOpen.value = !inventoryOpen.value
}
</script>
<template>
<div class="gameboy-chassis" :class="{ 'arcade-layout': store.isArcadeMode, 'adventure-layout': !store.isArcadeMode }">
<!-- Top Royal Header -->
<div class="chassis-top">
<div class="speaker-grill-left">
<span></span><span></span><span></span>
</div>
<div class="chassis-brand">
<span class="brand-text">KING SASCHA XL</span>
<span v-if="store.currentAct !== 'intro'" class="brand-act">
{{ store.currentAct === 'platformer' ? '• AKTE II' : store.currentAct === 'tavern' ? '• AKTE III' : store.currentAct === 'party_express' ? '• AKTE IV' : '' }}
</span>
</div>
<button class="sound-toggle" @click="toggleAudio">
{{ store.soundMuted ? '🔇 TON' : '🔊 135 BPM' }}
</button>
</div>
<!-- Active Screen Viewport (Takes full height in Adventure / Role Play mode!) -->
<div class="screen-bezel" :class="{ 'fullscreen-bezel': !store.isArcadeMode }">
<div class="screen-glass">
<IntroView v-if="store.currentAct === 'intro'" />
<PrologueView v-else-if="store.currentAct === 'prologue'" />
<PlatformerView v-else-if="store.currentAct === 'platformer'" ref="platformerRef" />
<TavernView v-else-if="store.currentAct === 'tavern'" />
<PartyExpressView v-else-if="store.currentAct === 'party_express'" />
<CertificateModal v-else-if="store.currentAct === 'finale'" />
</div>
</div>
<!-- ARCADE CONTROLS ONLY: Rendered during Jump&Run / Speed-Runner -->
<template v-if="store.isArcadeMode">
<!-- Medieval Stats Parchment Scroll -->
<div class="parchment-scroll">
<div class="parchment-content">
<span class="stat-badge">👑 KORKEN: <strong>{{ store.kronkorken }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🍻 HELLES: <strong>{{ store.beers }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🔊 <strong>135 BPM</strong></span>
</div>
</div>
<!-- Handheld Controller Lower Body -->
<div class="chassis-controls">
<!-- D-PAD -->
<div class="dpad-wrapper">
<div class="dpad">
<button class="dpad-btn dpad-up" disabled></button>
<button
class="dpad-btn dpad-left"
@touchstart.prevent="handleDpad('left', true)"
@touchend.prevent="handleDpad('left', false)"
@mousedown="handleDpad('left', true)"
@mouseup="handleDpad('left', false)"
>◀</button>
<div class="dpad-center"></div>
<button
class="dpad-btn dpad-right"
@touchstart.prevent="handleDpad('right', true)"
@touchend.prevent="handleDpad('right', false)"
@mousedown="handleDpad('right', true)"
@mouseup="handleDpad('right', false)"
>▶</button>
<button class="dpad-btn dpad-down" disabled></button>
</div>
<span class="control-label">D-PAD</span>
</div>
<!-- Center Logo & Utility Buttons -->
<div class="center-controls">
<div class="royal-crest">👑</div>
<div class="pill-buttons">
<button class="pill-btn" @click="toggleInventory">
<span class="pill-key">SELECT</span>
<span class="pill-sub">BEUTEL</span>
</button>
<button class="pill-btn" @click="toggleAudio">
<span class="pill-key">START</span>
<span class="pill-sub">BASS</span>
</button>
</div>
</div>
<!-- Action Buttons A & B -->
<div class="action-buttons-wrapper">
<div class="action-buttons">
<button
class="action-round-btn btn-b"
@click="store.addBeer"
>
<span class="btn-letter">B</span>
</button>
<button
class="action-round-btn btn-a"
@click="handleJump"
@touchstart.prevent="handleJump"
>
<span class="btn-letter">A</span>
</button>
</div>
<div class="action-labels">
<span>O'ZAPFT (B)</span>
<span>SPRUNG (A)</span>
</div>
</div>
</div>
</template>
<!-- Mini Bottom Status Bar for Adventure/Role Play Mode -->
<div v-else class="adventure-bottom-bar">
<div class="adv-stat">
<span class="adv-icon">👑</span>
<span class="adv-val">{{ store.kronkorken }} Korken</span>
</div>
<div class="adv-stat">
<span class="adv-icon">🍻</span>
<span class="adv-val">{{ store.beers }} Helles</span>
</div>
<button class="inv-mini-btn" @click="toggleInventory">
🎒 Beutel
</button>
</div>
<!-- Inventory Drawer Modal -->
<div v-if="inventoryOpen" class="inventory-drawer" @click="inventoryOpen = false">
<div class="inventory-card" @click.stop>
<h3>👑 KÖNIGLICHER LEERGUTSACK</h3>
<ul class="inventory-items">
<li v-for="item in store.inventory" :key="item" class="inv-item">
✨ {{ item }}
</li>
</ul>
<button class="close-inv-btn" @click="inventoryOpen = false">Schließen</button>
</div>
</div>
</div>
</template>
<style scoped>
.gameboy-chassis {
display: flex;
flex-direction: column;
width: 100%;
max-width: 440px;
height: 100dvh;
margin: 0 auto;
background: linear-gradient(180deg, #3d2214 0%, #26140b 100%);
border: 4px solid #b45309;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.9);
user-select: none;
overflow: hidden;
box-sizing: border-box;
}
/* Top Trim */
.chassis-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #2b1810;
border-bottom: 2px solid #78350f;
flex-shrink: 0;
}
.chassis-brand {
display: flex;
align-items: center;
gap: 6px;
}
.brand-text {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ffd700;
letter-spacing: 0.5px;
}
.brand-act {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #f59e0b;
}
.speaker-grill-left {
display: flex;
gap: 3px;
}
.speaker-grill-left span {
width: 3px;
height: 12px;
background: #1a0f0a;
border-radius: 2px;
}
.sound-toggle {
background: #78350f;
color: #fef08a;
border: 1px solid #ffd700;
border-radius: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 4px 6px;
cursor: pointer;
}
/* Screen Bezel */
.screen-bezel {
flex: 1;
background: #17100b;
margin: 6px 8px 4px 8px;
border-radius: 8px;
border: 3px solid #1a0f0a;
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.9);
padding: 4px;
display: flex;
overflow: hidden;
position: relative;
}
.fullscreen-bezel {
margin: 4px 6px 0 6px;
border-radius: 6px;
}
.screen-glass {
width: 100%;
height: 100%;
background: #000;
border-radius: 4px;
overflow: hidden;
position: relative;
}
/* Adventure Bottom Bar */
.adventure-bottom-bar {
display: flex;
justify-content: space-around;
align-items: center;
background: #2b1810;
border-top: 2px solid #78350f;
padding: 6px 12px;
flex-shrink: 0;
}
.adv-stat {
display: flex;
align-items: center;
gap: 4px;
}
.adv-icon {
font-size: 14px;
}
.adv-val {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
}
.inv-mini-btn {
background: #78350f;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 4px;
padding: 4px 8px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
cursor: pointer;
}
/* Parchment Status Bar */
.parchment-scroll {
background: #fdf6e2;
border: 2px solid #b45309;
border-radius: 4px;
margin: 4px 10px;
padding: 4px 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.parchment-content {
display: flex;
justify-content: space-around;
align-items: center;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #78350f;
}
.stat-divider {
color: #b45309;
}
/* Controls Section (Arcade Mode Only) */
.chassis-controls {
height: 130px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px 10px 12px;
flex-shrink: 0;
}
/* D-PAD */
.dpad-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.dpad {
position: relative;
width: 84px;
height: 84px;
}
.dpad-center {
position: absolute;
top: 28px;
left: 28px;
width: 28px;
height: 28px;
background: #78350f;
}
.dpad-btn {
position: absolute;
background: #854d0e;
border: 1.5px solid #d4af37;
color: #fef08a;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
.dpad-btn:active {
background: #b45309;
}
.dpad-up {
top: 0; left: 28px; width: 28px; height: 28px;
border-radius: 4px 4px 0 0;
}
.dpad-down {
bottom: 0; left: 28px; width: 28px; height: 28px;
border-radius: 0 0 4px 4px;
}
.dpad-left {
top: 28px; left: 0; width: 28px; height: 28px;
border-radius: 4px 0 0 4px;
}
.dpad-right {
top: 28px; right: 0; width: 28px; height: 28px;
border-radius: 0 4px 4px 0;
}
.control-label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #d4af37;
}
/* Center */
.center-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.royal-crest {
font-size: 18px;
}
.pill-buttons {
display: flex;
gap: 6px;
}
.pill-btn {
display: flex;
flex-direction: column;
align-items: center;
background: #1c1917;
border: 1px solid #78350f;
border-radius: 12px;
padding: 3px 6px;
cursor: pointer;
transform: rotate(-15deg);
}
.pill-key {
font-family: 'Press Start 2P', monospace;
font-size: 5px;
color: #94a3b8;
}
.pill-sub {
font-size: 7px;
color: #d4af37;
}
/* Action Buttons */
.action-buttons-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.action-buttons {
position: relative;
width: 84px;
height: 64px;
}
.action-round-btn {
position: absolute;
width: 38px;
height: 38px;
border-radius: 50%;
border: 2px solid #ffd700;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.action-round-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.btn-b {
bottom: 0;
left: 0;
background: #eab308;
}
.btn-a {
top: 0;
right: 0;
background: #dc2626;
}
.btn-letter {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fff;
text-shadow: 1px 1px 0 #000;
}
.action-labels {
display: flex;
gap: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
color: #d4af37;
}
/* Inventory Modal */
.inventory-drawer {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.inventory-card {
background: #2b1810;
border: 3px solid #d4af37;
border-radius: 8px;
padding: 16px;
width: 80%;
color: #fef3c7;
}
.inventory-card h3 {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ffd700;
margin-top: 0;
}
.inventory-items {
list-style: none;
padding: 0;
margin: 12px 0;
}
.inv-item {
font-family: 'MedievalSharp', serif;
font-size: 15px;
padding: 4px 0;
border-bottom: 1px dashed #78350f;
}
.close-inv-btn {
background: #78350f;
color: #fff;
border: 1px solid #d4af37;
border-radius: 4px;
padding: 6px 12px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
cursor: pointer;
}
</style>
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
function handleStart() {
sound.playFanfare()
store.startGame()
}
</script>
<template>
<div class="intro-screen">
<!-- Royal Header -->
<header class="intro-header">
<div class="crown-badge">👑 40 JAHRE REGENTSCHAFT 👑</div>
<h1 class="game-main-title">SASCHAS KRONKORKEN KREUZZUG</h1>
<h2 class="game-subtitle">40 JAHRE BASS & GERSTENSAFT</h2>
</header>
<!-- Key Art: King Sascha I. -->
<div class="king-hero">
<div class="king-frame">
<img
src="/assets/characters/sascha/portrait.jpg"
alt="König Sascha I."
class="king-hero-img"
/>
</div>
<span class="king-caption">KÖNIG SASCHA I. – DER 40-JÄHRIGE IMPERATOR</span>
</div>
<!-- Prequel & QR Introduction -->
<div class="intro-scroll">
<div class="scroll-section">
<h3 class="section-title">📜 DIE GESCHICHTE DES REICHES</h3>
<p class="story-text">
<strong>Hört, hört, wertes Feiervolk!</strong><br>
Du hast soeben über den königlichen QR-Code das offizielle Thronjubiläums-Spiel betreten!
</p>
<p class="story-text">
Seit 40 Jahren regiert König Sascha das stolze Herzogtum Haumdaucher mit eiserner Hand, dunkler Sonnenbrille und wummernden 135-BPM-Techno-Bässen. Doch kurz vor dem heutigen Thron-Rave geschah das Unglück:
</p>
<p class="story-highlight">
Die Schatzkammer wurde geplündert! Sämtliche 40 geheiligten Kronkorken des Reiches sind über ganz Haumdaucher verstreut!
Ohne Kronkorken kein kühles Helles, ohne Helles kein Bass – und ohne Bass keine Feier!
</p>
</div>
<div class="scroll-section">
<h3 class="section-title">🎮 WAS DICH IM SPIEL ERWARTET</h3>
<ul class="features-list">
<li><strong>🏃‍♂️ Retro Jump & Run:</strong> Hüpfe über die Dächer der Hopfen-Pfade und sammle die verlorenen Kronkorken ein.</li>
<li><strong>🍻 Tavernen-Abenteuer:</strong> Sprich mit Verena, Lutz (ZTUL), Armend, Amy, Mane & Pü und gewinne das bayerische Beleidigungs-Gstanzl-Fechten!</li>
<li><strong>🌴 Der Zauberspiegel:</strong> Finde das geheime Urlaubserinnerungs-Easter-Egg von Buschkönig Zuluman.</li>
<li><strong>🔊 Der 135-BPM-Express:</strong> Rase mit den Hof-DJs auf dem Bollerwagen und versorge das feiernde Gefolge.</li>
<li><strong>🏆 Highscore-Urkunde:</strong> Hol dir am Ende deine persönliche königliche Punktzahl und vergleiche dich mit allen Partygästen!</li>
</ul>
</div>
</div>
<!-- Start Action -->
<footer class="intro-footer">
<button class="start-btn" @click="handleStart">
⚔️ SPIEL STARTEN / RETTE DAS JUBILÄUM! ⚔️
</button>
<span class="audio-hint">🔊 Tipp: Smartphone-Lautstärke aufdrehen für echten 135-BPM-Mittelalter-Techno!</span>
</footer>
</div>
</template>
<style scoped>
.intro-screen {
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 20%, #3d2214 0%, #170d07 100%);
color: #fef3c7;
display: flex;
flex-direction: column;
padding: 14px 12px;
overflow-y: auto;
box-sizing: border-box;
}
.intro-header {
text-align: center;
margin-bottom: 10px;
}
.crown-badge {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #f59e0b;
margin-bottom: 6px;
letter-spacing: 1px;
}
.game-main-title {
font-family: 'Press Start 2P', monospace;
font-size: 11px;
line-height: 1.4;
color: #ffd700;
text-shadow: 2px 2px 0 #78350f, 0 0 10px rgba(255, 215, 0, 0.4);
margin: 0 0 4px 0;
}
.game-subtitle {
font-family: 'MedievalSharp', serif;
font-size: 15px;
color: #f97316;
margin: 0;
font-style: italic;
}
.king-hero {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12px;
}
.king-frame {
width: 120px;
height: 120px;
border: 4px solid #ffd700;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 20px rgba(245, 158, 11, 0.5), inset 0 0 10px rgba(0,0,0,0.5);
background: #2b1810;
}
.king-hero-img {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: crisp-edges;
}
.king-caption {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #d4af37;
margin-top: 6px;
text-align: center;
}
.intro-scroll {
flex: 1;
background: rgba(26, 15, 10, 0.7);
border: 2px solid #854d0e;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
overflow-y: auto;
}
.scroll-section {
margin-bottom: 14px;
}
.section-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ffd700;
border-bottom: 1px dashed #78350f;
padding-bottom: 4px;
margin: 0 0 8px 0;
}
.story-text {
font-family: 'MedievalSharp', serif;
font-size: 14px;
line-height: 1.4;
margin: 0 0 6px 0;
color: #fde68a;
}
.story-highlight {
background: rgba(185, 28, 28, 0.25);
border-left: 3px solid #ef4444;
padding: 6px 8px;
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
line-height: 1.35;
color: #fee2e2;
margin: 6px 0;
}
.features-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.features-list li {
font-family: 'MedievalSharp', serif;
font-size: 13px;
line-height: 1.3;
color: #e2e8f0;
padding-left: 4px;
border-left: 2px solid #f59e0b;
}
.features-list strong {
color: #facc15;
}
.intro-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.start-btn {
width: 100%;
background: linear-gradient(180deg, #dc2626 0%, #991b1b 100%);
color: #fff;
border: 3px solid #ffd700;
border-radius: 8px;
padding: 12px 14px;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
letter-spacing: 0.5px;
cursor: pointer;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.7), 0 0 12px rgba(220, 38, 38, 0.6);
animation: pulseBtn 1.8s infinite;
}
@keyframes pulseBtn {
0%, 100% { transform: scale(1); box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); }
50% { transform: scale(1.02); box-shadow: 0 0 20px rgba(245, 158, 11, 0.9); }
}
.audio-hint {
font-family: 'MedievalSharp', serif;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
</style>
@@ -0,0 +1,258 @@
<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 animId: number | null = null
let wagonX = 60
let wagonY = 220
let speed = 3.5
let distance = 0
const maxDistance = 2000
interface Guest {
x: number
name: string
served: boolean
avatarColor: string
}
let guests: Guest[] = [
{ x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' },
{ x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' },
{ x: 800, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' },
{ x: 1050, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' },
{ x: 1300, name: 'Maier Flo (Ton) 🎧', served: false, avatarColor: '#7c3aed' },
{ x: 1550, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' },
{ x: 1800, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' }
]
function throwBeer() {
sound.playGulp()
// Check if any guest is near
guests.forEach(g => {
if (!g.served && Math.abs(distance + wagonX - g.x) < 80) {
g.served = true
store.addBeer()
store.addKronkorken(3)
}
})
}
function updateGame() {
distance += speed
if (distance >= maxDistance) {
store.setAct('finale')
return
}
}
function draw(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.clearRect(0, 0, w, h)
// Sunset Party Sky
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, '#4a154b')
grad.addColorStop(0.5, '#993355')
grad.addColorStop(1, '#ffaa44')
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
// Distant party silhouettes with laser beams
const now = Date.now() * 0.003
ctx.strokeStyle = `hsl(${(now * 50) % 360}, 100%, 70%)`
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.sin(now), h)
ctx.moveTo(w / 2, 80)
ctx.lineTo(w * Math.cos(now), h)
ctx.stroke()
// Cobblestone road
ctx.fillStyle = '#292524'
ctx.fillRect(0, 240, w, 80)
ctx.fillStyle = '#44403c'
for (let r = 0; r < w; r += 30) {
ctx.fillRect((r - (distance % 30)), 240, 26, 4)
}
// Draw Guests along the road
guests.forEach(g => {
const screenX = g.x - distance
if (screenX > -50 && screenX < w + 50) {
// Guest body
ctx.fillStyle = g.avatarColor
ctx.fillRect(screenX, 195, 20, 45)
// Head
ctx.fillStyle = '#fed7aa'
ctx.beginPath()
ctx.arc(screenX + 10, 185, 10, 0, Math.PI * 2)
ctx.fill()
// Name & icon tag
ctx.fillStyle = '#fff'
ctx.font = 'bold 9px MedievalSharp, serif'
ctx.textAlign = 'center'
ctx.fillText(g.name, screenX + 10, 168)
if (g.served) {
ctx.fillStyle = '#22c55e'
ctx.font = 'bold 12px sans-serif'
ctx.fillText('🍻 PROST!', screenX + 10, 154)
} else {
ctx.fillStyle = '#fbbf24'
ctx.font = '10px monospace'
ctx.fillText('DURST!', screenX + 10, 154)
}
}
})
// Draw Royal Party Wagon (Bollerwagen)
const wx = wagonX
const wy = wagonY
const bob = Math.sin(Date.now() * 0.015) * 2
// Wagon wooden bed
ctx.fillStyle = '#78350f'
ctx.fillRect(wx - 20, wy + bob, 90, 24)
ctx.fillStyle = '#b45309'
ctx.fillRect(wx - 18, wy + bob + 3, 86, 18)
// Wheels
ctx.fillStyle = '#451a03'
ctx.beginPath()
ctx.arc(wx - 6, wy + 24, 10, 0, Math.PI * 2)
ctx.arc(wx + 54, wy + 24, 10, 0, Math.PI * 2)
ctx.fill()
ctx.strokeStyle = '#ffd700'
ctx.lineWidth = 2
ctx.stroke()
// Sascha standing on wagon with crown, sunglasses & beer mug
ctx.fillStyle = '#dc2626' // Cape
ctx.fillRect(wx + 10, wy - 30 + bob, 22, 34)
ctx.fillStyle = '#1d4ed8' // Tunic
ctx.fillRect(wx + 14, wy - 28 + bob, 16, 22)
ctx.fillStyle = '#fcd34d' // Face
ctx.fillRect(wx + 15, wy - 42 + bob, 14, 14)
// Sunglasses
ctx.fillStyle = '#0f172a'
ctx.fillRect(wx + 17, wy - 38 + bob, 5, 3)
ctx.fillRect(wx + 23, wy - 38 + bob, 5, 3)
// Crown
ctx.fillStyle = '#facc15'
ctx.fillRect(wx + 14, wy - 48 + bob, 16, 6)
// Giant Bass Speaker on the back of the wagon
ctx.fillStyle = '#18181b'
ctx.fillRect(wx - 16, wy - 34 + bob, 24, 34)
ctx.fillStyle = '#f43f5e'
ctx.beginPath()
ctx.arc(wx - 4, wy - 18 + bob, 8 + Math.abs(Math.sin(Date.now() * 0.02) * 3), 0, Math.PI * 2)
ctx.fill()
// Hof-DJs Banner
ctx.fillStyle = '#ffd700'
ctx.font = 'bold 9px "Press Start 2P", monospace'
ctx.textAlign = 'left'
ctx.fillText('135 BPM EXPRESS', 10, 25)
// Progress Bar
const progress = Math.min(1, distance / maxDistance)
ctx.fillStyle = 'rgba(0,0,0,0.5)'
ctx.fillRect(10, 35, w - 20, 8)
ctx.fillStyle = '#22c55e'
ctx.fillRect(10, 35, (w - 20) * progress, 8)
}
function loop() {
updateGame()
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) draw(ctx, canvasRef.value.width, canvasRef.value.height)
}
animId = requestAnimationFrame(loop)
}
onMounted(() => {
animId = requestAnimationFrame(loop)
})
onUnmounted(() => {
if (animId) cancelAnimationFrame(animId)
})
</script>
<template>
<div class="express-container">
<canvas ref="canvasRef" width="360" height="320" class="game-canvas"></canvas>
<div class="express-controls">
<button class="throw-beer-btn" @click="throwBeer">
🍺 BIER AN DIE GÄSTE SCHENKEN!
</button>
<span class="express-hint">Tippe, wenn du an deinen Freunden vorbeifährst!</span>
</div>
</div>
</template>
<style scoped>
.express-container {
position: relative;
width: 100%;
height: 100%;
background: #000;
display: flex;
flex-direction: column;
}
.game-canvas {
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.express-controls {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
z-index: 20;
}
.throw-beer-btn {
background: #eab308;
color: #1a0f0a;
border: 2px solid #fff;
border-radius: 8px;
font-family: 'Press Start 2P', monospace;
font-size: 9px;
padding: 10px 14px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.express-hint {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
text-shadow: 1px 1px 2px #000;
}
</style>
@@ -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>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { PROLOGUE_DIALOGUES, type DialogueNode } from '../config/dialogues'
import { CHARACTERS } from '../config/characters'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
const currentNodeId = ref('start')
function selectChoice(choice: typeof PROLOGUE_DIALOGUES['start']['choices'][0]) {
sound.playDialogueBlip()
if (choice.action === 'addKronkorken' && choice.reward) {
store.addKronkorken(choice.reward)
}
if (choice.action === 'unlockBridgerton') {
store.unlockEasterEgg('bridgerton')
}
if (choice.action === 'startPlatformer') {
store.setAct('platformer')
return
}
if (choice.nextId && PROLOGUE_DIALOGUES[choice.nextId]) {
currentNodeId.value = choice.nextId
}
}
const currentNode = () => PROLOGUE_DIALOGUES[currentNodeId.value]
const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['sascha']
</script>
<template>
<div class="prologue-container">
<!-- Balcony Visual Scene -->
<div class="scene-visual">
<img
:src="currentSpeaker().portrait"
:alt="currentSpeaker().name"
class="speaker-visual"
/>
<div class="scene-tag">
<span class="scene-title">SCHLOSS HAUMDAUCHER – DER BALKON</span>
</div>
</div>
<!-- Retro Dialogue Card -->
<div class="dialogue-card">
<div class="dialogue-header">
<div class="speaker-name-badge">
<span class="speaker-name">{{ currentSpeaker().name }}</span>
<span class="speaker-title">{{ currentSpeaker().title }}</span>
</div>
</div>
<div class="dialogue-body">
<p class="dialogue-text">„{{ currentNode().text }}“</p>
</div>
<div class="dialogue-choices">
<button
v-for="(choice, idx) in currentNode().choices"
:key="idx"
class="choice-btn"
@click="selectChoice(choice)"
>
<span class="choice-arrow">▶</span>
<span class="choice-text">„{{ choice.text }}“</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.prologue-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: #1a0f0a;
overflow: hidden;
}
.scene-visual {
position: relative;
flex: 1;
min-height: 140px;
max-height: 200px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background: #2b1810;
border-bottom: 2px solid #d4af37;
}
.speaker-visual {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
image-rendering: crisp-edges;
}
.scene-tag {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
border: 1px solid #ffd700;
padding: 3px 8px;
border-radius: 4px;
}
.scene-title {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #ffd700;
}
.dialogue-card {
flex: 1;
background: #26160e;
padding: 12px;
display: flex;
flex-direction: column;
border-top: 1px solid #78350f;
overflow-y: auto;
}
.dialogue-header {
margin-bottom: 8px;
}
.speaker-name-badge {
display: flex;
flex-direction: column;
}
.speaker-name {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #facc15;
}
.speaker-title {
font-size: 11px;
color: #cbd5e1;
font-family: 'MedievalSharp', serif;
}
.dialogue-body {
margin-bottom: 10px;
background: rgba(0, 0, 0, 0.4);
padding: 8px 10px;
border-left: 3px solid #d4af37;
border-radius: 4px;
}
.dialogue-text {
margin: 0;
font-family: 'MedievalSharp', serif;
font-size: 14px;
line-height: 1.35;
color: #fef3c7;
}
.dialogue-choices {
display: flex;
flex-direction: column;
gap: 6px;
}
.choice-btn {
display: flex;
align-items: center;
gap: 8px;
background: #3b2014;
border: 1px solid #b45309;
border-radius: 6px;
padding: 8px 10px;
text-align: left;
color: #fef08a;
font-family: 'MedievalSharp', serif;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.choice-btn:hover, .choice-btn:active {
background: #78350f;
border-color: #ffd700;
transform: translateX(3px);
}
.choice-arrow {
color: #f59e0b;
font-size: 10px;
}
</style>
@@ -0,0 +1,605 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { CHARACTERS } from '../config/characters'
import { GSTANZL_DUEL } from '../config/dialogues'
import { sound } from '../audio/synthAudio'
const store = useGameStore()
interface ActiveDialog {
characterId: string
name: string
title: string
portrait: string
text: string
actionLabel?: string
actionFn?: () => void
}
const activeDialog = ref<ActiveDialog | null>(null)
const duelActive = ref(false)
const duelStep = ref(0)
const duelWon = ref(false)
const zulumanMirrorActive = ref(false)
function openCharacterDialog(charId: string) {
const char = CHARACTERS[charId]
if (!char) return
sound.playDialogueBlip()
if (charId === 'verena') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Grüß Gott mein liebster König Sascha! Alles Gute zu 40 Jahren Regentschaft! Feier so viel du willst, hab die beste Zeit deines Lebens... aber eines merkst du dir: Ich putze nach der Party fei ned!',
actionLabel: 'Verstanden, Königin! (*Bussi geben*)',
actionFn: () => {
store.addBeer()
activeDialog.value = null
}
}
} else if (charId === 'armend') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Habe die Ehre Sascha! Schau dir diesen Prachtbart an. Ich habe gerade 100 deiner verlorenen Kronkorken auf dem Amboss eingeschmolzen und daraus den stabilsten Party-Subwoofer des Balkans geschmiedet! Hier, nimm diese 10 Meister-Korken!',
actionLabel: 'Vergelt\'s Gott, Armend! (+10 Kronkorken)',
actionFn: () => {
store.addKronkorken(10)
activeDialog.value = null
}
}
} else if (charId === 'lutz') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Glück auf von ganz oben, Euer Majestät! Der Kamin von Schloss Haumdaucher ist blitzblank gekehrt. ZTUL garantiert: Die Nebelmaschinen können auf Anschlag laufen!',
actionLabel: 'Bester Mann, Lutz!',
actionFn: () => {
store.unlockEasterEgg('ztul')
activeDialog.value = null
}
}
} else if (charId === 'amy') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Rave the Kingdom! Schau mal, ich male gerade das königliche Thron-Banner in Neonfarben. Ein bisschen Hopfen, ein bisschen Magie und ganz viel Liebe!',
actionLabel: 'Wunderschön, Amy!',
actionFn: () => {
store.addKronkorken(3)
activeDialog.value = null
}
}
} else if (charId === 'pue') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Majestät! Die magischen Kristall-Fackeln sind verkabelt und synchronisiert. Sobald der Drop bei 135 BPM einschlägt, brennt der Saal!',
actionLabel: 'Zünde den Stroboskop-Zauber!',
actionFn: () => {
store.addKronkorken(4)
activeDialog.value = null
}
}
} else if (charId === 'mane') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Servus beinand! Ich wandere grad gemütlich durch die Schenke. Wander-Tipp: Wenn du nachher auf den Festwagen steigst, halte dich an die Kronkorken-Spur!',
actionLabel: 'Danke Mane! Berg heil!',
actionFn: () => {
store.addKronkorken(2)
activeDialog.value = null
}
}
} else if (charId === 'mario') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Hoppla die Waldfee! Schau wie die Bierkrüge und Hopfendolden durch die Luft wirbeln! Ein Hoch auf König Sascha!',
actionLabel: 'Sauber jongliert, Mario! (+3 Kronkorken)',
actionFn: () => {
store.addKronkorken(3)
activeDialog.value = null
}
}
}
}
function inspectMirror() {
sound.playFanfare()
zulumanMirrorActive.value = true
store.unlockEasterEgg('zuluman')
}
function startGstanzlDuel() {
sound.playDialogueBlip()
duelActive.value = true
duelStep.value = 0
duelWon.value = false
}
function answerGstanzl(isCorrect: boolean) {
sound.playDialogueBlip()
if (isCorrect) {
if (duelStep.value < GSTANZL_DUEL.length - 1) {
duelStep.value++
} else {
duelWon.value = true
store.setGstanzlWon()
}
} else {
alert('Der Ritter lacht dich aus! Versuch einen schlagfertigeren bayerischen Konter!')
}
}
function proceedToExpress() {
store.setAct('party_express')
}
</script>
<template>
<div class="tavern-container">
<div class="scene-viewport">
<img src="/assets/scenes/tavern.jpg" alt="Taverne Zum wummernden Ritter" class="tavern-bg" />
<!-- Interactive Hotspots on the visual scene with Avatar Thumbnails -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
<img :src="CHARACTERS.verena.portrait" class="hotspot-thumb" alt="Verena" />
<span class="hotspot-badge">Verena 👑</span>
</button>
<button class="hotspot hotspot-armend" @click="openCharacterDialog('armend')">
<img :src="CHARACTERS.armend.portrait" class="hotspot-thumb" alt="Armend" />
<span class="hotspot-badge">Armend 🔨</span>
</button>
<button class="hotspot hotspot-lutz" @click="openCharacterDialog('lutz')">
<img :src="CHARACTERS.lutz.portrait" class="hotspot-thumb" alt="Lutz" />
<span class="hotspot-badge">Lutz (ZTUL) 🎩</span>
</button>
<button class="hotspot hotspot-amy" @click="openCharacterDialog('amy')">
<img :src="CHARACTERS.amy.portrait" class="hotspot-thumb" alt="Amy" />
<span class="hotspot-badge">Amy 🎨</span>
</button>
<button class="hotspot hotspot-mirror" @click="inspectMirror">
<span class="mirror-icon">🪞</span>
<span class="hotspot-badge pulse">Zuluman-Spiegel</span>
</button>
<button class="hotspot hotspot-pue" @click="openCharacterDialog('pue')">
<img :src="CHARACTERS.pue.portrait" class="hotspot-thumb" alt="Pü" />
<span class="hotspot-badge">Pü (Licht) 💡</span>
</button>
<button class="hotspot hotspot-mane" @click="openCharacterDialog('mane')">
<img :src="CHARACTERS.mane.portrait" class="hotspot-thumb" alt="Mane" />
<span class="hotspot-badge">Mane 🥾</span>
</button>
<button class="hotspot hotspot-mario" @click="openCharacterDialog('mario')">
<img :src="CHARACTERS.mario.portrait" class="hotspot-thumb" alt="Mario" />
<span class="hotspot-badge">Mario 🤹</span>
</button>
<!-- Bottom Scene Navigation & Duel -->
<div class="scene-bottom-nav">
<button class="duel-trigger" @click="startGstanzlDuel">
⚔️ Gstanzl-Fechten
</button>
<button class="next-level-btn" @click="proceedToExpress">
➡️ Zum Fest-Konvoi!
</button>
</div>
</div>
<!-- Monkey Island Dialog Modal -->
<div v-if="activeDialog" class="dialog-overlay" @click="activeDialog = null">
<div class="dialog-box" @click.stop>
<div class="dialog-header">
<img :src="activeDialog.portrait" class="speaker-avatar" alt="Avatar" />
<div class="speaker-info">
<h3 class="speaker-name">{{ activeDialog.name }}</h3>
<span class="speaker-title">{{ activeDialog.title }}</span>
</div>
</div>
<p class="dialog-text">„{{ activeDialog.text }}“</p>
<div class="dialog-actions">
<button class="action-btn" @click="activeDialog.actionFn ? activeDialog.actionFn() : (activeDialog = null)">
{{ activeDialog.actionLabel || 'Antworten' }}
</button>
<button class="close-btn" @click="activeDialog = null">Schließen</button>
</div>
</div>
</div>
<!-- Zuluman Magic Mirror Easter Egg -->
<div v-if="zulumanMirrorActive" class="dialog-overlay" @click="zulumanMirrorActive = false">
<div class="mirror-box" @click.stop>
<h2 class="mirror-title">🌴 DER ZAUBERSPIEGEL DER VERGANGENHEIT 🌴</h2>
<div class="mirror-frame">
<p class="mirror-lore">
Du blickst tief in das uralte Glas...<br>
Das Schloss verschwimmt... Palmen rauschen...<br>
<strong>VORHANG AUF FÜR BUSCHKÖNIG ZULUMAN!</strong>
</p>
<div class="zuluman-badge">👑 ZULUMAN: LEGENDARY RESORT KING UNLOCKED! 🏖️</div>
<p class="mirror-bonus">+5 Kronkorken & 500 Highscore-Punkte!</p>
</div>
<button class="action-btn" @click="zulumanMirrorActive = false">Zurück in die Gegenwart</button>
</div>
</div>
<!-- Gstanzl Duel Modal -->
<div v-if="duelActive" class="dialog-overlay">
<div class="dialog-box duel-box">
<div class="duel-header">
<h3>⚔️ BAYERISCHES BELEIDIGUNGS-GSTANZL</h3>
<span class="duel-score">Runde {{ duelStep + 1 }} von 3</span>
</div>
<div v-if="!duelWon">
<div class="insult-bubble">
<span class="speaker">Kater-Ritter:</span>
<p>„{{ GSTANZL_DUEL[duelStep].insult }}“</p>
</div>
<div class="choices-list">
<button
v-for="(ans, idx) in GSTANZL_DUEL[duelStep].answers"
:key="idx"
class="choice-btn"
@click="answerGstanzl(ans.correct)"
>
„{{ ans.text }}“
</button>
</div>
</div>
<div v-else class="duel-victory">
<h2 class="victory-title">👑 SIEG FÜR KÖNIG SASCHA!</h2>
<p>Der Kater-Ritter sinkt beschämt zu Boden. Deine Reime haben den Saal zum Beben gebracht!</p>
<p class="reward-text">+15 Kronkorken & 1.500 Highscore-Punkte!</p>
<button class="action-btn" @click="duelActive = false">Herrlich!</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tavern-container {
position: relative;
width: 100%;
height: 100%;
background: #1a0f0a;
overflow: hidden;
}
.scene-viewport {
position: relative;
width: 100%;
height: 100%;
}
.tavern-bg {
width: 100%;
height: 100%;
object-fit: cover;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* Hotspots with Avatar Thumbnails */
.hotspot {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
background: rgba(26, 15, 10, 0.85);
border: 1.5px solid #ffd700;
border-radius: 16px;
padding: 2px 6px 2px 2px;
cursor: pointer;
transform: translate(-50%, -50%);
transition: transform 0.2s, background 0.2s;
z-index: 10;
box-shadow: 0 2px 8px rgba(0,0,0,0.7);
}
.hotspot:hover, .hotspot:active {
transform: translate(-50%, -50%) scale(1.1);
background: rgba(185, 28, 28, 0.95);
border-color: #fef08a;
}
.hotspot-thumb {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid #ffd700;
object-fit: cover;
}
.mirror-icon {
font-size: 16px;
padding: 0 2px;
}
.hotspot-badge {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #fff;
white-space: nowrap;
}
.hotspot-verena { top: 72%; left: 54%; }
.hotspot-armend { top: 62%; left: 24%; }
.hotspot-lutz { top: 38%; left: 22%; }
.hotspot-mirror { top: 30%; left: 63%; }
.hotspot-amy { top: 52%; left: 84%; }
.hotspot-pue { top: 16%; left: 35%; }
.hotspot-mane { top: 44%; left: 68%; }
.hotspot-mario { top: 74%; left: 86%; }
.pulse {
animation: pulseAnim 1.5s infinite;
}
@keyframes pulseAnim {
0%, 100% { box-shadow: 0 0 4px #facc15; }
50% { box-shadow: 0 0 12px #ec4899; }
}
.scene-bottom-nav {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
display: flex;
justify-content: space-between;
gap: 8px;
z-index: 15;
}
.duel-trigger {
background: #b91c1c;
color: #fef08a;
border: 1.5px solid #ffd700;
border-radius: 6px;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
padding: 8px 10px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
.next-level-btn {
background: #15803d;
color: #fff;
border: 1.5px solid #86efac;
border-radius: 6px;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
padding: 8px 10px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* Modals */
.dialog-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 12px;
}
.dialog-box {
background: #2b1810;
border: 3px solid #d4af37;
border-radius: 8px;
padding: 14px;
max-width: 320px;
width: 100%;
color: #fef3c7;
box-shadow: 0 0 20px rgba(0,0,0,0.9);
}
.dialog-header {
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #78350f;
padding-bottom: 8px;
margin-bottom: 10px;
}
.speaker-avatar {
width: 56px;
height: 56px;
border-radius: 6px;
border: 2px solid #ffd700;
object-fit: cover;
background: #1a0f0a;
}
.speaker-name {
font-family: 'MedievalSharp', serif;
font-size: 17px;
color: #ffd700;
margin: 0;
}
.speaker-title {
font-size: 11px;
color: #cbd5e1;
}
.dialog-text {
font-family: 'MedievalSharp', serif;
font-size: 15px;
line-height: 1.4;
margin: 0 0 14px 0;
}
.dialog-actions {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
background: #d97706;
color: #fff;
border: 1.5px solid #fef08a;
border-radius: 4px;
padding: 8px 10px;
font-family: 'MedievalSharp', serif;
font-size: 13px;
font-weight: bold;
cursor: pointer;
}
.close-btn {
background: #475569;
color: #fff;
border: 1px solid #94a3b8;
border-radius: 4px;
padding: 8px;
font-size: 12px;
cursor: pointer;
}
/* Mirror */
.mirror-box {
background: #0f172a;
border: 3px solid #38bdf8;
border-radius: 10px;
padding: 16px;
text-align: center;
color: #e0f2fe;
}
.mirror-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #38bdf8;
margin-bottom: 12px;
}
.mirror-frame {
background: rgba(56, 189, 248, 0.15);
border: 1px dashed #38bdf8;
padding: 12px;
margin-bottom: 14px;
border-radius: 6px;
}
.mirror-lore {
font-family: 'MedievalSharp', serif;
font-size: 14px;
}
.zuluman-badge {
font-weight: bold;
color: #facc15;
margin: 10px 0;
font-size: 12px;
}
.mirror-bonus {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #38bdf8;
}
/* Gstanzl */
.duel-header {
border-bottom: 1px solid #78350f;
margin-bottom: 10px;
padding-bottom: 6px;
}
.duel-header h3 {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #facc15;
margin: 0 0 4px 0;
}
.duel-score {
font-size: 10px;
color: #94a3b8;
}
.insult-bubble {
background: #451a03;
border-left: 3px solid #ef4444;
padding: 8px 10px;
border-radius: 4px;
margin-bottom: 12px;
}
.insult-bubble .speaker {
font-weight: bold;
color: #f87171;
font-size: 12px;
}
.choices-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.choice-btn {
background: #3b2014;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 4px;
padding: 8px 10px;
text-align: left;
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
cursor: pointer;
transition: background 0.15s;
}
.choice-btn:hover {
background: #78350f;
}
.duel-victory {
text-align: center;
}
.victory-title {
color: #ffd700;
font-size: 12px;
font-family: 'Press Start 2P', monospace;
}
</style>
@@ -0,0 +1,129 @@
export interface CharacterConfig {
id: string
name: string
title: string
portrait: string
description: string
famousQuote: string
themeColor: string
}
export const CHARACTERS: Record<string, CharacterConfig> = {
sascha: {
id: 'sascha',
name: 'König Sascha I.',
title: 'Der 40-jährige Imperator',
portrait: '/assets/characters/sascha/portrait.jpg',
description: 'Herrscher über Haumdaucher, Träger der heiligen Sonnenbrille und Beschützer des 135-BPM-Basses.',
famousQuote: 'O zapft is im Reiche! Wo san meine Kronkorken?!',
themeColor: '#d4af37'
},
mop: {
id: 'mop',
name: 'Mop (Moritz)',
title: 'Der Welten-Erschaffer & Matrix-Architekt',
portrait: '/assets/characters/mop/portrait.jpg',
description: 'Sitzt auf einer Wolke über dem Schloss und baut aus Pixeln und Hopfen diese Welt.',
famousQuote: 'Just adding a few more polygons here... wait, pixels. Pixels.',
themeColor: '#38bdf8'
},
nat: {
id: 'nat',
name: 'NAT (Josef)',
title: 'Hof-Chronist & Barde des Wildschweins',
portrait: '/assets/characters/nat/portrait.jpg',
description: 'Kennt alle 3 Staffeln Bridgerton in- und auswendig, trägt florale Bomberjacken und erzählt epische Geschichten.',
famousQuote: 'Also pass auf Sascha... ich damals mit meinen Girls ab...',
themeColor: '#22c55e'
},
verena: {
id: 'verena',
name: 'Königin Verena',
title: 'Herrin des Hofes',
portrait: '/assets/characters/verena/portrait.jpg',
description: 'Liebt Sascha über alles auf der Welt, hat aber ein unumstößliches königliches Edikt erlassen.',
famousQuote: 'Ich hab dich lieb mein Schatz, aber ich putze nach der Party fei ned!',
themeColor: '#ec4899'
},
hansi: {
id: 'hansi',
name: 'Hansi (Johann)',
title: 'Ritter der floralen Garde',
portrait: '/assets/characters/hansi/portrait.jpg',
description: 'Perfekt gestylter Schnurrbart und florale Plattenrüstung.',
famousQuote: 'Habe die Ehre! Der Bass im Rittersaal ist auf Punkt.',
themeColor: '#6366f1'
},
lutz: {
id: 'lutz',
name: 'Lutz',
title: 'Der ZTUL-Hofschornsteinfeger',
portrait: '/assets/characters/lutz/portrait.jpg',
description: 'Ein Hüne von Gestalt mit Kappe, runder Brille und dem legendären ZTUL-Trikot.',
famousQuote: 'Glück auf, Euer Durchlaucht! Schornstein ist frei für den Party-Nebel.',
themeColor: '#64748b'
},
armend: {
id: 'armend',
name: 'Armend',
title: 'Meister-Schmied der Kronkorken-Manufaktur',
portrait: '/assets/characters/armend/portrait.jpg',
description: 'Prachtvoller Vollbart, formt aus 40 Kronkorken den goldenen Sound-Verstärker.',
famousQuote: 'Schlag zu, solange das Blech warm ist!',
themeColor: '#f97316'
},
amy: {
id: 'amy',
name: 'Amy (Amelie)',
title: 'Die Hof-Künstlerin',
portrait: '/assets/characters/amy/portrait.jpg',
description: 'Kreatives Genie in Latzhose mit Dutt und feinen Kräuter-Zigarren.',
famousQuote: 'Rave the Kingdom! Die Farben brauchen mehr Bass!',
themeColor: '#a855f7'
},
pue: {
id: 'pue',
name: 'Pü (Jakob)',
title: 'Der Lichtler des Hofes',
portrait: '/assets/characters/pue/portrait.jpg',
description: 'Früherer Club-Lichtmeister, bedient nun die magischen Stroboskop-Fackeln.',
famousQuote: 'Majestät, wann zünden wir die Stroboskop-Fackeln zum Drop?',
themeColor: '#eab308'
},
mario: {
id: 'mario',
name: 'Mario der Gaukler',
title: 'Hof-Jongleur & Stimmungs-Garant',
portrait: '/assets/characters/mario/portrait.jpg',
description: 'Jongliert geschickt mit Hopfen, Hefe und Bierkrügen.',
famousQuote: 'Hoppla die Waldfee! Ein Krug für den König!',
themeColor: '#14b8a6'
},
mane: {
id: 'mane',
name: 'Mane (Ramona)',
title: 'Die königliche Wanderin',
portrait: '/assets/characters/mane/portrait.jpg',
description: 'Wandert random mit Rucksack und Wanderstock quer durch alle Bildschirme.',
famousQuote: 'Schee ists hier! Noch 3 Kilometer bis zum Gipfel-Bier!',
themeColor: '#84cc16'
},
djs: {
id: 'djs',
name: 'Die Hof-DJs',
title: 'Minnesänger der 135 BPM',
portrait: '/assets/characters/pue/portrait.jpg',
description: 'Stehen an der königlichen Kanzel am doppelten Eichenholz-Plattenteller.',
famousQuote: 'Drop it like it\'s 1426! Mehr Bass auf die Burgzinnen!',
themeColor: '#06b6d4'
},
gefolge: {
id: 'gefolge',
name: 'Das Haumdaucher Gefolge',
title: 'Die feiernde Reichs-Crowd',
portrait: '/assets/characters/hansi/portrait.jpg',
description: 'Viktor der Vampir, der bärtige Guatemalteke, Fabian, Albin, Maier Flo, Markus & Nathalie.',
famousQuote: 'Ein Hoch auf König Sascha! Hoch soll er leben!',
themeColor: '#e11d48'
}
}
@@ -0,0 +1,106 @@
export interface DialogueChoice {
text: string
nextId?: string
action?: 'addKronkorken' | 'unlockZuluman' | 'unlockBridgerton' | 'startPlatformer' | 'startExpress' | 'startFinale'
reward?: number
reply?: {
speakerId: string
text: string
}
}
export interface DialogueNode {
id: string
speakerId: string
text: string
choices: DialogueChoice[]
}
export const PROLOGUE_DIALOGUES: Record<string, DialogueNode> = {
start: {
id: 'start',
speakerId: 'nat',
text: 'Seyd gegrüßet, Sascha! 40 Lenze zählet dein edles Antlitz! Aber ich sag\'s dir wie\'s is... der Hofstaat ist in heller Aufruhr. Ein Drama wie damals beim königlichen Ball in Staffel 2 von Bridgerton! Sämtliche 40 königlichen Kronkorken san über ganz Haumdaucher verstreut!',
choices: [
{
text: 'Zefix Josef! Ohne Kronkorken kein Bier, und ohne Bier kein Bass!',
nextId: 'mop_appears',
action: 'unlockBridgerton'
},
{
text: 'Bridgerton?! Josef, konzentrier dich! Wo san meine 40 Kronkorken?!',
nextId: 'mop_appears'
},
{
text: 'Lass mich raten... Du mit deinen Girls ab und zack, Leergut weg?',
nextId: 'nat_girls',
action: 'addKronkorken',
reward: 2
}
]
},
nat_girls: {
id: 'nat_girls',
speakerId: 'nat',
text: 'Ja mei, woher weißt du das immer?! Wir zogen durch die Lande, die Musik war laut, der Gerstensaft floss in Strömen... und dann hat der Wind die Korken über die Dächer geweht!',
choices: [
{
text: 'Na servus. Dann muss der König wohl selbst ran.',
nextId: 'mop_appears'
}
]
},
mop_appears: {
id: 'mop_appears',
speakerId: 'mop',
text: 'Habe die Ehre, Sascha! Keine Panik, dein Erschaffer hat schon die Sprungfedern in deine königlichen Stiefel programmiert. Sammle mindestens 15 Kronkorken auf den Hopfen-Pfaden, bevor wir in die Taverne einziehen!',
choices: [
{
text: 'Auf geht\'s Mop! Starte den Kronkorken-Kreuzzug!',
action: 'startPlatformer'
},
{
text: 'Zeig mir erst noch meine Sonnenbrille für den Style.',
nextId: 'sascha_ready'
}
]
},
sascha_ready: {
id: 'sascha_ready',
speakerId: 'sascha',
text: 'Sonnenbrille sitzt. Hermelinmantel weht im Wind. Haumdaucher, der König kommt!',
choices: [
{
text: 'Level 1 starten: Die Hopfen-Pfade!',
action: 'startPlatformer'
}
]
}
}
export const GSTANZL_DUEL = [
{
insult: 'Dein Techno-Bass ist so dünn wie schales Exportbier!',
answers: [
{ text: 'Dafür schallert mein Drop lauter als dei ganze Ahnengalerie!', correct: true },
{ text: 'Ich trinke eigentlich lieber Weißwein.', correct: false },
{ text: 'Aber meine Sonnenbrille ist dafür sehr dunkel!', correct: false }
]
},
{
insult: 'Mit 40 Lenzen bist du doch reif fürs Altenteil, alter Mann!',
answers: [
{ text: 'Mit 40 fängt der Rave erst an, und i tanz di locker an die Wand!', correct: true },
{ text: 'Ja mei, der Rücken zwickt schon a bisserl.', correct: false },
{ text: 'Ich frage mal schnell meinen Steuerberater.', correct: false }
]
},
{
insult: 'Niemand im ganzen Herzogtum feiert deine 135 BPM!',
answers: [
{ text: 'Ganz Haumdaucher bebt, wenn der König den Fader bewegt!', correct: true },
{ text: 'Vielleicht sollte ich lieber Schlager spielen.', correct: false },
{ text: 'Pü hat aber gesagt, das Licht passt dazu.', correct: false }
]
}
]
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
@@ -0,0 +1,150 @@
import { defineStore } from 'pinia'
import { sound } from '../audio/synthAudio'
export type GameAct = 'intro' | 'prologue' | 'platformer' | 'tavern' | 'party_express' | 'finale'
export interface GameState {
currentAct: GameAct
kronkorken: number
beers: number
guestsServed: number
gstanzlWon: boolean
partyMood: number
inventory: string[]
unlockedEasterEggs: string[]
startTime: number
elapsedSeconds: number
soundMuted: boolean
}
export const useGameStore = defineStore('game', {
state: (): GameState => ({
currentAct: 'intro',
kronkorken: 0,
beers: 0,
guestsServed: 0,
gstanzlWon: false,
partyMood: 20,
inventory: ['Königliche Sonnenbrille', 'Leergutsack'],
unlockedEasterEggs: [],
startTime: Date.now(),
elapsedSeconds: 0,
soundMuted: false,
}),
getters: {
isArcadeMode(state): boolean {
return state.currentAct === 'platformer' || state.currentAct === 'party_express'
},
totalScore(state): number {
const capPoints = state.kronkorken * 100
const beerPoints = state.beers * 250
const guestPoints = state.guestsServed * 300
const gstanzlBonus = state.gstanzlWon ? 1500 : 0
const eggBonus = state.unlockedEasterEggs.length * 500
const completionBonus = 2000
return capPoints + beerPoints + guestPoints + gstanzlBonus + eggBonus + completionBonus
},
scoreRank(): { rank: string; title: string; color: string } {
const score = this.totalScore
if (score >= 15000) {
return {
rank: 'S-RANG (LEGENDÄR)',
title: 'Göttlicher Imperator des 135-BPM-Schalldrucks',
color: '#ffd700'
}
} else if (score >= 12000) {
return {
rank: 'A-RANG (HERZOGLICH)',
title: 'Großfürst des Gerstensafts & Rave-Legende',
color: '#38bdf8'
}
} else if (score >= 9000) {
return {
rank: 'B-RANG (RITTERLICH)',
title: 'Ehren-Ritter der Hopfen-Manufaktur',
color: '#4ade80'
}
} else {
return {
rank: 'C-RANG (KNAPPE)',
title: 'Durstiger Party-Knappe von Haumdaucher',
color: '#fbbf24'
}
}
},
titleAward(state): string {
return this.scoreRank.title
}
},
actions: {
addKronkorken(amount: number = 1) {
this.kronkorken += amount
this.partyMood = Math.min(100, this.partyMood + 2 * amount)
sound.playCoin()
},
addBeer() {
this.beers++
this.partyMood = Math.min(100, this.partyMood + 10)
sound.playGulp()
},
serveGuest() {
this.guestsServed++
this.addBeer()
this.addKronkorken(3)
},
setGstanzlWon() {
this.gstanzlWon = true
this.addKronkorken(15)
},
addItem(item: string) {
if (!this.inventory.includes(item)) {
this.inventory.push(item)
}
},
unlockEasterEgg(egg: string) {
if (!this.unlockedEasterEggs.includes(egg)) {
this.unlockedEasterEggs.push(egg)
this.addKronkorken(5)
}
},
setAct(act: GameAct) {
this.currentAct = act
if (act === 'finale') {
sound.playFanfare()
}
},
startGame() {
this.currentAct = 'prologue'
sound.startBGM()
},
toggleMute() {
this.soundMuted = sound.toggleMute()
},
resetGame() {
this.currentAct = 'intro'
this.kronkorken = 0
this.beers = 0
this.guestsServed = 0
this.gstanzlWon = false
this.partyMood = 20
this.inventory = ['Königliche Sonnenbrille', 'Leergutsack']
this.unlockedEasterEggs = []
this.startTime = Date.now()
this.elapsedSeconds = 0
}
}
})
+28
View File
@@ -0,0 +1,28 @@
:root {
font-family: 'MedievalSharp', serif, system-ui;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
background-color: #0f0a07;
color: #fef3c7;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
touch-action: manipulation;
}
button {
user-select: none;
outline: none;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
host: true,
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB