feat(sasch40): add retro birthday game subproject and k8s deployment

- Add Vue 3 + Vite + TypeScript retro game: 'Saschas Kronkorken Kreuzzug: 40 Jahre Bass & Gerstensaft'
- Add dynamic GameBoy layout: controls visible only during arcade modes, fullscreen during dialogue and tavern
- Add QR code welcome & prequel screen with lore, feature preview, and audio hints
- Add competitive party highscore and ranking breakdown on royal certificate
- Add dedicated individual 1:1 character portraits for Sascha, Verena, NAT, Hansi, Mop, Lutz, Armend, Amy, Mane, Pü, and Mario
- Add Dockerfile, Makefile, and K8s deployment at sasch40.haumdaucher.de
- Update openclaw timeout configuration
This commit is contained in:
Moritz Graf
2026-09-23 21:56:34 +02:00
parent d11a84a0d3
commit 5852479c21
53 changed files with 5544 additions and 0 deletions
@@ -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>