Files
infrapuzzle/projects/sasch40/frontend/src/components/TavernView.vue
T
Moritz Graf 5852479c21 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
2026-09-23 21:56:34 +02:00

606 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>