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`) ### 9. Cellar Trapdoor & Easter Egg Invisibility Pattern (`TavernView.vue`)
* **The Decision**: Secret hotspots (e.g. `secret-tap-hotspot` in the Schankraum) must be 100% invisible initially. * **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. * **Why**: An Easter egg marked with a glowing button or text badge is not a secret; it invites immediate trivial clicking.
* **The Rule**: * **The Rule**:
* The button must render with transparent background, no border, no outline, no box shadow, no hover change, and no tooltip (`:title="undefined"`). * 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“*. * **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. * **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**: * **The Rule**:
@@ -151,7 +164,7 @@ make all
The `Makefile` targets: The `Makefile` targets:
* `compile`: Runs `npm run build` (`vue-tsc -b && vite build`). * `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`. * `tag`: Tags as `registry.haumdaucher.de/sasch40:latest`.
* `push`: Pushes to the Haumdaucher registry. * `push`: Pushes to the Haumdaucher registry.
* `deploy`: Executes `kubectl rollout restart deployment/sasch40 -n web` and monitors rollout status. * `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. 2. Verifies the Intro Screen and clicks Start.
3. Simulates dialogue options on the Castle Balcony (Prologue). 3. Simulates dialogue options on the Castle Balcony (Prologue).
4. Tests movement and jump controls in Act II (Hopfen-Pfade). 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). 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. Advances through the Party Express and reaches the Royal Certificate. 6. Tests reload persistence in `localStorage`.
7. Captures 10 screenshots to verify zero visual regressions. 7. Advances through the Party Express (Act IV) and reaches the Royal Certificate (Act V).
8. Asserts zero console errors, zero failed HTTP requests, and zero broken images. 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

+77 -27
View File
@@ -151,13 +151,52 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400)); 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...'); 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) { if (marktplatzTab) {
await marktplatzTab.click(); await marktplatzTab.click();
await new Promise(r => setTimeout(r, 1000)); 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') }); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_marktplatz.png') });
// Click Mario // Click Mario
@@ -172,22 +211,45 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400)); await new Promise(r => setTimeout(r, 400));
} }
// Click NAT (Josef) // Click NAT (Josef) & Test Wildschwein Duell
const natBtn = await page.$('.hotspot-nat'); const natBtn = await page.$('.hotspot-nat');
if (natBtn) { if (natBtn) {
await natBtn.click(); await natBtn.click();
await new Promise(r => setTimeout(r, 700)); await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT (Josef) Dialog...'); console.log('📸 09b: Capturing NAT (Josef) Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') }); 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(); // 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)); await new Promise(r => setTimeout(r, 400));
} }
} }
}
}
// 7. TAVERN: HINTERLAND (Mane hiking) // 7. TAVERN: HINTERLAND (Mane hiking)
console.log('🌲 Navigating to Hinterland...'); 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) { if (hinterlandTab) {
await hinterlandTab.click(); await hinterlandTab.click();
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
@@ -207,18 +269,18 @@ async function runPlaytest() {
} }
} }
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Secret Tap) // 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Luke zum Hinterraum)
console.log('🗝️ Returning to Schankraum to check Secret Tap (initially invisible)...'); console.log('🗝️ Returning to Schankraum to check Luke zum Hinterraum (trapdoor)...');
const schankraumTab = await page.$('.room-tab:nth-child(1)'); const schankraumTab = await page.$('.room-tab:nth-child(1)');
if (schankraumTab) { if (schankraumTab) {
await schankraumTab.click(); await schankraumTab.click();
await new Promise(r => setTimeout(r, 600)); await new Promise(r => setTimeout(r, 600));
} }
console.log('🪩 Triggering Secret Tap Easter Egg...'); console.log('🪩 Triggering Luke zum Hinterraum Easter Egg...');
const secretTap = await page.$('.secret-tap-hotspot'); const trapdoor = await page.$('.trapdoor-hotspot');
if (secretTap) { if (trapdoor) {
await secretTap.click(); await trapdoor.click();
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
console.log('📸 12: Capturing Secret VIP Hinterzimmer...'); console.log('📸 12: Capturing Secret VIP Hinterzimmer...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12_tavern_hinterzimmer_vip.png') }); 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 // 9. GSTANZL DUEL
console.log('🪞 Testing Zuluman Magic Mirror...'); console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const backToGaststube = await page.$('.hotspot-door-back-vip'); const backToGaststube = await page.$('.hotspot-door-back-vip');
if (backToGaststube) { if (backToGaststube) {
await backToGaststube.click(); await backToGaststube.click();
await new Promise(r => setTimeout(r, 600)); 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'); const duelBtn = await page.$('.duel-trigger');
if (duelBtn) { if (duelBtn) {
await duelBtn.click(); await duelBtn.click();
@@ -45,7 +45,7 @@ function toggleInventory() {
<div class="chassis-brand"> <div class="chassis-brand">
<span class="brand-text">KÖNIG SASCHAS KRONKORKENKREUZZUG</span> <span class="brand-text">KÖNIG SASCHAS KRONKORKENKREUZZUG</span>
<span v-if="store.currentAct !== 'intro'" class="brand-act"> <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> </span>
</div> </div>
<button class="sound-toggle" @click="toggleAudio"> <button class="sound-toggle" @click="toggleAudio">
@@ -203,8 +203,10 @@ function toggleInventory() {
.chassis-brand { .chassis-brand {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
gap: 2px;
overflow: hidden; overflow: hidden;
} }
@@ -218,7 +220,7 @@ function toggleInventory() {
.brand-act { .brand-act {
font-family: 'Press Start 2P', monospace; font-family: 'Press Start 2P', monospace;
font-size: 6px; font-size: 5.5px;
color: #f59e0b; color: #f59e0b;
white-space: nowrap; white-space: nowrap;
} }
@@ -7,7 +7,7 @@ import { sound } from '../audio/synthAudio'
const store = useGameStore() const store = useGameStore()
type TavernRoom = 'schankraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer' type TavernRoom = 'schankraum' | 'nebenraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer'
const currentRoom = ref<TavernRoom>('schankraum') const currentRoom = ref<TavernRoom>('schankraum')
interface ActiveDialog { interface ActiveDialog {
@@ -26,16 +26,16 @@ const duelStep = ref(0)
const duelWon = ref(false) const duelWon = ref(false)
const zulumanMirrorActive = ref(false) const zulumanMirrorActive = ref(false)
const secretTapDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer')) const trapdoorDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer'))
function switchRoom(room: TavernRoom) { function switchRoom(room: TavernRoom) {
sound.playDialogueBlip() sound.playDialogueBlip()
currentRoom.value = room currentRoom.value = room
} }
function triggerSecretTap() { function triggerTrapdoor() {
sound.playFanfare() sound.playFanfare()
secretTapDiscovered.value = true trapdoorDiscovered.value = true
store.unlockEasterEgg('hinterzimmer') store.unlockEasterEgg('hinterzimmer')
currentRoom.value = 'hinterzimmer' currentRoom.value = 'hinterzimmer'
} }
@@ -46,6 +46,91 @@ function enterSecretHinterzimmer() {
currentRoom.value = 'hinterzimmer' 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) { function openCharacterDialog(charId: string) {
const char = CHARACTERS[charId] const char = CHARACTERS[charId]
if (!char) return if (!char) return
@@ -123,12 +208,11 @@ function openCharacterDialog(charId: string) {
name: char.name, name: char.name,
title: char.title, title: char.title,
portrait: char.portrait, 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!', 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: 'Sauber Josef! (+5 Kronkorken & Helles)', actionLabel: '🐗 Wildschwein-Duell starten! (Best of 3)',
actionFn: () => { actionFn: () => {
store.addKronkorken(5)
store.addBeer()
activeDialog.value = null activeDialog.value = null
startWildBoarDuel()
} }
} }
} else if (charId === 'mane') { } else if (charId === 'mane') {
@@ -238,7 +322,7 @@ function proceedToExpress() {
<!-- ================= 1. GASTSTUBE (SCHANKRAUM) ================= --> <!-- ================= 1. GASTSTUBE (SCHANKRAUM) ================= -->
<template v-if="currentRoom === '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 --> <!-- Verena on the wooden gallery -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')"> <button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
@@ -246,6 +330,12 @@ function proceedToExpress() {
<span class="hotspot-badge">Verena 👑</span> <span class="hotspot-badge">Verena 👑</span>
</button> </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 --> <!-- Armend at the forge/anvil -->
<button class="hotspot hotspot-armend" @click="openCharacterDialog('armend')"> <button class="hotspot hotspot-armend" @click="openCharacterDialog('armend')">
<img :src="CHARACTERS.armend.portrait" class="hotspot-thumb" alt="Armend" /> <img :src="CHARACTERS.armend.portrait" class="hotspot-thumb" alt="Armend" />
@@ -258,43 +348,60 @@ function proceedToExpress() {
<span class="hotspot-badge">Lutz (ZTUL) 🎩</span> <span class="hotspot-badge">Lutz (ZTUL) 🎩</span>
</button> </button>
<!-- Amy at the tavern table --> <!-- Doorway to Marktplatz -->
<button class="hotspot hotspot-amy" @click="openCharacterDialog('amy')"> <button class="hotspot door-hotspot hotspot-door-marktplatz" @click="switchRoom('marktplatz')">
<img :src="CHARACTERS.amy.portrait" class="hotspot-thumb" alt="Amy" /> <span class="door-icon">🎪</span>
<span class="hotspot-badge">Amy 🎨</span> <span class="hotspot-badge glow-door">Zum Marktplatz ▶</span>
</button> </button>
<!-- Zuluman Magic Mirror on the pillar --> <!-- Luke zum Hinterraum (Completely invisible until clicked) -->
<button class="hotspot hotspot-mirror" @click="inspectMirror"> <button
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="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="trapdoor-icon">🚪</span>
<span class="trapdoor-badge">Luke zum Hinterraum</span>
</template>
</button>
</template>
<!-- ================= 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" />
<!-- Zuluman Magic Mirror on the timber wall -->
<button class="hotspot hotspot-mirror-nebenraum" @click="inspectMirror">
<span class="mirror-icon">🪞</span> <span class="mirror-icon">🪞</span>
<span class="hotspot-badge pulse">Zuluman-Spiegel</span> <span class="hotspot-badge pulse">Zuluman-Spiegel</span>
</button> </button>
<!-- Doorway to Marktplatz --> <!-- Amy at the easel painting the canvas -->
<button class="hotspot door-hotspot hotspot-door-marktplatz" @click="switchRoom('marktplatz')"> <button class="hotspot hotspot-amy-nebenraum" @click="openCharacterDialog('amy')">
<span class="door-icon">🚪</span> <img :src="CHARACTERS.amy.portrait" class="hotspot-thumb" alt="Amy" />
<span class="hotspot-badge glow-door">Zum Marktplatz ▶</span> <span class="hotspot-badge">Amy 🎨</span>
</button> </button>
<!-- SECRET EASTER EGG TRIGGER: Hidden Beer Tap to Hinterzimmer (Completely invisible until clicked) --> <!-- Door back to Gaststube (Schankraum) -->
<button <button class="hotspot door-hotspot hotspot-door-back-gaststube-nr" @click="switchRoom('schankraum')">
class="hotspot secret-tap-hotspot" <span class="door-icon">🍺</span>
:class="{ 'tap-invisible': !secretTapDiscovered && !store.unlockedEasterEggs.includes('hinterzimmer') }" <span class="hotspot-badge glow-door">◀ Zur Gaststube</span>
@click="triggerSecretTap"
:title="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Ein sonderbarer Zapfhahn...' : undefined"
>
<template v-if="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="secret-tap-icon pulse">🗝️</span>
<span class="secret-badge">Geheimer Zapfhahn</span>
</template>
</button> </button>
</template> </template>
<!-- ================= 2. MARKTPLATZ ================= --> <!-- ================= 3. MARKTPLATZ ================= -->
<template v-else-if="currentRoom === 'marktplatz'"> <template v-else-if="currentRoom === 'marktplatz'">
<img src="/assets/scenes/marktplatz.jpg" alt="Marktplatz Haumdaucher" class="tavern-bg" /> <img src="/assets/scenes/marktplatz_v2.jpg?v=2" alt="Marktplatz Haumdaucher" class="tavern-bg" />
<!-- Gaukler Mario juggling chainsaws with animations & flying sparks --> <!-- 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"> <div class="mario-container">
<button class="hotspot hotspot-mario" @click="openCharacterDialog('mario')"> <button class="hotspot hotspot-mario" @click="openCharacterDialog('mario')">
<div class="chainsaw-circus"> <div class="chainsaw-circus">
@@ -308,15 +415,9 @@ function proceedToExpress() {
</button> </button>
</div> </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 --> <!-- Door back to Gaststube -->
<button class="hotspot door-hotspot hotspot-door-back-gaststube" @click="switchRoom('schankraum')"> <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> <span class="hotspot-badge glow-door">◀ Zur Gaststube</span>
</button> </button>
@@ -388,6 +489,7 @@ function proceedToExpress() {
<div class="tavern-bar-title"> <div class="tavern-bar-title">
{{ {{
currentRoom === 'schankraum' ? '🍻 AKT III: GASTSTUBE ZUM WUMMERNDEN RITTER 🍻' : currentRoom === 'schankraum' ? '🍻 AKT III: GASTSTUBE ZUM WUMMERNDEN RITTER 🍻' :
currentRoom === 'nebenraum' ? '🎨 AKT III: KÖNIGLICHER NEBENRAUM & ATELIER 🎨' :
currentRoom === 'marktplatz' ? '🎪 AKT III: HAUMDAUCHER MARKTPLATZ 🎪' : currentRoom === 'marktplatz' ? '🎪 AKT III: HAUMDAUCHER MARKTPLATZ 🎪' :
currentRoom === 'hinterland' ? '🌲 AKT III: ALPINES HINTERLAND 🌲' : currentRoom === 'hinterland' ? '🌲 AKT III: ALPINES HINTERLAND 🌲' :
'🪩 AKT III: GEHEIMES VIP-HINTERZIMMER 🪩' '🪩 AKT III: GEHEIMES VIP-HINTERZIMMER 🪩'
@@ -403,6 +505,13 @@ function proceedToExpress() {
> >
🍺 Gaststube 🍺 Gaststube
</button> </button>
<button
class="room-tab"
:class="{ active: currentRoom === 'nebenraum' }"
@click="switchRoom('nebenraum')"
>
🎨 Nebenraum
</button>
<button <button
class="room-tab" class="room-tab"
:class="{ active: currentRoom === 'marktplatz' }" :class="{ active: currentRoom === 'marktplatz' }"
@@ -514,6 +623,79 @@ function proceedToExpress() {
</div> </div>
</div> </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> </div>
</template> </template>
@@ -594,24 +776,27 @@ function proceedToExpress() {
color: #38bdf8; color: #38bdf8;
} }
/* Secret Tap Easter Egg */ /* Trapdoor to Hinterzimmer Easter Egg */
.secret-tap-hotspot { .trapdoor-hotspot {
position: absolute; position: absolute;
top: 72%; top: 73%;
left: 82%; left: 82%;
background: rgba(234, 179, 8, 0.2); background: rgba(180, 83, 9, 0.4);
border: 1.5px dashed #ffd700; border: 1.5px dashed #ffd700;
border-radius: 12px; border-radius: 8px;
padding: 3px 6px; padding: 3px 6px;
cursor: pointer; cursor: pointer;
z-index: 12; z-index: 12;
transition: all 0.2s; transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
} }
.secret-tap-hotspot.tap-invisible, .trapdoor-hotspot.trap-invisible,
.secret-tap-hotspot.tap-invisible:hover, .trapdoor-hotspot.trap-invisible:hover,
.secret-tap-hotspot.tap-invisible:active, .trapdoor-hotspot.trap-invisible:active,
.secret-tap-hotspot.tap-invisible:focus { .trapdoor-hotspot.trap-invisible:focus {
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
@@ -623,29 +808,34 @@ function proceedToExpress() {
cursor: pointer; cursor: pointer;
} }
.secret-tap-icon { .trapdoor-icon {
font-size: 13px; font-size: 13px;
} }
.secret-badge { .trapdoor-badge {
font-family: 'Press Start 2P', monospace; font-family: 'Press Start 2P', monospace;
font-size: 5.5px; font-size: 5.5px;
color: #fef08a; color: #fef08a;
white-space: nowrap;
} }
/* 1. Schankraum Hotspots */ /* 1. Schankraum Hotspots */
.hotspot-verena { top: 17%; left: 28%; } .hotspot-verena { top: 17%; left: 28%; }
.hotspot-mirror { top: 30%; left: 68%; } .hotspot-door-nebenraum { top: 42%; left: 34%; }
.hotspot-amy { top: 45%; left: 32%; }
.hotspot-lutz { top: 54%; left: 56%; }
.hotspot-armend { top: 62%; left: 18%; } .hotspot-armend { top: 62%; left: 18%; }
.hotspot-lutz { top: 54%; left: 56%; }
.hotspot-door-marktplatz { top: 50%; left: 84%; } .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 { .mario-container {
position: absolute; position: absolute;
top: 44%; top: 66%;
left: 30%; left: 70%;
z-index: 10; z-index: 10;
} }
@@ -699,9 +889,9 @@ function proceedToExpress() {
100% { opacity: 1; transform: scale(1.3); } 100% { opacity: 1; transform: scale(1.3); }
} }
.hotspot-nat { top: 62%; left: 70%; } .hotspot-nat { top: 54%; left: 22%; }
.hotspot-door-back-gaststube { top: 72%; left: 20%; } .hotspot-door-back-gaststube { top: 74%; left: 20%; }
.hotspot-door-hinterland { top: 32%; left: 76%; } .hotspot-door-hinterland { top: 34%; left: 76%; }
/* 3. Hinterland Hotspots */ /* 3. Hinterland Hotspots */
.hotspot-mane { top: 44%; left: 36%; } .hotspot-mane { top: 44%; left: 36%; }
@@ -1074,4 +1264,170 @@ function proceedToExpress() {
font-size: 12px; font-size: 12px;
font-family: 'Press Start 2P', monospace; 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> </style>