426 lines
14 KiB
JavaScript
Executable File
426 lines
14 KiB
JavaScript
Executable File
// 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 (statusMsg) {
|
|
statusMsg.innerHTML = "✨ <strong>Schlüssel gefangen!</strong> Fliege jetzt zur schweren 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!<br><br>
|
|
<strong>Flugzeit:</strong> ${timeTaken} Sekunden<br>
|
|
<strong>Punkte:</strong> ${score}<br><br>
|
|
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');
|
|
}
|
|
})();
|