diff --git a/projects/sasch40/AGENTS.md b/projects/sasch40/AGENTS.md index 3f7d4ef..97f239d 100644 --- a/projects/sasch40/AGENTS.md +++ b/projects/sasch40/AGENTS.md @@ -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. --- diff --git a/projects/sasch40/frontend/public/assets/characters/maja/portrait.jpg b/projects/sasch40/frontend/public/assets/characters/maja/portrait.jpg new file mode 100644 index 0000000..37794e9 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/characters/maja/portrait.jpg differ diff --git a/projects/sasch40/frontend/public/assets/characters/miriam/portrait.jpg b/projects/sasch40/frontend/public/assets/characters/miriam/portrait.jpg new file mode 100644 index 0000000..f7a85ad Binary files /dev/null and b/projects/sasch40/frontend/public/assets/characters/miriam/portrait.jpg differ diff --git a/projects/sasch40/frontend/public/assets/characters/saskia/portrait.jpg b/projects/sasch40/frontend/public/assets/characters/saskia/portrait.jpg new file mode 100644 index 0000000..c213fed Binary files /dev/null and b/projects/sasch40/frontend/public/assets/characters/saskia/portrait.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/doodle_sky.jpg b/projects/sasch40/frontend/public/assets/scenes/doodle_sky.jpg new file mode 100644 index 0000000..cf6b315 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/doodle_sky.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg b/projects/sasch40/frontend/public/assets/scenes/hinterland.jpg index f6b80e3..f224699 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_old.jpg b/projects/sasch40/frontend/public/assets/scenes/hinterland_old.jpg new file mode 100644 index 0000000..f6b80e3 Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/hinterland_old.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/lute_stage.jpg b/projects/sasch40/frontend/public/assets/scenes/lute_stage.jpg new file mode 100644 index 0000000..f33f07a Binary files /dev/null and b/projects/sasch40/frontend/public/assets/scenes/lute_stage.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg b/projects/sasch40/frontend/public/assets/scenes/schankraum.jpg index f9ef98e..e95755d 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_v2.jpg b/projects/sasch40/frontend/public/assets/scenes/schankraum_v2.jpg index f9ef98e..e95755d 100644 Binary files a/projects/sasch40/frontend/public/assets/scenes/schankraum_v2.jpg and b/projects/sasch40/frontend/public/assets/scenes/schankraum_v2.jpg differ diff --git a/projects/sasch40/frontend/public/assets/scenes/schankraum_v3.jpg b/projects/sasch40/frontend/public/assets/scenes/schankraum_v3.jpg index f9ef98e..e95755d 100644 Binary files a/projects/sasch40/frontend/public/assets/scenes/schankraum_v3.jpg and b/projects/sasch40/frontend/public/assets/scenes/schankraum_v3.jpg differ diff --git a/projects/sasch40/frontend/scripts/playtest.mjs b/projects/sasch40/frontend/scripts/playtest.mjs index 14105b0..6868122 100644 --- a/projects/sasch40/frontend/scripts/playtest.mjs +++ b/projects/sasch40/frontend/scripts/playtest.mjs @@ -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 diff --git a/projects/sasch40/frontend/src/audio/synthAudio.ts b/projects/sasch40/frontend/src/audio/synthAudio.ts index 9f17abe..903b951 100644 --- a/projects/sasch40/frontend/src/audio/synthAudio.ts +++ b/projects/sasch40/frontend/src/audio/synthAudio.ts @@ -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 diff --git a/projects/sasch40/frontend/src/components/GameBoyFrame.vue b/projects/sasch40/frontend/src/components/GameBoyFrame.vue index 285c126..b5f7bc6 100644 --- a/projects/sasch40/frontend/src/components/GameBoyFrame.vue +++ b/projects/sasch40/frontend/src/components/GameBoyFrame.vue @@ -11,18 +11,32 @@ import CertificateModal from './CertificateModal.vue' const store = useGameStore() const platformerRef = ref | null>(null) -const inventoryOpen = ref(false) +const concertRef = ref | 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() + } } @@ -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' : '' }} - @@ -60,7 +92,7 @@ function toggleInventory() { - + @@ -99,7 +131,14 @@ function toggleInventory() { @mousedown="handleDpad('right', true)" @mouseup="handleDpad('right', false)" >▶ - + D-PAD @@ -108,9 +147,9 @@ function toggleInventory() {
👑
- @@ -137,8 +176,8 @@ function toggleInventory() {
- O'ZAPFT (B) - SPRUNG (A) + {{ store.currentAct === 'party_express' ? 'SAITE G (B)' : "O'ZAPFT (B)" }} + {{ store.currentAct === 'party_express' ? 'SAITE A (A)' : 'SPRUNG (A)' }}
@@ -154,21 +193,27 @@ function toggleInventory() { 🍻 {{ store.beers }} Helles - - -
-
-

👑 KÖNIGLICHER LEERGUTSACK

-
    -
  • - ✨ {{ item }} -
  • -
- + +
+
+
+

⚙️ EINSTELLUNGEN & PAUSE

+ +
+

⏸️ Spiel und Timer pausiert

+
+ + + +
@@ -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; } diff --git a/projects/sasch40/frontend/src/components/IntroView.vue b/projects/sasch40/frontend/src/components/IntroView.vue index 3589192..edf2b08 100644 --- a/projects/sasch40/frontend/src/components/IntroView.vue +++ b/projects/sasch40/frontend/src/components/IntroView.vue @@ -51,10 +51,10 @@ function handleStart() {

🎮 WAS DICH IM SPIEL ERWARTET

    -
  • 🏃‍♂️ Retro Jump & Run: Hüpfe über die Dächer der Hopfen-Pfade und sammle die verlorenen Kronkorken ein.
  • -
  • 🍻 Tavernen-Abenteuer: Sprich mit Verena, Lutz (ZTUL), Armend, Amy, Mane & Pü und gewinne das bayerische Beleidigungs-Gstanzl-Fechten!
  • +
  • ☁️ Doodle Jump Schwesternrettung: Hüpfe von Wolke zu Wolke hinauf und rette Saschas drei Schwestern Saskia, Maja und Miriam!
  • +
  • 🍻 Tavernen-Abenteuer: Erkunde Gaststube, Atelier, Marktplatz, Hinterland & den geheimen Party Bunker. Bezwinge NAT im Wildschwein-Duell und gewinne das bayerische Gstanzl-Fechten!
  • 🌴 Der Zauberspiegel: Finde das geheime Urlaubserinnerungs-Easter-Egg von Buschkönig Zuluman.
  • -
  • 🔊 Der 135-BPM-Express: Rase mit den Hof-DJs auf dem Bollerwagen und versorge das feiernde Gefolge.
  • +
  • 🎸 Mittelalter Guitar Hero: Rocke als Barden-Duo mit Ritter Hansi auf der Schlossbühne sein legendäres Abschiedskonzert nach Münchanien!
  • 🏆 Highscore-Urkunde: Hol dir am Ende deine persönliche königliche Punktzahl und vergleiche dich mit allen Partygästen!
diff --git a/projects/sasch40/frontend/src/components/PartyExpressView.vue b/projects/sasch40/frontend/src/components/PartyExpressView.vue index a3bc1bf..e1b641e 100644 --- a/projects/sasch40/frontend/src/components/PartyExpressView.vue +++ b/projects/sasch40/frontend/src/components/PartyExpressView.vue @@ -5,454 +5,556 @@ import { sound } from '../audio/synthAudio' const store = useGameStore() const canvasRef = ref(null) -const showExpressIntro = ref(true) +const showConcertIntro = ref(true) +const concertFinished = ref(false) + let animId: number | null = null -let wagonX = 70 -let wagonY = 325 -let speed = 3.2 -let distance = 0 -const maxDistance = 2100 +// Canvas dimensions +const W = 360 +const H = 480 -function startExpress() { - sound.playDialogueBlip() - showExpressIntro.value = false +// Fretboard layout +const boardX = 60 +const boardW = 240 +const strikeY = 360 +const laneCenters = [boardX + 40, boardX + 120, boardX + 200] // 100, 180, 260 +const laneColors = ['#ef4444', '#f59e0b', '#38bdf8'] +const laneGlow = ['rgba(239, 68, 68, 0.4)', 'rgba(245, 158, 11, 0.4)', 'rgba(56, 189, 248, 0.4)'] + +// Note structure +interface Note { + id: number + lane: 0 | 1 | 2 + targetTime: number // in seconds from concert start + hit: boolean + missed: boolean } -interface Guest { - id: string +interface HitPopup { x: number - name: string - served: boolean - avatarColor: string + y: number + text: string + color: string + life: number } -let guests: Guest[] = [ - { id: 'hansi', x: 300, name: 'Hansi 🧔', served: false, avatarColor: '#6366f1' }, - { id: 'viktor', x: 550, name: 'Viktor (Vampir) 🧛', served: false, avatarColor: '#881337' }, - { id: 'albin', x: 820, name: 'Albin (Fitness) 🏋️', served: false, avatarColor: '#0284c7' }, - { id: 'fabian', x: 1100, name: 'Fabian (Brauer) 🍺', served: false, avatarColor: '#d97706' }, - { id: 'maierflo', x: 1380, name: 'Maier Flo (DJ) 🎧', served: false, avatarColor: '#7c3aed' }, - { id: 'guate', x: 1650, name: 'Guatemalteke 🌋', served: false, avatarColor: '#059669' }, - { id: 'markus_nat', x: 1920, name: 'Markus & Nathalie 🥂', served: false, avatarColor: '#db2777' } -] +interface Particle { + x: number + y: number + vx: number + vy: number + life: number + color: string + size: number +} -function throwBeer() { - sound.playGulp() - // Check if any guest is near the wagon - guests.forEach(g => { - if (!g.served && Math.abs(distance + wagonX - g.x) < 95) { - g.served = true - store.serveGuest() +// Concert timeline +const SONG_DURATION = 42 // 42 seconds total concert +let startTime = 0 +let elapsedTime = 0 +let notes: Note[] = [] +let hitPopups: HitPopup[] = [] +let particles: Particle[] = [] + +// Score tracking +const score = ref(0) +const streak = ref(0) +const maxStreak = ref(0) +const totalHits = ref(0) +const totalPerfects = ref(0) +const totalMisses = ref(0) + +// Active button pressed state for visual feedback +const lanePressed = ref([false, false, false]) + +// Stage background +let stageImg: HTMLImageElement | null = null +if (typeof Image !== 'undefined') { + stageImg = new Image() + stageImg.src = '/assets/scenes/lute_stage.jpg' +} + +// Generate rhythmic 135-BPM note chart (~55 notes across 42 seconds) +function generateNoteChart() { + const chart: Note[] = [] + let id = 1 + const bpm = 135 + const beatInterval = 60 / bpm // ~0.444s + + // Intro riff (beats 4 to 16) + for (let b = 4; b < 16; b += 2) { + chart.push({ id: id++, lane: (b % 4 === 0 ? 0 : 1) as 0 | 1 | 2, targetTime: b * beatInterval, hit: false, missed: false }) + } + + // Main verse (beats 18 to 44) - driving rhythm across 3 lanes + const versePattern: (0 | 1 | 2)[] = [0, 1, 2, 1, 0, 0, 2, 1, 2, 0, 1, 2, 1] + for (let i = 0; i < versePattern.length; i++) { + chart.push({ + id: id++, + lane: versePattern[i], + targetTime: (18 + i * 2) * beatInterval, + hit: false, + missed: false + }) + } + + // Solo riff / Fast rock rhythm (beats 46 to 76) + for (let b = 46; b < 76; b += 1.5) { + const lane = (Math.floor(b * 2) % 3) as 0 | 1 | 2 + chart.push({ + id: id++, + lane, + targetTime: b * beatInterval, + hit: false, + missed: false + }) + } + + // Grand finale chords / double hits (beats 78 to 92) + for (let b = 78; b <= 90; b += 2) { + chart.push({ + id: id++, + lane: (b % 2 === 0 ? 1 : 2) as 0 | 1 | 2, + targetTime: b * beatInterval, + hit: false, + missed: false + }) + if (b % 4 === 0) { + chart.push({ + id: id++, + lane: 0, + targetTime: b * beatInterval, + hit: false, + missed: false + }) } - }) + } + + return chart } -function updateGame() { - if (showExpressIntro.value) return // Don't advance while reading intro! +function startConcert() { + sound.playDialogueBlip() + showConcertIntro.value = false + notes = generateNoteChart() + startTime = performance.now() + score.value = 0 + streak.value = 0 + maxStreak.value = 0 + totalHits.value = 0 + totalPerfects.value = 0 + totalMisses.value = 0 + concertFinished.value = false +} - distance += speed +// Trigger hitting a lane +function hitLane(lane: 0 | 1 | 2) { + if (showConcertIntro.value || concertFinished.value) return - if (distance >= maxDistance) { - store.setAct('finale') - return + lanePressed.value[lane] = true + setTimeout(() => { + lanePressed.value[lane] = false + }, 120) + + const currentT = (performance.now() - startTime) / 1000 + + // Find closest unhit note in this lane + let closestNote: Note | null = null + let minDiff = Infinity + + for (const n of notes) { + if (!n.hit && !n.missed && n.lane === lane) { + const diff = Math.abs(n.targetTime - currentT) + if (diff < minDiff && diff < 0.35) { + minDiff = diff + closestNote = n + } + } + } + + const laneX = laneCenters[lane] + + if (closestNote) { + closestNote.hit = true + sound.playLuteNote(lane, true) + streak.value++ + if (streak.value > maxStreak.value) maxStreak.value = streak.value + totalHits.value++ + + if (minDiff < 0.12) { + // Perfect! + totalPerfects.value++ + score.value += 150 * Math.min(4, Math.floor(streak.value / 5) + 1) + addPopup(laneX, strikeY - 15, 'PERFEKT! ✨', '#ffd700') + spawnParticles(laneX, strikeY, '#ffd700', 12) + } else { + // Good! + score.value += 80 * Math.min(4, Math.floor(streak.value / 5) + 1) + addPopup(laneX, strikeY - 15, 'GUT! 🎵', '#4ade80') + spawnParticles(laneX, strikeY, '#4ade80', 8) + } + } else { + // Strummed without note -> minor mistwizzle + sound.playLuteNote(lane, false) + if (streak.value > 3) { + addPopup(laneX, strikeY - 10, 'UPPS!', '#f87171') + } + streak.value = 0 } } -function draw(ctx: CanvasRenderingContext2D, w: number, h: number) { - ctx.clearRect(0, 0, w, h) +function addPopup(x: number, y: number, text: string, color: string) { + hitPopups.push({ x, y, text, color, life: 25 }) +} - // Sunset Party Sky Gradient - const grad = ctx.createLinearGradient(0, 0, 0, h) - grad.addColorStop(0, '#1e082b') - grad.addColorStop(0.4, '#4a154b') - grad.addColorStop(0.7, '#831843') - grad.addColorStop(0.9, '#c2410c') - grad.addColorStop(1, '#f59e0b') - ctx.fillStyle = grad - ctx.fillRect(0, 0, w, h) - - // Sweeping Festival Lasers & Party Beams - const now = Date.now() * 0.003 - ctx.save() - for (let b = 0; b < 3; b++) { - const angleOffset = b * (Math.PI / 3) - const targetX = w * 0.5 + Math.sin(now + angleOffset) * (w * 0.45) - ctx.strokeStyle = `hsla(${((now * 50) + b * 90) % 360}, 100%, 75%, 0.45)` - ctx.lineWidth = 3 - ctx.beginPath() - ctx.moveTo(w * 0.5, 90) - ctx.lineTo(targetX, 350) - ctx.stroke() +function spawnParticles(x: number, y: number, color: string, count = 8) { + for (let i = 0; i < count; i++) { + particles.push({ + x, + y, + vx: (Math.random() - 0.5) * 6, + vy: (Math.random() - 0.5) * 6, + life: 20 + Math.random() * 10, + color, + size: 2 + Math.random() * 3 + }) } - ctx.restore() +} - // Medieval Castle silhouette on the horizon - ctx.fillStyle = '#170c14' - ctx.beginPath() - ctx.moveTo(0, 310) - ctx.lineTo(30, 290); ctx.lineTo(50, 290); ctx.lineTo(50, 310) - ctx.lineTo(110, 310); ctx.lineTo(130, 275); ctx.lineTo(160, 275); ctx.lineTo(170, 310) - ctx.lineTo(240, 310); ctx.lineTo(255, 295); ctx.lineTo(275, 295); ctx.lineTo(290, 310) - ctx.lineTo(w, 310); ctx.lineTo(w, 350); ctx.lineTo(0, 350) - ctx.fill() +function finishConcert() { + concertFinished.value = true + sound.playFanfare() + store.serveGuest() + store.serveGuest() + store.addKronkorken(10) + store.unlockEasterEgg('lute_hero') +} - // Cobblestone Highway - ctx.fillStyle = '#1c1917' - ctx.fillRect(0, 350, w, 130) - ctx.fillStyle = '#292524' - for (let r = 0; r < w + 40; r += 32) { - const rx = (r - (distance % 32)) - ctx.fillRect(rx, 352, 26, 6) - ctx.fillRect(rx + 12, 362, 26, 6) +function proceedToCertificate() { + store.setAct('finale') +} + +// Controls +function handleKeyDown(e: KeyboardEvent) { + if (e.code === 'ArrowLeft' || e.code === 'KeyA') { + hitLane(0) + } else if (e.code === 'ArrowDown' || e.code === 'KeyS' || e.code === 'Space') { + hitLane(1) + } else if (e.code === 'ArrowRight' || e.code === 'KeyD') { + hitLane(2) } - // Curbs / Grass edge - ctx.fillStyle = '#365314' - ctx.fillRect(0, 348, w, 3) +} - // Draw Guests along the road - guests.forEach(g => { - const screenX = g.x - distance - if (screenX > -70 && screenX < w + 70) { - // Guest shadow - ctx.fillStyle = 'rgba(0,0,0,0.4)' - ctx.beginPath() - ctx.ellipse(screenX + 10, 350, 14, 5, 0, 0, Math.PI * 2) - ctx.fill() +defineExpose({ + hitLane +}) - if (g.id === 'viktor') { - // Red velvet vampire cape - ctx.fillStyle = '#4c0519' - ctx.fillRect(screenX - 5, 305, 30, 44) - } +function updatePhysics() { + if (store.isPaused || showConcertIntro.value || concertFinished.value) return - // Guest body - ctx.fillStyle = g.avatarColor - ctx.fillRect(screenX, 305, 20, 45) + elapsedTime = (performance.now() - startTime) / 1000 - // Head - ctx.fillStyle = '#fed7aa' - ctx.beginPath() - ctx.arc(screenX + 10, 295, 10, 0, Math.PI * 2) - ctx.fill() - - // Character specifics - if (g.id === 'hansi') { - // Moustache - ctx.fillStyle = '#451a03' - ctx.fillRect(screenX + 6, 297, 8, 2.5) - } else if (g.id === 'viktor') { - // Sunglasses & fangs - ctx.fillStyle = '#0f172a' - ctx.fillRect(screenX + 6, 293, 8, 3) - } else if (g.id === 'albin') { - // Gym Headband - ctx.fillStyle = '#38bdf8' - ctx.fillRect(screenX + 2, 288, 16, 3) - } else if (g.id === 'fabian') { - // Beer mug in hand - ctx.fillStyle = '#f59e0b' - ctx.fillRect(screenX + 20, 312, 8, 10) - ctx.fillStyle = '#ffffff' - ctx.fillRect(screenX + 19, 309, 10, 3) - } else if (g.id === 'maierflo') { - // Headphones - ctx.strokeStyle = '#c084fc' - ctx.lineWidth = 2.5 - ctx.beginPath() - ctx.arc(screenX + 10, 295, 12, Math.PI, 0) - ctx.stroke() - ctx.fillStyle = '#9333ea' - ctx.fillRect(screenX - 3, 293, 4, 6) - ctx.fillRect(screenX + 19, 293, 4, 6) - } else if (g.id === 'guate') { - // Bushy dark beard - ctx.fillStyle = '#1c1917' - ctx.fillRect(screenX + 4, 297, 12, 9) - } else if (g.id === 'markus_nat') { - // Second head for couple - ctx.fillStyle = '#fbcfe8' - ctx.beginPath() - ctx.arc(screenX + 24, 296, 9, 0, Math.PI * 2) - ctx.fill() - ctx.fillStyle = '#db2777' - ctx.fillRect(screenX + 16, 307, 16, 43) - } - - // Name & icon tag - ctx.fillStyle = '#fff' - ctx.font = 'bold 9px MedievalSharp, serif' - ctx.textAlign = 'center' - ctx.fillText(g.name, screenX + 10, 278) - - if (g.served) { - ctx.fillStyle = '#22c55e' - ctx.font = 'bold 11px sans-serif' - ctx.fillText('🍻 PROST!', screenX + 10, 264) - } else { - ctx.fillStyle = '#fbbf24' - ctx.font = '8px "Press Start 2P", monospace' - ctx.fillText('DURST!', screenX + 10, 264) - } + // Check missed notes + notes.forEach(n => { + if (!n.hit && !n.missed && elapsedTime - n.targetTime > 0.3) { + n.missed = true + totalMisses.value++ + streak.value = 0 + addPopup(laneCenters[n.lane], strikeY + 15, 'VORBEI! 💨', '#94a3b8') } }) - // ================= REDESIGNED BOLLERWAGEN (RETRO PIXEL ART) ================= - const wx = wagonX - const wy = wagonY - const bob = showExpressIntro.value ? 0 : Math.sin(Date.now() * 0.015) * 2.5 - - // 1. Shadow underneath the wagon - ctx.fillStyle = 'rgba(0,0,0,0.5)' - ctx.beginPath() - ctx.ellipse(wx + 30, wy + 32, 54, 8, 0, 0, Math.PI * 2) - ctx.fill() - - // 2. Heavy Rustic Oak Timber Bed with Iron Corner Brackets - ctx.fillStyle = '#451a03' // Dark wood outline - ctx.fillRect(wx - 26, wy + bob, 114, 26) - ctx.fillStyle = '#78350f' // Main oak plank - ctx.fillRect(wx - 24, wy + bob + 2, 110, 22) - ctx.fillStyle = '#92400e' // Highlight upper plank - ctx.fillRect(wx - 24, wy + bob + 4, 110, 8) - - // Iron bands and golden rivets - ctx.fillStyle = '#1c1917' - ctx.fillRect(wx - 26, wy + bob, 6, 26) - ctx.fillRect(wx + 28, wy + bob, 5, 26) - ctx.fillRect(wx + 82, wy + bob, 6, 26) - ctx.fillStyle = '#ffd700' - ;[wx - 23, wx + 30, wx + 85].forEach(rx => { - ctx.fillRect(rx, wy + bob + 4, 2, 2) - ctx.fillRect(rx, wy + bob + 14, 2, 2) + // Popups update + hitPopups.forEach(p => { + p.y -= 1 + p.life-- }) + hitPopups = hitPopups.filter(p => p.life > 0) - // 3. Side-Mounted Hopfen Beer Keg with Brass Faucet - ctx.fillStyle = '#292524' - ctx.fillRect(wx + 5, wy + bob - 12, 22, 14) - ctx.fillStyle = '#b45309' - ctx.fillRect(wx + 6, wy + bob - 11, 20, 12) - ctx.fillStyle = '#ffd700' // Faucet - ctx.fillRect(wx + 26, wy + bob - 6, 5, 3) - ctx.fillRect(wx + 29, wy + bob - 3, 2, 4) + // Particles update + particles.forEach(p => { + p.x += p.vx + p.y += p.vy + p.life-- + }) + particles = particles.filter(p => p.life > 0) - // 4. Giant 135-BPM Bass Subwoofer on the rear - ctx.fillStyle = '#09090b' - ctx.fillRect(wx - 22, wy - 36 + bob, 26, 38) - ctx.strokeStyle = '#ffd700' - ctx.lineWidth = 1.5 - ctx.strokeRect(wx - 22, wy - 36 + bob, 26, 38) + // Song completion + if (elapsedTime >= SONG_DURATION && !concertFinished.value) { + finishConcert() + } +} - // Pulsing Bass Speaker Cone - const bassKick = Math.abs(Math.sin(Date.now() * 0.02) * 5) - ctx.fillStyle = '#e11d48' - ctx.beginPath() - ctx.arc(wx - 9, wy - 17 + bob, 8 + bassKick, 0, Math.PI * 2) - ctx.fill() - ctx.strokeStyle = '#f43f5e' +function drawGame(ctx: CanvasRenderingContext2D) { + ctx.clearRect(0, 0, W, H) + + // 1. Stage Background + if (stageImg && stageImg.complete && stageImg.naturalWidth > 0) { + ctx.drawImage(stageImg, 0, 0, W, H) + } else { + // Atmospheric dark medieval rock concert gradient + const grad = ctx.createLinearGradient(0, 0, 0, H) + grad.addColorStop(0, '#0f172a') + grad.addColorStop(0.5, '#451a03') + grad.addColorStop(1, '#170c06') + ctx.fillStyle = grad + ctx.fillRect(0, 0, W, H) + } + + // Dark overlay over stage to keep fretboard high-contrast + ctx.fillStyle = 'rgba(10, 5, 3, 0.45)' + ctx.fillRect(0, 0, W, H) + + // 2. Fretboard (Parchment & Eiche Neck) + ctx.fillStyle = 'rgba(25, 12, 6, 0.88)' + ctx.fillRect(boardX, 0, boardW, H) + ctx.strokeStyle = '#d4af37' ctx.lineWidth = 2 - ctx.stroke() - // Speaker center cap - ctx.fillStyle = '#ffd700' - ctx.beginPath() - ctx.arc(wx - 9, wy - 17 + bob, 3, 0, Math.PI * 2) - ctx.fill() + ctx.strokeRect(boardX, 0, boardW, H) - // 5. Hof-DJ Mixer Console & Turntable on the front - ctx.fillStyle = '#18181b' - ctx.fillRect(wx + 46, wy - 16 + bob, 34, 18) - ctx.strokeStyle = '#38bdf8' - ctx.lineWidth = 1 - ctx.strokeRect(wx + 46, wy - 16 + bob, 34, 18) - - // Vinyl Turntable with spinning vinyl - ctx.fillStyle = '#0a0a0a' - ctx.beginPath() - ctx.arc(wx + 60, wy - 7 + bob, 7, 0, Math.PI * 2) - ctx.fill() - ctx.fillStyle = '#ef4444' // Center vinyl label - ctx.beginPath() - ctx.arc(wx + 60, wy - 7 + bob, 2.5, 0, Math.PI * 2) - ctx.fill() - - // Flashing Mixer LEDs - const ledFlash = Math.floor(Date.now() / 150) % 3 - ctx.fillStyle = ledFlash === 0 ? '#22c55e' : '#15803d' - ctx.fillRect(wx + 72, wy - 12 + bob, 3, 3) - ctx.fillStyle = ledFlash === 1 ? '#eab308' : '#854d0e' - ctx.fillRect(wx + 72, wy - 8 + bob, 3, 3) - ctx.fillStyle = ledFlash === 2 ? '#ef4444' : '#7f1d1d' - ctx.fillRect(wx + 72, wy - 4 + bob, 3, 3) - - // 6. KING SASCHA standing proud on the wagon - // Flowing Royal Red Velvet Cape - ctx.fillStyle = '#b91c1c' - ctx.fillRect(wx + 10, wy - 36 + bob, 24, 38) - - // Royal Blue Tunic with Gold Trim - ctx.fillStyle = '#1d4ed8' - ctx.fillRect(wx + 15, wy - 32 + bob, 18, 24) - ctx.fillStyle = '#d4af37' - ctx.fillRect(wx + 22, wy - 32 + bob, 4, 24) - - // Face & Beard - ctx.fillStyle = '#fed7aa' - ctx.fillRect(wx + 16, wy - 46 + bob, 16, 14) - // Short Blonde/Brown Hair (as requested) - ctx.fillStyle = '#b45309' - ctx.fillRect(wx + 15, wy - 50 + bob, 18, 5) - - // Deep Black Royal Sunglasses - ctx.fillStyle = '#0f172a' - ctx.fillRect(wx + 18, wy - 43 + bob, 6, 4) - ctx.fillRect(wx + 25, wy - 43 + bob, 6, 4) - ctx.fillRect(wx + 23, wy - 42 + bob, 3, 2) // Bridge - - // Golden Imperial Crown with Jewels - ctx.fillStyle = '#facc15' - ctx.fillRect(wx + 15, wy - 53 + bob, 18, 5) - ctx.beginPath() - ctx.moveTo(wx + 15, wy - 53 + bob); ctx.lineTo(wx + 15, wy - 58 + bob); ctx.lineTo(wx + 19, wy - 53 + bob) - ctx.lineTo(wx + 24, wy - 60 + bob); ctx.lineTo(wx + 29, wy - 53 + bob); ctx.lineTo(wx + 33, wy - 58 + bob) - ctx.lineTo(wx + 33, wy - 53 + bob) - ctx.fill() - // Crown jewels - ctx.fillStyle = '#dc2626' - ctx.fillRect(wx + 23, wy - 54 + bob, 2, 2) - - // Raised Beer Stein in Right Hand (Prost!) - ctx.fillStyle = '#f59e0b' // Golden Beer - ctx.fillRect(wx + 35, wy - 44 + bob, 8, 12) - ctx.fillStyle = '#ffffff' // White froth/foam - ctx.fillRect(wx + 34, wy - 47 + bob, 10, 4) - ctx.fillStyle = '#cbd5e1' // Glass handle - ctx.fillRect(wx + 42, wy - 42 + bob, 3, 7) - - // 7. Detailed Wooden Spoke Wheels with Golden Brass Caps - const wheelSpin = (distance * 0.08) % (Math.PI * 2) - ;[-8, 68].forEach(wheelOffset => { - ctx.save() - ctx.translate(wx + wheelOffset, wy + 26) - ctx.rotate(wheelSpin) - - // Outer dark tyre - ctx.fillStyle = '#1c1917' + // Frets (horizontal brass bars) + ctx.strokeStyle = 'rgba(212, 175, 55, 0.25)' + ctx.lineWidth = 1.5 + for (let y = 60; y < H; y += 45) { ctx.beginPath() - ctx.arc(0, 0, 13, 0, Math.PI * 2) - ctx.fill() + ctx.moveTo(boardX, y) + ctx.lineTo(boardX + boardW, y) + ctx.stroke() + } - // Inner wooden rim - ctx.fillStyle = '#78350f' + // 3 Strings (vertical glowing lines) + for (let i = 0; i < 3; i++) { + const lx = laneCenters[i] + ctx.strokeStyle = lanePressed.value[i] ? '#ffffff' : laneColors[i] + ctx.lineWidth = lanePressed.value[i] ? 4 : 2 ctx.beginPath() - ctx.arc(0, 0, 11, 0, Math.PI * 2) - ctx.fill() - - // Brass rim edge - ctx.strokeStyle = '#ffd700' - ctx.lineWidth = 1.5 + ctx.moveTo(lx, 0) + ctx.lineTo(lx, H) ctx.stroke() - // 8 Spokes - ctx.strokeStyle = '#b45309' - ctx.lineWidth = 1.5 - for (let s = 0; s < 4; s++) { - ctx.beginPath() - ctx.moveTo(-11 * Math.cos(s * Math.PI / 4), -11 * Math.sin(s * Math.PI / 4)) - ctx.lineTo(11 * Math.cos(s * Math.PI / 4), 11 * Math.sin(s * Math.PI / 4)) - ctx.stroke() - } - - // Center brass axle cap - ctx.fillStyle = '#facc15' + // Strike Target Circles at bottom + ctx.fillStyle = lanePressed.value[i] ? laneColors[i] : 'rgba(0, 0, 0, 0.6)' ctx.beginPath() - ctx.arc(0, 0, 4, 0, Math.PI * 2) + ctx.arc(lx, strikeY, 18, 0, Math.PI * 2) ctx.fill() - ctx.restore() + ctx.strokeStyle = laneColors[i] + ctx.lineWidth = 3 + ctx.stroke() + + // Target glyph + ctx.fillStyle = '#ffffff' + ctx.font = 'bold 9px monospace' + ctx.fillText(i === 0 ? 'D' : i === 1 ? 'G' : 'A', lx - 4, strikeY + 3) + } + + // 3. Falling Notes + const speed = 260 // pixels per second travel speed + const noteRadius = 14 + + notes.forEach(n => { + if (n.hit || n.missed) return + const timeDiff = n.targetTime - elapsedTime + const ny = strikeY - timeDiff * speed + + if (ny >= -20 && ny <= strikeY + 40) { + const nx = laneCenters[n.lane] + + // Outer glow + ctx.fillStyle = laneGlow[n.lane] + ctx.beginPath() + ctx.arc(nx, ny, noteRadius + 5, 0, Math.PI * 2) + ctx.fill() + + // Note body (flaming rune / beer cap pick) + ctx.fillStyle = laneColors[n.lane] + ctx.beginPath() + ctx.arc(nx, ny, noteRadius, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = '#ffd700' + ctx.lineWidth = 2 + ctx.stroke() + + // Center music note icon + ctx.fillStyle = '#ffffff' + ctx.font = 'bold 11px sans-serif' + ctx.fillText('♫', nx - 4, ny + 4) + } }) - // Top Banner HUD - ctx.fillStyle = '#ffd700' - ctx.font = 'bold 8px "Press Start 2P", monospace' - ctx.textAlign = 'center' - ctx.fillText('AKT IV: DER 135-BPM-FEST-KONVOI', w / 2, 22) + // 4. Hit Burst Particles + particles.forEach(p => { + ctx.fillStyle = p.color + ctx.beginPath() + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) + ctx.fill() + }) - // Progress Bar - const progress = Math.min(1, distance / maxDistance) - const barMargin = 20 - const barWidth = w - barMargin * 2 - ctx.fillStyle = 'rgba(0,0,0,0.7)' - ctx.fillRect(barMargin, 32, barWidth, 10) - ctx.strokeStyle = '#ffd700' - ctx.lineWidth = 1 - ctx.strokeRect(barMargin, 32, barWidth, 10) + // 5. Floating Hit Popups + hitPopups.forEach(p => { + ctx.fillStyle = p.color + ctx.font = 'bold 9px monospace' + ctx.fillText(p.text, p.x - 24, p.y) + }) - // Progress fill - ctx.fillStyle = '#22c55e' - ctx.fillRect(barMargin + 1, 33, (barWidth - 2) * progress, 8) - - // Moving Keg Icon on the progress bar - const kegX = barMargin + (barWidth * progress) - ctx.font = '10px sans-serif' - ctx.fillText('🍺', kegX, 39) + // 6. HUD (Top Banner) + drawHUD(ctx) } -function loop() { - updateGame() +function drawHUD(ctx: CanvasRenderingContext2D) { + // Top Header Banner + ctx.fillStyle = 'rgba(0, 0, 0, 0.75)' + ctx.fillRect(0, 0, W, 32) + ctx.strokeStyle = '#d4af37' + ctx.lineWidth = 1 + ctx.strokeRect(0, 0, W, 32) + + // Score & Streak + ctx.fillStyle = '#ffd700' + ctx.font = 'bold 8px monospace' + ctx.fillText(`PUNKTE: ${score.value}`, 8, 14) + + const multiplier = Math.min(4, Math.floor(streak.value / 5) + 1) + ctx.fillStyle = streak.value >= 5 ? '#f59e0b' : '#38bdf8' + ctx.fillText(`COMBO: x${multiplier} (${streak.value})`, 8, 26) + + // Progress Bar for Song + const progress = Math.min(1, Math.max(0, elapsedTime / SONG_DURATION)) + ctx.fillStyle = '#334155' + ctx.fillRect(W - 100, 10, 90, 12) + ctx.fillStyle = '#10b981' + ctx.fillRect(W - 98, 12, 86 * progress, 8) + ctx.strokeStyle = '#ffd700' + ctx.strokeRect(W - 100, 10, 90, 12) +} + +function gameLoop() { + updatePhysics() if (canvasRef.value) { const ctx = canvasRef.value.getContext('2d') - if (ctx) draw(ctx, canvasRef.value.width, canvasRef.value.height) + if (ctx) drawGame(ctx) } - animId = requestAnimationFrame(loop) + animId = requestAnimationFrame(gameLoop) } onMounted(() => { - animId = requestAnimationFrame(loop) + window.addEventListener('keydown', handleKeyDown) + animId = requestAnimationFrame(gameLoop) }) onUnmounted(() => { + window.removeEventListener('keydown', handleKeyDown) if (animId) cancelAnimationFrame(animId) }) diff --git a/projects/sasch40/frontend/src/components/PlatformerView.vue b/projects/sasch40/frontend/src/components/PlatformerView.vue index abffa38..9400992 100644 --- a/projects/sasch40/frontend/src/components/PlatformerView.vue +++ b/projects/sasch40/frontend/src/components/PlatformerView.vue @@ -2,32 +2,31 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useGameStore } from '../stores/gameStore' import { sound } from '../audio/synthAudio' +import { SISTER_RESCUE_DIALOGUES } from '../config/dialogues' +import { CHARACTERS } from '../config/characters' const store = useGameStore() const canvasRef = ref(null) const showLevelIntro = ref(true) +const rescuedSister = ref<'saskia' | 'maja' | 'miriam' | null>(null) +const currentLevel = ref<1 | 2 | 3>(1) +const fallNotification = ref(false) + let animationFrameId: number | null = null -function startLevel() { - sound.playDialogueBlip() - showLevelIntro.value = false -} +// Canvas dimensions +const W = 360 +const H = 480 -// Game state +// Game entities interface Platform { x: number y: number w: number h: number - type: 'roof' | 'barrel' | 'wood' -} - -interface Item { - x: number - y: number - type: 'cap' | 'beer' - collected: boolean - bobOffset: number + type: 'normal' | 'spring' | 'cloud' + hasItem?: 'cap' | 'beer' | null + itemCollected?: boolean } interface Particle { @@ -36,492 +35,671 @@ interface Particle { vx: number vy: number life: number + maxLife: number color: string + size: number } -let player = { - x: 60, - y: 350, +// Player state +const player = { + x: W / 2 - 16, + y: 0, w: 32, - h: 48, + h: 40, vx: 0, vy: 0, - isGrounded: false, - jumpCount: 0, - frame: 0 + facing: 'right' as 'left' | 'right', + squash: 1, // Visual bounce stretch/squash + jumpCount: 0 } -let cameraX = 0 -let levelWidth = 2800 +let cameraY = 0 +let levelHeight = 1800 // Total vertical height of climb let platforms: Platform[] = [] -let items: Item[] = [] let particles: Particle[] = [] // Input state -let keys = { +const keys = { left: false, - right: false, - jump: false + right: false } -// Generate level -function initLevel() { - platforms = [ - // Ground base - { x: 0, y: 400, w: 700, h: 80, type: 'roof' }, - { x: 780, y: 400, w: 600, h: 80, type: 'roof' }, - { x: 1460, y: 400, w: 700, h: 80, type: 'roof' }, - { x: 2240, y: 400, w: 600, h: 80, type: 'roof' }, +// Background image loading +let bgImg: HTMLImageElement | null = null +if (typeof Image !== 'undefined') { + bgImg = new Image() + bgImg.src = '/assets/scenes/doodle_sky.jpg' +} - // Platforms - { x: 180, y: 330, w: 100, h: 18, type: 'wood' }, - { x: 340, y: 260, w: 120, h: 18, type: 'wood' }, - { x: 520, y: 330, w: 90, h: 18, type: 'barrel' }, +function startLevel() { + sound.playDialogueBlip() + showLevelIntro.value = false + initCurrentLevel() +} - { x: 860, y: 310, w: 110, h: 18, type: 'wood' }, - { x: 1040, y: 240, w: 130, h: 18, type: 'wood' }, - { x: 1220, y: 320, w: 80, h: 18, type: 'barrel' }, +function initCurrentLevel() { + // Height increases slightly per level + levelHeight = currentLevel.value === 1 ? 1600 : currentLevel.value === 2 ? 1850 : 2100 - { x: 1560, y: 290, w: 120, h: 18, type: 'wood' }, - { x: 1740, y: 220, w: 140, h: 18, type: 'wood' }, - { x: 1950, y: 300, w: 90, h: 18, type: 'barrel' }, + // Reset player to ground + player.x = W / 2 - player.w / 2 + player.y = levelHeight - 70 + player.vx = 0 + player.vy = -10.5 + player.facing = 'right' + cameraY = levelHeight - H - { x: 2320, y: 280, w: 130, h: 18, type: 'wood' }, - { x: 2500, y: 220, w: 160, h: 18, type: 'wood' } - ] + platforms = [] + particles = [] - items = [] - // Scatter 35 bottle caps and 5 beers - for (let i = 0; i < 35; i++) { - const px = 120 + i * 72 - const py = 220 + (i % 3) * 60 - items.push({ + // Base platform at bottom + platforms.push({ + x: 20, + y: levelHeight - 30, + w: W - 40, + h: 18, + type: 'normal' + }) + + // Generate platforms climbing up to the top + const platformSpacing = 68 + const totalPlatforms = Math.floor((levelHeight - 120) / platformSpacing) + + for (let i = 1; i <= totalPlatforms; i++) { + const py = levelHeight - 30 - i * platformSpacing + const pw = Math.max(54, 76 - (i % 3) * 6) + const px = 20 + Math.random() * (W - pw - 40) + + // Platform type + const isSpring = i % 7 === 4 + const isCloud = i % 5 === 2 + + // Collectibles + let hasItem: 'cap' | 'beer' | null = null + if (i % 2 === 1) { + hasItem = 'cap' + } else if (i % 6 === 0) { + hasItem = 'beer' + } + + platforms.push({ x: px, y: py, - type: 'cap', - collected: false, - bobOffset: Math.random() * Math.PI * 2 + w: pw, + h: 14, + type: isSpring ? 'spring' : isCloud ? 'cloud' : 'normal', + hasItem, + itemCollected: false }) } - // A few Helles beers for big boost - ;[450, 1100, 1800, 2450].forEach(bx => { - items.push({ - x: bx, - y: 190, - type: 'beer', - collected: false, - bobOffset: 0 - }) + // Goal platform at the very top for the sister + platforms.push({ + x: W / 2 - 60, + y: 70, + w: 120, + h: 22, + type: 'normal' }) } -// Controller hooks for GameBoy buttons +// Exposed controls for GameBoy bezel buttons function onJump() { - if (player.isGrounded || player.jumpCount < 2) { - player.vy = -9.2 - player.jumpCount++ - player.isGrounded = false + // Manual extra hopfen boost + if (player.vy > -4) { + player.vy = -11.5 sound.playJump() - - // Jump dust particles - for (let i = 0; i < 6; i++) { - particles.push({ - x: player.x + player.w / 2, - y: player.y + player.h, - vx: (Math.random() - 0.5) * 3, - vy: (Math.random() - 0.5) * 2, - life: 20, - color: '#d4af37' - }) - } + spawnParticles(player.x + player.w / 2, player.y + player.h, '#ffd700', 8) } } function onLeft(pressed: boolean) { keys.left = pressed + if (pressed) player.facing = 'left' } function onRight(pressed: boolean) { keys.right = pressed + if (pressed) player.facing = 'right' } -// Expose to parent GameBoy console defineExpose({ onJump, onLeft, onRight }) -function updatePhysics() { - // Horizontal movement (auto slight forward jog + manual control) - const baseSpeed = 2.2 - if (keys.right) { - player.vx = baseSpeed + 2.5 - } else if (keys.left) { - player.vx = -baseSpeed - 1.5 +// Touch controls on screen halves +function handleTouchStart(e: TouchEvent) { + const touch = e.touches[0] + if (!touch || !canvasRef.value) return + const rect = canvasRef.value.getBoundingClientRect() + const touchX = touch.clientX - rect.left + if (touchX < rect.width / 2) { + onLeft(true) + onRight(false) } else { - // gentle casual auto-scroll jog - player.vx = baseSpeed + onRight(true) + onLeft(false) } +} +function handleTouchEnd() { + onLeft(false) + onRight(false) +} + +function handleKeyDown(e: KeyboardEvent) { + if (e.code === 'ArrowLeft' || e.code === 'KeyA') onLeft(true) + if (e.code === 'ArrowRight' || e.code === 'KeyD') onRight(true) + if (e.code === 'ArrowUp' || e.code === 'Space' || e.code === 'KeyW') onJump() +} + +function handleKeyUp(e: KeyboardEvent) { + if (e.code === 'ArrowLeft' || e.code === 'KeyA') onLeft(false) + if (e.code === 'ArrowRight' || e.code === 'KeyD') onRight(false) +} + +function spawnParticles(x: number, y: number, color: string, count = 6) { + for (let i = 0; i < count; i++) { + particles.push({ + x, + y, + vx: (Math.random() - 0.5) * 4, + vy: (Math.random() - 0.5) * 4, + life: 20 + Math.random() * 10, + maxLife: 30, + color, + size: 2 + Math.random() * 3 + }) + } +} + +function triggerSisterRescue(sisterKey: 'saskia' | 'maja' | 'miriam') { + sound.playRescueFanfare() + rescuedSister.value = sisterKey + store.addKronkorken(3) + store.addBeer() +} + +function proceedNextStage() { + rescuedSister.value = null + if (currentLevel.value === 1) { + currentLevel.value = 2 + initCurrentLevel() + } else if (currentLevel.value === 2) { + currentLevel.value = 3 + initCurrentLevel() + } else { + // All 3 sisters rescued! Proceed to Akt III Taverne + store.setAct('tavern') + } +} + +function updatePhysics() { + if (store.isPaused || showLevelIntro.value || rescuedSister.value) return + + // Horizontal motion + const speed = 4.6 + if (keys.left) { + player.vx = -speed + player.facing = 'left' + } else if (keys.right) { + player.vx = speed + player.facing = 'right' + } else { + player.vx *= 0.78 + if (Math.abs(player.vx) < 0.1) player.vx = 0 + } player.x += player.vx + // Screen wrap-around + if (player.x + player.w < 0) { + player.x = W + } else if (player.x > W) { + player.x = -player.w + } + // Gravity - player.vy += 0.42 - if (player.vy > 10) player.vy = 10 + player.vy += 0.36 + if (player.vy > 9.5) player.vy = 9.5 player.y += player.vy - player.isGrounded = false + // Visual squash/stretch recovery + player.squash += (1 - player.squash) * 0.15 - // Platform collision + // Platform collision (only when falling downwards) + if (player.vy > 0) { + for (const plat of platforms) { + if ( + player.x + player.w > plat.x && + player.x < plat.x + plat.w && + player.y + player.h >= plat.y && + player.y + player.h <= plat.y + 14 + ) { + // Goal platform collision check + if (plat.y <= 80) { + const sister = currentLevel.value === 1 ? 'saskia' : currentLevel.value === 2 ? 'maja' : 'miriam' + triggerSisterRescue(sister) + return + } + + // Bounce! + player.squash = 0.65 + if (plat.type === 'spring') { + player.vy = -16.0 + sound.playSpringBounce() + spawnParticles(player.x + player.w / 2, plat.y, '#f59e0b', 12) + } else { + player.vy = -10.8 + sound.playJump() + spawnParticles(player.x + player.w / 2, plat.y, '#d4af37', 5) + } + break + } + } + } + + // Collect item pickup for (const plat of platforms) { - if ( - player.x + player.w > plat.x && - player.x < plat.x + plat.w && - player.y + player.h >= plat.y && - player.y + player.h <= plat.y + 14 && - player.vy >= 0 - ) { - player.y = plat.y - player.h - player.vy = 0 - player.isGrounded = true - player.jumpCount = 0 + if (plat.hasItem && !plat.itemCollected) { + const itemX = plat.x + plat.w / 2 + const itemY = plat.y - 14 + const dx = (player.x + player.w / 2) - itemX + const dy = (player.y + player.h / 2) - itemY + if (Math.sqrt(dx * dx + dy * dy) < 24) { + plat.itemCollected = true + if (plat.hasItem === 'cap') { + store.addKronkorken(1) + sound.playCoin() + spawnParticles(itemX, itemY, '#ffd700', 8) + } else { + store.addBeer() + sound.playGulp() + spawnParticles(itemX, itemY, '#f59e0b', 10) + } + } } } - // Prevent falling to oblivion (respawn smoothly with cheer) - if (player.y > 460) { - player.y = 250 - player.vy = 0 - player.x = Math.max(60, player.x - 120) + // Camera upward scrolling + const targetCameraY = player.y - H * 0.45 + if (targetCameraY < cameraY) { + cameraY = targetCameraY } - // Camera tracking - cameraX = player.x - 80 + // Fall off bottom check + if (player.y > cameraY + H + 30) { + sound.playBoarHit() + fallNotification.value = true + setTimeout(() => { + fallNotification.value = false + }, 1800) - // Item pickup - items.forEach(item => { - if (item.collected) return - const dx = (player.x + player.w / 2) - item.x - const dy = (player.y + player.h / 2) - item.y - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 26) { - item.collected = true - if (item.type === 'cap') { - store.addKronkorken(1) - } else { - store.addBeer() - } + // Reset player to bottom of level gently + player.x = W / 2 - player.w / 2 + player.y = levelHeight - 80 + player.vy = -10.5 + cameraY = levelHeight - H + } - // Sparkles - for (let p = 0; p < 8; p++) { - particles.push({ - x: item.x, - y: item.y, - vx: (Math.random() - 0.5) * 4, - vy: (Math.random() - 0.5) * 4, - life: 25, - color: item.type === 'cap' ? '#ffd700' : '#f59e0b' - }) - } - } - }) - - // Particles update + // Particles particles.forEach(p => { p.x += p.vx p.y += p.vy p.life-- }) particles = particles.filter(p => p.life > 0) - - // Level completion check (reaching the Taverne gate) - if (player.x >= levelWidth - 100) { - store.setAct('tavern') - } - - player.frame++ } -function drawGame(ctx: CanvasRenderingContext2D, width: number, height: number) { - ctx.clearRect(0, 0, width, height) +function drawGame(ctx: CanvasRenderingContext2D) { + ctx.clearRect(0, 0, W, H) - // Sky gradient - const skyGrad = ctx.createLinearGradient(0, 0, 0, height) - skyGrad.addColorStop(0, '#3a7bd5') - skyGrad.addColorStop(0.65, '#f5af19') - skyGrad.addColorStop(1, '#e65c00') - ctx.fillStyle = skyGrad - ctx.fillRect(0, 0, width, height) - - // Parallax mountains & castle silhouette - ctx.fillStyle = 'rgba(74, 35, 90, 0.45)' - for (let m = -1; m < 5; m++) { - const mx = m * 240 - (cameraX * 0.2) % 240 - ctx.beginPath() - ctx.moveTo(mx, 400) - ctx.lineTo(mx + 120, 200) - ctx.lineTo(mx + 240, 400) - ctx.fill() - } - - // Castle towers in background - ctx.fillStyle = 'rgba(44, 24, 16, 0.65)' - for (let c = -1; c < 4; c++) { - const cx = c * 320 - (cameraX * 0.35) % 320 - ctx.fillRect(cx + 60, 220, 50, 180) - ctx.beginPath() - ctx.moveTo(cx + 50, 220) - ctx.lineTo(cx + 85, 160) - ctx.lineTo(cx + 120, 220) - ctx.fill() + // 1. Background sky parallax + if (bgImg && bgImg.complete && bgImg.naturalWidth > 0) { + const scrollFactor = (cameraY / (levelHeight - H)) + const bgY = -scrollFactor * (bgImg.naturalHeight - H) + ctx.drawImage(bgImg, 0, bgY, W, bgImg.naturalHeight * (W / bgImg.naturalWidth)) + } else { + // Fallback sky gradient + const skyGrad = ctx.createLinearGradient(0, 0, 0, H) + skyGrad.addColorStop(0, '#0284c7') + skyGrad.addColorStop(0.5, '#38bdf8') + skyGrad.addColorStop(1, '#93c5fd') + ctx.fillStyle = skyGrad + ctx.fillRect(0, 0, W, H) } ctx.save() - ctx.translate(-cameraX, 0) + ctx.translate(0, -cameraY) - // Draw platforms + // 2. Platforms for (const plat of platforms) { - if (plat.type === 'roof') { - ctx.fillStyle = '#b91c1c' // Red roof tiles - ctx.fillRect(plat.x, plat.y, plat.w, plat.h) - // Roof trim - ctx.fillStyle = '#7f1d1d' - ctx.fillRect(plat.x, plat.y, plat.w, 4) - } else if (plat.type === 'wood') { - ctx.fillStyle = '#854d0e' // Timber wood platform - ctx.fillRect(plat.x, plat.y, plat.w, plat.h) - ctx.fillStyle = '#713f12' - ctx.fillRect(plat.x, plat.y, plat.w, 3) - // Support beams - ctx.fillRect(plat.x + 8, plat.y + plat.h, 6, 40) - ctx.fillRect(plat.x + plat.w - 14, plat.y + plat.h, 6, 40) - } else { - // Beer barrel - ctx.fillStyle = '#92400e' - ctx.beginPath() - ctx.ellipse(plat.x + plat.w / 2, plat.y + plat.h / 2, plat.w / 2, plat.h / 2, 0, 0, Math.PI * 2) - ctx.fill() - ctx.strokeStyle = '#fef08a' - ctx.lineWidth = 2 - ctx.stroke() - } - } + if (plat.y < cameraY - 40 || plat.y > cameraY + H + 40) continue - // Draw Items (Kronkorken & Beer) - const time = Date.now() * 0.005 - for (const item of items) { - if (item.collected) continue - const bob = Math.sin(time + item.bobOffset) * 5 - - if (item.type === 'cap') { - // Golden Kronkorken with serrated edge & crown - ctx.save() - ctx.translate(item.x, item.y + bob) + if (plat.y <= 80) { + // Golden Castle Goal Platform + ctx.fillStyle = '#b45309' + ctx.fillRect(plat.x - 4, plat.y, plat.w + 8, plat.h) ctx.fillStyle = '#ffd700' - ctx.beginPath() - ctx.arc(0, 0, 9, 0, Math.PI * 2) - ctx.fill() - ctx.strokeStyle = '#b45309' - ctx.lineWidth = 2 - ctx.stroke() + ctx.fillRect(plat.x, plat.y + 2, plat.w, plat.h - 4) - // Star / 40 embossing - ctx.fillStyle = '#78350f' - ctx.font = 'bold 8px monospace' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText('40', 0, 0) - ctx.restore() - } else { - // Helles Beer Mug with white foam - ctx.save() - ctx.translate(item.x, item.y + bob) - ctx.fillStyle = '#f59e0b' - ctx.fillRect(-6, -6, 12, 14) + // Sister sprite standing on top + const sisterX = plat.x + plat.w / 2 - 12 + const sisterY = plat.y - 36 + drawSisterSprite(ctx, sisterX, sisterY, currentLevel.value) + + // Speech bubble "Hilfe!" ctx.fillStyle = '#ffffff' - ctx.fillRect(-7, -9, 14, 4) // Foam - // Handle - ctx.strokeStyle = '#d97706' - ctx.lineWidth = 2 - ctx.strokeRect(6, -3, 4, 8) - ctx.restore() + ctx.beginPath() + ctx.roundRect(sisterX - 14, sisterY - 18, 52, 16, 4) + ctx.fill() + ctx.fillStyle = '#b91c1c' + ctx.font = 'bold 8px monospace' + ctx.fillText('👑 HILFE!', sisterX - 8, sisterY - 6) + continue + } + + if (plat.type === 'spring') { + // Platform body + ctx.fillStyle = '#78350f' + ctx.fillRect(plat.x, plat.y, plat.w, plat.h) + ctx.fillStyle = '#92400e' + ctx.fillRect(plat.x + 2, plat.y + 2, plat.w - 4, plat.h - 4) + + // Spring coil on top + const springX = plat.x + plat.w / 2 - 8 + ctx.fillStyle = '#fbbf24' + ctx.fillRect(springX, plat.y - 7, 16, 7) + ctx.fillStyle = '#d97706' + ctx.fillRect(springX + 2, plat.y - 5, 12, 3) + } else if (plat.type === 'cloud') { + // Cloud platform + ctx.fillStyle = '#e0f2fe' + ctx.beginPath() + ctx.roundRect(plat.x, plat.y, plat.w, plat.h, 7) + ctx.fill() + ctx.fillStyle = '#bae6fd' + ctx.fillRect(plat.x + 4, plat.y + plat.h - 3, plat.w - 8, 2) + } else { + // Wooden platform + ctx.fillStyle = '#451a03' + ctx.fillRect(plat.x, plat.y, plat.w, plat.h) + ctx.fillStyle = '#78350f' + ctx.fillRect(plat.x + 2, plat.y + 2, plat.w - 4, plat.h - 4) + // Hopfen/grass trim + ctx.fillStyle = '#15803d' + ctx.fillRect(plat.x, plat.y, plat.w, 3) + } + + // Collectible Item on platform + if (plat.hasItem && !plat.itemCollected) { + const itemX = plat.x + plat.w / 2 + const itemY = plat.y - 12 + if (plat.hasItem === 'cap') { + // Golden Kronkorken + ctx.fillStyle = '#ffd700' + ctx.beginPath() + ctx.arc(itemX, itemY, 6, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = '#b45309' + ctx.lineWidth = 1.5 + ctx.stroke() + ctx.fillStyle = '#b45309' + ctx.font = 'bold 6px monospace' + ctx.fillText('40', itemX - 4, itemY + 2) + } else { + // Helles Beer Mug + ctx.fillStyle = '#f59e0b' + ctx.fillRect(itemX - 5, itemY - 6, 10, 12) + // Foam + ctx.fillStyle = '#ffffff' + ctx.fillRect(itemX - 6, itemY - 9, 12, 4) + // Handle + ctx.strokeStyle = '#d97706' + ctx.lineWidth = 1.5 + ctx.strokeRect(itemX + 5, itemY - 3, 3, 7) + } } } - // Draw Particles + // 3. Particles particles.forEach(p => { ctx.fillStyle = p.color - ctx.fillRect(p.x, p.y, 3, 3) + ctx.globalAlpha = p.life / p.maxLife + ctx.beginPath() + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) + ctx.fill() }) + ctx.globalAlpha = 1.0 - // Draw Goal Gate: Taverne 'Zum wummernden Ritter' - ctx.fillStyle = '#3e2723' - ctx.fillRect(levelWidth - 140, 270, 110, 130) + // 4. Pixel King Sascha + drawPlayer(ctx) + + ctx.restore() + + // 5. HUD Progress Bar (Climb Altitude) + drawHUD(ctx) +} + +function drawPlayer(ctx: CanvasRenderingContext2D) { + ctx.save() + ctx.translate(player.x + player.w / 2, player.y + player.h / 2) + + // Stretch / Squash + const sy = player.squash + const sx = 2 - sy + ctx.scale(sx, sy) + + // Cape + ctx.fillStyle = '#b91c1c' + const capeWave = Math.sin(Date.now() / 120) * 4 + ctx.fillRect(-12, -8, 24, 26 + (player.vy < 0 ? -4 : capeWave)) + + // Body / Bavarian royal doublet + ctx.fillStyle = '#1e3a8a' + ctx.fillRect(-9, -12, 18, 22) + + // Gold trim ctx.fillStyle = '#ffd700' - ctx.font = 'bold 9px MedievalSharp, serif' - ctx.fillText('ZUM WUMMERNDEN RITTER', levelWidth - 135, 285) - // Glowing door - ctx.fillStyle = '#f59e0b' - ctx.fillRect(levelWidth - 105, 320, 40, 80) + ctx.fillRect(-9, 0, 18, 3) - // Draw Player: König Sascha (Pixel King with sunglasses, crown & ermine coat) - const px = player.x - const py = player.y - const legOffset = Math.sin(player.frame * 0.3) * 4 + // Head + ctx.fillStyle = '#fbcfe8' + ctx.fillRect(-7, -26, 14, 14) - // Red Royal Ermine Cape (waving behind) - ctx.fillStyle = '#dc2626' - ctx.beginPath() - ctx.moveTo(px + 6, py + 16) - ctx.lineTo(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 42) - ctx.lineTo(px + 14, py + 42) - ctx.fill() - // White ermine fur trim - ctx.fillStyle = '#f8fafc' - ctx.fillRect(px - 10 + Math.sin(player.frame * 0.25) * 5, py + 38, 24, 4) + // Brown beard & hair + ctx.fillStyle = '#78350f' + ctx.fillRect(-8, -18, 16, 7) + ctx.fillRect(-8, -26, 16, 4) - // Royal Blue Tunic & Body - ctx.fillStyle = '#1d4ed8' - ctx.fillRect(px + 6, py + 16, 20, 22) - ctx.fillStyle = '#eab308' // Gold Belt - ctx.fillRect(px + 6, py + 30, 20, 3) - - // Legs & Boots - ctx.fillStyle = '#451a03' - ctx.fillRect(px + 8, py + 38, 6, 10 + (player.isGrounded ? legOffset : 0)) - ctx.fillRect(px + 18, py + 38, 6, 10 - (player.isGrounded ? legOffset : 0)) - - // Face - ctx.fillStyle = '#fcd34d' - ctx.fillRect(px + 8, py + 4, 16, 14) - - // Iconic Black Sunglasses 🕶️ + // Sunglasses (iconic Sascha style) ctx.fillStyle = '#0f172a' - ctx.fillRect(px + 9, py + 8, 7, 4) - ctx.fillRect(px + 17, py + 8, 7, 4) - ctx.fillRect(px + 15, py + 9, 3, 2) // Bridge + const eyeX = player.facing === 'right' ? -2 : -6 + ctx.fillRect(eyeX, -22, 9, 4) + ctx.fillStyle = '#38bdf8' + ctx.fillRect(eyeX + 1, -22, 2, 2) - // Golden Crown 👑 - ctx.fillStyle = '#facc15' - ctx.beginPath() - ctx.moveTo(px + 6, py + 4) - ctx.lineTo(px + 6, py - 4) - ctx.lineTo(px + 11, py - 1) - ctx.lineTo(px + 16, py - 6) - ctx.lineTo(px + 21, py - 1) - ctx.lineTo(px + 26, py - 4) - ctx.lineTo(px + 26, py + 4) - ctx.fill() - ctx.fillStyle = '#dc2626' - ctx.fillRect(px + 15, py - 2, 2, 2) // Ruby - - // Scepter in Hand - ctx.strokeStyle = '#facc15' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.moveTo(px + 24, py + 18) - ctx.lineTo(px + 28, py + 34) - ctx.stroke() - // Kronkorken on top of scepter + // Golden Crown ctx.fillStyle = '#ffd700' - ctx.beginPath() - ctx.arc(px + 24, py + 16, 4, 0, Math.PI * 2) - ctx.fill() + ctx.fillRect(-8, -31, 16, 6) + // Crown points + ctx.fillRect(-8, -34, 3, 3) + ctx.fillRect(-2, -35, 4, 4) + ctx.fillRect(5, -34, 3, 3) - ctx.restore() // End camera translation + // Legs / boots + ctx.fillStyle = '#3b82f6' + ctx.fillRect(-8, 10, 6, 8) + ctx.fillRect(2, 10, 6, 8) + ctx.fillStyle = '#78350f' + ctx.fillRect(-9, 16, 7, 4) + ctx.fillRect(2, 16, 7, 4) + + ctx.restore() +} + +function drawSisterSprite(ctx: CanvasRenderingContext2D, x: number, y: number, level: number) { + // Dirndl color per sister + const dirndlColor = level === 1 ? '#f43f5e' : level === 2 ? '#8b5cf6' : '#06b6d4' + const hairColor = level === 1 ? '#fde047' : level === 2 ? '#78350f' : '#b45309' + + // Body + ctx.fillStyle = dirndlColor + ctx.fillRect(x + 2, y + 14, 18, 18) + // White apron + ctx.fillStyle = '#ffffff' + ctx.fillRect(x + 6, y + 18, 10, 14) + + // Head + ctx.fillStyle = '#fbcfe8' + ctx.fillRect(x + 4, y + 4, 14, 12) + + // Hair + ctx.fillStyle = hairColor + ctx.fillRect(x + 2, y, 18, 6) + ctx.fillRect(x + 1, y + 5, 4, 14) // Left braid + ctx.fillRect(x + 17, y + 5, 4, 14) // Right braid + + // Tiara / flowers + ctx.fillStyle = '#ffd700' + ctx.fillRect(x + 5, y - 2, 12, 3) + + // Waving arm + const wave = Math.sin(Date.now() / 150) * 4 + ctx.fillStyle = '#fbcfe8' + ctx.fillRect(x - 3, y + 6 + wave, 5, 5) +} + +function drawHUD(ctx: CanvasRenderingContext2D) { + // Top Banner + ctx.fillStyle = 'rgba(0, 0, 0, 0.65)' + ctx.fillRect(0, 0, W, 28) + + ctx.fillStyle = '#ffd700' + ctx.font = 'bold 8px monospace' + const sisterName = currentLevel.value === 1 ? 'SASKIA' : currentLevel.value === 2 ? 'MAJA' : 'MIRIAM' + ctx.fillText(`ETAPPE ${currentLevel.value}/3: RETTE ${sisterName}!`, 10, 17) + + // Climb progress meter + const progress = Math.min(1, Math.max(0, 1 - cameraY / (levelHeight - H))) + ctx.fillStyle = '#334155' + ctx.fillRect(W - 84, 8, 74, 12) + ctx.fillStyle = '#22c55e' + ctx.fillRect(W - 82, 10, 70 * progress, 8) + ctx.strokeStyle = '#ffd700' + ctx.strokeRect(W - 84, 8, 74, 12) } function gameLoop() { - if (!showLevelIntro.value) { - updatePhysics() - } + updatePhysics() if (canvasRef.value) { const ctx = canvasRef.value.getContext('2d') - if (ctx) { - drawGame(ctx, canvasRef.value.width, canvasRef.value.height) - } + if (ctx) drawGame(ctx) } animationFrameId = requestAnimationFrame(gameLoop) } -// Keyboard controls -function handleKeyDown(e: KeyboardEvent) { - if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = true - if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = true - if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { - onJump() - } -} - -function handleKeyUp(e: KeyboardEvent) { - if (e.code === 'ArrowRight' || e.code === 'KeyD') keys.right = false - if (e.code === 'ArrowLeft' || e.code === 'KeyA') keys.left = false -} - onMounted(() => { - initLevel() window.addEventListener('keydown', handleKeyDown) window.addEventListener('keyup', handleKeyUp) + initCurrentLevel() animationFrameId = requestAnimationFrame(gameLoop) }) onUnmounted(() => { window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keyup', handleKeyUp) - if (animationFrameId) { - cancelAnimationFrame(animationFrameId) - } + if (animationFrameId) cancelAnimationFrame(animationFrameId) }) diff --git a/projects/sasch40/frontend/src/components/PrologueView.vue b/projects/sasch40/frontend/src/components/PrologueView.vue index 9315c54..1a0fd8b 100644 --- a/projects/sasch40/frontend/src/components/PrologueView.vue +++ b/projects/sasch40/frontend/src/components/PrologueView.vue @@ -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[' @@ -479,7 +514,7 @@ function proceedToExpress() { @@ -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 🪩' }}
@@ -532,18 +567,18 @@ function proceedToExpress() { :class="{ active: currentRoom === 'hinterzimmer' }" @click="switchRoom('hinterzimmer')" > - 🪩 Hinterzimmer + 🪩 Party Bunker
-
@@ -586,6 +621,50 @@ function proceedToExpress() { + +
+
+
+ MINISPIEL INTRO + +
+

⚔️ BAYERISCHES GSTANZL-FECHTEN

+

+ 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! +

+
+
🎶 3 Runden: Wähle den treffendsten bayerischen Reim-Konter.
+
🏆 Belohnung: +15 Kronkorken & 1.500 Punkte für den Highscore!
+
+ +
+
+ + +
+
+
+ MINISPIEL INTRO + +
+

🐗 WILDSCHWEIN-SCHNICK-SCHNACK-SCHNUCK

+

+ Hof-Chronist NAT fordert König Sascha zum traditionellen Haumdaucher Dickicht-Duell heraus! Wer beherrscht die Taktik des Waldes? +

+
+
🐗 Keiler bricht durch 🌿 Dickicht
+
🌿 Dickicht fängt 🏹 Lanze ab
+
🏹 Lanze erlegt 🐗 Keiler
+
👑 Format: Best of 3 (wer zuerst 2 Siege holt!).
+
+ +
+
+
@@ -660,17 +739,17 @@ function proceedToExpress() {
- - -