Overhaul: Golden ring flight course and magic spell dueling mini-game with mobile gestures

This commit is contained in:
Maxi
2026-09-22 19:38:57 +02:00
parent efb54befcc
commit 0e20549a44
10 changed files with 831 additions and 329 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
venv/
__pycache__/ __pycache__/
*.pyc *.pyc
venv/
Binary file not shown.
+36 -47
View File
@@ -11,7 +11,7 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
app = FastAPI(title="Hogwarts Escape Adventure - Die Kammern des Steins der Weisen") app = FastAPI(title="Hogwarts Escape Adventure - Die Prüfungen der Zauberei")
SECRET_KEY = os.getenv("SECRET_KEY", "alohomora_bootle_potion_secret_2026") SECRET_KEY = os.getenv("SECRET_KEY", "alohomora_bootle_potion_secret_2026")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="escape_session") app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="escape_session")
@@ -36,7 +36,7 @@ ALL_ITEMS = {
"id": "wand", "id": "wand",
"name": "Zauberstab", "name": "Zauberstab",
"icon": "🪄", "icon": "🪄",
"desc": "Stechpalme und Phönixfeder, 11 Zoll. Liegt warm in deiner Hand.", "desc": "Stechpalme und Phönixfeder, 11 Zoll. Liegt warm und vibrationsbereit in deiner Hand.",
"action": "lumos", "action": "lumos",
"action_label": "Lumos / Licht 💡" "action_label": "Lumos / Licht 💡"
}, },
@@ -44,10 +44,18 @@ ALL_ITEMS = {
"id": "broom", "id": "broom",
"name": "Nimbus 2000", "name": "Nimbus 2000",
"icon": "🧹", "icon": "🧹",
"desc": "Ein pfeilschneller Rennbesen. Perfekt balanciert für Wendemanöver im Schlüsselraum.", "desc": "Ein pfeilschneller Rennbesen. Perfekt für den rasanten Flug durch schwebende Ringe!",
"action": "broom_polish", "action": "broom_polish",
"action_label": "Besen polieren ✨" "action_label": "Besen polieren ✨"
}, },
"duel_badge": {
"id": "duel_badge",
"name": "Duellanten-Abzeichen",
"icon": "⚡",
"desc": "Ein goldenes Abzeichen für meisterhafte Zauberstab-Gesten im Duellier-Club von Hogwarts.",
"action": "spark_wand",
"action_label": "Funkenregen sprühen ⚡"
},
"snape_scroll": { "snape_scroll": {
"id": "snape_scroll", "id": "snape_scroll",
"name": "Snapes Logik-Rolle", "name": "Snapes Logik-Rolle",
@@ -56,22 +64,6 @@ ALL_ITEMS = {
"action": "read_scroll", "action": "read_scroll",
"action_label": "Rätsel-Verse lesen 📖" "action_label": "Rätsel-Verse lesen 📖"
}, },
"silver_key": {
"id": "silver_key",
"name": "Geknickter Silberschlüssel",
"icon": "🗝️",
"desc": "Der schwere antike Schlüssel aus der Schlüsselkammer. Sein geknickter Flügel verrät ihn.",
"action": "examine_key",
"action_label": "Schloss prüfen 🔍"
},
"chess_knight": {
"id": "chess_knight",
"name": "Schwarzer Springer",
"icon": "♟️",
"desc": "Ein massiver schwarzer Springer aus McGonagalls verzaubertem Riesenschach. Hat das weiße Heer geschlagen!",
"action": "chess_piece",
"action_label": "Stein berühren ⚡"
},
"philosophers_stone": { "philosophers_stone": {
"id": "philosophers_stone", "id": "philosophers_stone",
"name": "Stein der Weisen", "name": "Stein der Weisen",
@@ -98,7 +90,7 @@ def get_session_state(request: Request) -> dict:
"broom_solved": False, "broom_solved": False,
"broom_score": 0, "broom_score": 0,
"broom_time": 0, "broom_time": 0,
"chess_solved": False, "duel_solved": False,
"bottles_solved": False, "bottles_solved": False,
"sliding_solved": False, "sliding_solved": False,
"started_at": datetime.datetime.utcnow().isoformat(), "started_at": datetime.datetime.utcnow().isoformat(),
@@ -109,16 +101,14 @@ def get_session_state(request: Request) -> dict:
state = request.session["escape"] state = request.session["escape"]
if "inventory" not in state: if "inventory" not in state:
state["inventory"] = ["wand", "broom", "snape_scroll"] state["inventory"] = ["wand", "broom", "snape_scroll"]
if state.get("broom_solved"): if state.get("duel_solved"):
state["inventory"].append("silver_key") state["inventory"].append("duel_badge")
if state.get("chess_solved"):
state["inventory"].append("chess_knight")
if state.get("bottles_solved"): if state.get("bottles_solved"):
state["inventory"].append("philosophers_stone") state["inventory"].append("philosophers_stone")
if "house" not in state: if "house" not in state:
state["house"] = "gryffindor" state["house"] = "gryffindor"
if "chess_solved" not in state: if "duel_solved" not in state:
state["chess_solved"] = False state["duel_solved"] = False
request.session["escape"] = state request.session["escape"] = state
return request.session["escape"] return request.session["escape"]
@@ -159,18 +149,23 @@ async def page_broom(request: Request):
context=get_template_context(request, state, lang) context=get_template_context(request, state, lang)
) )
@app.get("/chess", response_class=HTMLResponse) @app.get("/duel", response_class=HTMLResponse)
async def page_chess(request: Request): async def page_duel(request: Request):
state = get_session_state(request) state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de")) lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang state["lang"] = lang
request.session["escape"] = state request.session["escape"] = state
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="chess.html", name="duel.html",
context=get_template_context(request, state, lang) context=get_template_context(request, state, lang)
) )
@app.get("/chess")
async def redirect_chess():
# Graceful redirect for old links
return RedirectResponse(url="/duel", status_code=302)
@app.get("/bottles", response_class=HTMLResponse) @app.get("/bottles", response_class=HTMLResponse)
async def page_bottles(request: Request): async def page_bottles(request: Request):
state = get_session_state(request) state = get_session_state(request)
@@ -223,7 +218,7 @@ async def api_get_inventory(request: Request):
"house": house_info, "house": house_info,
"score": state.get("broom_score", 0), "score": state.get("broom_score", 0),
"broom_solved": state.get("broom_solved", False), "broom_solved": state.get("broom_solved", False),
"chess_solved": state.get("chess_solved", False), "duel_solved": state.get("duel_solved", False),
"bottles_solved": state.get("bottles_solved", False), "bottles_solved": state.get("bottles_solved", False),
"sliding_solved": state.get("sliding_solved", False) "sliding_solved": state.get("sliding_solved", False)
}) })
@@ -251,7 +246,7 @@ async def api_reset_state(request: Request):
"broom_solved": False, "broom_solved": False,
"broom_score": 0, "broom_score": 0,
"broom_time": 0, "broom_time": 0,
"chess_solved": False, "duel_solved": False,
"bottles_solved": False, "bottles_solved": False,
"sliding_solved": False, "sliding_solved": False,
"started_at": datetime.datetime.utcnow().isoformat(), "started_at": datetime.datetime.utcnow().isoformat(),
@@ -270,32 +265,26 @@ async def api_broom_complete(request: Request):
state["broom_solved"] = True state["broom_solved"] = True
state["broom_score"] = int(data.get("score", 100)) state["broom_score"] = int(data.get("score", 100))
state["broom_time"] = int(data.get("time_taken", 30)) state["broom_time"] = int(data.get("time_taken", 30))
# Add Silver Key to Inventory
if "inventory" not in state:
state["inventory"] = ["wand", "broom", "snape_scroll"]
if "silver_key" not in state["inventory"]:
state["inventory"].append("silver_key")
request.session["escape"] = state request.session["escape"] = state
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"message": "Der Schlüssel passt ins Schloss! Die Tür zu McGonagalls Zauberschach steht offen!", "message": "Parcours gemeistert! Die Tür zum Zauberspruch-Duell steht offen!",
"new_item": ALL_ITEMS["silver_key"],
"state": state "state": state
}) })
@app.post("/api/chess/complete") @app.post("/api/duel/complete")
async def api_chess_complete(request: Request): async def api_duel_complete(request: Request):
state = get_session_state(request) state = get_session_state(request)
state["chess_solved"] = True state["duel_solved"] = True
if "inventory" not in state: if "inventory" not in state:
state["inventory"] = ["wand", "broom", "snape_scroll"] state["inventory"] = ["wand", "broom", "snape_scroll"]
if "chess_knight" not in state["inventory"]: if "duel_badge" not in state["inventory"]:
state["inventory"].append("chess_knight") state["inventory"].append("duel_badge")
request.session["escape"] = state request.session["escape"] = state
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"message": "Schachmatt! Die weiße Königin senkt ihr Schwert. Der Weg zur Flammenkammer ist frei!", "message": "Zauberspruch-Duell gewonnen! Die Pforte zur Flammenkammer steht weit offen!",
"new_item": ALL_ITEMS["chess_knight"], "new_item": ALL_ITEMS["duel_badge"],
"state": state "state": state
}) })
@@ -318,7 +307,7 @@ async def api_bottles_drink(request: Request):
if content == "ahead": if content == "ahead":
if target_gate == "ahead": if target_gate == "ahead":
state["bottles_solved"] = True state["bottles_solved"] = True
if state.get("broom_solved"): if state.get("broom_solved") or state.get("duel_solved"):
state["completed_at"] = datetime.datetime.utcnow().isoformat() state["completed_at"] = datetime.datetime.utcnow().isoformat()
if "inventory" not in state: if "inventory" not in state:
state["inventory"] = ["wand", "broom", "snape_scroll"] state["inventory"] = ["wand", "broom", "snape_scroll"]
@@ -349,7 +338,7 @@ async def api_bottles_drink(request: Request):
"result": "success_back", "result": "success_back",
"content": content, "content": content,
"message": msg_de if lang == "de" else msg_en, "message": msg_de if lang == "de" else msg_en,
"next_url": "/chess" "next_url": "/duel"
}) })
else: else:
msg_de = "Dieser Trank schützt vor den violetten Flammen, führt dich aber nicht vorwärts durch das schwarze Feuer!" msg_de = "Dieser Trank schützt vor den violetten Flammen, führt dich aber nicht vorwärts durch das schwarze Feuer!"
+241 -232
View File
@@ -1,4 +1,4 @@
// Broom flight mini-game: Winged Keys Chamber (Flitwick's Challenge) - Mobile & Touch Optimized // Broomstick Flight: Golden Ring Course (Quidditch-Flugtraining) - Mobile & Touch Optimized
(function() { (function() {
const canvas = document.getElementById('broomCanvas'); const canvas = document.getElementById('broomCanvas');
if (!canvas) return; if (!canvas) return;
@@ -7,88 +7,90 @@
// UI elements // UI elements
const scoreVal = document.getElementById('scoreVal'); const scoreVal = document.getElementById('scoreVal');
const timerVal = document.getElementById('timerVal'); const timerVal = document.getElementById('timerVal');
const ringsVal = document.getElementById('ringsVal');
const comboVal = document.getElementById('comboVal');
const statusMsg = document.getElementById('statusMsg'); const statusMsg = document.getElementById('statusMsg');
const modalBackdrop = document.getElementById('modalBackdrop'); const modalBackdrop = document.getElementById('modalBackdrop');
const modalTitle = document.getElementById('modalTitle'); const modalTitle = document.getElementById('modalTitle');
const modalText = document.getElementById('modalText'); const modalText = document.getElementById('modalText');
const modalBtn = document.getElementById('modalBtn'); const modalBtn = document.getElementById('modalBtn');
let width = 800, height = 480; let width = 800, height = 460;
function resize() { function resize() {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
width = Math.round(rect.width || canvas.parentElement.clientWidth || 800); width = Math.round(rect.width || canvas.parentElement.clientWidth || 800);
height = Math.round(Math.min(520, Math.max(300, width * 0.58))); height = Math.round(Math.min(500, Math.max(280, width * 0.56)));
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
canvas.style.height = height + 'px'; canvas.style.height = height + 'px';
if (door) {
door.x = width - 55;
door.y = height / 2 - 45;
}
} }
// Target Door // Game Configuration
const door = { const TOTAL_RINGS_NEEDED = 12;
x: width - 55,
y: height / 2 - 45,
w: 42,
h: 90
};
// Game State
let gameRunning = true; let gameRunning = true;
let startTime = Date.now(); let startTime = Date.now();
let score = 0; let score = 0;
let keyCaught = false; let ringsCollected = 0;
let doorUnlocked = false; let combo = 0;
// Player (Broomstick) // Player (Broomstick)
const player = { const player = {
x: 80, x: 90,
y: 200, y: 220,
targetY: 220,
targetX: 90,
vx: 0, vx: 0,
vy: 0, vy: 0,
speed: 6.0, speed: 6.5,
boost: 1.0, boost: 1.0,
angle: 0, angle: 0,
trail: [] trail: []
}; };
// Keys Swarm // Flying Golden Rings
const keys = []; const rings = [];
const NUM_KEYS = 22; let lastRingSpawn = Date.now();
// Master Key (Damaged wing silver key) function spawnRing() {
const masterKey = { // Rings glide from right to left
x: 600, const ringY = Math.random() * (height - 120) + 60;
y: 200, const isGoldenBonus = Math.random() > 0.75;
vx: -2.5, rings.push({
vy: 1.5, x: width + 40,
wingPhase: 0, y: ringY,
caught: false, radius: isGoldenBonus ? 28 : 24,
size: 17 speed: (Math.random() * 1.5 + 3.2),
}; golden: isGoldenBonus,
collected: false,
// Initialize Canvas Size scale: 1.0,
window.addEventListener('resize', resize); alpha: 1.0,
resize(); pulse: 0
// 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
}); });
} }
// Keyboard Controls // Sparkles & Confetti particles
const particles = [];
function spawnSparkles(x, y, color) {
for (let i = 0; i < 18; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = Math.random() * 5 + 2;
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
color: color || '#ffd700',
size: Math.random() * 4 + 2,
alpha: 1.0
});
}
}
// Initialize Canvas
window.addEventListener('resize', resize);
resize();
// Controls: Keyboard
const keysDown = {}; const keysDown = {};
window.addEventListener('keydown', e => { window.addEventListener('keydown', e => {
keysDown[e.key.toLowerCase()] = true; keysDown[e.key.toLowerCase()] = true;
@@ -102,8 +104,8 @@
if (e.key === ' ' || e.key === 'Shift') player.boost = 1.0; if (e.key === ' ' || e.key === 'Shift') player.boost = 1.0;
}); });
// Touch & Pointer Steering // Touch & Pointer Direct Steering (Smooth Follow-Touch)
let pointerTarget = null; let touchSteering = false;
function getCanvasCoords(clientX, clientY) { function getCanvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -113,11 +115,13 @@
}; };
} }
// Direct Touch Listeners to prevent browser scrolling
canvas.addEventListener('touchstart', e => { canvas.addEventListener('touchstart', e => {
e.preventDefault(); e.preventDefault();
touchSteering = true;
if (e.touches.length > 0) { if (e.touches.length > 0) {
pointerTarget = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); const coords = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY);
player.targetX = coords.x;
player.targetY = coords.y;
player.boost = 1.4; player.boost = 1.4;
if (window.soundFX) window.soundFX.playWhoosh(); if (window.soundFX) window.soundFX.playWhoosh();
} }
@@ -126,31 +130,38 @@
canvas.addEventListener('touchmove', e => { canvas.addEventListener('touchmove', e => {
e.preventDefault(); e.preventDefault();
if (e.touches.length > 0) { if (e.touches.length > 0) {
pointerTarget = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); const coords = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY);
player.targetX = coords.x;
player.targetY = coords.y;
} }
}, { passive: false }); }, { passive: false });
canvas.addEventListener('touchend', e => { canvas.addEventListener('touchend', e => {
e.preventDefault(); e.preventDefault();
pointerTarget = null; touchSteering = false;
player.boost = 1.0; player.boost = 1.0;
}, { passive: false }); }, { passive: false });
// Pointer events for mouse // Pointer events for Desktop Mouse
canvas.addEventListener('pointerdown', e => { canvas.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') { if (e.pointerType === 'mouse') {
pointerTarget = getCanvasCoords(e.clientX, e.clientY); touchSteering = true;
const coords = getCanvasCoords(e.clientX, e.clientY);
player.targetX = coords.x;
player.targetY = coords.y;
player.boost = 1.5; player.boost = 1.5;
if (window.soundFX) window.soundFX.playWhoosh(); if (window.soundFX) window.soundFX.playWhoosh();
} }
}); });
canvas.addEventListener('pointermove', e => { canvas.addEventListener('pointermove', e => {
if (e.pointerType === 'mouse' && (e.buttons > 0 || pointerTarget)) { if (touchSteering) {
pointerTarget = getCanvasCoords(e.clientX, e.clientY); const coords = getCanvasCoords(e.clientX, e.clientY);
player.targetX = coords.x;
player.targetY = coords.y;
} }
}); });
canvas.addEventListener('pointerup', () => { canvas.addEventListener('pointerup', () => {
pointerTarget = null; touchSteering = false;
player.boost = 1.0; player.boost = 1.0;
}); });
@@ -190,103 +201,115 @@
btnBoost.addEventListener('pointerleave', endBoost); btnBoost.addEventListener('pointerleave', endBoost);
} }
// Pre-spawn 3 rings
rings.push({ x: width + 50, y: height * 0.4, radius: 25, speed: 3.5, golden: false, collected: false, scale: 1, alpha: 1, pulse: 0 });
rings.push({ x: width + 280, y: height * 0.65, radius: 25, speed: 3.6, golden: true, collected: false, scale: 1, alpha: 1, pulse: 0 });
rings.push({ x: width + 520, y: height * 0.3, radius: 25, speed: 3.7, golden: false, collected: false, scale: 1, alpha: 1, pulse: 0 });
function update() { function update() {
if (!gameRunning) return; if (!gameRunning) return;
const elapsedSec = Math.floor((Date.now() - startTime) / 1000); const elapsedSec = Math.floor((Date.now() - startTime) / 1000);
if (timerVal) timerVal.innerText = elapsedSec + 's'; if (timerVal) timerVal.innerText = elapsedSec + 's';
// Keyboard & D-Pad Movement // Spawn new rings continuously
if (Date.now() - lastRingSpawn > 1400) {
spawnRing();
lastRingSpawn = Date.now();
}
// Steer player
let ax = 0, ay = 0; let ax = 0, ay = 0;
if (keysDown['arrowup'] || keysDown['w']) ay -= 1; if (keysDown['arrowup'] || keysDown['w']) ay -= 1;
if (keysDown['arrowdown'] || keysDown['s']) ay += 1; if (keysDown['arrowdown'] || keysDown['s']) ay += 1;
if (keysDown['arrowleft'] || keysDown['a']) ax -= 1; if (keysDown['arrowleft'] || keysDown['a']) ax -= 1;
if (keysDown['arrowright'] || keysDown['d']) ax += 1; if (keysDown['arrowright'] || keysDown['d']) ax += 1;
// Pointer steer (Touch / Mouse direct following) if (touchSteering) {
if (pointerTarget) { const dx = player.targetX - player.x;
const dx = pointerTarget.x - player.x; const dy = player.targetY - player.y;
const dy = pointerTarget.y - player.y;
const dist = Math.hypot(dx, dy); const dist = Math.hypot(dx, dy);
if (dist > 12) { if (dist > 8) {
ax = dx / dist; ax = dx / dist;
ay = dy / dist; ay = dy / dist;
} }
} }
const curSpeed = player.speed * player.boost; const curSpeed = player.speed * player.boost;
player.vx = player.vx * 0.82 + ax * curSpeed * 0.22; player.vx = player.vx * 0.82 + ax * curSpeed * 0.24;
player.vy = player.vy * 0.82 + ay * curSpeed * 0.22; player.vy = player.vy * 0.82 + ay * curSpeed * 0.24;
player.x += player.vx; player.x += player.vx;
player.y += player.vy; player.y += player.vy;
// Bounds // Keep inside bounds
player.x = Math.max(30, Math.min(width - 65, player.x)); player.x = Math.max(35, Math.min(width - 55, player.x));
player.y = Math.max(30, Math.min(height - 30, player.y)); player.y = Math.max(30, Math.min(height - 35, player.y));
player.angle = Math.atan2(player.vy, player.vx + 2) * 0.55; player.angle = Math.atan2(player.vy, player.vx + 3) * 0.5;
// Particles trail // Particle trail from broom tail
player.trail.push({ x: player.x - 22, y: player.y, alpha: 1, size: Math.random() * 4 + 2 }); player.trail.push({ x: player.x - 24, y: player.y, alpha: 1, size: Math.random() * 4 + 2 });
if (player.trail.length > 22) player.trail.shift(); if (player.trail.length > 20) player.trail.shift();
player.trail.forEach(p => { p.alpha *= 0.88; p.x -= 2; }); player.trail.forEach(p => { p.alpha *= 0.88; p.x -= 2; });
// Update decoy keys // Update rings
keys.forEach(k => { for (let i = rings.length - 1; i >= 0; i--) {
k.wingPhase += k.wingSpeed; const r = rings[i];
k.x += k.vx; r.x -= r.speed * (player.boost > 1.2 ? 1.4 : 1.0);
k.y += k.vy; r.pulse += 0.08;
if (k.x < 40 || k.x > width - 90) k.vx *= -1;
if (k.y < 30 || k.y > height - 30) k.vy *= -1;
});
// Update Master Key if (!r.collected) {
if (!masterKey.caught) { // Check if player flew through ring
masterKey.wingPhase += 0.35; const dist = Math.hypot(player.x - r.x, player.y - r.y);
const dToPlayer = Math.hypot(player.x - masterKey.x, player.y - masterKey.y); if (dist < r.radius + 18) {
if (dToPlayer < 150) { r.collected = true;
masterKey.vx += (masterKey.x - player.x) * 0.045; ringsCollected++;
masterKey.vy += (masterKey.y - player.y) * 0.045; combo++;
const ringPts = (r.golden ? 300 : 150) * Math.min(4, combo);
score += ringPts;
if (scoreVal) scoreVal.innerText = score;
if (ringsVal) ringsVal.innerText = `${ringsCollected}/${TOTAL_RINGS_NEEDED}`;
if (comboVal) comboVal.innerText = combo > 1 ? `x${combo} Combo!` : '';
if (window.soundFX) window.soundFX.playKeyChime();
spawnSparkles(r.x, r.y, r.golden ? '#ffd700' : '#00ffff');
// Check Course Win
if (ringsCollected >= TOTAL_RINGS_NEEDED) {
gameRunning = false;
onCourseWin(elapsedSec);
}
}
} else { } else {
masterKey.vx += (Math.random() - 0.5) * 0.9; // Animation after collection
masterKey.vy += (Math.random() - 0.5) * 0.9; r.scale += 0.08;
} r.alpha -= 0.08;
masterKey.vx = Math.max(-5.5, Math.min(5.5, masterKey.vx * 0.95)); if (r.alpha <= 0) {
masterKey.vy = Math.max(-4.5, Math.min(4.5, masterKey.vy * 0.95)); rings.splice(i, 1);
continue;
masterKey.x += masterKey.vx;
masterKey.y += masterKey.vy;
masterKey.x = Math.max(90, Math.min(width - 100, masterKey.x));
masterKey.y = Math.max(40, Math.min(height - 40, masterKey.y));
// Catch check (generous hit box for mobile thumbs: 42px)
if (dToPlayer < 42) {
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 = "✨ <strong>Schlüssel gefangen & im Beutel!</strong> Fliege jetzt zur schweren Holztür rechts!";
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 // Remove rings that flew past screen
const dToDoor = Math.hypot(player.x - (door.x + door.w / 2), player.y - (door.y + door.h / 2)); if (r.x < -60) {
if (dToDoor < 60 && !doorUnlocked) { if (!r.collected) {
doorUnlocked = true; combo = 0; // Combo reset on miss
gameRunning = false; if (comboVal) comboVal.innerText = '';
onGameWin(elapsedSec); }
rings.splice(i, 1);
}
}
// Update sparkles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha *= 0.92;
if (p.alpha <= 0.02) {
particles.splice(i, 1);
} }
} }
} }
@@ -294,121 +317,100 @@
function render() { function render() {
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
// Dungeon stone background & arches // Night sky gradient with Hogwarts towers silhouettes
ctx.fillStyle = '#0f0e17'; const skyGrad = ctx.createLinearGradient(0, 0, 0, height);
skyGrad.addColorStop(0, '#0c0b17');
skyGrad.addColorStop(1, '#1b142e');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Stone pillars // Stars
ctx.fillStyle = 'rgba(25, 22, 36, 0.7)'; ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
for (let x = 110; x < width - 90; x += 160) { for (let i = 0; i < 25; i++) {
ctx.fillRect(x, 0, 30, height); const sx = (i * 37 + (Date.now() * 0.02)) % width;
ctx.strokeStyle = '#352e47'; const sy = (i * 53) % (height * 0.65);
ctx.lineWidth = 2; ctx.fillRect(sx, sy, 1.5, 1.5);
ctx.strokeRect(x, 0, 30, height);
} }
// Vaulted arches at top // Hogwarts distant castle spires in background
ctx.strokeStyle = '#2d273d'; ctx.fillStyle = 'rgba(15, 12, 25, 0.85)';
ctx.lineWidth = 3;
ctx.beginPath(); ctx.beginPath();
for (let x = 0; x < width; x += 80) { ctx.moveTo(0, height);
ctx.moveTo(x, 0); ctx.lineTo(80, height - 90);
ctx.quadraticCurveTo(x + 40, 50, x + 80, 0); ctx.lineTo(120, height - 160);
} ctx.lineTo(160, height - 90);
ctx.stroke(); ctx.lineTo(240, height - 120);
ctx.lineTo(310, height - 190);
// The Door ctx.lineTo(380, height - 100);
ctx.fillStyle = doorUnlocked ? '#1f6f3e' : '#4a2f16'; ctx.lineTo(460, height - 140);
ctx.fillRect(door.x, door.y, door.w, door.h); ctx.lineTo(550, height - 80);
ctx.strokeStyle = '#e5b948'; ctx.lineTo(650, height - 180);
ctx.lineWidth = 3; ctx.lineTo(750, height - 110);
ctx.strokeRect(door.x, door.y, door.w, door.h); ctx.lineTo(width, height - 90);
ctx.lineTo(width, height);
// Keyhole ctx.closePath();
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.fill();
ctx.fillRect(door.x + door.w / 2 - 3, door.y + door.h / 2 - 6, 6, 14);
if (keyCaught) { // Sparkle trail behind broom
ctx.shadowColor = '#ffd700';
ctx.shadowBlur = 15;
}
// Sparkle trail
player.trail.forEach(p => { player.trail.forEach(p => {
ctx.fillStyle = `rgba(229, 185, 72, ${p.alpha})`; ctx.fillStyle = `rgba(229, 185, 72, ${p.alpha})`;
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
}); });
ctx.shadowBlur = 0;
// Touch target indicator // Touch steer indicator
if (pointerTarget && gameRunning) { if (touchSteering && gameRunning) {
ctx.strokeStyle = 'rgba(229, 185, 72, 0.5)'; ctx.strokeStyle = 'rgba(229, 185, 72, 0.45)';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.arc(pointerTarget.x, pointerTarget.y, 20 + Math.sin(Date.now() * 0.01) * 4, 0, Math.PI * 2); ctx.arc(player.targetX, player.targetY, 22 + Math.sin(Date.now() * 0.01) * 3, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
} }
// Decoy keys // Render Golden Rings
keys.forEach(k => { rings.forEach(r => {
ctx.save(); ctx.save();
ctx.translate(k.x, k.y); ctx.translate(r.x, r.y);
const wingY = Math.sin(k.wingPhase) * (k.size * 0.7); ctx.scale(r.scale, r.scale);
ctx.fillStyle = k.gold ? 'rgba(255, 215, 0, 0.7)' : 'rgba(200, 220, 255, 0.65)'; ctx.globalAlpha = r.alpha;
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 // Outer golden glow
ctx.fillStyle = k.gold ? '#e5b948' : '#c0c5cc'; ctx.shadowColor = r.golden ? '#ffd700' : '#4cc9f0';
ctx.shadowBlur = r.golden ? 18 : 12;
// Ring Outer Circle
ctx.strokeStyle = r.golden ? '#ffd700' : '#7209b7';
ctx.lineWidth = r.golden ? 5 : 4;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, k.size * 0.35, 0, Math.PI * 2); ctx.arc(0, 0, r.radius, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillRect(-1.5, 0, 3, k.size * 1.1);
ctx.fillRect(0, k.size * 0.7, k.size * 0.4, 2.5); // Inner Ring Highlights
ctx.strokeStyle = r.golden ? '#fff275' : '#4895ef';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, r.radius - 3, 0, Math.PI * 2);
ctx.stroke();
// Ring Mount / Pole at bottom
ctx.fillStyle = '#6b5338';
ctx.fillRect(-2, r.radius, 4, 30);
ctx.restore(); 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 ? 6 : 14;
// 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
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);
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; ctx.shadowBlur = 0;
// Render Sparkles
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.globalAlpha = p.alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1.0;
// Player (Broom & Wizard) // Player (Broom & Wizard)
ctx.save(); ctx.save();
ctx.translate(player.x, player.y); ctx.translate(player.x, player.y);
@@ -444,11 +446,11 @@
ctx.lineTo(2, -26); ctx.lineTo(2, -26);
ctx.closePath(); ctx.closePath();
ctx.fill(); ctx.fill();
// Cloak fluttering // Gryffindor / House cloak fluttering
ctx.fillStyle = '#7a1f28'; ctx.fillStyle = '#800e13';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, -6); ctx.moveTo(0, -6);
ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.01) * 3); ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.012) * 3);
ctx.lineTo(-24, 6); ctx.lineTo(-24, 6);
ctx.lineTo(10, 0); ctx.lineTo(10, 0);
ctx.closePath(); ctx.closePath();
@@ -457,9 +459,9 @@
// Wand tip spark // Wand tip spark
ctx.fillStyle = '#ffff99'; ctx.fillStyle = '#ffff99';
ctx.shadowColor = '#ffff00'; ctx.shadowColor = '#ffff00';
ctx.shadowBlur = 10; ctx.shadowBlur = 12;
ctx.beginPath(); ctx.beginPath();
ctx.arc(24, -8, 3, 0, Math.PI * 2); ctx.arc(24, -8, 3.5, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
@@ -473,25 +475,32 @@
} }
requestAnimationFrame(loop); requestAnimationFrame(loop);
async function onGameWin(timeTaken) { async function onCourseWin(elapsedSec) {
if (window.soundFX) window.soundFX.playMagicSuccess(); if (window.soundFX) window.soundFX.playMagicSuccess();
try { try {
await fetch('/api/broom/complete', { const res = await fetch('/api/broom/complete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ score: score, time_taken: timeTaken }) body: JSON.stringify({ score: score, time_taken: elapsedSec })
}); });
const data = await res.json();
if (data.new_item && window.notifyNewItem) {
window.notifyNewItem(data.new_item);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
modalTitle.innerText = "🏆 Raum 1 gemeistert!"; modalTitle.innerText = "🏆 Ring-Parcours 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> modalText.innerHTML = `
<strong>Flugzeit:</strong> ${timeTaken} Sekunden<br> Mit atemberaubenden Manövern hast du alle <strong>${TOTAL_RINGS_NEEDED} goldenen Ringe</strong> durchflogen!<br><br>
<strong>Punkte:</strong> ${score}<br><br> <strong>Flugzeit:</strong> ${elapsedSec} Sekunden<br>
Die Tür schwingt knarrend auf... Vor dir liegt McGonagalls gigantisches Zauberschach!`; <strong>Punkte:</strong> ${score}<br><br>
modalBtn.innerText = "Weiter zu McGonagalls Zauberschach ♟️"; Madam Hooch nickt anerkennend und überreicht dir die Flug-Auszeichnung!<br>
modalBtn.onclick = () => { window.location.href = '/chess'; }; Das Tor zur nächsten Kammer steht offen!
`;
modalBtn.innerText = "Weiter zum Zauberspruch-Duell 🪄";
modalBtn.onclick = () => { window.location.href = '/duel'; };
modalBackdrop.classList.add('show'); modalBackdrop.classList.add('show');
} }
})(); })();
+454
View File
@@ -0,0 +1,454 @@
// Wand Spell Tracing & Dueling Mini-Game (Das Zauberspruch-Duell)
(function() {
const canvas = document.getElementById('spellCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const spellNameEl = document.getElementById('spellName');
const spellDescEl = document.getElementById('spellDesc');
const spellProgressEl = document.getElementById('spellProgress');
const dummyHpEl = document.getElementById('dummyHp');
const modalBackdrop = document.getElementById('modalBackdrop');
const modalTitle = document.getElementById('modalTitle');
const modalText = document.getElementById('modalText');
const modalBtn = document.getElementById('modalBtn');
let width = 700, height = 440;
function resize() {
const rect = canvas.getBoundingClientRect();
width = Math.round(rect.width || canvas.parentElement.clientWidth || 700);
height = Math.round(Math.min(480, Math.max(280, width * 0.62)));
canvas.width = width;
canvas.height = height;
canvas.style.height = height + 'px';
setupSpellNodes();
}
// 3 Sequential Spells with distinct gestures
const SPELLS = [
{
id: 'flipendo',
name: 'Flipendo ⚡',
incantation: '"Flipendo!" (Ruck-Schockzauber)',
desc: 'Verbinde die 4 Runensterne im Zickzack-Muster, um den Schockzauber zu entfesseln!',
color: '#00f2fe',
beamColor: '#4facfe',
// Relative coordinates (0 to 1) for responsive scaling
points: [
{ x: 0.22, y: 0.28 },
{ x: 0.52, y: 0.28 },
{ x: 0.25, y: 0.72 },
{ x: 0.55, y: 0.72 }
]
},
{
id: 'incendio',
name: 'Incendio 🔥',
incantation: '"Incendio!" (Feuer-Zauber)',
desc: 'Zeichne das lodernde Flammendreieck, um die Zielattrappe in Brand zu setzen!',
color: '#ff4500',
beamColor: '#ff8c00',
points: [
{ x: 0.38, y: 0.22 },
{ x: 0.55, y: 0.74 },
{ x: 0.22, y: 0.74 },
{ x: 0.38, y: 0.22 }
]
},
{
id: 'alohomora',
name: 'Alohomora 🗝️',
incantation: '"Alohomora!" (Entriegelungszauber)',
desc: 'Zeichne die magische Schlüsselloch-Kurve, um das verzauberte Siegel zu sprengen!',
color: '#ffd700',
beamColor: '#fff275',
points: [
{ x: 0.38, y: 0.25 },
{ x: 0.48, y: 0.38 },
{ x: 0.42, y: 0.52 },
{ x: 0.42, y: 0.78 }
]
}
];
let currentSpellIdx = 0;
let spellNodes = [];
let nextNodeIdx = 0;
let isDrawing = false;
let userPath = [];
let wandParticles = [];
let blastParticles = [];
let dummyShake = 0;
let spellCastAnim = 0;
let gameWon = false;
function setupSpellNodes() {
const spell = SPELLS[currentSpellIdx];
if (!spell) return;
spellNodes = spell.points.map((pt, idx) => ({
x: Math.round(pt.x * width),
y: Math.round(pt.y * height),
radius: 20,
hit: idx < nextNodeIdx
}));
}
window.addEventListener('resize', resize);
resize();
function updateSpellUI() {
const spell = SPELLS[currentSpellIdx];
if (!spell) return;
if (spellNameEl) spellNameEl.innerText = spell.name;
if (spellDescEl) spellDescEl.innerText = spell.desc;
if (spellProgressEl) spellProgressEl.innerText = `Zauberspruch ${currentSpellIdx + 1} von ${SPELLS.length}`;
if (dummyHpEl) dummyHpEl.innerText = `${SPELLS.length - currentSpellIdx} Zauber übrig`;
setupSpellNodes();
}
updateSpellUI();
// Touch & Pointer Coordinates
function getCanvasCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
return {
x: (clientX - rect.left) * (canvas.width / rect.width),
y: (clientY - rect.top) * (canvas.height / rect.height)
};
}
function addWandSpark(x, y, color) {
for (let i = 0; i < 4; i++) {
wandParticles.push({
x: x,
y: y,
vx: (Math.random() - 0.5) * 3,
vy: (Math.random() - 0.5) * 3,
color: color || '#fff',
size: Math.random() * 4 + 2,
alpha: 1.0
});
}
}
function handleInputStart(x, y) {
if (gameWon) return;
isDrawing = true;
userPath = [{ x, y }];
checkNodeHit(x, y);
}
function handleInputMove(x, y) {
if (!isDrawing || gameWon) return;
userPath.push({ x, y });
addWandSpark(x, y, SPELLS[currentSpellIdx].color);
checkNodeHit(x, y);
}
function handleInputEnd() {
isDrawing = false;
// If not all nodes hit, reset progress for this spell
if (nextNodeIdx < spellNodes.length) {
nextNodeIdx = 0;
userPath = [];
setupSpellNodes();
}
}
function checkNodeHit(x, y) {
if (nextNodeIdx >= spellNodes.length) return;
const targetNode = spellNodes[nextNodeIdx];
const dist = Math.hypot(x - targetNode.x, y - targetNode.y);
// Tolerant hit radius for easy finger play: 36px
if (dist < 36) {
targetNode.hit = true;
nextNodeIdx++;
if (window.soundFX) window.soundFX.playKeyChime();
addWandSpark(targetNode.x, targetNode.y, '#ffd700');
// All nodes connected for this spell!
if (nextNodeIdx >= spellNodes.length) {
castSpellSuccess();
}
}
}
// Touch events with touch-action none
canvas.addEventListener('touchstart', e => {
e.preventDefault();
if (e.touches.length > 0) {
const p = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY);
handleInputStart(p.x, p.y);
}
}, { passive: false });
canvas.addEventListener('touchmove', e => {
e.preventDefault();
if (e.touches.length > 0) {
const p = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY);
handleInputMove(p.x, p.y);
}
}, { passive: false });
canvas.addEventListener('touchend', e => {
e.preventDefault();
handleInputEnd();
}, { passive: false });
// Pointer events for desktop
canvas.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') {
const p = getCanvasCoords(e.clientX, e.clientY);
handleInputStart(p.x, p.y);
}
});
canvas.addEventListener('pointermove', e => {
if (e.pointerType === 'mouse' && isDrawing) {
const p = getCanvasCoords(e.clientX, e.clientY);
handleInputMove(p.x, p.y);
}
});
canvas.addEventListener('pointerup', () => {
handleInputEnd();
});
function castSpellSuccess() {
const spell = SPELLS[currentSpellIdx];
spellCastAnim = 1.0;
dummyShake = 18;
if (window.soundFX) window.soundFX.playMagicSuccess();
// Explosion on target dummy
const dummyX = width * 0.78;
const dummyY = height * 0.5;
for (let i = 0; i < 40; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = Math.random() * 8 + 3;
blastParticles.push({
x: dummyX,
y: dummyY,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
color: spell.color,
size: Math.random() * 5 + 3,
alpha: 1.0
});
}
setTimeout(() => {
currentSpellIdx++;
nextNodeIdx = 0;
userPath = [];
if (currentSpellIdx >= SPELLS.length) {
gameWon = true;
onDuelWon();
} else {
updateSpellUI();
}
}, 800);
}
async function onDuelWon() {
if (window.soundFX) window.soundFX.playMagicSuccess();
try {
const res = await fetch('/api/duel/complete', { method: 'POST' });
const data = await res.json();
if (data.new_item && window.notifyNewItem) {
window.notifyNewItem(data.new_item);
}
} catch (e) {
console.error(e);
}
modalTitle.innerText = "⚡ Duell Gemeistert!";
modalText.innerHTML = `
Mit meisterhafter Zauberstabführung hast du alle 3 Zaubersprüche entfesselt und die verzauberte Rüstung bezwungen!<br><br>
<strong>Gelehrte Sprüche:</strong> Flipendo ⚡, Incendio 🔥 & Alohomora 🗝️<br><br>
Professor Flitwick applaudiert begeistert. Das uralte Schlosstor schwingt auf...
Dahinter flackern die unheimlichen Flammen von <strong>Snapes Flammenkammer</strong>!<br><br>
🎒 <em>Das goldene Duellanten-Abzeichen wurde deinem Zauberbeutel hinzugefügt!</em>
`;
modalBtn.innerText = "Weiter zu Snapes Flaschenrätsel 🧪";
modalBtn.onclick = () => { window.location.href = '/bottles'; };
modalBackdrop.classList.add('show');
}
function render() {
ctx.clearRect(0, 0, width, height);
// Dungeon stone chamber background
const bgGrad = ctx.createRadialGradient(width * 0.5, height * 0.5, 30, width * 0.5, height * 0.5, width * 0.6);
bgGrad.addColorStop(0, '#1c172e');
bgGrad.addColorStop(1, '#0c0a14');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
// Stone floor tiles
ctx.strokeStyle = '#2d253d';
ctx.lineWidth = 1;
for (let y = height * 0.75; y < height; y += 25) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
const spell = SPELLS[currentSpellIdx] || SPELLS[SPELLS.length - 1];
// Draw Spell Beam if casting
if (spellCastAnim > 0) {
ctx.save();
ctx.strokeStyle = spell.beamColor;
ctx.lineWidth = 14 * spellCastAnim;
ctx.shadowColor = spell.color;
ctx.shadowBlur = 30;
ctx.beginPath();
ctx.moveTo(width * 0.45, height * 0.5);
ctx.lineTo(width * 0.78, height * 0.5);
ctx.stroke();
ctx.restore();
spellCastAnim -= 0.05;
}
// Draw Enchanted Target Dummy (Right side)
ctx.save();
const dummyBaseX = width * 0.78 + (dummyShake > 0 ? (Math.random() - 0.5) * dummyShake : 0);
const dummyBaseY = height * 0.5;
if (dummyShake > 0) dummyShake *= 0.85;
// Dummy wooden post & shield
ctx.fillStyle = '#4a3319';
ctx.fillRect(dummyBaseX - 8, dummyBaseY - 60, 16, 120);
// Armor Breastplate
ctx.fillStyle = '#6c757d';
ctx.strokeStyle = '#e5b948';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(dummyBaseX, dummyBaseY - 10, 32, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Armor Helmet
ctx.fillStyle = '#495057';
ctx.beginPath();
ctx.arc(dummyBaseX, dummyBaseY - 50, 20, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Glowing Rune Shield Aura on Dummy
ctx.strokeStyle = spell.color;
ctx.shadowColor = spell.color;
ctx.shadowBlur = 15;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(dummyBaseX, dummyBaseY - 10, 42 + Math.sin(Date.now() * 0.008) * 4, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// Draw Spell Tracing Guide Lines (Left / Center side)
if (spellNodes.length > 1 && !gameWon) {
ctx.save();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.setLineDash([8, 8]);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(spellNodes[0].x, spellNodes[0].y);
for (let i = 1; i < spellNodes.length; i++) {
ctx.lineTo(spellNodes[i].x, spellNodes[i].y);
}
ctx.stroke();
ctx.restore();
}
// Draw Player's Drawn Line
if (userPath.length > 1) {
ctx.save();
ctx.strokeStyle = spell.color;
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowColor = spell.color;
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.moveTo(userPath[0].x, userPath[0].y);
for (let i = 1; i < userPath.length; i++) {
ctx.lineTo(userPath[i].x, userPath[i].y);
}
ctx.stroke();
ctx.restore();
}
// Draw Spell Nodes (Rune Stars to connect)
if (!gameWon) {
spellNodes.forEach((node, idx) => {
ctx.save();
const isNext = (idx === nextNodeIdx);
const isHit = node.hit;
ctx.shadowColor = isHit ? '#2ecc71' : (isNext ? spell.color : '#888');
ctx.shadowBlur = isNext ? 20 : 8;
ctx.fillStyle = isHit ? '#2ecc71' : (isNext ? '#ffffff' : '#332a42');
ctx.strokeStyle = isNext ? spell.color : '#e5b948';
ctx.lineWidth = isNext ? 4 : 2;
const r = node.radius + (isNext ? Math.sin(Date.now() * 0.01) * 3 : 0);
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Number label
ctx.fillStyle = isHit ? '#000' : (isNext ? '#000' : '#e5b948');
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(idx + 1, node.x, node.y);
ctx.restore();
});
}
// Update & Render Wand Sparks
for (let i = wandParticles.length - 1; i >= 0; i--) {
const p = wandParticles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha *= 0.90;
if (p.alpha <= 0.02) {
wandParticles.splice(i, 1);
continue;
}
ctx.fillStyle = p.color;
ctx.globalAlpha = p.alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
// Update & Render Blast Particles
for (let i = blastParticles.length - 1; i >= 0; i--) {
const p = blastParticles[i];
p.x += p.vx;
p.y += p.vy;
p.alpha *= 0.92;
if (p.alpha <= 0.02) {
blastParticles.splice(i, 1);
continue;
}
ctx.fillStyle = p.color;
ctx.globalAlpha = p.alpha;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1.0;
requestAnimationFrame(render);
}
requestAnimationFrame(render);
})();
+10 -9
View File
@@ -119,12 +119,12 @@
<strong style="color: var(--gold);">{{ state.broom_score }} Pkt</strong> <strong style="color: var(--gold);">{{ state.broom_score }} Pkt</strong>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span>🧹 Besenflug:</span> <span>🧹 Ring-Flug:</span>
<span>{% if state.broom_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}</span> <span>{% if state.broom_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}</span>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span>♟️ Zauberschach:</span> <span>🪄 Spruch-Duell:</span>
<span>{% if state.chess_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}</span> <span>{% if state.duel_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}</span>
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span>🧪 Snapes Flammen:</span> <span>🧪 Snapes Flammen:</span>
@@ -150,15 +150,15 @@
<a href="/broom" class="room-nav-item {% if request.url.path == '/broom' %}current{% endif %}"> <a href="/broom" class="room-nav-item {% if request.url.path == '/broom' %}current{% endif %}">
<span class="room-icon">🧹</span> <span class="room-icon">🧹</span>
<div class="room-info"> <div class="room-info">
<strong>Kammer I: Der Besenflug</strong> <strong>Kammer I: Der Ring-Flug</strong>
<small>{% if state.broom_solved %}✅ Gemeistert{% else %}🗝️ Geflügelte Schlüssel jagen{% endif %}</small> <small>{% if state.broom_solved %}✅ Gemeistert{% else %}⭕ Goldene Ringe durchfliegen{% endif %}</small>
</div> </div>
</a> </a>
<a href="/chess" class="room-nav-item {% if request.url.path == '/chess' %}current{% endif %}"> <a href="/duel" class="room-nav-item {% if request.url.path == '/duel' %}current{% endif %}">
<span class="room-icon">♟️</span> <span class="room-icon">🪄</span>
<div class="room-info"> <div class="room-info">
<strong>Kammer II: McGonagalls Schach</strong> <strong>Kammer II: Das Spruch-Duell</strong>
<small>{% if state.chess_solved %}✅ Gemeistert{% else %}⚔️ Riesen-Zauberschach{% endif %}</small> <small>{% if state.duel_solved %}✅ Gemeistert{% else %}⚡ Zaubergesten zeichnen{% endif %}</small>
</div> </div>
</a> </a>
<a href="/bottles" class="room-nav-item {% if request.url.path == '/bottles' %}current{% endif %}"> <a href="/bottles" class="room-nav-item {% if request.url.path == '/bottles' %}current{% endif %}">
@@ -169,6 +169,7 @@
</div> </div>
</a> </a>
<a href="/sliding" class="room-nav-item {% if request.url.path == '/sliding' %}current{% endif %}"> <a href="/sliding" class="room-nav-item {% if request.url.path == '/sliding' %}current{% endif %}">
<span class="room-icon">🧩</span> <span class="room-icon">🧩</span>
<div class="room-info"> <div class="room-info">
+12 -11
View File
@@ -1,22 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Kammer I: Der Flug der geflügelten Schlüssel · Hogwarts Escape{% endblock %} {% block title %}Kammer I: Der Goldene Ring-Flug · Hogwarts Escape{% endblock %}
{% block content %} {% block content %}
<div class="parchment-scroll"> <div class="parchment-scroll">
<h1 class="parchment-title">🧹 Kammer I: Der Besenflug 🗝️</h1> <h1 class="parchment-title">🧹 Kammer I: Der Goldene Ring-Flug ⭕</h1>
<p class="riddle-poem" style="font-size: 1.25rem; margin: 0.5rem 0;"> <p class="riddle-poem" style="font-size: 1.25rem; margin: 0.5rem 0;">
"Hunderte metallischer Flügel schwirren wie Vögel unter der Decke des Gewölbes.<br> "Schwinge dich auf deinen Rennbesen über den Dächern von Hogwarts!<br>
Die schwere Kerkertür am anderen Ende ist fest verschlossen.<br> Durchfliege die magisch schwebenden goldenen Ringe, halte die Combo aufrecht<br>
Nur ein einziger Schlüssel passt: Ein alter, großer Silberschlüssel mit geknicktem blauem Flügel!" und meistere den Parcours von Madam Hooch, um die nächste Pforte zu öffnen!"
</p> </p>
</div> </div>
<div class="canvas-wrapper"> <div class="canvas-wrapper">
<div class="game-hud"> <div class="game-hud">
<div>⏱️ Zeit: <strong id="timerVal">0s</strong></div> <div>⏱️ Zeit: <strong id="timerVal">0s</strong></div>
<div>⭕ Ringe: <strong id="ringsVal" style="color: #ffd700;">0/12</strong></div>
<div>✨ Punkte: <strong id="scoreVal">0</strong></div> <div>✨ Punkte: <strong id="scoreVal">0</strong></div>
<div>🎯 Ziel: <span id="missionText">Schlüssel fangen!</span></div> <div id="comboVal" style="color: #00ffff; font-weight: bold; min-width: 80px;"></div>
</div> </div>
<canvas id="broomCanvas"></canvas> <canvas id="broomCanvas"></canvas>
@@ -39,25 +40,25 @@
⚡ TURBO ⚡ TURBO
</button> </button>
<div class="touch-hint"> <div class="touch-hint">
💡 <em>Tipp: Du kannst auch direkt mit dem Finger über das Spielfeld wischen!</em> 💡 <em>Ziehe den Besen einfach mit dem Finger direkt von Ring zu Ring!</em>
</div> </div>
</div> </div>
</div> </div>
<div class="dungeon-card" style="margin-top: 1.5rem;"> <div class="dungeon-card" style="margin-top: 1.5rem;">
<p id="statusMsg" style="text-align: center; font-size: 1.15rem; color: var(--gold); margin-bottom: 0.8rem;"> <p id="statusMsg" style="text-align: center; font-size: 1.15rem; color: var(--gold); margin-bottom: 0.8rem;">
🎮 <strong>Steuerung:</strong> Pfeiltasten / WASD / Touch-Wischen zum Fliegen · <strong>Leertaste / Turbo</strong> für Besen-Beschleunigung! 🎮 <strong>Steuerung:</strong> Wische mit dem Finger über das Spielfeld oder nutze Pfeiltasten / D-Pad zum Fliegen · <strong>Turbo</strong> für rasanten Schub!
</p> </p>
<p style="text-align: center; color: var(--text-dim); font-size: 0.95rem;"> <p style="text-align: center; color: var(--text-dim); font-size: 0.95rem;">
Fange den großen Silberschlüssel mit dem geknickten Flügel und fliege zur schweren Holztür ganz rechts! Sammle 12 Ringe, um den Parcours abzuschließen. Schnelligkeit und Combos bringen Zusatzpunkte!
</p> </p>
</div> </div>
<div style="text-align: center; margin-top: 1rem;"> <div style="text-align: center; margin-top: 1rem;">
<a href="/chess" class="nav-link" style="color: var(--text-dim);">Direkt weiter zu McGonagalls Zauberschach ♟️ ➔</a> <a href="/duel" class="nav-link" style="color: var(--text-dim);">Direkt weiter zum Zauberspruch-Duell 🪄 ➔</a>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/broom.js?v=2.0"></script> <script src="/static/js/broom.js?v=3.0"></script>
{% endblock %} {% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Kammer II: Das Zauberspruch-Duell · Hogwarts Escape{% endblock %}
{% block content %}
<div class="parchment-scroll">
<h1 class="parchment-title">🪄 Kammer II: Das Zauberspruch-Duell ⚡</h1>
<p class="riddle-poem" style="font-size: 1.25rem; margin: 0.5rem 0;">
"Eine uralte verzauberte Rüstung bewacht die Pforte zur Flammenkammer.<br>
Zücke deinen Zauberstab! Verbinde die leuchtenden Runensterne mit dem Finger oder der Maus,<br>
um mächtige Hogwarts-Zaubersprüche zu entfesseln und die Rüstung zu bezwingen!"
</p>
</div>
<div class="canvas-wrapper">
<div class="game-hud">
<div>🪄 Spruch: <strong id="spellName" style="color: var(--gold);">Flipendo ⚡</strong></div>
<div id="spellProgress" style="color: #4cc9f0; font-weight: bold;">Zauberspruch 1 von 3</div>
<div>🛡️ Ziel: <span id="dummyHp">3 Zauber übrig</span></div>
</div>
<canvas id="spellCanvas" style="touch-action: none; -webkit-user-select: none; user-select: none; width: 100%; display: block;"></canvas>
</div>
<div class="dungeon-card" style="margin-top: 1.5rem; text-align: center;">
<p id="spellDesc" style="font-size: 1.15rem; color: var(--gold); margin-bottom: 0.6rem;">
Verbinde die leuchtenden Runensterne von 1 bis 4, um den Zauber zu wirken!
</p>
<p style="color: var(--text-dim); font-size: 0.95rem;">
💡 <em>Tipp: Ziehe deinen Finger oder die Maus flüssig durch die nummerierten Sterne auf dem Bildschirm.</em>
</p>
</div>
<div style="text-align: center; margin-top: 1.5rem;">
<a href="/bottles" class="btn-magical">
Direkt weiter zu Snapes Flammenkammer 🧪 ➔
</a>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/duel.js?v=1.0"></script>
{% endblock %}
+15 -14
View File
@@ -21,34 +21,35 @@
</p> </p>
<div class="chamber-grid"> <div class="chamber-grid">
<!-- Chamber 1: Winged Keys --> <!-- Chamber 1: Ring Flight -->
<div class="chamber-card"> <div class="chamber-card">
<span class="chamber-badge {% if state.broom_solved %}solved{% endif %}"> <span class="chamber-badge {% if state.broom_solved %}solved{% endif %}">
{% if state.broom_solved %}✅ Gemeistert{% else %}🔓 Bereit{% endif %} {% if state.broom_solved %}✅ Gemeistert{% else %}🔓 Bereit{% endif %}
</span> </span>
<div class="chamber-icon">🧹🗝️</div> <div class="chamber-icon">🧹⭕</div>
<h3 class="chamber-title">Kammer I: Der Besenflug</h3> <h3 class="chamber-title">Kammer I: Der Ring-Flug</h3>
<p class="chamber-desc"> <p class="chamber-desc">
Professor Flitwicks verzauberte Kammer. Schwärme geflügelter Schlüssel surren unter der hohen Gewölbedecke. Madam Hoochs Quidditch-Parcours! Schwinge dich auf deinen Rennbesen, steuere geschmeidig durch
Finde den großen, alten Silberschlüssel mit dem geknickten Flügel und schließe die Tür auf! die schwebenden goldenen Ringe und sammle Punkte und Speed-Combos!
</p> </p>
<a href="/broom" class="btn-magical">Auf den Besen schwingen 🧹</a> <a href="/broom" class="btn-magical">Parcours fliegen 🧹</a>
</div> </div>
<!-- Chamber 2: McGonagall's Wizard Chess (NEW!) --> <!-- Chamber 2: Wand Spell Dueling -->
<div class="chamber-card"> <div class="chamber-card">
<span class="chamber-badge {% if state.chess_solved %}solved{% endif %}"> <span class="chamber-badge {% if state.duel_solved %}solved{% endif %}">
{% if state.chess_solved %}✅ Gemeistert{% else %}⚔️ Bereit{% endif %} {% if state.duel_solved %}✅ Gemeistert{% else %}⚡ Bereit{% endif %}
</span> </span>
<div class="chamber-icon">♟️⚔️</div> <div class="chamber-icon">🪄⚡</div>
<h3 class="chamber-title">Kammer II: McGonagalls Schach</h3> <h3 class="chamber-title">Kammer II: Das Spruch-Duell</h3>
<p class="chamber-desc"> <p class="chamber-desc">
Das gigantische, lebendige Zauberschach. Übernimm Rons Rolle als schwarzer Springer, Zücke deinen Zauberstab! Zeichne die magischen Runen-Gesten (Flipendo, Incendio & Alohomora)
überliste die weiße Königin und setze den feindlichen König schachmatt! flüssig mit dem Finger nach und zerschmettere die verzauberte Trainingsrüstung!
</p> </p>
<a href="/chess" class="btn-magical">Schachbrett betreten ♟️</a> <a href="/duel" class="btn-magical">Duell beginnen 🪄</a>
</div> </div>
<!-- Chamber 3: Snape's Potions --> <!-- Chamber 3: Snape's Potions -->
<div class="chamber-card"> <div class="chamber-card">
<span class="chamber-badge {% if state.bottles_solved %}solved{% endif %}"> <span class="chamber-badge {% if state.bottles_solved %}solved{% endif %}">
+19 -15
View File
@@ -13,11 +13,16 @@ def test_endpoints():
res = client.get("/broom") res = client.get("/broom")
assert res.status_code == 200 assert res.status_code == 200
assert "Der Besenflug" in res.text assert "Der Goldene Ring-Flug" in res.text
res = client.get("/chess") res = client.get("/duel")
assert res.status_code == 200 assert res.status_code == 200
assert "McGonagalls Zauberschach" in res.text assert "Das Zauberspruch-Duell" in res.text
# Test /chess redirects to /duel
res = client.get("/chess", follow_redirects=False)
assert res.status_code in [301, 302]
assert res.headers["location"] == "/duel"
res = client.get("/bottles") res = client.get("/bottles")
assert res.status_code == 200 assert res.status_code == 200
@@ -43,24 +48,23 @@ def test_endpoints():
assert "snape_scroll" in item_ids assert "snape_scroll" in item_ids
# Test House API # Test House API
res = client.post("/api/player/house", json={"house": "slytherin"}) res = client.post("/api/player/house", json={"house": "ravenclaw"})
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["house"]["name"] == "Slytherin" assert res.json()["house"]["name"] == "Ravenclaw"
# Test API Broom complete (adds silver_key) # Test API Broom complete
res = client.post("/api/broom/complete", json={"score": 500, "time_taken": 25}) res = client.post("/api/broom/complete", json={"score": 850, "time_taken": 22})
assert res.status_code == 200 assert res.status_code == 200
data = res.json() data = res.json()
assert data["success"] is True assert data["success"] is True
assert "silver_key" in data["state"]["inventory"]
# Test API Chess complete (adds chess_knight) # Test API Duel complete (adds duel_badge)
res = client.post("/api/chess/complete") res = client.post("/api/duel/complete")
assert res.status_code == 200 assert res.status_code == 200
chess_data = res.json() duel_data = res.json()
assert chess_data["success"] is True assert duel_data["success"] is True
assert chess_data["new_item"]["id"] == "chess_knight" assert duel_data["new_item"]["id"] == "duel_badge"
assert "chess_knight" in chess_data["state"]["inventory"] assert "duel_badge" in duel_data["state"]["inventory"]
# Test API Bottles drink: Ahead / Black fire (Bottle 3 - Dwarf, adds philosophers_stone) # Test API Bottles drink: Ahead / Black fire (Bottle 3 - Dwarf, adds philosophers_stone)
res = client.post("/api/bottles/drink", json={"bottle": 3, "gate": "ahead"}) res = client.post("/api/bottles/drink", json={"bottle": 3, "gate": "ahead"})
@@ -75,7 +79,7 @@ def test_endpoints():
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["success"] is True assert res.json()["success"] is True
print("ALL TESTS (INCLUDING CHESS & MOBILE FLIGHT) PASSED! ♟️🧹✨") print("ALL TESTS (RING FLIGHT & SPELL DUEL) PASSED! ⭕🪄✨")
if __name__ == "__main__": if __name__ == "__main__":
test_endpoints() test_endpoints()