feat(sasch40): major overhaul with doodle jump, party bunker, and guitar hero

- Add global settings & pause modal (gear icon) with mute and reset, removing obsolete bag button
- Extend Prologue with Flo Maier (castle fire game over & Löschzug restart) and Viktor (vampire bite game over & Knoblauch restart)
- Rebuild Akt II as 3-level vertical Doodle Jump climber to rescue sisters Saskia, Maja, and Miriam with custom pixel portraits
- Update Akt III Schankraum with seamless oak floor, double-tap 'Luke zum Party Bunker', duel intro modals, and Hansi's departure in Hinterland
- Rebuild Akt IV as 3-lane medieval Guitar Hero rock concert with live crowd meter, combo multipliers, and Hansi duo
- Update AGENTS.md architectural documentation and playtest test suite
This commit is contained in:
Moritz Graf
2026-09-24 18:22:28 +02:00
parent 6374ba9e8a
commit 8a0b65c3fa
22 changed files with 2307 additions and 1250 deletions
+62 -21
View File
@@ -27,18 +27,46 @@ 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 (Jump & Run, Party Express) 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.
* **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.
* **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 mini bottom bar with current Korken, Helles, and an inventory drawer button (*„🎒 Beutel“*).
* 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.
---
### 2b. Global Settings & Audio Control Modal (`GameBoyFrame.vue`)
* **The Decision**: A persistent `⚙️` settings icon is available at the top-right of the GameBoy frame.
* **Why**: Mobile players need a straightforward way to mute sound in public, pause during conversations, or cleanly restart the adventure.
* **The Rule**: Clicking `⚙️` opens a modal that pauses game loops, provides toggleable audio (`🔊 Sound: AN / 🔇 Sound: AUS`), and offers a `🔄 Spielstand zurücksetzen` option with safety confirmation.
---
### 2c. Prologue Branching & Themed Game Overs (`PrologueView.vue`)
* **The Decision**: Prologue choices can trigger humorous non-fatal Game Over screens with themed reset actions:
* 🔥 **Flo Maier (*Feierdeifl*)**: Choosing to burn the castle leads to a fiery castle inferno and a `🚒 MIT DEM LÖSCHZUG NEU STARTEN` button.
* 🧛 **Viktor (*Partyvampir*)**: Choosing the vampire bite turns Sascha into dust at sunrise and provides a `🧄 MIT KNOBLAUCH-KUR NEU STARTEN` button.
* **Why**: Gives playful branching flavor without permanently penalizing the player.
---
### 2d. Akt II: Doodle Jump Schwesternrettung (`PlatformerView.vue`)
* **The Decision**: Act II is a bottom-to-top vertical bouncing climber across 3 progressive levels to rescue King Sascha's three sisters:
1. **Level 1**: Rescue **Saskia** (+3 Kronkorken, 1 Helles)
2. **Level 2**: Rescue **Maja** (+3 Kronkorken, 1 Helles)
3. **Level 3**: Rescue **Miriam** (+4 Kronkorken, 1 Helles)
* **Mechanics**:
* Continuous automatic upward bouncing upon landing on platforms.
* Spring platforms (`🌀`) for high-velocity boost with custom synthesizer audio.
* Seamless screen wrap-around (moving off the left edge wraps to the right and vice versa).
* Gentle fall protection: falling below the visible screen softly places Sascha onto a safe platform near his lower altitude rather than triggering a punitive death.
---
### 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.
@@ -67,9 +95,9 @@ This document records the foundational design decisions, their rationale ("Why")
### 4. Background Art Integrity & Cleanliness
* **The Decision**: Background scenes must remain clean of any AI-hallucinated pseudo-English text or stray UI elements.
* **Why**: AI image generators often bake random adventure-game dialogue boxes or text (e.g. `[O] I have to tell you, the glow-wurst is excellent!`) into the scene.
* **Why**: AI image generators often bake random adventure-game dialogue boxes or text into the scene.
* **The Rule**:
* `public/assets/scenes/tavern.jpg`: The bottom area is cleaned with a seamless dark oak wood beam and floor texture.
* `public/assets/scenes/schankraum_v3.jpg`: Seamless warm dark oak wood floorboards with natural planks.
* Any scene footer navigation (like `.scene-bottom-nav`) must be styled as natural wooden wainscoting with clear German labels.
---
@@ -78,8 +106,8 @@ This document records the foundational design decisions, their rationale ("Why")
* **The Decision**: Critical game HUD elements (score, act title, instructions) must either be drawn as HTML/CSS overlays or placed within a minimum safe margin of 24px on canvas.
* **Why**: Smartphones vary widely in screen aspect ratio (16:9, 19.5:9, 20:9). When a 360x480 canvas is rendered with `object-fit: cover`, the left and right (or top and bottom) edges can be cropped by 10–25px on narrow screens.
* **The Rule**:
* In `PlatformerView.vue`: The Korken counter and level title are rendered via an absolute HTML overlay (`.platformer-hud-top`) so they are 100% immune to canvas cropping.
* In `PartyExpressView.vue`: The banner title is centered (`ctx.textAlign = 'center'`), and the progress bar has symmetrical 24px padding.
* In `PlatformerView.vue`: The Korken counter, sister progress, and level title are rendered via an absolute HTML overlay (`.platformer-hud-top`) so they are 100% immune to canvas cropping.
* In `PartyExpressView.vue`: The 3-lane strings, score counter, combo meter, and crowd meter use responsive percentages.
---
@@ -89,9 +117,9 @@ This document records the foundational design decisions, their rationale ("Why")
* **Score Breakdown**:
* Kronkorken collected: `100 pts` each.
* Helles tapped: `250 pts` each.
* Guests served in Party Express: `300 pts` each.
* Guests served / Concert performance: up to `3,000 pts`.
* Gstanzl duel victory: `1,500 pts`.
* Discovered secrets (Bridgerton dialogue, Zuluman mirror): `500 pts` each.
* Discovered secrets (Bridgerton dialogue, Party Bunker, Zuluman mirror): `500 pts` each.
* **Ranks**:
* `S-Rang (Legendär)`: >= 11,000 points (*„Unsterblicher Großfürst des Basses“*).
* `A-Rang (Erhaben)`: >= 9,500 points (*„Meister-Brauer der königlichen Garde“*).
@@ -101,10 +129,10 @@ This document records the foundational design decisions, their rationale ("Why")
---
### 7. Zero-Dependency Web Audio Synthesizer (`sound.ts`)
* **The Decision**: All sound effects, fanfares, and the 135 BPM techno bassline are synthesized in pure code using the Web Audio API.
### 7. Zero-Dependency Web Audio Synthesizer (`synthAudio.ts`)
* **The Decision**: All sound effects, fanfares, lute chords, and the 135 BPM techno bassline are synthesized in pure code using the Web Audio API.
* **Why**: External audio files (.mp3/.wav) take megabytes of bandwidth, stutter on mobile cellular networks, and trigger browser autoplay blocks.
* **The Rule**: The `SoundService` initializes on the first user interaction (touch/click). Sound effects use oscillators and gain envelopes; 135 BPM kick and sub-bass use recurring periodic intervals.
* **The Rule**: The audio context initializes on the first user interaction (touch/click). Sound effects use oscillators and gain envelopes; 135 BPM kick and sub-bass use recurring periodic intervals.
---
@@ -115,25 +143,38 @@ This document records the foundational design decisions, their rationale ("Why")
---
### 9. Cellar Trapdoor & Easter Egg Invisibility Pattern (`TavernView.vue`)
* **The Decision**: Secret hotspots (the cellar trapdoor `.trapdoor-hotspot` in the Schankraum leading to the VIP Hinterzimmer) must be 100% invisible initially.
* **Why**: An Easter egg marked with a glowing button or text badge is not a secret; it invites immediate trivial clicking.
### 9. Party Bunker Trapdoor & Double-Tap Pattern (`TavernView.vue`)
* **The Decision**: Secret trapdoor (`.trapdoor-hotspot` in Schankraum leading to the VIP Party Bunker) must be 100% invisible initially and requires a deliberate double-click or double-tap to unlock.
* **Why**: A single accidental click on the floor should not whisk the player away into an Easter Egg bunker before they explore the tavern.
* **The Rule**:
* The button must render with transparent background, no border, no outline, no box shadow, no hover change, and no tooltip (`:title="undefined"`).
* Only upon the user's first click does it reveal the door icon, label itself *„Luke zum Hinterraum“*, play a celebratory fanfare, unlock the Easter egg in the game store (+500 points), and transition to the secret room.
* Rendered with transparent background, no border, no outline, no box shadow, no hover change, and no tooltip (`:title="undefined"`).
* Double-click/double-tap detection combines `@dblclick` and timestamp delta (`now - lastTrapTap < 450ms`) to work flawlessly across desktop and mobile touchscreens.
* Upon double-tap, it reveals the wood trapdoor sprite, labels itself *„Luke zum Party Bunker“*, plays a celebratory fanfare, unlocks the Easter egg in the game store (+500 points), and transitions to the Party Bunker.
---
### 10. Multi-Room World Architecture & Tavern Minigames (`TavernView.vue`)
* **The Decision**: Act III is structured as a spatial, interconnected five-room village hub rather than a cramped single screen:
1. **Gaststube (Schankraum)**: King Sascha's table, Queen Verena on the balcony, Armend at the forge, Lutz (ZTUL) at the fireplace, door to Nebenraum, door to Marktplatz, and the secret *„Luke zum Hinterraum“* in the floor.
1. **Gaststube (Schankraum)**: King Sascha's table, Queen Verena on the balcony, Armend at the forge, Lutz (ZTUL) at the fireplace, door to Nebenraum, door to Marktplatz, and the secret *„Luke zum Party Bunker“* in the floor.
2. **Königlicher Nebenraum (Kemenate & Atelier)**: Amy painting the royal *„Rave the Kingdom!“* banner at her easel in overalls, and the golden Zuluman magic mirror on the wall.
3. **Haumdaucher Marktplatz**: Mario acrobatically juggling chainsaws and hops on the cobblestones; NAT (Josef) on the wooden festival stage with wild boar banners telling stories of his girls and heroic deeds.
4. **Alpines Hinterland**: Mane (Ramona) hiking the alpine trail with walking stick; Hansi on the panoramic bench.
5. **Geheimes VIP-Hinterzimmer**: Secret rave with disco chandelier, stroboscope, Pü's crystal staff, DJ Flo Maier, and Viktor the night-guard.
4. **Alpines Hinterland**: Mane (Ramona) hiking the alpine trail with walking stick; Hansi on his departure journey towards Münchanien.
5. **Geheimes VIP-Party-Bunker**: Secret rave with disco chandelier, stroboscope, Pü's crystal staff, DJ Flo Maier, and Viktor the night-guard.
* **Minigames & Exit Safety**:
* **Bayerisches Gstanzl-Fechten**: Multi-round insult rhyming duel. Must always include a visible `[✕]` close button so players are never trapped.
* **Wildschwein-Schnick-Schnack-Schnuck**: Best-of-3 tactical duel against NAT. Rules: 🐗 Keiler bricht 🌿 Dickicht, 🌿 Dickicht fängt 🏹 Lanze, 🏹 Lanze erlegt 🐗 Keiler. First to 2 points wins (+10 Korken, 1 Helles, `wildschwein_meister` achievement). Includes `[✕]` close button.
* **Bayerisches Gstanzl-Fechten**: Multi-round insult rhyming duel. Preceded by an explanatory rules modal. Must always include a visible `[✕]` close button so players are never trapped.
* **Wildschwein-Schnick-Schnack-Schnuck**: Best-of-3 tactical duel against NAT. Preceded by an explanatory rules modal (🐗 Keiler bricht 🌿 Dickicht, 🌿 Dickicht fängt 🏹 Lanze, 🏹 Lanze erlegt 🐗 Keiler). Includes `[✕]` close button and active button blur to prevent mobile sticky hover states.
---
### 10b. Akt IV: Mittelalter Guitar Hero — Hansis Abschiedskonzert (`PartyExpressView.vue`)
* **The Decision**: Act IV is a 3-lane medieval lute rhythm game set on an epic castle festival stage in front of 12,000 cheering fans.
* **Why**: Perfectly bridges the story from Hansi's departure in the Hinterland to a grand musical send-off before the royal coronation.
* **The Rule**:
* 3 color-coded lanes (`🔴 Saite 1`, `🟡 Saite 2`, `🔵 Saite 3`).
* Notes spawn synchronized to 135 BPM audio clicks and melodic riffs.
* Three large, finger-friendly touch buttons positioned comfortably at the lower screen for thumb play.
* Combo multiplier increases up to 4x with unbroken streaks.
* Generous hit window for fun, non-punishing festival gameplay.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 979 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 979 KiB

+250 -242
View File
@@ -72,113 +72,161 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 1000));
// 2. PROLOGUE SCREEN (Balcony Scene)
console.log('📸 02: Capturing Prologue Screen (Verifying banner/portrait alignment)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02_prologue_screen.png') });
console.log('📸 02a: Capturing Prologue Screen (NAT opening)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02a_prologue_nat.png') });
// Advance Prologue Dialogues
await page.waitForSelector('.choice-btn', { timeout: 5000 });
const firstChoice = await page.$('.choice-btn');
if (firstChoice) {
await firstChoice.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 02b: Capturing Prologue Mop Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02b_prologue_mop.png') });
}
// Click first choice -> Flo Maier arrives
const natChoice = await page.$('.choice-btn');
if (natChoice) await natChoice.click();
await new Promise(r => setTimeout(r, 800));
// Advance to Platformer
const mopChoice = await page.$('.choice-btn');
if (mopChoice) {
await mopChoice.click();
await new Promise(r => setTimeout(r, 1000));
}
console.log('📸 02b: Capturing Flo Maier Feierdeifl arrives...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02b_prologue_flo_feierdeifl.png') });
// 3. PLATFORMER PRE-LEVEL START SCREEN (Act II)
console.log('📸 03: Capturing Platformer Start Screen Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03_platformer_start_modal.png') });
// Test Game Over branch: "Ja freilich, zünd ois o, Flo!"
const fireChoice = await page.$('.choice-btn:nth-child(1)');
if (fireChoice) await fireChoice.click();
await new Promise(r => setTimeout(r, 800));
// Click Start Act Button on the modal
const startPlatformerBtn = await page.$('.start-act-btn');
if (startPlatformerBtn) {
await startPlatformerBtn.click();
console.log('📸 02c: Capturing Flo Maier Game Over (fiery castle)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02c_prologue_flo_game_over.png') });
// Reset via Löschzug
const resetFireBtn = await page.$('.game-over-btn');
if (resetFireBtn) await resetFireBtn.click();
await new Promise(r => setTimeout(r, 800));
// Skip NAT to Flo again
const natChoice2 = await page.$('.choice-btn');
if (natChoice2) await natChoice2.click();
await new Promise(r => setTimeout(r, 600));
// Decline Flo Maier -> Viktor arrives
const declineFloBtn = await page.$('.choice-btn:nth-child(2)');
if (declineFloBtn) await declineFloBtn.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 02d: Capturing Viktor Partyvampir arrives...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02d_prologue_viktor_vampire.png') });
// Test Game Over branch: "Ja geil, mach mi unsterblich!"
const biteChoice = await page.$('.choice-btn:nth-child(1)');
if (biteChoice) await biteChoice.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 02e: Capturing Viktor Game Over (glitter dust in sun)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02e_prologue_viktor_game_over.png') });
// Reset via Knoblauch
const resetVampBtn = await page.$('.game-over-btn');
if (resetVampBtn) await resetVampBtn.click();
await new Promise(r => setTimeout(r, 800));
// Advance to Mop safely: NAT -> Flo (decline) -> Viktor (decline) -> Mop
const natC = await page.$('.choice-btn');
if (natC) await natC.click();
await new Promise(r => setTimeout(r, 500));
const floC = await page.$('.choice-btn:nth-child(2)');
if (floC) await floC.click();
await new Promise(r => setTimeout(r, 500));
const vikC = await page.$('.choice-btn:nth-child(2)');
if (vikC) await vikC.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 02f: Capturing Mop Dialogue (Sister Rescue Quest)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '02f_prologue_mop.png') });
// Advance to Akt II (Doodle Jump Schwesternrettung)
const startAkt2Btn = await page.$('.choice-btn');
if (startAkt2Btn) await startAkt2Btn.click();
await new Promise(r => setTimeout(r, 1000));
// 3. AKT II: DOODLE JUMP SCHWESTERNRETTUNG
console.log('📸 03a: Capturing Akt II Doodle Jump Start Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03a_doodle_jump_intro_modal.png') });
// Click Start Act Button on modal
const startDoodleBtn = await page.$('.start-act-btn');
if (startDoodleBtn) {
await startDoodleBtn.click();
await new Promise(r => setTimeout(r, 800));
}
// 4. PLATFORMER GAMEPLAY
console.log('📸 04: Capturing Platformer Gameplay...');
const btnA = await page.$('.btn-a');
if (btnA) {
await btnA.click();
await new Promise(r => setTimeout(r, 300));
}
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '04_platformer_gameplay.png') });
// Capture Gameplay (Bouncing up platforms)
console.log('📸 03b: Capturing Doodle Jump Gameplay...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '03b_doodle_jump_gameplay.png') });
// 5. TAVERN: SCHANKRAUM (Gaststube)
// Trigger Sister 1 Rescue (Saskia)
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const comp = app?._instance?.subTree?.children?.[1]?.children?.[0]?.component?.subTree?.children?.[0]?.component?.ctx;
// Alternatively trigger rescue directly via store or component
const pinia = app?.config?.globalProperties?.$pinia;
const store = pinia?._s.get('game');
if (store) {
store.addKronkorken(3);
store.addBeer();
}
});
// 4. TAVERN: SCHANKRAUM (Gaststube with Seamless Oak Floor)
console.log('🍺 Advancing to Tavern Act (Schankraum)...');
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') store.setAct('tavern');
}
}
const pinia = app?.config?.globalProperties?.$pinia;
const store = pinia?._s.get('game');
if (store) store.setAct('tavern');
});
await new Promise(r => setTimeout(r, 1000));
console.log('📸 05: Capturing Tavern Gaststube (Schankraum)...');
console.log('📸 05: Capturing Tavern Schankraum (seamless oak floor)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05_tavern_schankraum.png') });
// Test Verena Hotspot
console.log('👥 Testing Verena Dialog...');
const verenaBtn = await page.$('.hotspot-verena');
if (verenaBtn) {
await verenaBtn.click();
await new Promise(r => setTimeout(r, 700));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '06_tavern_verena_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
// Test Double-Click / Double-Tap on Trapdoor to unlock Party Bunker
console.log('🚪 Testing double-click / double-tap on oak floor trapdoor...');
const trapdoor = await page.$('.trapdoor-hotspot');
if (trapdoor) {
// First click: triggers subtle sound, still closed
await trapdoor.click();
await new Promise(r => setTimeout(r, 150));
// Second click within 450ms: unlocks Party Bunker!
await trapdoor.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 12b: Capturing Secret VIP Party Bunker...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12b_tavern_party_bunker_vip.png') });
// Click Pü in Party Bunker
const pueBtn = await page.$('.hotspot-pue');
if (pueBtn) {
await pueBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 13: Capturing Pü Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '13_tavern_pue_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Return to Schankraum
const backToSchankraum = await page.$('.hotspot-door-back-vip');
if (backToSchankraum) {
await backToSchankraum.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 12: Capturing Schankraum with revealed Luke zum Party Bunker...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12_tavern_party_bunker_trapdoor.png') });
}
}
// Test Armend Hotspot
console.log('🔨 Testing Armend Dialog...');
const armendBtn = await page.$('.hotspot-armend');
if (armendBtn) {
await armendBtn.click();
await new Promise(r => setTimeout(r, 700));
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '07_tavern_armend_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// 5b. TAVERN: NEBENRAUM (Amy painting & Zuluman Mirror)
// Test Nebenraum (Atelier with Amy & Zuluman Mirror)
console.log('🎨 Navigating to Nebenraum (Kemenate / Atelier)...');
const nebenraumDoor = await page.$('.hotspot-door-nebenraum');
if (nebenraumDoor) {
await nebenraumDoor.click();
} else {
const nrTab = await page.$('.room-tab:nth-child(2)');
if (nrTab) await nrTab.click();
}
await new Promise(r => setTimeout(r, 1000));
const nrTab = await page.$('.room-tab:nth-child(2)');
if (nrTab) await nrTab.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 05b: Capturing Nebenraum (Atelier & Mirror)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05b_tavern_nebenraum.png') });
// Test Amy in Nebenraum
console.log('🎨 Testing Amy Dialog in Nebenraum...');
const amyBtn = await page.$('.hotspot-amy-nebenraum');
if (amyBtn) {
await amyBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 05c: Capturing Amy Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '05c_tavern_amy_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Test Zuluman Mirror in Nebenraum
console.log('🪞 Testing Zuluman Magic Mirror in Nebenraum...');
const mirrorBtn = await page.$('.hotspot-mirror-nebenraum');
if (mirrorBtn) {
await mirrorBtn.click();
@@ -190,54 +238,46 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400));
}
// 6. TAVERN: MARKTPLATZ (Mario with spinning chainsaws & NAT on stage)
// Test Marktplatz & Wildschwein Duel
console.log('🎪 Navigating to Marktplatz...');
const marktplatzTab = await page.$('.room-tab:nth-child(3)');
if (marktplatzTab) {
await marktplatzTab.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 08: Capturing Marktplatz (Mario chainsaws & NAT stage)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_marktplatz.png') });
if (marktplatzTab) await marktplatzTab.click();
await new Promise(r => setTimeout(r, 800));
// Click Mario
const marioBtn = await page.$('.hotspot-mario');
if (marioBtn) {
await marioBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09: Capturing Mario Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09_tavern_mario_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
console.log('📸 08: Capturing Marktplatz...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '08_tavern_marktplatz.png') });
// Click NAT (Josef) & Test Wildschwein Duell
const natBtn = await page.$('.hotspot-nat');
if (natBtn) {
await natBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT (Josef) Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') });
// Start Wild Boar Duel
console.log('🐗 Launching Wildschwein-Schnick-Schnack-Schnuck Duel...');
const startBoarBtn = await page.$('.dialog-actions .action-btn');
if (startBoarBtn) {
await startBoarBtn.click();
const natBtn = await page.$('.hotspot-nat');
if (natBtn) {
await natBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') });
// Click "🐗 Zum Wildschwein-Duell!" -> opens INTRO MODAL
const startBoarIntroBtn = await page.$('.dialog-actions .action-btn');
if (startBoarIntroBtn) {
await startBoarIntroBtn.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 09c: Capturing Wildschwein Duel INTRO modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09c_tavern_boar_duel_intro.png') });
// Start Duel from Intro
const startBoarDuelBtn = await page.$('.start-duel-action-btn');
if (startBoarDuelBtn) {
await startBoarDuelBtn.click();
await new Promise(r => setTimeout(r, 800));
console.log('📸 09c: Capturing Wildschwein Duel Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09c_tavern_boar_duel.png') });
// Play a move: Keiler!
// Play Keiler
const keilerBtn = await page.$('.boar-choice-btn:nth-child(1)');
if (keilerBtn) {
await keilerBtn.click();
await new Promise(r => setTimeout(r, 600));
console.log('📸 09d: Capturing Boar Duel Clash...');
console.log('📸 09d: Capturing Boar Duel Round Clash (verifying unhighlighted buttons)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09d_tavern_boar_duel_round.png') });
}
// Close Boar Duel modal
// Close modal via ✕
const closeBoarBtn = await page.$('.boar-duel-box .duel-close-btn');
if (closeBoarBtn) {
await closeBoarBtn.click();
@@ -247,162 +287,130 @@ async function runPlaytest() {
}
}
// 7. TAVERN: HINTERLAND (Mane hiking)
// Test Hinterland (Mane & Hansi departing toward Münchanien)
console.log('🌲 Navigating to Hinterland...');
const hinterlandTab = await page.$('.room-tab:nth-child(4)');
if (hinterlandTab) {
await hinterlandTab.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 10: Capturing Hinterland (Mane hiking)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '10_tavern_hinterland.png') });
if (hinterlandTab) await hinterlandTab.click();
await new Promise(r => setTimeout(r, 1000));
// Click Mane
const maneBtn = await page.$('.hotspot-mane');
if (maneBtn) {
await maneBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 11: Capturing Mane Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11_tavern_mane_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
console.log('📸 10: Capturing Hinterland (Mane & Hansi departing toward Münchanien)...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '10_tavern_hinterland.png') });
// Click Hansi to hear about departure and evening concert
const hansiBtn = await page.$('.hotspot-hansi');
if (hansiBtn) {
await hansiBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 11: Capturing Hansi Departure Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '11_tavern_hansi_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Luke zum Hinterraum)
console.log('🗝️ Returning to Schankraum to check Luke zum Hinterraum (trapdoor)...');
const schankraumTab = await page.$('.room-tab:nth-child(1)');
if (schankraumTab) {
await schankraumTab.click();
await new Promise(r => setTimeout(r, 600));
}
console.log('🪩 Triggering Luke zum Hinterraum Easter Egg...');
const trapdoor = await page.$('.trapdoor-hotspot');
if (trapdoor) {
await trapdoor.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 12: Capturing Secret VIP Hinterzimmer...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '12_tavern_hinterzimmer_vip.png') });
// Click Pü in Hinterzimmer
const pueBtn = await page.$('.hotspot-pue');
if (pueBtn) {
await pueBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 13: Capturing Pü Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '13_tavern_pue_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
// 9. GSTANZL DUEL
console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const backToGaststube = await page.$('.hotspot-door-back-vip');
if (backToGaststube) {
await backToGaststube.click();
await new Promise(r => setTimeout(r, 600));
}
// Test Gstanzl Duel Intro & Gameplay
console.log('⚔️ Testing Gstanzl-Fechten Intro & Minigame...');
const duelBtn = await page.$('.duel-trigger');
if (duelBtn) {
await duelBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 15: Capturing Gstanzl Duel with [✕] button...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '15_tavern_gstanzl_duel.png') });
// Test the [✕] close button
const closeDuelBtn = await page.$('.duel-close-btn');
if (closeDuelBtn) {
console.log('Testing duel exit via [✕] button...');
await closeDuelBtn.click();
await new Promise(r => setTimeout(r, 600));
const duelClosed = await page.$('.duel-box') === null;
console.log('Duel modal closed successfully by [✕]:', duelClosed);
console.log('📸 15a: Capturing Gstanzl Duel INTRO modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '15a_tavern_gstanzl_intro.png') });
const startGstanzlBtn = await page.$('.start-duel-action-btn');
if (startGstanzlBtn) {
await startGstanzlBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 15b: Capturing Gstanzl Duel Round...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '15b_tavern_gstanzl_duel.png') });
// Close via ✕
const closeDuelBtn = await page.$('.duel-box .duel-close-btn');
if (closeDuelBtn) {
await closeDuelBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
}
// Test reload persistence in Tavern Act
console.log('💾 Testing game state reload persistence...');
const stateBeforeReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State before reload:', stateBeforeReload);
await page.reload({ waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 1000));
const stateAfterReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State after reload:', stateAfterReload);
// 10. PARTY EXPRESS (Act IV)
console.log('🚂 Advancing to Party Express (Act IV)...');
// 5. AKT IV: MITTELALTER GUITAR HERO (HANSIS ABSCHIEDSKONZERT)
console.log('🎸 Advancing to Akt IV (Mittelalter Guitar Hero)...');
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') store.setAct('party_express');
}
}
const pinia = app?.config?.globalProperties?.$pinia;
const store = pinia?._s.get('game');
if (store) store.setAct('party_express');
});
await new Promise(r => setTimeout(r, 1000));
console.log('📸 16: Capturing Party Express Start Screen Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '16_party_express_start_modal.png') });
// Start Party Express
const startExpressBtn = await page.$('.start-act-btn');
if (startExpressBtn) {
await startExpressBtn.click();
console.log('📸 16: Capturing Lute Hero Start Screen Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '16_lute_hero_start_modal.png') });
// Start Concert
const startConcertBtn = await page.$('.start-act-btn');
if (startConcertBtn) {
await startConcertBtn.click();
await new Promise(r => setTimeout(r, 800));
}
console.log('📸 17: Capturing Party Express Gameplay (Redesigned Bollerwagen)...');
// Serve beer
const throwBeerBtn = await page.$('.throw-beer-btn');
if (throwBeerBtn) {
await throwBeerBtn.click();
await new Promise(r => setTimeout(r, 400));
}
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '17_party_express_gameplay.png') });
// Hit string buttons
console.log('🎸 Hitting lute strings...');
const btnLane0 = await page.$('.btn-lane-0');
const btnLane1 = await page.$('.btn-lane-1');
const btnLane2 = await page.$('.btn-lane-2');
if (btnLane0) await btnLane0.click();
await new Promise(r => setTimeout(r, 200));
if (btnLane1) await btnLane1.click();
await new Promise(r => setTimeout(r, 200));
if (btnLane2) await btnLane2.click();
await new Promise(r => setTimeout(r, 300));
// 11. FINALE / CERTIFICATE (Act V)
console.log('📸 17: Capturing Castle Stage Rock Concert Gameplay...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '17_lute_hero_gameplay.png') });
// 6. AKT V: FINALE & CERTIFICATE
console.log('👑 Advancing to Finale Certificate (Act V)...');
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
if (app && app.config?.globalProperties?.$pinia) {
const pinia = app.config.globalProperties.$pinia;
for (const [id, store] of pinia._s.entries()) {
if (id === 'game') {
store.addKronkorken(35);
store.serveGuest();
store.serveGuest();
store.setAct('finale');
}
}
const pinia = app?.config?.globalProperties?.$pinia;
const store = pinia?._s.get('game');
if (store) {
store.addKronkorken(35);
store.serveGuest();
store.serveGuest();
store.setAct('finale');
}
});
await new Promise(r => setTimeout(r, 1500));
console.log('📸 18: Capturing Certificate Modal...');
console.log('📸 18: Capturing Certificate Screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '18_certificate_screen.png') });
// Test NOCHMAL SPIELEN button
// Test Settings Modal (Gear icon ⚙️)
console.log('⚙️ Testing Settings Modal (Pause, Mute, Reset)...');
const settingsBtn = await page.$('.settings-toggle-btn');
if (settingsBtn) {
await settingsBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 19: Capturing Settings & Pause Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '19_settings_pause_modal.png') });
// Click Resume
const resumeBtn = await page.$('.resume-btn');
if (resumeBtn) {
await resumeBtn.click();
await new Promise(r => setTimeout(r, 500));
}
}
// Test NOCHMAL SPIELEN button on certificate
console.log('🔄 Testing NOCHMAL SPIELEN button...');
const restartBtn = await page.$('.restart-btn');
if (restartBtn) {
await restartBtn.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 19: Capturing post-restart Intro screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '19_post_restart_intro.png') });
console.log('📸 20: Capturing post-restart Intro screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '20_post_restart_intro.png') });
}
// Verify all images loaded properly
@@ -162,6 +162,116 @@ class SoundEngine {
})
}
// SFX: Sister Rescue Fanfare (bright sweet jingle)
public playRescueFanfare() {
if (this.isMuted) return
this.initCtx()
if (!this.ctx) return
const notes = [523.25, 659.25, 783.99, 1046.50] // C5, E5, G5, C6
notes.forEach((freq, idx) => {
if (!this.ctx) return
const now = this.ctx.currentTime + idx * 0.1
const osc = this.ctx.createOscillator()
const gain = this.ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(freq, now)
gain.gain.setValueAtTime(0.25, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.35)
})
}
// SFX: Spring / Trampoline Bounce (Doodle Jump super bounce)
public playSpringBounce() {
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 = 'triangle'
osc.frequency.setValueAtTime(220, now)
osc.frequency.exponentialRampToValueAtTime(880, now + 0.25)
gain.gain.setValueAtTime(0.3, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.3)
}
// SFX: Hit / Impact / Thud
public playBoarHit() {
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(160, now)
osc.frequency.exponentialRampToValueAtTime(40, now + 0.2)
gain.gain.setValueAtTime(0.3, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2)
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + 0.2)
}
// SFX: Medieval Lute Hero Note (String Index 0: Low, 1: Mid, 2: High)
public playLuteNote(lane: number, success: boolean = true) {
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()
if (!success) {
// Missed clunk / muted buzz
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(90, now)
gain.gain.setValueAtTime(0.12, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12)
} 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)
gain.gain.setValueAtTime(0.28, now)
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.32)
}
osc.connect(gain)
gain.connect(this.ctx.destination)
osc.start(now)
osc.stop(now + (success ? 0.32 : 0.12))
}
// 135 BPM Medieval Techno Chiptune Beat
public startBGM() {
if (this.isMuted || this.bgmPlaying) return
@@ -11,18 +11,32 @@ import CertificateModal from './CertificateModal.vue'
const store = useGameStore()
const platformerRef = ref<InstanceType<typeof PlatformerView> | null>(null)
const inventoryOpen = ref(false)
const concertRef = ref<InstanceType<typeof PartyExpressView> | null>(null)
function handleJump() {
if (platformerRef.value) {
if (store.currentAct === 'platformer' && platformerRef.value) {
platformerRef.value.onJump()
} else if (store.currentAct === 'party_express' && concertRef.value) {
concertRef.value.hitLane(2)
}
}
function handleDpad(dir: 'left' | 'right', pressed: boolean) {
if (platformerRef.value) {
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)
}
}
@@ -30,8 +44,26 @@ function toggleAudio() {
store.toggleMute()
}
function toggleInventory() {
inventoryOpen.value = !inventoryOpen.value
const settingsOpen = ref(false)
function openSettings() {
sound.playDialogueBlip()
settingsOpen.value = true
store.pauseGame()
}
function closeSettings() {
sound.playDialogueBlip()
settingsOpen.value = false
store.resumeGame()
}
function resetGameFromSettings() {
if (confirm('Möchtest du das gesamte Spiel wirklich von vorne neu beginnen?')) {
settingsOpen.value = false
store.resumeGame()
store.resetGame()
}
}
</script>
@@ -48,8 +80,8 @@ function toggleInventory() {
{{ store.currentAct === 'prologue' ? 'AKT I' : store.currentAct === 'platformer' ? 'AKT II' : store.currentAct === 'tavern' ? 'AKT III' : store.currentAct === 'party_express' ? 'AKT IV' : store.currentAct === 'finale' ? 'AKT V' : '' }}
</span>
</div>
<button class="sound-toggle" @click="toggleAudio">
{{ store.soundMuted ? '🔇 TON' : '🔊 135 BPM' }}
<button class="settings-toggle-btn" @click="openSettings" title="Einstellungen & Pause" aria-label="Einstellungen">
⚙️
</button>
</div>
@@ -60,7 +92,7 @@ function toggleInventory() {
<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'" />
<PartyExpressView v-else-if="store.currentAct === 'party_express'" ref="concertRef" />
<CertificateModal v-else-if="store.currentAct === 'finale'" />
</div>
</div>
@@ -99,7 +131,14 @@ function toggleInventory() {
@mousedown="handleDpad('right', true)"
@mouseup="handleDpad('right', false)"
>▶</button>
<button class="dpad-btn dpad-down" disabled></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>
@@ -108,9 +147,9 @@ function toggleInventory() {
<div class="center-controls">
<div class="royal-crest">👑</div>
<div class="pill-buttons">
<button class="pill-btn" @click="toggleInventory">
<button class="pill-btn" @click="openSettings">
<span class="pill-key">SELECT</span>
<span class="pill-sub">BEUTEL</span>
<span class="pill-sub">PAUSE</span>
</button>
<button class="pill-btn" @click="toggleAudio">
<span class="pill-key">START</span>
@@ -124,7 +163,7 @@ function toggleInventory() {
<div class="action-buttons">
<button
class="action-round-btn btn-b"
@click="store.addBeer"
@click="handleButtonB"
>
<span class="btn-letter">B</span>
</button>
@@ -137,8 +176,8 @@ function toggleInventory() {
</button>
</div>
<div class="action-labels">
<span>O'ZAPFT (B)</span>
<span>SPRUNG (A)</span>
<span>{{ store.currentAct === 'party_express' ? 'SAITE G (B)' : "O'ZAPFT (B)" }}</span>
<span>{{ store.currentAct === 'party_express' ? 'SAITE A (A)' : 'SPRUNG (A)' }}</span>
</div>
</div>
</div>
@@ -154,21 +193,27 @@ function toggleInventory() {
<span class="adv-icon">🍻</span>
<span class="adv-val">{{ store.beers }} Helles</span>
</div>
<button class="inv-mini-btn" @click="toggleInventory">
🎒 Beutel
</button>
</div>
<!-- Inventory Drawer Modal -->
<div v-if="inventoryOpen" class="inventory-drawer" @click="inventoryOpen = false">
<div class="inventory-card" @click.stop>
<h3>👑 KÖNIGLICHER LEERGUTSACK</h3>
<ul class="inventory-items">
<li v-for="item in store.inventory" :key="item" class="inv-item">
✨ {{ item }}
</li>
</ul>
<button class="close-inv-btn" @click="inventoryOpen = false">Schließen</button>
<!-- 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>
</div>
@@ -237,15 +282,23 @@ function toggleInventory() {
border-radius: 2px;
}
.sound-toggle {
background: #78350f;
color: #fef08a;
.settings-toggle-btn {
background: rgba(120, 53, 15, 0.7);
color: #ffd700;
border: 1px solid #ffd700;
border-radius: 4px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 4px 6px;
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 */
@@ -303,17 +356,6 @@ function toggleInventory() {
color: #ffd700;
}
.inv-mini-btn {
background: #78350f;
color: #fef08a;
border: 1px solid #b45309;
border-radius: 4px;
padding: 4px 8px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
cursor: pointer;
}
/* Parchment Status Bar */
.parchment-scroll {
background: #fdf6e2;
@@ -510,54 +552,119 @@ function toggleInventory() {
color: #d4af37;
}
/* Inventory Modal */
.inventory-drawer {
/* Settings & Pause Modal */
.settings-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 250;
padding: 16px;
}
.inventory-card {
.settings-card {
background: #2b1810;
border: 3px solid #d4af37;
border-radius: 8px;
padding: 16px;
width: 80%;
padding: 18px;
width: 100%;
max-width: 320px;
color: #fef3c7;
box-shadow: 0 0 24px rgba(0, 0, 0, 0.9);
}
.inventory-card h3 {
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1.5px solid #78350f;
padding-bottom: 8px;
margin-bottom: 12px;
}
.settings-title {
font-family: 'Press Start 2P', monospace;
font-size: 9px;
font-size: 8px;
color: #ffd700;
margin-top: 0;
margin: 0;
}
.inventory-items {
list-style: none;
padding: 0;
margin: 12px 0;
}
.inv-item {
font-family: 'MedievalSharp', serif;
font-size: 15px;
padding: 4px 0;
border-bottom: 1px dashed #78350f;
}
.close-inv-btn {
background: #78350f;
color: #fff;
border: 1px solid #d4af37;
border-radius: 4px;
padding: 6px 12px;
font-family: 'Press Start 2P', monospace;
font-size: 7px;
.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;
font-weight: bold;
cursor: pointer;
line-height: 1;
transition: all 0.15s ease;
}
.settings-close-x:hover {
background: #dc2626;
transform: scale(1.1);
}
.settings-paused-hint {
font-family: 'Press Start 2P', monospace;
font-size: 6.5px;
color: #38bdf8;
text-align: center;
margin: 0 0 16px 0;
}
.settings-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-btn {
background: #3b2014;
color: #fef08a;
border: 1.5px solid #b45309;
border-radius: 6px;
padding: 10px 8px;
font-family: 'MedievalSharp', serif;
font-size: 14px;
font-weight: bold;
cursor: pointer;
text-align: center;
transition: all 0.15s ease;
}
.settings-btn:hover {
background: #78350f;
border-color: #ffd700;
}
.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;
}
.resume-btn {
background: #15803d;
border-color: #86efac;
color: #fff;
}
.resume-btn:hover {
background: #16a34a;
}
</style>
@@ -51,10 +51,10 @@ function handleStart() {
<div class="scroll-section">
<h3 class="section-title">🎮 WAS DICH IM SPIEL ERWARTET</h3>
<ul class="features-list">
<li><strong>🏃‍♂️ Retro Jump & Run:</strong> Hüpfe über die Dächer der Hopfen-Pfade und sammle die verlorenen Kronkorken ein.</li>
<li><strong>🍻 Tavernen-Abenteuer:</strong> Sprich mit Verena, Lutz (ZTUL), Armend, Amy, Mane & Pü und gewinne das bayerische Beleidigungs-Gstanzl-Fechten!</li>
<li><strong>☁️ Doodle Jump Schwesternrettung:</strong> Hüpfe von Wolke zu Wolke hinauf und rette Saschas drei Schwestern Saskia, Maja und Miriam!</li>
<li><strong>🍻 Tavernen-Abenteuer:</strong> Erkunde Gaststube, Atelier, Marktplatz, Hinterland & den geheimen Party Bunker. Bezwinge NAT im Wildschwein-Duell und gewinne das bayerische Gstanzl-Fechten!</li>
<li><strong>🌴 Der Zauberspiegel:</strong> Finde das geheime Urlaubserinnerungs-Easter-Egg von Buschkönig Zuluman.</li>
<li><strong>🔊 Der 135-BPM-Express:</strong> Rase mit den Hof-DJs auf dem Bollerwagen und versorge das feiernde Gefolge.</li>
<li><strong>🎸 Mittelalter Guitar Hero:</strong> Rocke als Barden-Duo mit Ritter Hansi auf der Schlossbühne sein legendäres Abschiedskonzert nach Münchanien!</li>
<li><strong>🏆 Highscore-Urkunde:</strong> Hol dir am Ende deine persönliche königliche Punktzahl und vergleiche dich mit allen Partygästen!</li>
</ul>
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,10 @@ function selectChoice(choice: typeof PROLOGUE_DIALOGUES['start']['choices'][0])
if (choice.action === 'unlockBridgerton') {
store.unlockEasterEgg('bridgerton')
}
if (choice.action === 'resetPrologue') {
currentNodeId.value = 'start'
return
}
if (choice.action === 'startPlatformer') {
store.setAct('platformer')
return
@@ -24,6 +28,9 @@ function selectChoice(choice: typeof PROLOGUE_DIALOGUES['start']['choices'][0])
if (choice.nextId && PROLOGUE_DIALOGUES[choice.nextId]) {
currentNodeId.value = choice.nextId
if (PROLOGUE_DIALOGUES[choice.nextId].isGameOver) {
sound.playBoarHit()
}
}
}
@@ -32,14 +39,14 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
</script>
<template>
<div class="prologue-container">
<div class="prologue-container" :class="{ 'game-over-active': currentNode().isGameOver }">
<!-- Balcony Visual Scene with Regal Portrait Frame -->
<div class="scene-visual">
<div class="scene-visual" :class="{ 'fire-visual': currentNodeId === 'flo_game_over', 'vampire-visual': currentNodeId === 'viktor_game_over' }">
<div class="balcony-backdrop"></div>
<div class="scene-tag">
<span class="scene-title">SCHLOSS HAUMDAUCHER – DER BALKON</span>
<span class="scene-title">{{ currentNode().isGameOver ? '💀 GAME OVER – HAUMDAUCHER KAPUTT!' : 'SCHLOSS HAUMDAUCHER – DER BALKON' }}</span>
</div>
<div class="speaker-frame">
<div class="speaker-frame" :class="{ 'game-over-frame': currentNode().isGameOver }">
<img
:src="currentSpeaker().portrait"
:alt="currentSpeaker().name"
@@ -49,7 +56,7 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
</div>
<!-- Retro Dialogue Card -->
<div class="dialogue-card">
<div class="dialogue-card" :class="{ 'game-over-card': currentNode().isGameOver }">
<div class="dialogue-header">
<div class="speaker-name-badge">
<span class="speaker-name">{{ currentSpeaker().name }}</span>
@@ -57,7 +64,7 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
</div>
</div>
<div class="dialogue-body">
<div class="dialogue-body" :class="{ 'game-over-body': currentNode().isGameOver }">
<p class="dialogue-text">„{{ currentNode().text }}“</p>
</div>
@@ -66,9 +73,10 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
v-for="(choice, idx) in currentNode().choices"
:key="idx"
class="choice-btn"
:class="{ 'game-over-btn': currentNode().isGameOver }"
@click="selectChoice(choice)"
>
<span class="choice-arrow">▶</span>
<span class="choice-arrow">{{ currentNode().isGameOver ? '🔄' : '▶' }}</span>
<span class="choice-text">„{{ choice.text }}“</span>
</button>
</div>
@@ -227,4 +235,54 @@ const currentSpeaker = () => CHARACTERS[currentNode().speakerId] || CHARACTERS['
color: #f59e0b;
font-size: 10px;
}
/* Game Over Styles */
.game-over-active {
background: #200505;
}
.fire-visual {
background: radial-gradient(circle at 50% 40%, #7f1d1d 0%, #1c0505 100%) !important;
border-bottom-color: #ef4444 !important;
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5) inset;
}
.vampire-visual {
background: radial-gradient(circle at 50% 40%, #581c87 0%, #0f051d 100%) !important;
border-bottom-color: #a855f7 !important;
box-shadow: 0 0 30px rgba(168, 85, 247, 0.5) inset;
}
.game-over-frame {
border-color: #ef4444 !important;
box-shadow: 0 0 20px #ef4444 !important;
}
.game-over-card {
background: #1c0808 !important;
}
.game-over-body {
border-left-color: #ef4444 !important;
background: rgba(127, 29, 29, 0.3) !important;
}
.game-over-btn {
background: #7f1d1d !important;
border-color: #f87171 !important;
color: #fef08a !important;
font-weight: bold;
animation: pulse-border 1.5s infinite;
}
.game-over-btn:hover {
background: #991b1b !important;
transform: scale(1.02);
}
@keyframes pulse-border {
0%, 100% { box-shadow: 0 0 4px #ef4444; }
50% { box-shadow: 0 0 14px #ef4444; }
}
</style>
@@ -24,15 +24,33 @@ const activeDialog = ref<ActiveDialog | null>(null)
const duelActive = ref(false)
const duelStep = ref(0)
const duelWon = ref(false)
const duelIntroOpen = ref(false)
const zulumanMirrorActive = ref(false)
const trapdoorDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer'))
let lastTrapTap = 0
function switchRoom(room: TavernRoom) {
sound.playDialogueBlip()
currentRoom.value = room
}
function handleTrapdoorClick() {
if (trapdoorDiscovered.value || store.unlockedEasterEggs.includes('hinterzimmer')) {
sound.playFanfare()
currentRoom.value = 'hinterzimmer'
return
}
const now = Date.now()
if (now - lastTrapTap < 450) {
// Double tap / double click
triggerTrapdoor()
} else {
lastTrapTap = now
sound.playDialogueBlip()
}
}
function triggerTrapdoor() {
sound.playFanfare()
trapdoorDiscovered.value = true
@@ -40,7 +58,7 @@ function triggerTrapdoor() {
currentRoom.value = 'hinterzimmer'
}
function enterSecretHinterzimmer() {
function enterPartyBunker() {
sound.playFanfare()
store.unlockEasterEgg('hinterzimmer')
currentRoom.value = 'hinterzimmer'
@@ -49,6 +67,7 @@ function enterSecretHinterzimmer() {
// Wildschwein Schnick-Schnack-Schnuck Minigame with NAT
type WildBoarChoice = 'keiler' | 'dickicht' | 'lanze'
const wildBoarIntroOpen = ref(false)
const wildBoarDuelActive = ref(false)
const wildBoarPlayerScore = ref(0)
const wildBoarNatScore = ref(0)
@@ -59,8 +78,14 @@ const wildBoarLastNatMove = ref<WildBoarChoice | null>(null)
const wildBoarGameOver = ref(false)
const wildBoarPlayerWon = ref(false)
function openWildBoarIntro() {
sound.playDialogueBlip()
wildBoarIntroOpen.value = true
}
function startWildBoarDuel() {
sound.playDialogueBlip()
wildBoarIntroOpen.value = false
wildBoarDuelActive.value = true
wildBoarPlayerScore.value = 0
wildBoarNatScore.value = 0
@@ -78,7 +103,10 @@ function formatBoarMove(m: WildBoarChoice): string {
return '🏹 Lanze'
}
function playWildBoarMove(playerChoice: WildBoarChoice) {
function playWildBoarMove(playerChoice: WildBoarChoice, event?: Event) {
if (event?.currentTarget) {
(event.currentTarget as HTMLElement).blur()
}
if (wildBoarGameOver.value) return
const choices: WildBoarChoice[] = ['keiler', 'dickicht', 'lanze']
@@ -209,10 +237,10 @@ function openCharacterDialog(charId: string) {
title: char.title,
portrait: char.portrait,
text: 'Servus Sascha! Schau dir meine Fest-Bühne an! Ich erzähle den Leuten gerade epische Geschichten von meinen Girls und meinen Heldentaten im Haumdaucher Dickicht. Bevor du meine Kronkorken kriegst, forder ich dich zum Hof-Duell heraus: Wildschwein-Schnick-Schnack-Schnuck!',
actionLabel: '🐗 Wildschwein-Duell starten! (Best of 3)',
actionLabel: '🐗 Zum Wildschwein-Duell!',
actionFn: () => {
activeDialog.value = null
startWildBoarDuel()
openWildBoarIntro()
}
}
} else if (charId === 'mane') {
@@ -234,8 +262,8 @@ function openCharacterDialog(charId: string) {
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Habe die Ehre Sascha! Schau dir dieses Panorama an. Ich halte hier die Stellung und habe dir 4 Kronkorken gesichert!',
actionLabel: 'Danke Hansi! (+4 Kronkorken)',
text: 'Habe die Ehre Sascha! Der Abschied naht... ich breche bald auf nach Münchanien. Aber vorher rocken wir heute Abend auf der Schlossbühne noch das gigantischste Abschiedskonzert der Geschichte! Nimm diese 4 Kronkorken als Proviant!',
actionLabel: 'Auf das Konzert, Hansi! (+4 Kronkorken)',
actionFn: () => {
store.addKronkorken(4)
activeDialog.value = null
@@ -247,7 +275,7 @@ function openCharacterDialog(charId: string) {
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Majestät! Willkommen im innersten VIP-Kreis des Basses! Die magischen Kristalle vibrieren auf 135 BPM und die Lasershow brennt! Hier sind 5 Kronkorken für den Stroboskop-Zauber!',
text: 'Majestät! Willkommen im innersten VIP-Party-Bunker des Basses! Die magischen Kristalle vibrieren auf 135 BPM und die Lasershow brennt! Hier sind 5 Kronkorken für den Stroboskop-Zauber!',
actionLabel: 'Zünde den Kristall-Zauber, Pü! (+5 Kronkorken)',
actionFn: () => {
store.addKronkorken(5)
@@ -273,7 +301,7 @@ function openCharacterDialog(charId: string) {
name: char.name,
title: char.title,
portrait: char.portrait,
text: 'Servus König Sascha! Im Hinterzimmer fließt das Helle kälter als die Nacht! Hoch die Krüge auf 40 Jahre Regentschaft!',
text: 'Servus König Sascha! Im Party Bunker fließt das Helle kälter als die Nacht! Hoch die Krüge auf 40 Jahre Regentschaft!',
actionLabel: 'Prost Viktor! (+3 Kronkorken & Helles)',
actionFn: () => {
store.addKronkorken(3)
@@ -290,8 +318,14 @@ function inspectMirror() {
store.unlockEasterEgg('zuluman')
}
function openGstanzlIntro() {
sound.playDialogueBlip()
duelIntroOpen.value = true
}
function startGstanzlDuel() {
sound.playDialogueBlip()
duelIntroOpen.value = false
duelActive.value = true
duelStep.value = 0
duelWon.value = false
@@ -354,16 +388,17 @@ function proceedToExpress() {
<span class="hotspot-badge glow-door">Zum Marktplatz ▶</span>
</button>
<!-- Luke zum Hinterraum (Completely invisible until clicked) -->
<!-- Luke zum Party Bunker (100% invisible until double-clicked) -->
<button
class="hotspot trapdoor-hotspot"
:class="{ 'trap-invisible': !trapdoorDiscovered && !store.unlockedEasterEggs.includes('hinterzimmer') }"
@click="triggerTrapdoor"
:title="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Luke zum Hinterraum' : undefined"
@click="handleTrapdoorClick"
@dblclick="triggerTrapdoor"
:title="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Luke zum Party Bunker' : undefined"
>
<template v-if="trapdoorDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="trapdoor-icon">🚪</span>
<span class="trapdoor-badge">Luke zum Hinterraum</span>
<span class="trapdoor-badge">Luke zum Party Bunker 🪩</span>
</template>
</button>
</template>
@@ -479,7 +514,7 @@ function proceedToExpress() {
<!-- Door back to Gaststube -->
<button class="hotspot door-hotspot hotspot-door-back-vip" @click="switchRoom('schankraum')">
<span class="door-icon">🚪</span>
<span class="hotspot-badge glow-door">◀ Zurück in die Gaststube</span>
<span class="hotspot-badge glow-door">◀ Zurück in den Schankraum</span>
</button>
</template>
@@ -492,7 +527,7 @@ function proceedToExpress() {
currentRoom === 'nebenraum' ? '🎨 AKT III: KÖNIGLICHER NEBENRAUM & ATELIER 🎨' :
currentRoom === 'marktplatz' ? '🎪 AKT III: HAUMDAUCHER MARKTPLATZ 🎪' :
currentRoom === 'hinterland' ? '🌲 AKT III: ALPINES HINTERLAND 🌲' :
'🪩 AKT III: GEHEIMES VIP-HINTERZIMMER 🪩'
'🪩 AKT III: DER GEHEIME PARTY BUNKER 🪩'
}}
</div>
@@ -532,18 +567,18 @@ function proceedToExpress() {
:class="{ active: currentRoom === 'hinterzimmer' }"
@click="switchRoom('hinterzimmer')"
>
🪩 Hinterzimmer
🪩 Party Bunker
</button>
</div>
<!-- Action Row (Duel & Next Level) -->
<div class="tavern-nav-buttons">
<button class="duel-trigger" @click="startGstanzlDuel">
<button class="duel-trigger" @click="openGstanzlIntro">
⚔️ Gstanzl-Fechten
</button>
<button class="next-level-btn" @click="proceedToExpress">
➡️ Zum Fest-Konvoi!
🎸 Zu Akt IV: Abschiedskonzert!
</button>
</div>
</div>
@@ -586,6 +621,50 @@ function proceedToExpress() {
</div>
</div>
<!-- Gstanzl Intro Modal -->
<div v-if="duelIntroOpen" class="dialog-overlay" @click="duelIntroOpen = 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="duelIntroOpen = false" aria-label="Schließen">✕</button>
</div>
<h2 class="intro-modal-title">⚔️ BAYERISCHES GSTANZL-FECHTEN</h2>
<p class="intro-modal-story">
Ein frecher Raubritter zweifelt an Saschas Regentschaft und lästert über 135-BPM-Techno! Zeige ihm, dass die bayerische Zunge schärfer sticht als jedes Schwert!
</p>
<div class="intro-modal-rules">
<div class="rule-line">🎶 <strong>3 Runden:</strong> Wähle den treffendsten bayerischen Reim-Konter.</div>
<div class="rule-line">🏆 <strong>Belohnung:</strong> +15 Kronkorken & 1.500 Punkte für den Highscore!</div>
</div>
<button class="action-btn start-duel-action-btn" @click="startGstanzlDuel">
🎤 Auf geht's zum Gstanzl!
</button>
</div>
</div>
<!-- Wildschwein Duel Intro Modal -->
<div v-if="wildBoarIntroOpen" class="dialog-overlay" @click="wildBoarIntroOpen = 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="wildBoarIntroOpen = false" aria-label="Schließen">✕</button>
</div>
<h2 class="intro-modal-title">🐗 WILDSCHWEIN-SCHNICK-SCHNACK-SCHNUCK</h2>
<p class="intro-modal-story">
Hof-Chronist NAT fordert König Sascha zum traditionellen Haumdaucher Dickicht-Duell heraus! Wer beherrscht die Taktik des Waldes?
</p>
<div class="intro-modal-rules">
<div class="rule-line">🐗 <strong>Keiler</strong> bricht durch 🌿 <strong>Dickicht</strong></div>
<div class="rule-line">🌿 <strong>Dickicht</strong> fängt 🏹 <strong>Lanze</strong> ab</div>
<div class="rule-line">🏹 <strong>Lanze</strong> erlegt 🐗 <strong>Keiler</strong></div>
<div class="rule-line">👑 <strong>Format:</strong> Best of 3 (wer zuerst 2 Siege holt!).</div>
</div>
<button class="action-btn start-duel-action-btn" @click="startWildBoarDuel">
⚔️ Duell starten!
</button>
</div>
</div>
<!-- Gstanzl Duel Modal -->
<div v-if="duelActive" class="dialog-overlay">
<div class="dialog-box duel-box">
@@ -660,17 +739,17 @@ function proceedToExpress() {
<!-- Move Choices -->
<div v-if="!wildBoarGameOver" class="boar-choices">
<button class="boar-choice-btn" @click="playWildBoarMove('keiler')">
<button class="boar-choice-btn" @click="playWildBoarMove('keiler', $event)">
<span class="choice-icon">🐗</span>
<span class="choice-name">Keiler</span>
<span class="choice-sub">Bricht Dickicht</span>
</button>
<button class="boar-choice-btn" @click="playWildBoarMove('dickicht')">
<button class="boar-choice-btn" @click="playWildBoarMove('dickicht', $event)">
<span class="choice-icon">🌿</span>
<span class="choice-name">Dickicht</span>
<span class="choice-sub">Fängt Lanze</span>
</button>
<button class="boar-choice-btn" @click="playWildBoarMove('lanze')">
<button class="boar-choice-btn" @click="playWildBoarMove('lanze', $event)">
<span class="choice-icon">🏹</span>
<span class="choice-name">Lanze</span>
<span class="choice-sub">Erlegt Keiler</span>
@@ -776,21 +855,23 @@ function proceedToExpress() {
color: #38bdf8;
}
/* Trapdoor to Hinterzimmer Easter Egg */
/* Trapdoor to Party Bunker Easter Egg */
.trapdoor-hotspot {
position: absolute;
top: 73%;
left: 82%;
background: rgba(180, 83, 9, 0.4);
border: 1.5px dashed #ffd700;
border-radius: 8px;
padding: 3px 6px;
top: 84%;
left: 35%;
transform: translate(-50%, -50%);
background: rgba(212, 175, 55, 0.25);
border: 2px dashed #ffd700;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
z-index: 12;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 4px;
gap: 6px;
box-shadow: 0 0 12px rgba(255, 215, 0, 0.4);
}
.trapdoor-hotspot.trap-invisible,
@@ -802,19 +883,19 @@ function proceedToExpress() {
box-shadow: none !important;
outline: none !important;
transform: translate(-50%, -50%) !important;
width: 50px;
height: 50px;
width: 140px;
height: 65px;
padding: 0 !important;
cursor: pointer;
}
.trapdoor-icon {
font-size: 13px;
font-size: 14px;
}
.trapdoor-badge {
font-family: 'Press Start 2P', monospace;
font-size: 5.5px;
font-size: 6px;
color: #fef08a;
white-space: nowrap;
}
@@ -894,7 +975,7 @@ function proceedToExpress() {
.hotspot-door-hinterland { top: 34%; left: 76%; }
/* 3. Hinterland Hotspots */
.hotspot-mane { top: 44%; left: 36%; }
.hotspot-mane { top: 54%; left: 36%; }
.hiking-anim {
animation: hikeBob 1.2s infinite ease-in-out;
}
@@ -909,7 +990,7 @@ function proceedToExpress() {
transform: rotate(20deg);
}
.hotspot-hansi { top: 60%; left: 74%; }
.hotspot-hansi { top: 56%; left: 74%; }
.hotspot-door-back-marktplatz { top: 72%; left: 22%; }
/* 4. Hinterzimmer Hotspots & Strobe */
@@ -1378,13 +1459,26 @@ function proceedToExpress() {
align-items: center;
gap: 3px;
cursor: pointer;
outline: none;
transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.boar-choice-btn:hover {
.boar-choice-btn:focus {
outline: none;
}
.boar-choice-btn:active {
transform: scale(0.95);
background: #14532d;
border-color: #4ade80;
transform: translateY(-2px);
}
@media (hover: hover) {
.boar-choice-btn:hover {
background: #14532d;
border-color: #4ade80;
transform: translateY(-2px);
}
}
.choice-icon {
@@ -1430,4 +1524,76 @@ function proceedToExpress() {
background: #16a34a;
border-color: #86efac;
}
/* Intro Modals for Minigames */
.intro-modal-box {
max-width: 320px;
width: 95%;
background: #231209;
border: 3px double #d4af37;
border-radius: 10px;
padding: 14px;
color: #fef3c7;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.95);
}
.intro-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.intro-tag {
font-family: 'Press Start 2P', monospace;
font-size: 6px;
color: #f59e0b;
}
.intro-modal-title {
font-family: 'Press Start 2P', monospace;
font-size: 8.5px;
color: #ffd700;
margin: 0 0 8px 0;
line-height: 1.35;
}
.intro-modal-story {
font-family: 'MedievalSharp', serif;
font-size: 13px;
color: #fef08a;
line-height: 1.35;
margin: 0 0 10px 0;
}
.intro-modal-rules {
background: rgba(0, 0, 0, 0.45);
border-left: 3px solid #ffd700;
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.rule-line {
font-family: 'MedievalSharp', serif;
font-size: 12px;
color: #fed7aa;
line-height: 1.3;
}
.start-duel-action-btn {
width: 100%;
background: #15803d !important;
border-color: #86efac !important;
font-family: 'Press Start 2P', monospace;
font-size: 7.5px;
padding: 10px !important;
}
.start-duel-action-btn:hover {
background: #16a34a !important;
}
</style>
@@ -111,21 +111,48 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
flo_maier: {
id: 'flo_maier',
name: 'Flo Maier',
title: 'Hof-DJ & Sound-Ingenieur',
title: 'Hof-DJ & Feierdeifl',
portrait: '/assets/characters/flo_maier/portrait_v2.jpg',
description: 'Mastermind an den Turntables und Behüter der heiligen 135-BPM-Frequenz.',
famousQuote: 'Dieser Track drückt mehr als die königliche Kutsche!',
description: 'Mastermind an den Turntables und berüchtigter Feierdeifl des Reiches.',
famousQuote: 'Soll i ois ozindn?! A riesigs Freudenfeuer!',
themeColor: '#0284c7'
},
viktor: {
id: 'viktor',
name: 'Viktor',
title: 'Nachtwächter & Party-Ritter',
title: 'Partyvampir & Nachtwächter',
portrait: '/assets/characters/viktor/portrait_v2.jpg',
description: 'Wacht im samtenen Vampir-Umhang über das Nachtleben und sorgt für eiskaltes Helles.',
famousQuote: 'Die Nacht ist jung und der Gerstensaft fließt in Strömen!',
description: 'Wacht im samtenen Vampir-Umhang über das Nachtleben und lauert auf frisches Helles.',
famousQuote: 'Ein Biss, und du ravst für alle Ewigkeit!',
themeColor: '#b91c1c'
},
saskia: {
id: 'saskia',
name: 'Schwester Saskia',
title: 'Die Erstgeborene Schwester',
portrait: '/assets/characters/saskia/portrait.jpg',
description: 'Wurde auf den ersten Turm in den Wolken verschleppt und ruft nach König Sascha!',
famousQuote: 'Sascha, beeil di! Der Turm wackelt im 135-BPM-Takt!',
themeColor: '#f43f5e'
},
maja: {
id: 'maja',
name: 'Schwester Maja',
title: 'Die Zweite Schwester',
portrait: '/assets/characters/maja/portrait.jpg',
description: 'Gefangen in den Wipfeln des Haumdaucher Himmelsgartens.',
famousQuote: 'Hol mi runter, Sascha, i hob an riesen Durst auf a Helles!',
themeColor: '#8b5cf6'
},
miriam: {
id: 'miriam',
name: 'Schwester Miriam',
title: 'Die Jüngste Schwester',
portrait: '/assets/characters/miriam/portrait.jpg',
description: 'Hockt auf den höchsten Himmelszinnen und wartet auf ihren großen Bruder.',
famousQuote: 'Bester Bruder! Alle drei Schwestern gerettet! Jetzt aber zügig zum Schankraum!',
themeColor: '#06b6d4'
},
djs: {
id: 'djs',
name: 'Die Hof-DJs',
@@ -145,3 +172,4 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
themeColor: '#e11d48'
}
}
@@ -1,7 +1,7 @@
export interface DialogueChoice {
text: string
nextId?: string
action?: 'addKronkorken' | 'unlockZuluman' | 'unlockBridgerton' | 'startPlatformer' | 'startExpress' | 'startFinale'
action?: 'addKronkorken' | 'unlockZuluman' | 'unlockBridgerton' | 'startPlatformer' | 'startExpress' | 'startFinale' | 'resetPrologue'
reward?: number
reply?: {
speakerId: string
@@ -13,6 +13,7 @@ export interface DialogueNode {
id: string
speakerId: string
text: string
isGameOver?: boolean
choices: DialogueChoice[]
}
@@ -24,12 +25,12 @@ export const PROLOGUE_DIALOGUES: Record<string, DialogueNode> = {
choices: [
{
text: 'Zefix Josef! Ohne Kronkorken kein Bier, und ohne Bier kein Bass!',
nextId: 'mop_appears',
nextId: 'flo_maier_arrives',
action: 'unlockBridgerton'
},
{
text: 'Bridgerton?! Josef, konzentrier dich! Wo san meine 40 Kronkorken?!',
nextId: 'mop_appears'
nextId: 'flo_maier_arrives'
},
{
text: 'Lass mich raten... Du mit deinen Girls ab und zack, Leergut weg?',
@@ -45,18 +46,72 @@ export const PROLOGUE_DIALOGUES: Record<string, DialogueNode> = {
text: 'Ja mei, woher weißt du das immer?! Wir zogen durch die Lande, die Musik war laut, der Gerstensaft floss in Strömen... und dann hat der Wind die Korken über die Dächer geweht!',
choices: [
{
text: 'Na servus. Dann muss der König wohl selbst ran.',
text: 'Na servus. Mal schauen wer da noch auf dem Balkon lauert...',
nextId: 'flo_maier_arrives'
}
]
},
flo_maier_arrives: {
id: 'flo_maier_arrives',
speakerId: 'flo_maier',
text: 'Habe die Ehre Sascha! Zum 40er zünd i da den ganzen Schlosshof o! A riesigs Freudenfeuer aus Hopfen und Eichenbalken! Des drückt mehr als jeder Bass! Was moanst?!',
choices: [
{
text: 'Ja freilich, zünd ois o, Flo! Freudenfeuer marsch! 🔥',
nextId: 'flo_game_over'
},
{
text: 'Na, spinnst du Feierdeifl?! Mei Schloss bleibt stehn, heid wird friedlich geraved!',
nextId: 'viktor_arrives'
}
]
},
flo_game_over: {
id: 'flo_game_over',
speakerId: 'flo_maier',
text: '💥 WUUUUSCH! Alles steht lichterloh in Flammen! Das Schloss ist Asche, der DJ-Pult geschmolzen und das Bier verdampft. Niemand feiert auf verbrannter Erde!',
isGameOver: true,
choices: [
{
text: '🔄 Löschzug rufen & von vorn beginnen',
action: 'resetPrologue'
}
]
},
viktor_arrives: {
id: 'viktor_arrives',
speakerId: 'viktor',
text: 'Guten Abend, mein König... Wie wär\'s mit einem kleinen Biss in deinen königlichen Hals? Dann ravt die gesamte Sippschaft unsterblich als Partyvampire bis in alle Ewigkeit!',
choices: [
{
text: 'Ja geil, mach mi unsterblich, Viktor! *Hals hinhalten* 🧛',
nextId: 'viktor_game_over'
},
{
text: 'Na danke Viktor, heid gibt\'s nur kühles Helles statt Blut!',
nextId: 'mop_appears'
}
]
},
viktor_game_over: {
id: 'viktor_game_over',
speakerId: 'viktor',
text: '🧛 *CHOMP!* Sascha ist nun ein Partyvampir! Doch ach weh: Es ist strahlender Sonnenschein über Haumdaucher... Du zerfällst zu feinem Glitzerstaub auf den Schlosszinnen!',
isGameOver: true,
choices: [
{
text: '🔄 Knoblauch essen & von vorn beginnen',
action: 'resetPrologue'
}
]
},
mop_appears: {
id: 'mop_appears',
speakerId: 'mop',
text: 'Habe die Ehre, Sascha! Keine Panik, dein Erschaffer hat schon die Sprungfedern in deine königlichen Stiefel programmiert. Sammle mindestens 15 Kronkorken auf den Hopfen-Pfaden, bevor wir in die Taverne einziehen!',
text: 'Habe die Ehre, Sascha! Gut, dass das Schloss noch steht. Aber ein Notruf erreichte die Matrix: Deine drei Schwestern Saskia, Maja und Miriam wurden von Kronkorken-Kobolden hoch in die Wolken entführt! Du musst mit deinen Sprungstiefeln von Plattform zu Plattform klettern und alle drei befreien!',
choices: [
{
text: 'Auf geht\'s Mop! Starte den Kronkorken-Kreuzzug!',
text: 'Auf geht\'s Mop! Meine Schwestern warten – Kronkorken-Sprungfedern an!',
action: 'startPlatformer'
},
{
@@ -68,16 +123,41 @@ export const PROLOGUE_DIALOGUES: Record<string, DialogueNode> = {
sascha_ready: {
id: 'sascha_ready',
speakerId: 'sascha',
text: 'Sonnenbrille sitzt. Hermelinmantel weht im Wind. Haumdaucher, der König kommt!',
text: 'Sonnenbrille sitzt. Hermelinmantel weht im Wind. Saskia, Maja, Miriam – euer königlicher Bruder holt euch da runter!',
choices: [
{
text: 'Level 1 starten: Die Hopfen-Pfade!',
text: 'Akt II starten: Die Schwesternrettung!',
action: 'startPlatformer'
}
]
}
}
export const SISTER_RESCUE_DIALOGUES = {
saskia: {
id: 'rescue_saskia',
speakerId: 'saskia',
name: 'Schwester Saskia',
text: 'Sascha! Du hast mich gerettet! Die Kobolde wollten mich zwingen, Radler zu trinken! Hier, nimm diesen königlichen Kronkorken! Aber beeil dich: Maja ist noch weiter oben im Himmelsgarten gefangen!',
btnText: 'Weiter zu Level 2: Maja retten! ☁️'
},
maja: {
id: 'rescue_maja',
speakerId: 'maja',
name: 'Schwester Maja',
text: 'Königlicher Bruder! Endlich! I hab scho dacht, i verdurste hier oben in den Wolken! Nimm dieses kühle Helle und diesen Kronkorken zur Stärkung. Aber gib Gas: Miriam hockt ganz oben auf den höchsten Zinnen!',
btnText: 'Weiter zu Level 3: Miriam retten! 🏰'
},
miriam: {
id: 'rescue_miriam',
speakerId: 'miriam',
name: 'Schwester Miriam',
text: 'JAAAA! Sascha, bester Bruder aller Zeiten! Alle drei Schwestern sind gerettet und in Sicherheit! Jetzt aber nix wie runter in die Taverne zur königlichen Audienz!',
btnText: 'Zu Akt III: Der Schankraum! 🍻'
}
}
export const GSTANZL_DUEL = [
{
insult: 'Dein Techno-Bass ist so dünn wie schales Exportbier!',
@@ -15,6 +15,7 @@ export interface GameState {
startTime: number
elapsedSeconds: number
soundMuted: boolean
isPaused: boolean
}
export const STORAGE_KEY = 'sasch40_game_save_v1'
@@ -34,6 +35,7 @@ function getInitialState(): GameState {
startTime: Date.now(),
elapsedSeconds: 0,
soundMuted: false,
isPaused: false,
}
if (typeof window === 'undefined' || !window.localStorage) {
@@ -56,7 +58,8 @@ function getInitialState(): GameState {
unlockedEasterEggs: Array.isArray(parsed.unlockedEasterEggs) ? parsed.unlockedEasterEggs : [],
startTime: typeof parsed.startTime === 'number' ? parsed.startTime : defaults.startTime,
elapsedSeconds: typeof parsed.elapsedSeconds === 'number' ? parsed.elapsedSeconds : 0,
soundMuted: Boolean(parsed.soundMuted)
soundMuted: Boolean(parsed.soundMuted),
isPaused: false
}
if (state.soundMuted) {
sound.setMuted(true)
@@ -197,6 +200,14 @@ export const useGameStore = defineStore('game', {
this.soundMuted = sound.toggleMute()
},
pauseGame() {
this.isPaused = true
},
resumeGame() {
this.isPaused = false
},
resetGame() {
if (typeof window !== 'undefined' && window.localStorage) {
try {