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
@@ -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>