feat(sasch40): v4.0 full-screen mobile conversion, Hinter den Bergen room with Liars Dice, beat-only concert audio, and updated art

This commit is contained in:
Moritz Graf
2026-09-24 21:11:12 +02:00
parent 8a0b65c3fa
commit fbc4158016
17 changed files with 1272 additions and 629 deletions
+24 -10
View File
@@ -25,17 +25,14 @@ This document records the foundational design decisions, their rationale ("Why")
---
### 2. Dual Game Modes & Dynamic Chassis Layout (`GameBoyFrame.vue`)
* **The Decision**: The GameBoy physical controls (D-Pad and A/B action buttons) MUST dynamically hide and show based on the active game mode.
* **Why**: The game alternates between arcade mini-games (Doodle Jump Schwesternrettung, Mittelalter Guitar Hero) and point-and-click / visual-novel adventure scenes (Prologue, Tavern, Finale Certificate). Displaying a chunky D-Pad and large A/B buttons during dialogue or tavern exploration wastes mobile screen space and ruins immersion.
### 2. Full-Screen Mobile-First Web App (`GameBoyFrame.vue`)
* **The Decision**: The GameBoy bezel chassis, D-Pad, and external A/B action buttons have been completely removed across the entire application in favor of a 100% full-screen responsive mobile layout.
* **Why**: Mobile players interact directly with high-fidelity touch controls on modern smartphone screens. Virtual GameBoy bezels and D-Pads wasted over 40% of the viewport and degraded playability in arcade and visual-novel acts.
* **The Rule**:
* **Arcade Acts** (`currentAct === 'platformer'` or `'party_express'`):
* Show the GameBoy D-Pad and A/B action buttons (`arcade-layout`).
* Display the arcade HUD with Korken, Helles, and 135 BPM counter.
* **Adventure / Dialogue Acts** (`currentAct === 'intro'`, `'prologue'`, `'tavern'`, or `'finale'`):
* Hide the D-Pad and A/B buttons completely (`adventure-layout`).
* Display a sleek top-right settings button (`⚙️`) offering pause, mute/unmute, and game reset.
* On `currentAct === 'finale'`, hide even the mini bar so the certificate occupies the full screen.
* The app container fills 100% of viewport height and width.
* **Header**: Line 1 displays `KÖNIG SASCHAS KRONKORKENKREUZZUG`, Line 2 displays the act badge (e.g. `AKT III`). The right side displays inventory counts (`👑`, `🍻`) and the persistent `⚙️` settings icon.
* **Controls**: Act II uses tap/touch screen halves (`left`/`right`) or arrow keys; Act IV uses full-width on-screen highway strum buttons (`[D LINKS]`, `[G MITTE]`, `[A RECHTS]`).
---
@@ -67,6 +64,23 @@ This document records the foundational design decisions, their rationale ("Why")
---
### 2e. Akt III: Raum „Hinter den Bergen“ & Liar's Dice (`TavernView.vue`)
* **The Decision**: Akt III features a special mountain hideaway scene *„Hinter den Bergen“* accessible via Hinterland signpost or tab bar.
* **Why**: Introduces Saschas father Papa Markus and his Taiwanese connection with Gaoliang drinking culture into the anniversary narrative.
* **Mechanics**:
* Papa Markus sits at a table with 6 Taiwanese friends in an alpine valley nested with Taiwanese/Tokyo skyscrapers.
* Minigame **Liar's Dice (Chui Niu / 吹牛)**: 5 dice each, 1s are wild, sequential bidding, bluff-calling reveals, loser drinks a 58% Kinmen Gaoliang shot (`🍶`).
* Rewards: +10 Kronkorken, inventory item *58% Kinmen Gaoliang*, Easter Egg title `taiwan_dice_champion`.
---
### 2f. Akt IV: Beat-Only Metronom & Harmonische Lautenakkorde (`PartyExpressView.vue` & `synthAudio.ts`)
* **The Decision**: During the Castle Stage rock concert in Act IV, the synthesizer BGM switches to `beat_only` mode (melody muted, playing solely the 135-BPM kick/hi-hat rhythm).
* **Why**: Playing a rhythm game while a pre-recorded synthesizer melody plays in parallel creates cacophony and ruins the illusion that the player's notes compose the music.
* **The Rule**: Successful hits on D, G, A trigger full 3-note harmonic medieval chords; misses trigger a muted plink. On act completion or exit, BGM restores to `full`.
---
### 3. Protagonist System & Extensibility (`characters.ts`)
* **The Decision**: All character portraits are strictly 1:1 square pixel-art illustrations based directly on real-life friends and family photos, maintained in a centralized registry.
* **Why**: Guests at the party must immediately recognize themselves and their friends. Previous attempts at generic AI portraits caused confusion because hair, beards, and styles did not match the actual people.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 979 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

@@ -308,6 +308,92 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400));
}
// Test Münchanien Signpost Hint Dialog
console.log('🪧 Testing Münchanien Signpost Hint...');
const signBtn = await page.$('.hotspot-sign-munchanien');
if (signBtn) {
await signBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 11b: Capturing Münchanien Signpost Hint Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11b_tavern_munchanien_hint.png') });
const hintCloseBtn = await page.$('.intro-modal-box .action-btn');
if (hintCloseBtn) await hintCloseBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Test Hinter den Bergen (Taiwan Valley & Papa Markus)
console.log('🏮 Navigating to Hinter den Bergen...');
const hdbDoor = await page.$('.hotspot-door-hinter-den-bergen');
if (hdbDoor) {
await hdbDoor.click();
} else {
const hdbTab = await page.$('.room-tab:nth-child(5)');
if (hdbTab) await hdbTab.click();
}
await new Promise(r => setTimeout(r, 1000));
console.log('📸 11c: Capturing Hinter den Bergen Scene (Taiwan Alpine Skyline & Papa Markus)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11c_tavern_hinter_den_bergen.png') });
// Click Markus for Liar's Dice
const markusBtn = await page.$('.hotspot-markus');
if (markusBtn) {
await markusBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 11d: Capturing Papa Markus Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11d_tavern_markus_dialog.png') });
// Click "🎲 Auf geht's Papa, Liar's Dice spielen!" -> opens intro modal
const startDiceIntroBtn = await page.$('.dialog-actions .action-btn');
if (startDiceIntroBtn) {
await startDiceIntroBtn.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 11e: Capturing Liar\'s Dice INTRO Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11e_tavern_liars_dice_intro.png') });
// Start Liar's Dice Match
const startMatchBtn = await page.$('.start-duel-action-btn');
if (startMatchBtn) {
await startMatchBtn.click();
await new Promise(r => setTimeout(r, 1200));
console.log('📸 11f: Capturing Liar\'s Dice Initial Round View...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11f_tavern_liars_dice_gameplay.png') });
// Submit player bid
const bidBtn = await page.$('.bid-action-btn');
if (bidBtn) {
await bidBtn.click();
await new Promise(r => setTimeout(r, 1500)); // wait for Markus response
console.log('📸 11g: Capturing Liar\'s Dice Markus Counter-Bid...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11g_tavern_liars_dice_markus_turn.png') });
}
// Call bluff
const bluffBtn = await page.$('.call-bluff-btn');
if (bluffBtn) {
await bluffBtn.click();
await new Promise(r => setTimeout(r, 1200)); // wait for shot animation
console.log('📸 11h: Capturing Liar\'s Dice Bluff Resolution & Gaoliang Shot...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11h_tavern_liars_dice_bluff_call.png') });
}
// Close modal
const closeDiceBtn = await page.$('.liars-dice-box .duel-close-btn');
if (closeDiceBtn) {
await closeDiceBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
}
}
// Return to Hinterland
const backToHinterland = await page.$('.hotspot-door-back-hinterland');
if (backToHinterland) {
await backToHinterland.click();
await new Promise(r => setTimeout(r, 600));
}
// Test Gstanzl Duel Intro & Gameplay
console.log('⚔️ Testing Gstanzl-Fechten Intro & Minigame...');
const duelBtn = await page.$('.duel-trigger');
+144 -52
View File
@@ -244,37 +244,119 @@ class SoundEngine {
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
if (!success) {
// Missed clunk / muted buzz
// Missed clunk / muted buzz ('plink')
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(90, now)
gain.gain.setValueAtTime(0.12, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12)
osc.frequency.setValueAtTime(110, now)
osc.frequency.exponentialRampToValueAtTime(45, now + 0.08)
gain.gain.setValueAtTime(0.15, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.08)
} else {
// Resonant lute string twang
const freqs = [293.66, 392.00, 587.33] // D4, G4, D5
const freq = freqs[lane % 3] || 440
osc.type = 'triangle'
osc.frequency.setValueAtTime(freq, now)
// Resonant medieval lute chords (2 harmonic notes for rich sound)
const chordRoots = [
[293.66, 440.00], // D4 + A4 (Saite 1 - Deep resonant D power)
[392.00, 587.33], // G4 + D5 (Saite 2 - Bright G medieval fifth)
[440.00, 659.25] // A4 + E5 (Saite 3 - High piercing A rock lute)
]
const notes = chordRoots[lane % 3] || [392.00, 587.33]
gain.gain.setValueAtTime(0.28, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.32)
notes.forEach((freq, idx) => {
if (!this.ctx) return
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'triangle'
osc.frequency.setValueAtTime(freq, now + idx * 0.012)
gain.gain.setValueAtTime(0.24, now + idx * 0.012)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.38)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now + idx * 0.012)
osc.stop(now + 0.38)
})
}
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + (success ? 0.32 : 0.12))
}
// SFX: Liar's Dice - Shake dice in leather cup
public playDiceShake() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
for (let i = 0; i < 4; i++) {
const t = now + i * 0.06
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'square'
osc.frequency.setValueAtTime(180 + Math.random() * 120, t)
gain.gain.setValueAtTime(0.12, t)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.04)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(t)
osc.stop(t + 0.04)
}
}
// SFX: Liar's Dice - Cup slam on tavern wood table
public playCupSlam() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(110, now)
osc.frequency.exponentialRampToValueAtTime(30, now + 0.12)
gain.gain.setValueAtTime(0.35, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.15)
}
// SFX: Gaoliang Shot Pour / Clink
public playGaoliangShot() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const now = this.ctx.currentTime
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(1760, now) // A6 glass clink
osc.frequency.setValueAtTime(2637, now + 0.04) // E7
gain.gain.setValueAtTime(0.2, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.4)
}
private bgmMode: 'full' | 'beat_only' = 'full'
// 135 BPM Medieval Techno Chiptune Beat
public startBGM() {
if (this.isMuted || this.bgmPlaying) return
public startBGM(mode: 'full' | 'beat_only' = 'full') {
this.bgmMode = mode
if (this.isMuted) return
if (this.bgmPlaying) {
// If already playing, just update mode
return
}
this.initCtx()
this.bgmPlaying = true
@@ -340,47 +422,50 @@ class SoundEngine {
hatNode.start(now)
}
// Bassline (sawtooth filtered)
const bassFreq = bassline[this.step % bassline.length]
if (bassFreq > 0) {
const bassOsc = this.ctx.createOscillator()
const bassGain = this.ctx.createGain()
const bassFilter = this.ctx.createBiquadFilter()
// ONLY PLAY BASSLINE & MELODY IF NOT IN BEAT-ONLY MODE!
if (this.bgmMode === 'full') {
// Bassline (sawtooth filtered)
const bassFreq = bassline[this.step % bassline.length]
if (bassFreq > 0) {
const bassOsc = this.ctx.createOscillator()
const bassGain = this.ctx.createGain()
const bassFilter = this.ctx.createBiquadFilter()
bassOsc.type = 'sawtooth'
bassOsc.frequency.setValueAtTime(bassFreq, now)
bassOsc.type = 'sawtooth'
bassOsc.frequency.setValueAtTime(bassFreq, now)
bassFilter.type = 'lowpass'
bassFilter.frequency.setValueAtTime(450, now)
bassFilter.type = 'lowpass'
bassFilter.frequency.setValueAtTime(450, now)
bassGain.gain.setValueAtTime(0.12, now)
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2)
bassGain.gain.setValueAtTime(0.12, now)
bassGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2)
bassOsc.connect(bassFilter)
bassFilter.connect(bassGain)
bassGain.connect(this.ctx.destination)
bassOsc.connect(bassFilter)
bassFilter.connect(bassGain)
bassGain.connect(this.ctx.destination)
bassOsc.start(now)
bassOsc.stop(now + 0.2)
}
bassOsc.start(now)
bassOsc.stop(now + 0.2)
}
// Medieval Lute Lead Melody
const melFreq = melody[this.step % melody.length]
if (melFreq > 0) {
const melOsc = this.ctx.createOscillator()
const melGain = this.ctx.createGain()
// Medieval Lute Lead Melody
const melFreq = melody[this.step % melody.length]
if (melFreq > 0) {
const melOsc = this.ctx.createOscillator()
const melGain = this.ctx.createGain()
melOsc.type = 'square'
melOsc.frequency.setValueAtTime(melFreq, now)
melOsc.type = 'square'
melOsc.frequency.setValueAtTime(melFreq, now)
melGain.gain.setValueAtTime(0.08, now)
melGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
melGain.gain.setValueAtTime(0.08, now)
melGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18)
melOsc.connect(melGain)
melGain.connect(this.ctx.destination)
melOsc.connect(melGain)
melGain.connect(this.ctx.destination)
melOsc.start(now)
melOsc.stop(now + 0.18)
melOsc.start(now)
melOsc.stop(now + 0.18)
}
}
this.step++
@@ -390,6 +475,13 @@ class SoundEngine {
playLoop()
}
public setBGMMode(mode: 'full' | 'beat_only') {
this.bgmMode = mode
if (!this.bgmPlaying && !this.isMuted) {
this.startBGM(mode)
}
}
public stopBGM() {
this.bgmPlaying = false
if (this.bgmTimer) {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useGameStore } from '../stores/gameStore'
import { sound } from '../audio/synthAudio'
import IntroView from './IntroView.vue'
@@ -10,35 +10,17 @@ import PartyExpressView from './PartyExpressView.vue'
import CertificateModal from './CertificateModal.vue'
const store = useGameStore()
const platformerRef = ref<InstanceType<typeof PlatformerView> | null>(null)
const concertRef = ref<InstanceType<typeof PartyExpressView> | null>(null)
function handleJump() {
if (store.currentAct === 'platformer' && platformerRef.value) {
platformerRef.value.onJump()
} else if (store.currentAct === 'party_express' && concertRef.value) {
concertRef.value.hitLane(2)
const actBadge = computed(() => {
switch (store.currentAct) {
case 'prologue': return 'AKT I'
case 'platformer': return 'AKT II'
case 'tavern': return 'AKT III'
case 'party_express': return 'AKT IV'
case 'finale': return 'AKT V'
default: return ''
}
}
function handleButtonB() {
if (store.currentAct === 'party_express' && concertRef.value) {
concertRef.value.hitLane(1)
} else {
store.addBeer()
}
}
function handleDpad(dir: 'left' | 'right' | 'down', pressed: boolean) {
if (store.currentAct === 'platformer' && platformerRef.value) {
if (dir === 'left') platformerRef.value.onLeft(pressed)
if (dir === 'right') platformerRef.value.onRight(pressed)
} else if (store.currentAct === 'party_express' && concertRef.value && pressed) {
if (dir === 'left') concertRef.value.hitLane(0)
if (dir === 'down') concertRef.value.hitLane(1)
if (dir === 'right') concertRef.value.hitLane(2)
}
}
})
function toggleAudio() {
store.toggleMute()
@@ -68,557 +50,273 @@ function resetGameFromSettings() {
</script>
<template>
<div class="gameboy-chassis" :class="{ 'arcade-layout': store.isArcadeMode, 'adventure-layout': !store.isArcadeMode }">
<div class="app-container">
<!-- Top Royal Header -->
<div class="chassis-top">
<div class="speaker-grill-left">
<span></span><span></span><span></span>
<header class="app-header">
<div class="header-left">
<span class="header-brand-title">KÖNIG SASCHAS KRONKORKENKREUZZUG</span>
<span v-if="actBadge" class="header-act-badge">{{ actBadge }}</span>
</div>
<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' : '' }}
</span>
</div>
<button class="settings-toggle-btn" @click="openSettings" title="Einstellungen & Pause" aria-label="Einstellungen">
⚙️
</button>
</div>
<!-- Active Screen Viewport (Takes full height in Adventure / Role Play mode!) -->
<div class="screen-bezel" :class="{ 'fullscreen-bezel': !store.isArcadeMode }">
<div class="screen-glass">
<IntroView v-if="store.currentAct === 'intro'" />
<PrologueView v-else-if="store.currentAct === 'prologue'" />
<PlatformerView v-else-if="store.currentAct === 'platformer'" ref="platformerRef" />
<TavernView v-else-if="store.currentAct === 'tavern'" />
<PartyExpressView v-else-if="store.currentAct === 'party_express'" ref="concertRef" />
<CertificateModal v-else-if="store.currentAct === 'finale'" />
</div>
</div>
<!-- ARCADE CONTROLS ONLY: Rendered during Jump&Run / Speed-Runner -->
<template v-if="store.isArcadeMode">
<!-- Medieval Stats Parchment Scroll -->
<div class="parchment-scroll">
<div class="parchment-content">
<span class="stat-badge">👑 KORKEN: <strong>{{ store.kronkorken }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🍻 HELLES: <strong>{{ store.beers }}</strong></span>
<span class="stat-divider">|</span>
<span class="stat-badge">🔊 <strong>135 BPM</strong></span>
<div class="header-right">
<div v-if="store.currentAct !== 'intro' && store.currentAct !== 'finale'" class="header-stats">
<span class="stat-pill">👑 {{ store.kronkorken }}</span>
<span class="stat-pill">🍻 {{ store.beers }}</span>
</div>
<button class="settings-toggle-btn" @click="openSettings" title="Einstellungen & Pause" aria-label="Einstellungen">
⚙️
</button>
</div>
</header>
<!-- Handheld Controller Lower Body -->
<div class="chassis-controls">
<!-- D-PAD -->
<div class="dpad-wrapper">
<div class="dpad">
<button class="dpad-btn dpad-up" disabled></button>
<button
class="dpad-btn dpad-left"
@touchstart.prevent="handleDpad('left', true)"
@touchend.prevent="handleDpad('left', false)"
@mousedown="handleDpad('left', true)"
@mouseup="handleDpad('left', false)"
>◀</button>
<div class="dpad-center"></div>
<button
class="dpad-btn dpad-right"
@touchstart.prevent="handleDpad('right', true)"
@touchend.prevent="handleDpad('right', false)"
@mousedown="handleDpad('right', true)"
@mouseup="handleDpad('right', false)"
>▶</button>
<button
class="dpad-btn dpad-down"
:disabled="store.currentAct !== 'party_express'"
@touchstart.prevent="handleDpad('down', true)"
@touchend.prevent="handleDpad('down', false)"
@mousedown="handleDpad('down', true)"
@mouseup="handleDpad('down', false)"
>▼</button>
</div>
<span class="control-label">D-PAD</span>
</div>
<!-- Main Active Viewport (100% full screen responsive) -->
<main class="app-viewport">
<IntroView v-if="store.currentAct === 'intro'" />
<PrologueView v-else-if="store.currentAct === 'prologue'" />
<PlatformerView v-else-if="store.currentAct === 'platformer'" />
<TavernView v-else-if="store.currentAct === 'tavern'" />
<PartyExpressView v-else-if="store.currentAct === 'party_express'" />
<CertificateModal v-else-if="store.currentAct === 'finale'" />
</main>
<!-- Center Logo & Utility Buttons -->
<div class="center-controls">
<div class="royal-crest">👑</div>
<div class="pill-buttons">
<button class="pill-btn" @click="openSettings">
<span class="pill-key">SELECT</span>
<span class="pill-sub">PAUSE</span>
</button>
<button class="pill-btn" @click="toggleAudio">
<span class="pill-key">START</span>
<span class="pill-sub">BASS</span>
</button>
<!-- Settings Modal -->
<transition name="fade">
<div v-if="settingsOpen" class="settings-modal-overlay" @click.self="closeSettings">
<div class="settings-modal-card">
<div class="settings-modal-header">
<div class="settings-title-group">
<span class="settings-icon">⚙️</span>
<h3 class="settings-title">SPIELPAUSE & OPTIONEN</h3>
</div>
<button class="settings-close-x" @click="closeSettings" aria-label="Schließen">✕</button>
</div>
</div>
<!-- Action Buttons A & B -->
<div class="action-buttons-wrapper">
<div class="action-buttons">
<button
class="action-round-btn btn-b"
@click="handleButtonB"
>
<span class="btn-letter">B</span>
</button>
<button
class="action-round-btn btn-a"
@click="handleJump"
@touchstart.prevent="handleJump"
>
<span class="btn-letter">A</span>
</button>
</div>
<div class="action-labels">
<span>{{ store.currentAct === 'party_express' ? 'SAITE G (B)' : "O'ZAPFT (B)" }}</span>
<span>{{ store.currentAct === 'party_express' ? 'SAITE A (A)' : 'SPRUNG (A)' }}</span>
<div class="settings-body">
<p class="settings-lore">
Das Spiel ist pausiert. Trinke einen Schluck Helles oder passe deine königlichen Einstellungen an!
</p>
<div class="settings-actions">
<!-- Sound Toggle -->
<button class="settings-btn sound-btn" @click="toggleAudio">
<span class="btn-icon">{{ store.soundMuted ? '🔇' : '🔊' }}</span>
<span class="btn-text">Sound: <strong>{{ store.soundMuted ? 'AUS' : 'AN (135 BPM)' }}</strong></span>
</button>
<!-- Resume Game -->
<button class="settings-btn resume-btn" @click="closeSettings">
<span class="btn-icon">▶️</span>
<span class="btn-text">Weiterspielen</span>
</button>
<!-- Reset Game -->
<button class="settings-btn reset-btn" @click="resetGameFromSettings">
<span class="btn-icon">🔄</span>
<span class="btn-text">Spielstand zurücksetzen</span>
</button>
</div>
</div>
</div>
</div>
</template>
<!-- Mini Bottom Status Bar for Adventure/Role Play Mode (Hidden on Finale Certificate) -->
<div v-else-if="store.currentAct !== 'finale'" class="adventure-bottom-bar">
<div class="adv-stat">
<span class="adv-icon">👑</span>
<span class="adv-val">{{ store.kronkorken }} Korken</span>
</div>
<div class="adv-stat">
<span class="adv-icon">🍻</span>
<span class="adv-val">{{ store.beers }} Helles</span>
</div>
</div>
<!-- Settings & Pause Modal -->
<div v-if="settingsOpen" class="settings-overlay" @click="closeSettings">
<div class="settings-card" @click.stop>
<div class="settings-header">
<h3 class="settings-title">⚙️ EINSTELLUNGEN & PAUSE</h3>
<button class="settings-close-x" @click="closeSettings">✕</button>
</div>
<p class="settings-paused-hint">⏸️ Spiel und Timer pausiert</p>
<div class="settings-actions">
<button class="settings-btn" @click="toggleAudio">
{{ store.soundMuted ? '🔇 Ton: AUS (Einschalten)' : '🔊 Ton: 135 BPM AN (Stummschalten)' }}
</button>
<button class="settings-btn reset-danger-btn" @click="resetGameFromSettings">
🔄 Spiel neu starten (Reset)
</button>
<button class="settings-btn resume-btn" @click="closeSettings">
▶ Weiterspielen
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.gameboy-chassis {
display: flex;
flex-direction: column;
width: 100%;
max-width: 440px;
.app-container {
width: 100vw;
height: 100vh;
height: 100dvh;
margin: 0 auto;
background: linear-gradient(180deg, #3d2214 0%, #26140b 100%);
border: 4px solid #b45309;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.9);
user-select: none;
overflow: hidden;
box-sizing: border-box;
}
/* Top Trim */
.chassis-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #2b1810;
border-bottom: 2px solid #78350f;
flex-shrink: 0;
}
.chassis-brand {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
background-color: #0c0a09;
color: #fef3c7;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.brand-text {
font-family: 'Press Start 2P', monospace;
font-size: 6.8px;
color: #ffd700;
letter-spacing: 0.2px;
white-space: nowrap;
}
.brand-act {
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
color: #f59e0b;
white-space: nowrap;
}
.speaker-grill-left {
/* Header */
.app-header {
height: 48px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: linear-gradient(180deg, #1c1917 0%, #141210 100%);
border-bottom: 2px solid #78350f;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
z-index: 40;
}
.header-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.speaker-grill-left span {
width: 3px;
height: 12px;
background: #1a0f0a;
border-radius: 2px;
}
.settings-toggle-btn {
background: rgba(120, 53, 15, 0.7);
color: #ffd700;
border: 1px solid #ffd700;
border-radius: 4px;
font-size: 13px;
padding: 2px 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.settings-toggle-btn:hover, .settings-toggle-btn:active {
background: #b45309;
transform: scale(1.1);
}
/* Screen Bezel */
.screen-bezel {
flex: 1;
background: #17100b;
margin: 6px 8px 4px 8px;
border-radius: 8px;
border: 3px solid #1a0f0a;
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.9);
padding: 4px;
display: flex;
overflow: hidden;
position: relative;
}
.fullscreen-bezel {
margin: 4px 6px 0 6px;
border-radius: 6px;
}
.screen-glass {
width: 100%;
height: 100%;
background: #000;
border-radius: 4px;
overflow: hidden;
position: relative;
}
/* Adventure Bottom Bar */
.adventure-bottom-bar {
display: flex;
justify-content: space-around;
align-items: center;
background: #2b1810;
border-top: 2px solid #78350f;
padding: 6px 12px;
flex-shrink: 0;
}
.adv-stat {
display: flex;
align-items: center;
gap: 4px;
}
.adv-icon {
font-size: 14px;
}
.adv-val {
.header-brand-title {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
}
/* Parchment Status Bar */
.parchment-scroll {
background: #fdf6e2;
border: 2px solid #b45309;
border-radius: 4px;
margin: 4px 10px;
padding: 4px 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.parchment-content {
display: flex;
justify-content: space-around;
align-items: center;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #78350f;
}
.stat-divider {
color: #b45309;
}
/* Controls Section (Arcade Mode Only) */
.chassis-controls {
height: 130px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 12px 10px 12px;
flex-shrink: 0;
}
/* D-PAD */
.dpad-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.dpad {
position: relative;
width: 84px;
height: 84px;
}
.dpad-center {
position: absolute;
top: 28px;
left: 28px;
width: 28px;
height: 28px;
background: #78350f;
}
.dpad-btn {
position: absolute;
background: #854d0e;
border: 1.5px solid #d4af37;
color: #fef08a;
font-size: 11px;
font-weight: bold;
cursor: pointer;
}
.dpad-btn:active {
background: #b45309;
}
.dpad-up {
top: 0; left: 28px; width: 28px; height: 28px;
border-radius: 4px 4px 0 0;
}
.dpad-down {
bottom: 0; left: 28px; width: 28px; height: 28px;
border-radius: 0 0 4px 4px;
}
.dpad-left {
top: 28px; left: 0; width: 28px; height: 28px;
border-radius: 4px 0 0 4px;
}
.dpad-right {
top: 28px; right: 0; width: 28px; height: 28px;
border-radius: 0 4px 4px 0;
}
.control-label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #d4af37;
}
/* Center */
.center-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.royal-crest {
font-size: 18px;
}
.pill-buttons {
display: flex;
gap: 6px;
}
.pill-btn {
display: flex;
flex-direction: column;
align-items: center;
background: #1c1917;
border: 1px solid #78350f;
border-radius: 12px;
padding: 3px 6px;
cursor: pointer;
transform: rotate(-15deg);
}
.pill-key {
font-family: 'Press Start 2P', monospace;
font-size: 5px;
color: #94a3b8;
}
.pill-sub {
font-size: 7px;
color: #d4af37;
}
/* Action Buttons */
.action-buttons-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.action-buttons {
position: relative;
width: 84px;
height: 64px;
}
.action-round-btn {
position: absolute;
width: 38px;
height: 38px;
border-radius: 50%;
border: 2px solid #ffd700;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.action-round-btn:active {
transform: translateY(2px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.btn-b {
bottom: 0;
left: 0;
background: #eab308;
}
.btn-a {
top: 0;
right: 0;
background: #dc2626;
}
.btn-letter {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fff;
color: #f59e0b;
letter-spacing: 0.3px;
white-space: nowrap;
text-shadow: 1px 1px 0 #000;
}
.action-labels {
display: flex;
gap: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
color: #d4af37;
@media (min-width: 640px) {
.header-brand-title {
font-size: 11px;
}
}
/* Settings & Pause Modal */
.settings-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
.header-act-badge {
font-family: monospace;
font-size: 9px;
font-weight: 800;
padding: 1px 5px;
border-radius: 4px;
background-color: #451a03;
color: #fde68a;
border: 1px solid #b45309;
white-space: nowrap;
line-height: 1.1;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header-stats {
display: flex;
align-items: center;
gap: 6px;
}
.stat-pill {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
color: #fef08a;
background: rgba(0, 0, 0, 0.4);
padding: 3px 6px;
border-radius: 4px;
border: 1px solid #78350f;
}
.settings-toggle-btn {
background: #292524;
border: 1px solid #78350f;
border-radius: 6px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
color: #fbbf24;
transition: transform 0.1s, background-color 0.15s;
}
.settings-toggle-btn:hover {
background: #44403c;
transform: scale(1.05);
}
.settings-toggle-btn:active {
transform: scale(0.95);
}
/* Main Viewport */
.app-viewport {
flex: 1;
width: 100%;
height: calc(100% - 48px);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
z-index: 250;
padding: 16px;
background-color: #000;
}
.settings-card {
background: #2b1810;
border: 3px solid #d4af37;
border-radius: 8px;
padding: 18px;
width: 100%;
max-width: 320px;
color: #fef3c7;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.9);
}
.settings-header {
/* Settings Modal */
.settings-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1.5px solid #78350f;
padding-bottom: 8px;
margin-bottom: 12px;
justify-content: center;
padding: 16px;
z-index: 100;
}
.settings-modal-card {
width: 100%;
max-width: 360px;
background: #1c1917;
border: 2px solid #b45309;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.9);
overflow: hidden;
}
.settings-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #292524;
border-bottom: 1px solid #78350f;
}
.settings-title-group {
display: flex;
align-items: center;
gap: 8px;
}
.settings-icon {
font-size: 18px;
}
.settings-title {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
color: #ffd700;
margin: 0;
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #fbbf24;
}
.settings-close-x {
background: #7f1d1d;
color: #fef08a;
border: 1px solid #ef4444;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
background: transparent;
border: none;
color: #a8a29e;
font-size: 16px;
font-weight: bold;
cursor: pointer;
line-height: 1;
transition: all 0.15s ease;
padding: 4px 8px;
}
.settings-close-x:hover {
background: #dc2626;
transform: scale(1.1);
color: #ef4444;
}
.settings-paused-hint {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #38bdf8;
.settings-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-lore {
margin: 0;
font-size: 12px;
color: #d6d3d1;
line-height: 1.4;
text-align: center;
margin: 0 0 16px 0;
}
.settings-actions {
@@ -628,43 +326,49 @@ function resetGameFromSettings() {
}
.settings-btn {
background: #3b2014;
color: #fef08a;
border: 1.5px solid #b45309;
border-radius: 6px;
padding: 10px 8px;
font-family: 'MedievalSharp', serif;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: bold;
cursor: pointer;
text-align: center;
transition: all 0.15s ease;
transition: transform 0.1s, opacity 0.15s;
border: 1px solid transparent;
}
.settings-btn:hover {
background: #78350f;
border-color: #ffd700;
.settings-btn:active {
transform: scale(0.98);
}
.reset-danger-btn {
background: rgba(127, 29, 29, 0.4);
border-color: #ef4444;
color: #fca5a5;
}
.reset-danger-btn:hover {
background: #991b1b;
color: #fff;
border-color: #f87171;
.sound-btn {
background: #3b82f6;
color: #ffffff;
border-color: #60a5fa;
}
.resume-btn {
background: #15803d;
border-color: #86efac;
color: #fff;
color: #ffffff;
border-color: #22c55e;
}
.resume-btn:hover {
background: #16a34a;
.reset-btn {
background: #7f1d1d;
color: #fca5a5;
border-color: #dc2626;
font-size: 11px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -136,6 +136,7 @@ function generateNoteChart() {
function startConcert() {
sound.playDialogueBlip()
sound.setBGMMode('beat_only')
showConcertIntro.value = false
notes = generateNoteChart()
startTime = performance.now()
@@ -224,6 +225,7 @@ function spawnParticles(x: number, y: number, color: string, count = 8) {
function finishConcert() {
concertFinished.value = true
sound.setBGMMode('full')
sound.playFanfare()
store.serveGuest()
store.serveGuest()
@@ -444,6 +446,7 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
sound.setBGMMode('full')
if (animId) cancelAnimationFrame(animId)
})
</script>
@@ -7,7 +7,7 @@ import { sound } from '../audio/synthAudio'
const store = useGameStore()
type TavernRoom = 'schankraum' | 'nebenraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer'
type TavernRoom = 'schankraum' | 'nebenraum' | 'marktplatz' | 'hinterland' | 'hinterzimmer' | 'hinter_den_bergen'
const currentRoom = ref<TavernRoom>('schankraum')
interface ActiveDialog {
@@ -309,9 +309,242 @@ function openCharacterDialog(charId: string) {
activeDialog.value = null
}
}
} else if (charId === 'markus') {
activeDialog.value = {
characterId: charId,
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Servus mein Sohn! Schau di an, 40 Jahre und herrschst über ganz Haumdaucher! Meine taiwanischen Freunde und ich trinken 58% Kinmen-Gaoliang und spielen Liar\'s Dice (Chui Niu / 吹牛)! Traust du dich gegen deinen alten Herrn anzutreten? Wer verliert, kippt einen Kurzen!',
actionLabel: '🎲 Auf geht\'s Papa, Liar\'s Dice spielen!',
actionFn: () => {
activeDialog.value = null
openLiarsDiceIntro()
}
}
}
}
// Münchanien Hint Modal
const munchanienHintActive = ref(false)
function openMunchanienHint() {
sound.playDialogueBlip()
munchanienHintActive.value = true
}
// Liar's Dice (Chui Niu / 吹牛) State & Logic
const liarsDiceIntroOpen = ref(false)
const liarsDiceActive = ref(false)
const liarsDiceGameOver = ref(false)
const liarsDicePlayerWon = ref(false)
const liarsDiceRound = ref(1)
const liarsDicePlayerWins = ref(0)
const liarsDiceMarkusWins = ref(0)
const playerDice = ref<number[]>([1, 2, 3, 4, 5])
const markusDice = ref<number[]>([1, 2, 3, 4, 5])
const markusDiceRevealed = ref(false)
const currentBid = ref<{ count: number; value: number } | null>(null)
const lastBidder = ref<'player' | 'markus' | null>(null)
const liarsDiceMsg = ref('')
const selectedBidCount = ref(2)
const selectedBidValue = ref(2)
const isShaking = ref(false)
const shotAnimation = ref<'sascha' | 'markus' | null>(null)
const isRoundEvaluating = ref(false)
function dieIcon(val: number): string {
const icons = ['', '⚀', '⚁', '⚂', '⚃', '⚄', '⚅']
return icons[val] || '🎲'
}
function openLiarsDiceIntro() {
sound.playDialogueBlip()
liarsDiceIntroOpen.value = true
}
function startLiarsDiceMatch() {
sound.playDialogueBlip()
liarsDiceIntroOpen.value = false
liarsDiceActive.value = true
liarsDiceGameOver.value = false
liarsDicePlayerWon.value = false
liarsDicePlayerWins.value = 0
liarsDiceMarkusWins.value = 0
liarsDiceRound.value = 1
startNewDiceRound()
}
function startNewDiceRound() {
sound.playDiceShake()
isShaking.value = true
markusDiceRevealed.value = false
currentBid.value = null
lastBidder.value = null
shotAnimation.value = null
isRoundEvaluating.value = false
selectedBidCount.value = 2
selectedBidValue.value = 2
// Generate 5 random dice each (1 to 6)
playerDice.value = Array.from({ length: 5 }, () => Math.floor(Math.random() * 6) + 1).sort((a, b) => a - b)
markusDice.value = Array.from({ length: 5 }, () => Math.floor(Math.random() * 6) + 1).sort((a, b) => a - b)
setTimeout(() => {
isShaking.value = false
liarsDiceMsg.value = `Runde ${liarsDiceRound.value}: Schüttle die Becher! Gib dein Gebot ab (z.B. mindestens zwei Dreier).`
}, 400)
}
function canMakeBid(count: number, value: number): boolean {
if (!currentBid.value) return count >= 1 && count <= 10
if (count > currentBid.value.count) return true
if (count === currentBid.value.count && value > currentBid.value.value) return true
return false
}
function makePlayerBid() {
if (isRoundEvaluating.value || isShaking.value) return
if (!canMakeBid(selectedBidCount.value, selectedBidValue.value)) {
alert('Dein Gebot muss höher sein als das vorherige (höhere Anzahl oder bei gleicher Anzahl höhere Augenzahl)!')
return
}
sound.playDialogueBlip()
currentBid.value = { count: selectedBidCount.value, value: selectedBidValue.value }
lastBidder.value = 'player'
liarsDiceMsg.value = `Du sagst an: Mindestens ${currentBid.value.count}x die ${dieIcon(currentBid.value.value)}!`
// Next minimum suggestion for controls
if (selectedBidValue.value < 6) {
selectedBidValue.value++
} else {
selectedBidCount.value++
selectedBidValue.value = 2
}
// Trigger Markus AI after a pause
setTimeout(() => {
markusTurn()
}, 1000)
}
function markusTurn() {
if (isRoundEvaluating.value || !currentBid.value) return
const bid = currentBid.value
// Count how many matching dice Markus has (1s are wild unless bid is on 1)
const markusMatches = markusDice.value.filter(d => d === bid.value || (bid.value !== 1 && d === 1)).length
const expectedPlayerMatches = bid.value === 1 ? 0.83 : 1.67
const totalEstimated = markusMatches + expectedPlayerMatches
// Call bluff if the bid is too high or improbable
const bluffThreshold = bid.count >= 6 ? 0.8 : 1.3
if (bid.count > totalEstimated + bluffThreshold) {
callBluff('markus')
return
}
// Otherwise, Markus raises!
const counts: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 }
markusDice.value.forEach(d => counts[d]++)
let bestVal = 2
let maxC = 0
for (let v = 2; v <= 6; v++) {
const effective = counts[v] + counts[1]
if (effective > maxC) {
maxC = effective
bestVal = v
}
}
let nextCount = bid.count
let nextVal = bid.value + 1
if (nextVal > 6) {
nextCount = bid.count + 1
nextVal = Math.min(6, Math.max(2, bestVal))
}
if (nextCount === bid.count && bestVal > bid.value) {
nextVal = bestVal
}
sound.playDialogueBlip()
currentBid.value = { count: nextCount, value: nextVal }
lastBidder.value = 'markus'
liarsDiceMsg.value = `Papa Markus erhöht: Mindestens ${nextCount}x die ${dieIcon(nextVal)}! Was sagst du, mein Sohn?`
// Update player selectors to be just above Markus' bid
if (nextVal < 6) {
selectedBidCount.value = nextCount
selectedBidValue.value = nextVal + 1
} else {
selectedBidCount.value = nextCount + 1
selectedBidValue.value = 2
}
}
function callBluff(caller: 'player' | 'markus') {
if (isRoundEvaluating.value || !currentBid.value) return
isRoundEvaluating.value = true
sound.playCupSlam()
markusDiceRevealed.value = true
const bid = currentBid.value
const allDice = [...playerDice.value, ...markusDice.value]
const totalMatches = allDice.filter(d => d === bid.value || (bid.value !== 1 && d === 1)).length
const bidWasTrue = totalMatches >= bid.count
setTimeout(() => {
sound.playGaoliangShot()
if (caller === 'player') {
if (bidWasTrue) {
// Player called bluff wrongly -> Player loses round!
liarsDiceMarkusWins.value++
shotAnimation.value = 'sascha'
liarsDiceMsg.value = `Aufgedeckt: Es liegen tatsächlich ${totalMatches}x ${dieIcon(bid.value)}! Das Gebot stimmte. Sascha kippt einen Kurzen Gaoliang! 🍶`
} else {
// Player caught Markus bluffing -> Markus loses round!
liarsDicePlayerWins.value++
shotAnimation.value = 'markus'
liarsDiceMsg.value = `Aufgedeckt: Nur ${totalMatches}x ${dieIcon(bid.value)}! Markus hat geblufft und muss einen Gaoliang kippen! 乾杯!`
}
} else {
// Markus called bluff
if (bidWasTrue) {
// Markus called bluff wrongly -> Markus loses round!
liarsDicePlayerWins.value++
shotAnimation.value = 'markus'
liarsDiceMsg.value = `Aufgedeckt: Es liegen ${totalMatches}x ${dieIcon(bid.value)}! Saschas Gebot stimmte! Markus kippt einen Gaoliang!`
} else {
// Markus caught player bluffing -> Player loses round!
liarsDiceMarkusWins.value++
shotAnimation.value = 'sascha'
liarsDiceMsg.value = `Aufgedeckt: Nur ${totalMatches}x ${dieIcon(bid.value)}! Du hast geblufft, Sascha! Trink einen Gaoliang!`
}
}
// Check Match End (Best of 3)
if (liarsDicePlayerWins.value >= 2) {
liarsDiceGameOver.value = true
liarsDicePlayerWon.value = true
sound.playFanfare()
store.addKronkorken(10)
store.addItem('58% Kinmen Gaoliang')
store.unlockEasterEgg('taiwan_dice_champion')
} else if (liarsDiceMarkusWins.value >= 2) {
liarsDiceGameOver.value = true
liarsDicePlayerWon.value = false
}
}, 600)
}
function proceedNextDiceRound() {
liarsDiceRound.value++
startNewDiceRound()
}
function inspectMirror() {
sound.playFanfare()
zulumanMirrorActive.value = true
@@ -356,7 +589,7 @@ function proceedToExpress() {
<!-- ================= 1. GASTSTUBE (SCHANKRAUM) ================= -->
<template v-if="currentRoom === 'schankraum'">
<img src="/assets/scenes/schankraum_v2.jpg?v=3" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<img src="/assets/scenes/schankraum.jpg" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<!-- Verena on the wooden gallery -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
@@ -463,7 +696,7 @@ function proceedToExpress() {
</button>
</template>
<!-- ================= 3. HINTERLAND ================= -->
<!-- ================= 4. HINTERLAND ================= -->
<template v-else-if="currentRoom === 'hinterland'">
<img src="/assets/scenes/hinterland.jpg" alt="Hinterland Haumdaucher Alm" class="tavern-bg" />
@@ -474,10 +707,22 @@ function proceedToExpress() {
<span class="hotspot-badge">Mane (Wanderin) 🥾</span>
</button>
<!-- Hansi enjoying the view on the bench -->
<!-- Hansi walking towards Münchanien -->
<button class="hotspot hotspot-hansi" @click="openCharacterDialog('hansi')">
<img :src="CHARACTERS.hansi.portrait" class="hotspot-thumb" alt="Hansi" />
<span class="hotspot-badge">Hansi 🧔</span>
<span class="hotspot-badge">Hansi (Richtung Münchanien) 🧔</span>
</button>
<!-- Signpost to Münchanien -->
<button class="hotspot hotspot-sign-munchanien" @click="openMunchanienHint">
<span class="sign-icon">🪧</span>
<span class="hotspot-badge">Wegweiser: Münchanien 🏰</span>
</button>
<!-- Trail to Hinter den Bergen (Taiwan & Papa Markus) -->
<button class="hotspot door-hotspot hotspot-door-hinter-den-bergen" @click="switchRoom('hinter_den_bergen')">
<span class="door-icon">🏔️</span>
<span class="hotspot-badge glow-door">Hinter den Bergen ▶</span>
</button>
<!-- Trail back to Marktplatz -->
@@ -487,7 +732,24 @@ function proceedToExpress() {
</button>
</template>
<!-- ================= 4. HINTERZIMMER (SECRET VIP RAVE) ================= -->
<!-- ================= 5. HINTER DEN BERGEN (TAIWAN & PAPA MARKUS) ================= -->
<template v-else-if="currentRoom === 'hinter_den_bergen'">
<img src="/assets/scenes/hinter_den_bergen.jpg" alt="Hinter den Bergen mit Papa Markus" class="tavern-bg" />
<!-- Markus and Taiwanese companions table -->
<button class="hotspot hotspot-markus" @click="openCharacterDialog('markus')">
<img :src="CHARACTERS.markus.portrait" class="hotspot-thumb" alt="Papa Markus" />
<span class="hotspot-badge">Papa Markus 🎲🍶</span>
</button>
<!-- Trail back to Hinterland -->
<button class="hotspot door-hotspot hotspot-door-back-hinterland" @click="switchRoom('hinterland')">
<span class="door-icon">🌲</span>
<span class="hotspot-badge glow-door">◀ Zum Hinterland</span>
</button>
</template>
<!-- ================= 6. HINTERZIMMER (SECRET VIP RAVE) ================= -->
<template v-else-if="currentRoom === 'hinterzimmer'">
<img src="/assets/scenes/hinterzimmer.jpg" alt="Geheimes VIP Hinterzimmer" class="tavern-bg" />
<div class="strobe-overlay"></div>
@@ -527,6 +789,7 @@ function proceedToExpress() {
currentRoom === 'nebenraum' ? '🎨 AKT III: KÖNIGLICHER NEBENRAUM & ATELIER 🎨' :
currentRoom === 'marktplatz' ? '🎪 AKT III: HAUMDAUCHER MARKTPLATZ 🎪' :
currentRoom === 'hinterland' ? '🌲 AKT III: ALPINES HINTERLAND 🌲' :
currentRoom === 'hinter_den_bergen' ? '🏮 AKT III: HINTER DEN BERGEN (TAIWAN) 🏮' :
'🪩 AKT III: DER GEHEIME PARTY BUNKER 🪩'
}}
</div>
@@ -561,6 +824,13 @@ function proceedToExpress() {
>
🌲 Hinterland
</button>
<button
class="room-tab"
:class="{ active: currentRoom === 'hinter_den_bergen' }"
@click="switchRoom('hinter_den_bergen')"
>
🏮 Hinter den Bergen
</button>
<button
v-if="store.unlockedEasterEggs.includes('hinterzimmer') || currentRoom === 'hinterzimmer'"
class="room-tab vip-tab"
@@ -775,6 +1045,178 @@ function proceedToExpress() {
</div>
</div>
</div>
<!-- Münchanien Hint Modal -->
<div v-if="munchanienHintActive" class="dialog-overlay" @click="munchanienHintActive = false">
<div class="dialog-box intro-modal-box" @click.stop>
<div class="intro-modal-header">
<span class="intro-tag">WEGWEISER NACH MÜNCHANIE</span>
<button class="duel-close-btn" @click="munchanienHintActive = false" aria-label="Schließen">✕</button>
</div>
<h2 class="intro-modal-title">🏰 WEGWEISER: NACH MÜNCHANIE</h2>
<p class="intro-modal-story">
Auf dem geschnitzten Wegweiser steht mit Kreide geschrieben:<br><br>
<em>„Hansi ist schon vorausgegangen, um die mächtige Sound-Anlage für das Abschiedskonzert in der Residenzstadt Münchanie aufzubauen! Bereite dich gut vor, sammle Kronkorken und Helles in allen Räumen und besuche alle Freunde, bevor du nach Akt IV aufbrichst!“</em>
</p>
<button class="action-btn" @click="munchanienHintActive = false">
Verstanden! Weiter erkunden
</button>
</div>
</div>
<!-- Liar's Dice Intro Modal -->
<div v-if="liarsDiceIntroOpen" class="dialog-overlay" @click="liarsDiceIntroOpen = false">
<div class="dialog-box intro-modal-box" @click.stop>
<div class="intro-modal-header">
<span class="intro-tag">MINISPIEL INTRO</span>
<button class="duel-close-btn" @click="liarsDiceIntroOpen = false" aria-label="Schließen">✕</button>
</div>
<h2 class="intro-modal-title">🎲 LIAR'S DICE (CHUI NIU / 吹牛)</h2>
<p class="intro-modal-story">
Papa Markus fordert dich zu einer Partie Liar's Dice heraus, wie man es in den Gassen von Taipeh und Kinmen spielt! Wer blufft besser und wer muss den 58% Gaoliang-Schnaps kippen?
</p>
<div class="intro-modal-rules">
<div class="rule-line">🎲 <strong>5 Würfel pro Spieler:</strong> Jeder sieht nur seine eigenen Würfel!</div>
<div class="rule-line">✨ <strong>1er sind Joker:</strong> Zählen für alle Augenzahlen (außer wenn auf 1er geboten wird).</div>
<div class="rule-line">🗣️ <strong>Gebote steigern:</strong> Schätze die Gesamtzahl aller Würfel (deine + Markus). Nächstes Gebot muss höhere Anzahl oder bei gleicher Anzahl höhere Augenzahl sein.</div>
<div class="rule-line">🍶 <strong>Bluff anzweifeln:</strong> Glaubst du Markus lügt? Sag „Bluff!“ – wer unrecht hat, kippt einen Schnaps!</div>
<div class="rule-line">🏆 <strong>Modus:</strong> Best of 3! Belohnung: +10 Kronkorken & 58% Kinmen Gaoliang!</div>
</div>
<button class="action-btn start-duel-action-btn" @click="startLiarsDiceMatch">
🎲 Würfelbecher schütteln!
</button>
</div>
</div>
<!-- Liar's Dice Game Modal -->
<div v-if="liarsDiceActive" class="dialog-overlay">
<div class="dialog-box liars-dice-box">
<div class="liars-dice-header">
<div class="dice-title-group">
<h3>🎲 LIAR'S DICE GEGEN PAPA MARKUS</h3>
<span class="dice-score">Stand: Sascha {{ liarsDicePlayerWins }} : {{ liarsDiceMarkusWins }} Markus (Best of 3)</span>
</div>
<button class="duel-close-btn" @click="liarsDiceActive = false" aria-label="Spiel verlassen">✕</button>
</div>
<!-- Status Message -->
<div class="dice-status-msg">
{{ liarsDiceMsg }}
</div>
<!-- Cups and Dice Arena -->
<div class="dice-arena">
<!-- Markus Side -->
<div class="dice-player-row markus-row">
<div class="dice-player-label">
<img :src="CHARACTERS.markus.portrait" class="dice-avatar" alt="Papa Markus" />
<span>Papa Markus</span>
</div>
<div class="dice-cup-area">
<div v-if="!markusDiceRevealed" class="cup-covered">
<span class="cup-icon">🥤</span>
<span class="cup-text">[ 5 verdeckte Würfel ]</span>
</div>
<div v-else class="dice-revealed-row">
<span v-for="(die, idx) in markusDice" :key="idx" class="dice-die markus-die">
{{ dieIcon(die) }}
</span>
</div>
</div>
</div>
<!-- Current Bid Banner -->
<div class="current-bid-banner" v-if="currentBid">
<span class="bid-label">Aktuelles Gebot:</span>
<span class="bid-value">Mindestens {{ currentBid.count }}x {{ dieIcon(currentBid.value) }}</span>
<span class="bidder-tag">({{ lastBidder === 'player' ? 'von Sascha' : 'von Papa Markus' }})</span>
</div>
<!-- Player Side -->
<div class="dice-player-row player-row">
<div class="dice-player-label">
<span class="player-icon">👑</span>
<span>König Sascha (Du)</span>
</div>
<div class="dice-revealed-row">
<span v-for="(die, idx) in playerDice" :key="idx" class="dice-die player-die">
{{ dieIcon(die) }}
</span>
</div>
</div>
<!-- Shot Drink Penalty Animation Banner -->
<div v-if="shotAnimation" class="shot-animation-banner">
🍶 {{ shotAnimation === 'sascha' ? 'Sascha trinkt einen 58% Gaoliang Shot! 🥴' : 'Markus kippt den Gaoliang Shot! 乾杯! 🎉' }}
</div>
</div>
<!-- Controls / Actions -->
<div v-if="!liarsDiceGameOver && !isRoundEvaluating" class="dice-controls">
<div class="bid-selectors">
<div class="selector-col">
<label>Anzahl:</label>
<div class="counter-btns">
<button class="mini-btn" @click="selectedBidCount = Math.max(1, selectedBidCount - 1)">-</button>
<span class="counter-val">{{ selectedBidCount }}</span>
<button class="mini-btn" @click="selectedBidCount = Math.min(10, selectedBidCount + 1)">+</button>
</div>
</div>
<div class="selector-col">
<label>Augenzahl:</label>
<div class="die-val-btns">
<button
v-for="val in [2, 3, 4, 5, 6, 1]"
:key="val"
class="die-select-btn"
:class="{ active: selectedBidValue === val }"
@click="selectedBidValue = val"
>
{{ dieIcon(val) }} ({{ val }})
</button>
</div>
</div>
</div>
<div class="dice-action-buttons">
<button class="action-btn bid-action-btn" @click="makePlayerBid">
🗣️ Gebot abgeben ({{ selectedBidCount }}x {{ dieIcon(selectedBidValue) }})
</button>
<button
v-if="currentBid && lastBidder === 'markus'"
class="action-btn call-bluff-btn"
@click="callBluff('player')"
>
🤥 Lüge! (Bluff aufdecken)
</button>
</div>
</div>
<!-- Next Round or Game Over -->
<div v-else-if="isRoundEvaluating && !liarsDiceGameOver" class="dice-round-eval">
<button class="action-btn next-round-btn" @click="proceedNextDiceRound">
🎲 Nächste Runde starten ▶
</button>
</div>
<div v-else-if="liarsDiceGameOver" class="dice-game-over">
<template v-if="liarsDicePlayerWon">
<h2 class="victory-title">👑 SASCHA GEWINNT DAS LIAR'S DICE MATCH!</h2>
<p class="dice-over-desc">Papa Markus klopft dir auf die Schulter: „Respekt, Sascha! Du hast das Zeug zum König von Taiwan!“</p>
<p class="reward-text">+10 Kronkorken, 58% Kinmen Gaoliang Flasche & Titel freigeschaltet!</p>
<button class="action-btn" @click="liarsDiceActive = false">乾杯 Papa! Zurücklehnen</button>
</template>
<template v-else>
<h2 class="defeat-title">🍶 PAPA MARKUS TRIUMPHIERT!</h2>
<p class="dice-over-desc">Papa Markus lacht schallend: „Gegen jahrzehntelange Gaoliang-Erfahrung kommst du noch nicht an, Sascha!“</p>
<div class="boar-retry-actions">
<button class="action-btn retry-btn" @click="startLiarsDiceMatch">Revanche spielen!</button>
<button class="close-btn" @click="liarsDiceActive = false">Vielleicht später</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
@@ -901,11 +1343,11 @@ function proceedToExpress() {
}
/* 1. Schankraum Hotspots */
.hotspot-verena { top: 17%; left: 28%; }
.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%; }
.hotspot-verena { top: 21%; left: 28%; }
.hotspot-door-nebenraum { top: 45%; left: 34%; }
.hotspot-armend { top: 58%; left: 74%; }
.hotspot-lutz { top: 56%; left: 52%; }
.hotspot-door-marktplatz { top: 48%; left: 86%; }
/* 2. Nebenraum Hotspots */
.hotspot-mirror-nebenraum { top: 36%; left: 24%; }
@@ -974,8 +1416,8 @@ function proceedToExpress() {
.hotspot-door-back-gaststube { top: 74%; left: 20%; }
.hotspot-door-hinterland { top: 34%; left: 76%; }
/* 3. Hinterland Hotspots */
.hotspot-mane { top: 54%; left: 36%; }
/* 4. Hinterland Hotspots */
.hotspot-mane { top: 52%; left: 82%; }
.hiking-anim {
animation: hikeBob 1.2s infinite ease-in-out;
}
@@ -990,10 +1432,16 @@ function proceedToExpress() {
transform: rotate(20deg);
}
.hotspot-hansi { top: 56%; left: 74%; }
.hotspot-door-back-marktplatz { top: 72%; left: 22%; }
.hotspot-hansi { top: 44%; left: 63%; }
.hotspot-sign-munchanien { top: 53%; left: 24%; }
.hotspot-door-hinter-den-bergen { top: 42%; left: 24%; }
.hotspot-door-back-marktplatz { top: 74%; left: 16%; }
/* 4. Hinterzimmer Hotspots & Strobe */
/* 5. Hinter den Bergen Hotspots */
.hotspot-markus { top: 56%; left: 50%; }
.hotspot-door-back-hinterland { top: 74%; left: 20%; }
/* 6. Hinterzimmer Hotspots & Strobe */
.strobe-overlay {
position: absolute;
inset: 0;
@@ -1596,4 +2044,291 @@ function proceedToExpress() {
.start-duel-action-btn:hover {
background: #16a34a !important;
}
/* Liar's Dice (Chui Niu) Modal & UI */
.liars-dice-box {
max-width: 360px;
width: 95%;
}
.liars-dice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #78350f;
margin-bottom: 10px;
padding-bottom: 6px;
}
.dice-title-group {
flex: 1;
}
.dice-title-group h3 {
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
color: #ffd700;
margin: 0 0 4px 0;
line-height: 1.3;
}
.dice-score {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #fef08a;
}
.dice-status-msg {
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
color: #fef3c7;
min-height: 38px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
line-height: 1.3;
margin-bottom: 8px;
}
.dice-arena {
background: #1a0f0a;
border: 1.5px solid #d97706;
border-radius: 6px;
padding: 10px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.dice-player-row {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(0, 0, 0, 0.4);
padding: 6px 8px;
border-radius: 6px;
}
.dice-player-label {
display: flex;
align-items: center;
gap: 6px;
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #fef08a;
}
.dice-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #ffd700;
object-fit: cover;
}
.player-icon {
font-size: 14px;
}
.dice-cup-area {
display: flex;
align-items: center;
}
.cup-covered {
display: flex;
align-items: center;
gap: 4px;
color: #94a3b8;
font-family: 'MedievalSharp', serif;
font-size: 12px;
}
.cup-icon {
font-size: 18px;
}
.dice-revealed-row {
display: flex;
gap: 4px;
}
.dice-die {
font-size: 20px;
line-height: 1;
}
.markus-die {
color: #f87171;
}
.player-die {
color: #60a5fa;
}
.current-bid-banner {
background: #451a03;
border: 1px dashed #ffd700;
border-radius: 4px;
padding: 5px 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 11px;
color: #fef08a;
text-align: center;
}
.bid-label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #f59e0b;
}
.bid-value {
font-weight: bold;
font-size: 13px;
color: #ffd700;
}
.bidder-tag {
font-size: 9.5px;
color: #cbd5e1;
}
.shot-animation-banner {
background: #7f1d1d;
border: 1.5px solid #ef4444;
border-radius: 4px;
padding: 6px;
text-align: center;
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
animation: pulseAnim 0.8s infinite alternate;
}
.dice-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.bid-selectors {
display: flex;
gap: 8px;
}
.selector-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.selector-col label {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #fed7aa;
}
.counter-btns {
display: flex;
align-items: center;
background: #1a0f0a;
border: 1px solid #78350f;
border-radius: 4px;
justify-content: space-between;
padding: 2px 4px;
}
.mini-btn {
background: #b45309;
border: 1px solid #f59e0b;
color: #fff;
border-radius: 3px;
width: 24px;
height: 24px;
font-weight: bold;
cursor: pointer;
}
.counter-val {
font-family: 'Press Start 2P', monospace;
font-size: 10px;
color: #ffd700;
}
.die-val-btns {
display: flex;
gap: 2px;
}
.die-select-btn {
background: #231209;
border: 1px solid #78350f;
border-radius: 3px;
color: #fef3c7;
font-size: 10px;
padding: 4px 3px;
cursor: pointer;
flex: 1;
}
.die-select-btn.active {
background: #d97706;
border-color: #ffd700;
color: #fff;
font-weight: bold;
}
.dice-action-buttons {
display: flex;
gap: 8px;
}
.bid-action-btn {
flex: 1.5;
background: #15803d !important;
border-color: #86efac !important;
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
padding: 8px !important;
}
.call-bluff-btn {
flex: 1;
background: #dc2626 !important;
border-color: #fca5a5 !important;
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
padding: 8px !important;
}
.dice-round-eval {
text-align: center;
}
.next-round-btn {
width: 100%;
background: #2563eb !important;
border-color: #93c5fd !important;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
padding: 10px !important;
}
.dice-game-over {
text-align: center;
}
.dice-over-desc {
font-family: 'MedievalSharp', serif;
font-size: 13.5px;
margin: 6px 0;
color: #fef3c7;
}
</style>
@@ -76,8 +76,8 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'amy',
name: 'Amy (Amelie)',
title: 'Die Hof-Künstlerin',
portrait: '/assets/characters/amy/portrait_v2.jpg',
description: 'Kreatives Genie in Latzhose mit Dutt und feinen Kräuter-Zigarren.',
portrait: '/assets/characters/amy/portrait.jpg',
description: 'Kreatives Genie in Latzhose mit Brille, Dutt und feinen Kräuter-Zigarren.',
famousQuote: 'Rave the Kingdom! Die Farben brauchen mehr Bass!',
themeColor: '#a855f7'
},
@@ -153,6 +153,15 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
famousQuote: 'Bester Bruder! Alle drei Schwestern gerettet! Jetzt aber zügig zum Schankraum!',
themeColor: '#06b6d4'
},
markus: {
id: 'markus',
name: 'Markus (Saschas Vater)',
title: 'Herrscher Hinter den Bergen & Gaoliang-Meister',
portrait: '/assets/characters/markus/portrait.jpg',
description: 'Saschas gutmütiger, lebensfroher Vater. Sitzt mit seinen taiwanischen Freunden an einem rustikalen Tisch und spielt Liar\'s Dice.',
famousQuote: 'Servus mein Sohn! Trink erst mal an Schluck Gaoliang mit uns, bevor du weiterziehst!',
themeColor: '#f59e0b'
},
djs: {
id: 'djs',
name: 'Die Hof-DJs',
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB