// Broom flight mini-game: Winged Keys Chamber (Flitwick's Challenge) (function() { const canvas = document.getElementById('broomCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); // UI elements const scoreVal = document.getElementById('scoreVal'); const timerVal = document.getElementById('timerVal'); const statusMsg = document.getElementById('statusMsg'); const modalBackdrop = document.getElementById('modalBackdrop'); const modalTitle = document.getElementById('modalTitle'); const modalText = document.getElementById('modalText'); const modalBtn = document.getElementById('modalBtn'); let width, height; function resize() { width = canvas.parentElement.clientWidth; height = Math.min(520, window.innerHeight * 0.65); canvas.width = width; canvas.height = height; } window.addEventListener('resize', resize); resize(); // Game State let gameRunning = true; let startTime = Date.now(); let score = 0; let keyCaught = false; let doorUnlocked = false; // Player (Broomstick) const player = { x: 80, y: height / 2, vx: 0, vy: 0, speed: 5.5, boost: 1.0, angle: 0, trail: [] }; // Keys Swarm const keys = []; const NUM_KEYS = 24; // Master Key (Damaged wing silver key) const masterKey = { x: width - 150, y: height / 2, vx: -2, vy: 1.5, wingPhase: 0, caught: false, size: 16 }; // Target Door const door = { x: width - 60, y: height / 2 - 45, w: 45, h: 90 }; // Spawn decoy keys for (let i = 0; i < NUM_KEYS; i++) { keys.push({ x: Math.random() * (width - 150) + 100, y: Math.random() * (height - 60) + 30, vx: (Math.random() - 0.5) * 4, vy: (Math.random() - 0.5) * 3, size: Math.random() * 6 + 8, wingPhase: Math.random() * Math.PI * 2, wingSpeed: Math.random() * 0.3 + 0.2, gold: Math.random() > 0.4 }); } // Controls const keysDown = {}; window.addEventListener('keydown', e => { keysDown[e.key.toLowerCase()] = true; if (e.key === ' ' || e.key === 'Shift') player.boost = 1.6; }); window.addEventListener('keyup', e => { keysDown[e.key.toLowerCase()] = false; if (e.key === ' ' || e.key === 'Shift') player.boost = 1.0; }); // Pointer / Touch control let pointerTarget = null; canvas.addEventListener('pointermove', e => { const rect = canvas.getBoundingClientRect(); pointerTarget = { x: e.clientX - rect.left, y: e.clientY - rect.top }; }); canvas.addEventListener('pointerdown', e => { player.boost = 1.6; if (window.soundFX) window.soundFX.playWhoosh(); }); canvas.addEventListener('pointerup', () => { player.boost = 1.0; }); canvas.addEventListener('pointerleave', () => { pointerTarget = null; player.boost = 1.0; }); // Mobile buttons const btnUp = document.getElementById('mBtnUp'); const btnDown = document.getElementById('mBtnDown'); const btnBoost = document.getElementById('mBtnBoost'); if (btnUp) { btnUp.addEventListener('pointerdown', () => { keysDown['arrowup'] = true; }); btnUp.addEventListener('pointerup', () => { keysDown['arrowup'] = false; }); } if (btnDown) { btnDown.addEventListener('pointerdown', () => { keysDown['arrowdown'] = true; }); btnDown.addEventListener('pointerup', () => { keysDown['arrowdown'] = false; }); } if (btnBoost) { btnBoost.addEventListener('pointerdown', () => { player.boost = 1.8; if (window.soundFX) window.soundFX.playWhoosh(); }); btnBoost.addEventListener('pointerup', () => { player.boost = 1.0; }); } function update() { if (!gameRunning) return; const elapsedSec = Math.floor((Date.now() - startTime) / 1000); if (timerVal) timerVal.innerText = elapsedSec + 's'; // Keyboard Movement let ax = 0, ay = 0; if (keysDown['arrowup'] || keysDown['w']) ay -= 1; if (keysDown['arrowdown'] || keysDown['s']) ay += 1; if (keysDown['arrowleft'] || keysDown['a']) ax -= 1; if (keysDown['arrowright'] || keysDown['d']) ax += 1; // Pointer steer if (pointerTarget) { const dx = pointerTarget.x - player.x; const dy = pointerTarget.y - player.y; const dist = Math.hypot(dx, dy); if (dist > 15) { ax = dx / dist; ay = dy / dist; } } const curSpeed = player.speed * player.boost; player.vx = player.vx * 0.85 + ax * curSpeed * 0.2; player.vy = player.vy * 0.85 + ay * curSpeed * 0.2; player.x += player.vx; player.y += player.vy; // Bounds player.x = Math.max(30, Math.min(width - 70, player.x)); player.y = Math.max(30, Math.min(height - 30, player.y)); player.angle = Math.atan2(player.vy, player.vx + 2) * 0.5; // Particles trail player.trail.push({ x: player.x - 20, y: player.y, alpha: 1, size: Math.random() * 4 + 2 }); if (player.trail.length > 20) player.trail.shift(); player.trail.forEach(p => { p.alpha *= 0.9; p.x -= 2; }); // Update decoy keys keys.forEach(k => { k.wingPhase += k.wingSpeed; k.x += k.vx; k.y += k.vy; if (k.x < 50 || k.x > width - 100) k.vx *= -1; if (k.y < 30 || k.y > height - 30) k.vy *= -1; }); // Update Master Key if (!masterKey.caught) { masterKey.wingPhase += 0.35; // Evasive maneuver if player is close const dToPlayer = Math.hypot(player.x - masterKey.x, player.y - masterKey.y); if (dToPlayer < 140) { masterKey.vx += (masterKey.x - player.x) * 0.04; masterKey.vy += (masterKey.y - player.y) * 0.04; } else { masterKey.vx += (Math.random() - 0.5) * 0.8; masterKey.vy += (Math.random() - 0.5) * 0.8; } masterKey.vx = Math.max(-5, Math.min(5, masterKey.vx * 0.95)); masterKey.vy = Math.max(-4, Math.min(4, masterKey.vy * 0.95)); masterKey.x += masterKey.vx; masterKey.y += masterKey.vy; masterKey.x = Math.max(120, Math.min(width - 120, masterKey.x)); masterKey.y = Math.max(40, Math.min(height - 40, masterKey.y)); // Catch check if (dToPlayer < 35) { masterKey.caught = true; keyCaught = true; score += 500; if (scoreVal) scoreVal.innerText = score; if (window.soundFX) window.soundFX.playKeyChime(); if (window.notifyNewItem) { window.notifyNewItem({ icon: "🗝️", name: "Geknickter Silberschlüssel" }); } if (statusMsg) { statusMsg.innerHTML = "✨ Schlüssel gefangen & im Beutel! Fliege zur Holztür rechts und öffne das Schloss!"; statusMsg.style.color = "#2ecc71"; } } } else { // Master key follows player masterKey.x = player.x + 10; masterKey.y = player.y - 15; // Check if player reached the door const dToDoor = Math.hypot(player.x - (door.x + door.w / 2), player.y - (door.y + door.h / 2)); if (dToDoor < 55 && !doorUnlocked) { doorUnlocked = true; gameRunning = false; onGameWin(elapsedSec); } } } function render() { ctx.clearRect(0, 0, width, height); // Dungeon stone background & arches ctx.fillStyle = '#0f0e17'; ctx.fillRect(0, 0, width, height); // Stone pillars ctx.fillStyle = 'rgba(25, 22, 36, 0.7)'; for (let x = 120; x < width - 100; x += 180) { ctx.fillRect(x, 0, 35, height); ctx.strokeStyle = '#352e47'; ctx.lineWidth = 2; ctx.strokeRect(x, 0, 35, height); } // Vaulted arches at top ctx.strokeStyle = '#2d273d'; ctx.lineWidth = 4; ctx.beginPath(); for (let x = 0; x < width; x += 90) { ctx.moveTo(x, 0); ctx.quadraticCurveTo(x + 45, 60, x + 90, 0); } ctx.stroke(); // The Door ctx.fillStyle = doorUnlocked ? '#1f6f3e' : '#4a2f16'; ctx.fillRect(door.x, door.y, door.w, door.h); ctx.strokeStyle = '#e5b948'; ctx.lineWidth = 3; ctx.strokeRect(door.x, door.y, door.w, door.h); // Keyhole ctx.fillStyle = keyCaught ? '#ffd700' : '#111'; ctx.beginPath(); ctx.arc(door.x + door.w / 2, door.y + door.h / 2 - 6, 6, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(door.x + door.w / 2 - 3, door.y + door.h / 2 - 6, 6, 14); if (keyCaught) { ctx.shadowColor = '#ffd700'; ctx.shadowBlur = 15; } // Sparkle trail player.trail.forEach(p => { ctx.fillStyle = `rgba(229, 185, 72, ${p.alpha})`; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); }); ctx.shadowBlur = 0; // Decoy keys keys.forEach(k => { ctx.save(); ctx.translate(k.x, k.y); // wings const wingY = Math.sin(k.wingPhase) * (k.size * 0.7); ctx.fillStyle = k.gold ? 'rgba(255, 215, 0, 0.7)' : 'rgba(200, 220, 255, 0.65)'; ctx.beginPath(); ctx.ellipse(-k.size * 0.6, wingY, k.size * 0.8, k.size * 0.35, 0.3, 0, Math.PI * 2); ctx.ellipse(k.size * 0.6, wingY, k.size * 0.8, k.size * 0.35, -0.3, 0, Math.PI * 2); ctx.fill(); // Key body ctx.fillStyle = k.gold ? '#e5b948' : '#c0c5cc'; ctx.beginPath(); ctx.arc(0, 0, k.size * 0.35, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(-1.5, 0, 3, k.size * 1.1); ctx.fillRect(0, k.size * 0.7, k.size * 0.4, 2.5); ctx.restore(); }); // Master Key (Damaged Wing) ctx.save(); ctx.translate(masterKey.x, masterKey.y); const mWingY = Math.sin(masterKey.wingPhase) * (masterKey.size * 0.8); ctx.shadowColor = '#00ffff'; ctx.shadowBlur = masterKey.caught ? 5 : 12; // Right intact wing ctx.fillStyle = 'rgba(180, 225, 255, 0.85)'; ctx.beginPath(); ctx.ellipse(masterKey.size * 0.7, mWingY, masterKey.size * 0.9, masterKey.size * 0.4, -0.3, 0, Math.PI * 2); ctx.fill(); // Left BENT wing (as in the book!) ctx.fillStyle = 'rgba(140, 190, 230, 0.75)'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-masterKey.size * 0.5, mWingY * 0.6); ctx.lineTo(-masterKey.size * 0.9, mWingY * 0.3); // bent angle! ctx.lineTo(-masterKey.size * 0.4, mWingY * 0.2); ctx.closePath(); ctx.fill(); // Heavy antique key body ctx.fillStyle = '#e0e8f0'; ctx.beginPath(); ctx.arc(0, 0, masterKey.size * 0.4, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(-2, 0, 4, masterKey.size * 1.3); ctx.fillRect(0, masterKey.size * 0.8, masterKey.size * 0.5, 3); ctx.fillRect(0, masterKey.size * 1.05, masterKey.size * 0.4, 3); ctx.restore(); ctx.shadowBlur = 0; // Player (Broom & Wizard) ctx.save(); ctx.translate(player.x, player.y); ctx.rotate(player.angle); // Broomstick handle ctx.strokeStyle = '#5a3d1b'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(-35, 2); ctx.lineTo(30, -2); ctx.stroke(); // Broom twigs ctx.fillStyle = '#8a6229'; ctx.beginPath(); ctx.moveTo(-35, 2); ctx.lineTo(-55, -8); ctx.lineTo(-58, 12); ctx.closePath(); ctx.fill(); // Wizard silhouette (Cloak & Hat) ctx.fillStyle = '#1c172a'; ctx.beginPath(); ctx.arc(5, -12, 7, 0, Math.PI * 2); // head ctx.fill(); // Hat ctx.fillStyle = '#120e1e'; ctx.beginPath(); ctx.moveTo(-4, -13); ctx.lineTo(14, -13); ctx.lineTo(2, -26); ctx.closePath(); ctx.fill(); // Cloak fluttering ctx.fillStyle = '#7a1f28'; // Gryffindor red cloak ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.01) * 3); ctx.lineTo(-24, 6); ctx.lineTo(10, 0); ctx.closePath(); ctx.fill(); // Wand tip spark ctx.fillStyle = '#ffff99'; ctx.shadowColor = '#ffff00'; ctx.shadowBlur = 10; ctx.beginPath(); ctx.arc(24, -8, 3, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; ctx.restore(); } function loop() { update(); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop); async function onGameWin(timeTaken) { if (window.soundFX) window.soundFX.playMagicSuccess(); try { await fetch('/api/broom/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ score: score, time_taken: timeTaken }) }); } catch (e) { console.error(e); } modalTitle.innerText = "🏆 Raum 1 gemeistert!"; modalText.innerHTML = `Mit waghalsigem Flug hast du den großen Silberschlüssel mit dem geknickten Flügel erwischt und das schwere Schloss entriegelt!

Flugzeit: ${timeTaken} Sekunden
Punkte: ${score}

Die Tür schwingt knarrend auf... Vor dir lodern violette und schwarze Flammen in Snapes Prüfung!`; modalBtn.innerText = "Weiter zur Flammenkammer 🧪"; modalBtn.onclick = () => { window.location.href = '/bottles'; }; modalBackdrop.classList.add('show'); } })();