feat(sasch40): add Nebenraum, Marktplatz minigame & trapdoor to Hinterzimmer

- Split GameBoy chassis brand header: 'AKT III' on clean dedicated second line
- Add 5-room tavern architecture: Schankraum, Nebenraum, Marktplatz, Hinterland, Hinterzimmer
- Introduce Nebenraum (Kemenate/Atelier) with Amy at her easel painting the neon banner and Zuluman magic mirror
- Update Marktplatz scene with Mario juggling chainsaws/hops and NAT on the festival stage
- Implement Wildschwein-Schnick-Schnack-Schnuck minigame against NAT (Best of 3, Keiler > Dickicht > Lanze > Keiler)
- Replace secret tap with cellar floor trapdoor ('Luke zum Hinterraum') initially invisible
- Update automated playtest covering all 25 screens with zero console/network errors
- Document all architectural decisions in projects/sasch40/AGENTS.md
This commit is contained in:
Moritz Graf
2026-09-24 12:37:56 +02:00
parent bbae8a8c78
commit 6374ba9e8a
11 changed files with 524 additions and 101 deletions
+24 -9
View File
@@ -115,16 +115,29 @@ This document records the foundational design decisions, their rationale ("Why")
---
### 9. Easter Egg Invisibility Pattern (`TavernView.vue`)
* **The Decision**: Secret hotspots (e.g. `secret-tap-hotspot` in the Schankraum) must be 100% invisible initially.
### 9. Cellar Trapdoor & Easter Egg Invisibility Pattern (`TavernView.vue`)
* **The Decision**: Secret hotspots (the cellar trapdoor `.trapdoor-hotspot` in the Schankraum leading to the VIP Hinterzimmer) must be 100% invisible initially.
* **Why**: An Easter egg marked with a glowing button or text badge is not a secret; it invites immediate trivial clicking.
* **The Rule**:
* The button must render with transparent background, no border, no outline, no box shadow, no hover change, and no tooltip (`:title="undefined"`).
* Only upon the user's first click does it reveal the gold key icon, play a celebratory fanfare, unlock the Easter egg in the game store (+500 points), and transition to the secret room.
* Only upon the user's first click does it reveal the door icon, label itself *„Luke zum Hinterraum“*, play a celebratory fanfare, unlock the Easter egg in the game store (+500 points), and transition to the secret room.
---
### 10. LocalStorage Persistence & Clean Game Reset (`gameStore.ts`)
### 10. Multi-Room World Architecture & Tavern Minigames (`TavernView.vue`)
* **The Decision**: Act III is structured as a spatial, interconnected five-room village hub rather than a cramped single screen:
1. **Gaststube (Schankraum)**: King Sascha's table, Queen Verena on the balcony, Armend at the forge, Lutz (ZTUL) at the fireplace, door to Nebenraum, door to Marktplatz, and the secret *„Luke zum Hinterraum“* in the floor.
2. **Königlicher Nebenraum (Kemenate & Atelier)**: Amy painting the royal *„Rave the Kingdom!“* banner at her easel in overalls, and the golden Zuluman magic mirror on the wall.
3. **Haumdaucher Marktplatz**: Mario acrobatically juggling chainsaws and hops on the cobblestones; NAT (Josef) on the wooden festival stage with wild boar banners telling stories of his girls and heroic deeds.
4. **Alpines Hinterland**: Mane (Ramona) hiking the alpine trail with walking stick; Hansi on the panoramic bench.
5. **Geheimes VIP-Hinterzimmer**: Secret rave with disco chandelier, stroboscope, Pü's crystal staff, DJ Flo Maier, and Viktor the night-guard.
* **Minigames & Exit Safety**:
* **Bayerisches Gstanzl-Fechten**: Multi-round insult rhyming duel. Must always include a visible `[✕]` close button so players are never trapped.
* **Wildschwein-Schnick-Schnack-Schnuck**: Best-of-3 tactical duel against NAT. Rules: 🐗 Keiler bricht 🌿 Dickicht, 🌿 Dickicht fängt 🏹 Lanze, 🏹 Lanze erlegt 🐗 Keiler. First to 2 points wins (+10 Korken, 1 Helles, `wildschwein_meister` achievement). Includes `[✕]` close button.
---
### 11. LocalStorage Persistence & Clean Game Reset (`gameStore.ts`)
* **The Decision**: Game state automatically synchronizes to `localStorage` (`sasch40_game_save_v1`) on every mutation and is cleanly wiped when clicking *„🔄 NOCHMAL SPIELEN“*.
* **Why**: Mobile users frequently refresh or switch apps to snap photos. Without persistence, a browser reload resets their progress back to Act I.
* **The Rule**:
@@ -151,7 +164,7 @@ make all
The `Makefile` targets:
* `compile`: Runs `npm run build` (`vue-tsc -b && vite build`).
* `build`: Runs `docker build --platform linux/amd64 -t sasch40:latest .` (copies `dist/` directly into `nginx:alpine`).
* `build`: Runs `docker build --no-cache --platform linux/amd64 -t sasch40:latest .` (copies `dist/` directly into `nginx:alpine`).
* `tag`: Tags as `registry.haumdaucher.de/sasch40:latest`.
* `push`: Pushes to the Haumdaucher registry.
* `deploy`: Executes `kubectl rollout restart deployment/sasch40 -n web` and monitors rollout status.
@@ -171,7 +184,9 @@ This script:
2. Verifies the Intro Screen and clicks Start.
3. Simulates dialogue options on the Castle Balcony (Prologue).
4. Tests movement and jump controls in Act II (Hopfen-Pfade).
5. Interacts with hotspots, dialogue modals, and the Gstanzl duel in Act III (Taverne).
6. Advances through the Party Express and reaches the Royal Certificate.
7. Captures 10 screenshots to verify zero visual regressions.
8. Asserts zero console errors, zero failed HTTP requests, and zero broken images.
5. Interacts with all 5 rooms, hotspots, dialogue modals, Zuluman mirror, Luke zum Hinterraum, Gstanzl duel, and the Wildschwein duel in Act III (Taverne).
6. Tests reload persistence in `localStorage`.
7. Advances through the Party Express (Act IV) and reaches the Royal Certificate (Act V).
8. Tests the `🔄 NOCHMAL SPIELEN` button and verifies return to Intro.
9. Captures 25 screenshots to verify zero visual regressions.
10. Asserts zero console errors, zero failed HTTP requests, and zero broken images.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

+78 -28
View File
@@ -151,13 +151,52 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400));
}
// 6. TAVERN: MARKTPLATZ (Mario with spinning chainsaws)
// 5b. TAVERN: NEBENRAUM (Amy painting & Zuluman Mirror)
console.log('🎨 Navigating to Nebenraum (Kemenate / Atelier)...');
const nebenraumDoor = await page.$('.hotspot-door-nebenraum');
if (nebenraumDoor) {
await nebenraumDoor.click();
} else {
const nrTab = await page.$('.room-tab:nth-child(2)');
if (nrTab) await nrTab.click();
}
await new Promise(r => setTimeout(r, 1000));
console.log('📸 05b: Capturing Nebenraum (Atelier & Mirror)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05b_tavern_nebenraum.png') });
// Test Amy in Nebenraum
console.log('🎨 Testing Amy Dialog in Nebenraum...');
const amyBtn = await page.$('.hotspot-amy-nebenraum');
if (amyBtn) {
await amyBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 05c: Capturing Amy Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05c_tavern_amy_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Test Zuluman Mirror in Nebenraum
console.log('🪞 Testing Zuluman Magic Mirror in Nebenraum...');
const mirrorBtn = await page.$('.hotspot-mirror-nebenraum');
if (mirrorBtn) {
await mirrorBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 14: Capturing Zuluman Mirror...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '14_tavern_zuluman_mirror.png') });
const closeBtn = await page.$('.mirror-box .action-btn');
if (closeBtn) await closeBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// 6. TAVERN: MARKTPLATZ (Mario with spinning chainsaws & NAT on stage)
console.log('🎪 Navigating to Marktplatz...');
const marktplatzTab = await page.$('.room-tab:nth-child(2)');
const marktplatzTab = await page.$('.room-tab:nth-child(3)');
if (marktplatzTab) {
await marktplatzTab.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 08: Capturing Marktplatz (Mario chainsaws)...');
console.log('📸 08: Capturing Marktplatz (Mario chainsaws & NAT stage)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_marktplatz.png') });
// Click Mario
@@ -172,22 +211,45 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400));
}
// Click NAT (Josef)
// Click NAT (Josef) & Test Wildschwein Duell
const natBtn = await page.$('.hotspot-nat');
if (natBtn) {
await natBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT (Josef) Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
// Start Wild Boar Duel
console.log('🐗 Launching Wildschwein-Schnick-Schnack-Schnuck Duel...');
const startBoarBtn = await page.$('.dialog-actions .action-btn');
if (startBoarBtn) {
await startBoarBtn.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 09c: Capturing Wildschwein Duel Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09c_tavern_boar_duel.png') });
// Play a move: Keiler!
const keilerBtn = await page.$('.boar-choice-btn:nth-child(1)');
if (keilerBtn) {
await keilerBtn.click();
await new Promise(r => setTimeout(r, 600));
console.log('📸 09d: Capturing Boar Duel Clash...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09d_tavern_boar_duel_round.png') });
}
// Close Boar Duel modal
const closeBoarBtn = await page.$('.boar-duel-box .duel-close-btn');
if (closeBoarBtn) {
await closeBoarBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
}
}
// 7. TAVERN: HINTERLAND (Mane hiking)
console.log('🌲 Navigating to Hinterland...');
const hinterlandTab = await page.$('.room-tab:nth-child(3)');
const hinterlandTab = await page.$('.room-tab:nth-child(4)');
if (hinterlandTab) {
await hinterlandTab.click();
await new Promise(r => setTimeout(r, 1000));
@@ -207,18 +269,18 @@ async function runPlaytest() {
}
}
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Secret Tap)
console.log('🗝️ Returning to Schankraum to check Secret Tap (initially invisible)...');
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Luke zum Hinterraum)
console.log('🗝️ Returning to Schankraum to check Luke zum Hinterraum (trapdoor)...');
const schankraumTab = await page.$('.room-tab:nth-child(1)');
if (schankraumTab) {
await schankraumTab.click();
await new Promise(r => setTimeout(r, 600));
}
console.log('🪩 Triggering Secret Tap Easter Egg...');
const secretTap = await page.$('.secret-tap-hotspot');
if (secretTap) {
await secretTap.click();
console.log('🪩 Triggering Luke zum Hinterraum Easter Egg...');
const trapdoor = await page.$('.trapdoor-hotspot');
if (trapdoor) {
await trapdoor.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 12: Capturing Secret VIP Hinterzimmer...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12_tavern_hinterzimmer_vip.png') });
@@ -236,25 +298,13 @@ async function runPlaytest() {
}
}
// 9. ZULUMAN MAGIC MIRROR & GSTANZL DUEL
console.log('🪞 Testing Zuluman Magic Mirror...');
// 9. GSTANZL DUEL
console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const backToGaststube = await page.$('.hotspot-door-back-vip');
if (backToGaststube) {
await backToGaststube.click();
await new Promise(r => setTimeout(r, 600));
}
const mirrorBtn = await page.$('.hotspot-mirror');
if (mirrorBtn) {
await mirrorBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 14: Capturing Zuluman Mirror...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '14_tavern_zuluman_mirror.png') });
const closeBtn = await page.$('.mirror-box .action-btn');
if (closeBtn) await closeBtn.click();
await new Promise(r => setTimeout(r, 400));
}
console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const duelBtn = await page.$('.duel-trigger');
if (duelBtn) {
await duelBtn.click();
@@ -45,7 +45,7 @@ function toggleInventory() {
<div class="chassis-brand">
<span class="brand-text">KÖNIG SASCHAS KRONKORKENKREUZZUG</span>
<span v-if="store.currentAct !== 'intro'" class="brand-act">
{{ store.currentAct === 'prologue' ? '• AKT I' : store.currentAct === 'platformer' ? '• AKT II' : store.currentAct === 'tavern' ? '• AKT III' : store.currentAct === 'party_express' ? '• AKT IV' : store.currentAct === 'finale' ? '• AKT V' : '' }}
{{ store.currentAct === 'prologue' ? 'AKT I' : store.currentAct === 'platformer' ? 'AKT II' : store.currentAct === 'tavern' ? 'AKT III' : store.currentAct === 'party_express' ? 'AKT IV' : store.currentAct === 'finale' ? 'AKT V' : '' }}
</span>
</div>
<button class="sound-toggle" @click="toggleAudio">
@@ -203,8 +203,10 @@ function toggleInventory() {
.chassis-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
justify-content: center;
gap: 2px;
overflow: hidden;
}
@@ -218,7 +220,7 @@ function toggleInventory() {
.brand-act {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
font-size: 5.5px;
color: #f59e0b;
white-space: nowrap;
}
@@ -7,7 +7,7 @@ import { sound } from '../audio/synthAudio'
const store = useGameStore()
type TavernRoom = 'schankraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer'
type TavernRoom = 'schankraum' | 'nebenraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer'
const currentRoom = ref<TavernRoom>('schankraum')
interface ActiveDialog {
@@ -26,16 +26,16 @@ const duelStep = ref(0)
const duelWon = ref(false)
const zulumanMirrorActive = ref(false)
const secretTapDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer'))
const trapdoorDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer'))
function switchRoom(room: TavernRoom) {
sound.playDialogueBlip()
currentRoom.value = room
}
function triggerSecretTap() {
function triggerTrapdoor() {
sound.playFanfare()
secretTapDiscovered.value = true
trapdoorDiscovered.value = true
store.unlockEasterEgg('hinterzimmer')
currentRoom.value = 'hinterzimmer'
}
@@ -46,6 +46,91 @@ function enterSecretHinterzimmer() {
currentRoom.value = 'hinterzimmer'
}
// Wildschwein Schnick-Schnack-Schnuck Minigame with NAT
type WildBoarChoice = 'keiler' | 'dickicht' | 'lanze'
const wildBoarDuelActive = ref(false)
const wildBoarPlayerScore = ref(0)
const wildBoarNatScore = ref(0)
const wildBoarRound = ref(1)
const wildBoarResultMsg = ref('')
const wildBoarLastPlayerMove = ref<WildBoarChoice | null>(null)
const wildBoarLastNatMove = ref<WildBoarChoice | null>(null)
const wildBoarGameOver = ref(false)
const wildBoarPlayerWon = ref(false)
function startWildBoarDuel() {
sound.playDialogueBlip()
wildBoarDuelActive.value = true
wildBoarPlayerScore.value = 0
wildBoarNatScore.value = 0
wildBoarRound.value = 1
wildBoarResultMsg.value = 'Wähle deine Taktik für Runde 1!'
wildBoarLastPlayerMove.value = null
wildBoarLastNatMove.value = null
wildBoarGameOver.value = false
wildBoarPlayerWon.value = false
}
function formatBoarMove(m: WildBoarChoice): string {
if (m === 'keiler') return '🐗 Keiler'
if (m === 'dickicht') return '🌿 Dickicht'
return '🏹 Lanze'
}
function playWildBoarMove(playerChoice: WildBoarChoice) {
if (wildBoarGameOver.value) return
const choices: WildBoarChoice[] = ['keiler', 'dickicht', 'lanze']
const natChoice = choices[Math.floor(Math.random() * choices.length)]
wildBoarLastPlayerMove.value = playerChoice
wildBoarLastNatMove.value = natChoice
if (playerChoice === natChoice) {
sound.playDialogueBlip()
wildBoarResultMsg.value = 'Unentschieden! Zwei Keiler prallen mit den Köpfen zusammen!'
} else if (
(playerChoice === 'keiler' && natChoice === 'dickicht') ||
(playerChoice === 'dickicht' && natChoice === 'lanze') ||
(playerChoice === 'lanze' && natChoice === 'keiler')
) {
sound.playCoin()
wildBoarPlayerScore.value++
const explanation =
playerChoice === 'keiler'
? 'Dein Keiler bricht durch NATs Dickicht!'
: playerChoice === 'dickicht'
? 'Dein Eichen-Dickicht fängt NATs Lanze ab!'
: 'Deine Lanze stoppt NATs Keiler!'
wildBoarResultMsg.value = `Punkt für Sascha! ${explanation}`
} else {
sound.playGulp()
wildBoarNatScore.value++
const explanation =
natChoice === 'keiler'
? 'NATs Keiler bricht durch dein Dickicht!'
: natChoice === 'dickicht'
? 'NATs Dickicht verheddert deine Lanze!'
: 'NATs Lanze stoppt deinen Keiler!'
wildBoarResultMsg.value = `Punkt für NAT! ${explanation}`
}
wildBoarRound.value++
if (wildBoarPlayerScore.value >= 2) {
wildBoarGameOver.value = true
wildBoarPlayerWon.value = true
sound.playFanfare()
store.addKronkorken(10)
store.addBeer()
store.unlockEasterEgg('wildschwein_meister')
} else if (wildBoarNatScore.value >= 2) {
wildBoarGameOver.value = true
wildBoarPlayerWon.value = false
}
}
function openCharacterDialog(charId: string) {
const char = CHARACTERS[charId]
if (!char) return
@@ -123,12 +208,11 @@ function openCharacterDialog(charId: string) {
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Servus Sascha! Schau dir meinen Wildschwein-Harnisch an – der glänzt wie ein Kronjuwel! Ich hab dir schon 3 kühle Helle und 5 Kronkorken gesichert, bevor die Meute alles wegsäuft!',
actionLabel: 'Sauber Josef! (+5 Kronkorken & Helles)',
text: 'Servus Sascha! Schau dir meine Fest-Bühne an! Ich erzähle den Leuten gerade epische Geschichten von meinen Girls und meinen Heldentaten im Haumdaucher Dickicht. Bevor du meine Kronkorken kriegst, forder ich dich zum Hof-Duell heraus: Wildschwein-Schnick-Schnack-Schnuck!',
actionLabel: '🐗 Wildschwein-Duell starten! (Best of 3)',
actionFn: () => {
store.addKronkorken(5)
store.addBeer()
activeDialog.value = null
startWildBoarDuel()
}
}
} else if (charId === 'mane') {
@@ -238,7 +322,7 @@ function proceedToExpress() {
<!-- ================= 1. GASTSTUBE (SCHANKRAUM) ================= -->
<template v-if="currentRoom === 'schankraum'">
<img src="/assets/scenes/schankraum_v2.jpg?v=2" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<img src="/assets/scenes/schankraum_v2.jpg?v=3" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<!-- Verena on the wooden gallery -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
@@ -246,6 +330,12 @@ function proceedToExpress() {
<span class="hotspot-badge">Verena 👑</span>
</button>
<!-- Door to Nebenraum (Kemenate / Atelier) -->
<button class="hotspot door-hotspot hotspot-door-nebenraum" @click="switchRoom('nebenraum')">
<span class="door-icon">🚪</span>
<span class="hotspot-badge glow-door">Zum Nebenraum ▶</span>
</button>
<!-- Armend at the forge/anvil -->
<button class="hotspot hotspot-armend" @click="openCharacterDialog('armend')">
<img :src="CHARACTERS.armend.portrait" class="hotspot-thumb" alt="Armend" />
@@ -258,43 +348,60 @@ function proceedToExpress() {
<span class="hotspot-badge">Lutz (ZTUL) 🎩</span>
</button>
<!-- Amy at the tavern table -->
<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>
<!-- Zuluman Magic Mirror on the pillar -->
<button class="hotspot hotspot-mirror" @click="inspectMirror">
<span class="mirror-icon">🪞</span>
<span class="hotspot-badge pulse">Zuluman-Spiegel</span>
</button>
<!-- Doorway to Marktplatz -->
<button class="hotspot door-hotspot hotspot-door-marktplatz" @click="switchRoom('marktplatz')">
<span class="door-icon">🚪</span>
<span class="door-icon">🎪</span>
<span class="hotspot-badge glow-door">Zum Marktplatz ▶</span>
</button>
<!-- SECRET EASTER EGG TRIGGER: Hidden Beer Tap to Hinterzimmer (Completely invisible until clicked) -->
<!-- Luke zum Hinterraum (Completely invisible until clicked) -->
<button
class="hotspot secret-tap-hotspot"
:class="{ 'tap-invisible': !secretTapDiscovered && !store.unlockedEasterEggs.includes('hinterzimmer') }"
@click="triggerSecretTap"
:title="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Ein sonderbarer Zapfhahn...' : undefined"
class="hotspot trapdoor-hotspot"
:class="{ 'trap-invisible': !trapdoorDiscovered && !store.unlockedEasterEggs.includes('hinterzimmer') }"
@click="triggerTrapdoor"
:title="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Luke zum Hinterraum' : undefined"
>
<template v-if="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="secret-tap-icon pulse">🗝️</span>
<span class="secret-badge">Geheimer Zapfhahn</span>
<template v-if="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="trapdoor-icon">🚪</span>
<span class="trapdoor-badge">Luke zum Hinterraum</span>
</template>
</button>
</template>
<!-- ================= 2. MARKTPLATZ ================= -->
<template v-else-if="currentRoom === 'marktplatz'">
<img src="/assets/scenes/marktplatz.jpg" alt="Marktplatz Haumdaucher" class="tavern-bg" />
<!-- ================= 2. NEBENRAUM (KEMENATE & ATELIER) ================= -->
<template v-else-if="currentRoom === 'nebenraum'">
<img src="/assets/scenes/nebenraum_v2.jpg?v=2" alt="Königlicher Nebenraum & Atelier" class="tavern-bg" />
<!-- Gaukler Mario juggling chainsaws with animations & flying sparks -->
<!-- Zuluman Magic Mirror on the timber wall -->
<button class="hotspot hotspot-mirror-nebenraum" @click="inspectMirror">
<span class="mirror-icon">🪞</span>
<span class="hotspot-badge pulse">Zuluman-Spiegel</span>
</button>
<!-- Amy at the easel painting the canvas -->
<button class="hotspot hotspot-amy-nebenraum" @click="openCharacterDialog('amy')">
<img :src="CHARACTERS.amy.portrait" class="hotspot-thumb" alt="Amy" />
<span class="hotspot-badge">Amy 🎨</span>
</button>
<!-- Door back to Gaststube (Schankraum) -->
<button class="hotspot door-hotspot hotspot-door-back-gaststube-nr" @click="switchRoom('schankraum')">
<span class="door-icon">🍺</span>
<span class="hotspot-badge glow-door">◀ Zur Gaststube</span>
</button>
</template>
<!-- ================= 3. MARKTPLATZ ================= -->
<template v-else-if="currentRoom === 'marktplatz'">
<img src="/assets/scenes/marktplatz_v2.jpg?v=2" alt="Marktplatz Haumdaucher" class="tavern-bg" />
<!-- NAT on the festival stage telling stories of his girls and heroic deeds -->
<button class="hotspot hotspot-nat" @click="openCharacterDialog('nat')">
<img :src="CHARACTERS.nat.portrait" class="hotspot-thumb" alt="NAT" />
<span class="hotspot-badge">NAT (Josef) 🐗</span>
</button>
<!-- Gaukler Mario juggling chainsaws & hops on the cobblestones -->
<div class="mario-container">
<button class="hotspot hotspot-mario" @click="openCharacterDialog('mario')">
<div class="chainsaw-circus">
@@ -308,15 +415,9 @@ function proceedToExpress() {
</button>
</div>
<!-- NAT at the fountain -->
<button class="hotspot hotspot-nat" @click="openCharacterDialog('nat')">
<img :src="CHARACTERS.nat.portrait" class="hotspot-thumb" alt="NAT" />
<span class="hotspot-badge">NAT (Josef) 🐗</span>
</button>
<!-- Door back to Gaststube -->
<button class="hotspot door-hotspot hotspot-door-back-gaststube" @click="switchRoom('schankraum')">
<span class="door-icon">🚪</span>
<span class="door-icon">🍺</span>
<span class="hotspot-badge glow-door">◀ Zur Gaststube</span>
</button>
@@ -388,6 +489,7 @@ function proceedToExpress() {
<div class="tavern-bar-title">
{{
currentRoom === 'schankraum' ? '🍻 AKT III: GASTSTUBE ZUM WUMMERNDEN RITTER 🍻' :
currentRoom === 'nebenraum' ? '🎨 AKT III: KÖNIGLICHER NEBENRAUM & ATELIER 🎨' :
currentRoom === 'marktplatz' ? '🎪 AKT III: HAUMDAUCHER MARKTPLATZ 🎪' :
currentRoom === 'hinterland' ? '🌲 AKT III: ALPINES HINTERLAND 🌲' :
'🪩 AKT III: GEHEIMES VIP-HINTERZIMMER 🪩'
@@ -403,6 +505,13 @@ function proceedToExpress() {
>
🍺 Gaststube
</button>
<button
class="room-tab"
:class="{ active: currentRoom === 'nebenraum' }"
@click="switchRoom('nebenraum')"
>
🎨 Nebenraum
</button>
<button
class="room-tab"
:class="{ active: currentRoom === 'marktplatz' }"
@@ -514,6 +623,79 @@ function proceedToExpress() {
</div>
</div>
</div>
<!-- Wildschwein Schnick-Schnack-Schnuck Minigame Modal -->
<div v-if="wildBoarDuelActive" class="dialog-overlay">
<div class="dialog-box boar-duel-box">
<div class="boar-header">
<div class="boar-title-group">
<h3>🐗 WILDSCHWEIN-SCHNICK-SCHNACK-SCHNUCK</h3>
<span class="boar-score">Stand: Sascha {{ wildBoarPlayerScore }} : {{ wildBoarNatScore }} NAT (Best of 3)</span>
</div>
<button class="duel-close-btn" @click="wildBoarDuelActive = false" aria-label="Duell verlassen">✕</button>
</div>
<!-- Rules / Explanations -->
<div class="boar-rules-banner">
🐗 Keiler bricht 🌿 Dickicht | 🌿 Dickicht fängt 🏹 Lanze | 🏹 Lanze erlegt 🐗 Keiler
</div>
<div class="boar-arena">
<div class="boar-status-msg">
{{ wildBoarResultMsg }}
</div>
<div v-if="wildBoarLastPlayerMove && wildBoarLastNatMove" class="boar-clash">
<div class="clash-side">
<span class="clash-label">Sascha:</span>
<span class="clash-move">{{ formatBoarMove(wildBoarLastPlayerMove) }}</span>
</div>
<span class="clash-vs">VS</span>
<div class="clash-side">
<span class="clash-label">NAT:</span>
<span class="clash-move">{{ formatBoarMove(wildBoarLastNatMove) }}</span>
</div>
</div>
</div>
<!-- Move Choices -->
<div v-if="!wildBoarGameOver" class="boar-choices">
<button class="boar-choice-btn" @click="playWildBoarMove('keiler')">
<span class="choice-icon">🐗</span>
<span class="choice-name">Keiler</span>
<span class="choice-sub">Bricht Dickicht</span>
</button>
<button class="boar-choice-btn" @click="playWildBoarMove('dickicht')">
<span class="choice-icon">🌿</span>
<span class="choice-name">Dickicht</span>
<span class="choice-sub">Fängt Lanze</span>
</button>
<button class="boar-choice-btn" @click="playWildBoarMove('lanze')">
<span class="choice-icon">🏹</span>
<span class="choice-name">Lanze</span>
<span class="choice-sub">Erlegt Keiler</span>
</button>
</div>
<!-- Game Over Screen -->
<div v-else class="boar-game-over">
<template v-if="wildBoarPlayerWon">
<h2 class="victory-title">👑 SASCHA IST WILDSCHWEIN-MEISTER!</h2>
<p class="boar-victory-desc">NAT verneigt sich ehrfürchtig! „Du kennst das Dickicht besser als jeder Keiler!“</p>
<p class="reward-text">+10 Kronkorken, 1 Kaltes Helles & Titel freigeschaltet!</p>
<button class="action-btn" @click="wildBoarDuelActive = false">Herrlich! Prost NAT!</button>
</template>
<template v-else>
<h2 class="defeat-title">🐗 NAT GEWINNT DAS DUELL!</h2>
<p class="boar-defeat-desc">NAT lacht schallend: „Das Haumdaucher Dickicht verlangt höchste Meisterschaft, Sascha!“</p>
<div class="boar-retry-actions">
<button class="action-btn retry-btn" @click="startWildBoarDuel">Revanche fordern!</button>
<button class="close-btn" @click="wildBoarDuelActive = false">Vielleicht später</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -594,24 +776,27 @@ function proceedToExpress() {
color: #38bdf8;
}
/* Secret Tap Easter Egg */
.secret-tap-hotspot {
/* Trapdoor to Hinterzimmer Easter Egg */
.trapdoor-hotspot {
position: absolute;
top: 72%;
top: 73%;
left: 82%;
background: rgba(234, 179, 8, 0.2);
background: rgba(180, 83, 9, 0.4);
border: 1.5px dashed #ffd700;
border-radius: 12px;
border-radius: 8px;
padding: 3px 6px;
cursor: pointer;
z-index: 12;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.secret-tap-hotspot.tap-invisible,
.secret-tap-hotspot.tap-invisible:hover,
.secret-tap-hotspot.tap-invisible:active,
.secret-tap-hotspot.tap-invisible:focus {
.trapdoor-hotspot.trap-invisible,
.trapdoor-hotspot.trap-invisible:hover,
.trapdoor-hotspot.trap-invisible:active,
.trapdoor-hotspot.trap-invisible:focus {
background: transparent !important;
border: none !important;
box-shadow: none !important;
@@ -623,29 +808,34 @@ function proceedToExpress() {
cursor: pointer;
}
.secret-tap-icon {
.trapdoor-icon {
font-size: 13px;
}
.secret-badge {
.trapdoor-badge {
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
color: #fef08a;
white-space: nowrap;
}
/* 1. Schankraum Hotspots */
.hotspot-verena { top: 17%; left: 28%; }
.hotspot-mirror { top: 30%; left: 68%; }
.hotspot-amy { top: 45%; left: 32%; }
.hotspot-lutz { top: 54%; left: 56%; }
.hotspot-door-nebenraum { top: 42%; left: 34%; }
.hotspot-armend { top: 62%; left: 18%; }
.hotspot-lutz { top: 54%; left: 56%; }
.hotspot-door-marktplatz { top: 50%; left: 84%; }
/* 2. Marktplatz Hotspots */
/* 2. Nebenraum Hotspots */
.hotspot-mirror-nebenraum { top: 36%; left: 24%; }
.hotspot-door-back-gaststube-nr { top: 48%; left: 56%; }
.hotspot-amy-nebenraum { top: 64%; left: 80%; }
/* 3. Marktplatz Hotspots */
.mario-container {
position: absolute;
top: 44%;
left: 30%;
top: 66%;
left: 70%;
z-index: 10;
}
@@ -699,9 +889,9 @@ function proceedToExpress() {
100% { opacity: 1; transform: scale(1.3); }
}
.hotspot-nat { top: 62%; left: 70%; }
.hotspot-door-back-gaststube { top: 72%; left: 20%; }
.hotspot-door-hinterland { top: 32%; left: 76%; }
.hotspot-nat { top: 54%; left: 22%; }
.hotspot-door-back-gaststube { top: 74%; left: 20%; }
.hotspot-door-hinterland { top: 34%; left: 76%; }
/* 3. Hinterland Hotspots */
.hotspot-mane { top: 44%; left: 36%; }
@@ -1074,4 +1264,170 @@ function proceedToExpress() {
font-size: 12px;
font-family: 'Press Start 2P', monospace;
}
/* Wild Boar Duel Modal */
.boar-duel-box {
max-width: 360px;
}
.boar-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #78350f;
margin-bottom: 10px;
padding-bottom: 6px;
}
.boar-title-group {
flex: 1;
}
.boar-title-group h3 {
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #22c55e;
margin: 0 0 4px 0;
line-height: 1.3;
}
.boar-score {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #fef08a;
}
.boar-rules-banner {
background: rgba(34, 197, 94, 0.15);
border: 1px dashed #22c55e;
border-radius: 4px;
padding: 5px 8px;
font-size: 8.5px;
color: #bbf7d0;
text-align: center;
margin-bottom: 10px;
font-family: 'MedievalSharp', serif;
}
.boar-arena {
background: #1a0f0a;
border: 1.5px solid #78350f;
border-radius: 6px;
padding: 10px;
text-align: center;
margin-bottom: 12px;
}
.boar-status-msg {
font-family: 'MedievalSharp', serif;
font-size: 14px;
color: #fef3c7;
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
}
.boar-clash {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #451a03;
}
.clash-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.clash-label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #94a3b8;
}
.clash-move {
font-size: 13px;
font-weight: bold;
color: #ffd700;
}
.clash-vs {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #ef4444;
}
.boar-choices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.boar-choice-btn {
background: #3b2014;
border: 1.5px solid #16a34a;
border-radius: 6px;
padding: 8px 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.boar-choice-btn:hover {
background: #14532d;
border-color: #4ade80;
transform: translateY(-2px);
}
.choice-icon {
font-size: 20px;
}
.choice-name {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #fef08a;
}
.choice-sub {
font-size: 8.5px;
color: #94a3b8;
font-family: 'MedievalSharp', serif;
}
.boar-game-over {
text-align: center;
}
.boar-victory-desc, .boar-defeat-desc {
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
margin: 6px 0;
color: #fef3c7;
}
.defeat-title {
color: #ef4444;
font-size: 10px;
font-family: 'Press Start 2P', monospace;
}
.boar-retry-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.retry-btn {
background: #16a34a;
border-color: #86efac;
}
</style>