diff --git a/projects/sasch40/AGENTS.md b/projects/sasch40/AGENTS.md index 97f239d..d8cfb70 100644 --- a/projects/sasch40/AGENTS.md +++ b/projects/sasch40/AGENTS.md @@ -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. diff --git a/projects/sasch40/frontend/public/assets/characters/amy/portrait.jpg b/projects/sasch40/frontend/public/assets/characters/amy/portrait.jpg index 837259d..dcd0a9e 100644 Binary files a/projects/sasch40/frontend/public/assets/characters/amy/portrait.jpg and b/projects/sasch40/frontend/public/assets/characters/amy/portrait.jpg differ diff --git a/projects/sasch40/frontend/public/assets/characters/amy/portrait_v2.jpg b/projects/sasch40/frontend/public/assets/characters/amy/portrait_v2.jpg index 837259d..dcd0a9e 100644 Binary files a/projects/sasch40/frontend/public/assets/characters/amy/portrait_v2.jpg and b/projects/sasch40/frontend/public/assets/characters/amy/portrait_v2.jpg differ diff --git a/projects/sasch40/frontend/public/assets/characters/markus/portrait.jpg b/projects/sasch40/frontend/public/assets/characters/markus/portrait.jpg new file mode 100644 index 0000000..2b836e2 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/characters/markus/portrait.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/hinter_den_bergen.jpg b/projects/sasch40/frontend/public/assets/scenes/hinter_den_bergen.jpg new file mode 100644 index 0000000..adf5bc4 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/hinter_den_bergen.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg b/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg index f224699..71ffb48 100644 Binary files a/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg and b/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/hinterland_v3.jpg b/projects/sasch40/frontend/public/assets/scenes/hinterland_v3.jpg new file mode 100644 index 0000000..71ffb48 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/hinterland_v3.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg b/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg index e95755d..dba335e 100644 Binary files a/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg and b/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/schankraum_v4.jpg b/projects/sasch40/frontend/public/assets/scenes/schankraum_v4.jpg new file mode 100644 index 0000000..dba335e Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/schankraum_v4.jpg differ diff --git a/projects/sasch40/frontend/scripts/playtest.mjs b/projects/sasch40/frontend/scripts/playtest.mjs index 6868122..828cd7b 100644 --- a/projects/sasch40/frontend/scripts/playtest.mjs +++ b/projects/sasch40/frontend/scripts/playtest.mjs @@ -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'); diff --git a/projects/sasch40/frontend/src/audio/synthAudio.ts b/projects/sasch40/frontend/src/audio/synthAudio.ts index 903b951..5a61a2a 100644 --- a/projects/sasch40/frontend/src/audio/synthAudio.ts +++ b/projects/sasch40/frontend/src/audio/synthAudio.ts @@ -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) { diff --git a/projects/sasch40/frontend/src/components/GameBoyFrame.vue b/projects/sasch40/frontend/src/components/GameBoyFrame.vue index b5f7bc6..332c6c5 100644 --- a/projects/sasch40/frontend/src/components/GameBoyFrame.vue +++ b/projects/sasch40/frontend/src/components/GameBoyFrame.vue @@ -1,5 +1,5 @@ diff --git a/projects/sasch40/frontend/src/components/PartyExpressView.vue b/projects/sasch40/frontend/src/components/PartyExpressView.vue index e1b641e..094acaf 100644 --- a/projects/sasch40/frontend/src/components/PartyExpressView.vue +++ b/projects/sasch40/frontend/src/components/PartyExpressView.vue @@ -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) }) diff --git a/projects/sasch40/frontend/src/components/TavernView.vue b/projects/sasch40/frontend/src/components/TavernView.vue index f12ab67..1389cae 100644 --- a/projects/sasch40/frontend/src/components/TavernView.vue +++ b/projects/sasch40/frontend/src/components/TavernView.vue @@ -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('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([1, 2, 3, 4, 5]) +const markusDice = ref([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 = { 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() { - + - + + + + @@ -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; +} diff --git a/projects/sasch40/frontend/src/config/characters.ts b/projects/sasch40/frontend/src/config/characters.ts index 5d9c833..f447d39 100644 --- a/projects/sasch40/frontend/src/config/characters.ts +++ b/projects/sasch40/frontend/src/config/characters.ts @@ -76,8 +76,8 @@ export const CHARACTERS: Record = { 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 = { 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', diff --git a/projects/sasch40/source_photos/markus.jpeg b/projects/sasch40/source_photos/markus.jpeg new file mode 100644 index 0000000..6eab140 Binary files /dev/null and b/projects/sasch40/source_photos/markus.jpeg differ diff --git a/projects/sasch40/source_photos/markus2.jpeg b/projects/sasch40/source_photos/markus2.jpeg new file mode 100644 index 0000000..32b8fd8 Binary files /dev/null and b/projects/sasch40/source_photos/markus2.jpeg differ