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
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -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;"]
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 869 KiB |
|
After Width: | Height: | Size: 979 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
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 |
|
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 77 KiB |