Initial commit: Harry Potter Escape Adventure on bootle.dev.waiswuaschd.de

This commit is contained in:
Maxi
2026-09-22 16:26:48 +02:00
commit c5c0bad3e6
22 changed files with 2413 additions and 0 deletions
Executable
+2
View File
@@ -0,0 +1,2 @@
PORT=8002
SECRET_KEY=hogwarts_magic_bootle_potion_key_2026_escape
+3
View File
@@ -0,0 +1,3 @@
venv/
__pycache__/
*.pyc
Binary file not shown.
Executable
+230
View File
@@ -0,0 +1,230 @@
import os
import uuid
import json
import datetime
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
load_dotenv()
app = FastAPI(title="Hogwarts Escape Adventure - Die Kammern des Steins der Weisen")
SECRET_KEY = os.getenv("SECRET_KEY", "alohomora_bootle_potion_secret_2026")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="escape_session")
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
# Standard Classic Riddle Solution (Book Accurate):
# 1: Poison
# 2: Wine
# 3: Ahead (Smallest / Dwarf - passes through black fire)
# 4: Poison
# 5: Poison
# 6: Wine
# 7: Back (Largest / Giant - passes through purple fire)
CLASSIC_SOLUTION = {
1: "poison",
2: "wine",
3: "ahead",
4: "poison",
5: "poison",
6: "wine",
7: "back"
}
def get_session_state(request: Request) -> dict:
if "escape" not in request.session:
request.session["escape"] = {
"session_id": str(uuid.uuid4()),
"broom_solved": False,
"broom_score": 0,
"broom_time": 0,
"bottles_solved": False,
"sliding_solved": False,
"started_at": datetime.datetime.utcnow().isoformat(),
"completed_at": None,
"lang": "de"
}
return request.session["escape"]
@app.get("/", response_class=HTMLResponse)
async def page_index(request: Request):
state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang
request.session["escape"] = state
return templates.TemplateResponse(
request=request,
name="index.html",
context={"state": state, "lang": lang}
)
@app.get("/broom", response_class=HTMLResponse)
async def page_broom(request: Request):
state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang
request.session["escape"] = state
return templates.TemplateResponse(
request=request,
name="broom.html",
context={"state": state, "lang": lang}
)
@app.get("/bottles", response_class=HTMLResponse)
async def page_bottles(request: Request):
state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang
request.session["escape"] = state
return templates.TemplateResponse(
request=request,
name="bottles.html",
context={"state": state, "lang": lang}
)
@app.get("/sliding", response_class=HTMLResponse)
async def page_sliding(request: Request):
state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang
request.session["escape"] = state
return templates.TemplateResponse(
request=request,
name="sliding.html",
context={"state": state, "lang": lang}
)
@app.get("/finale", response_class=HTMLResponse)
async def page_finale(request: Request):
state = get_session_state(request)
lang = request.query_params.get("lang", state.get("lang", "de"))
state["lang"] = lang
request.session["escape"] = state
return templates.TemplateResponse(
request=request,
name="finale.html",
context={"state": state, "lang": lang}
)
@app.get("/api/state")
async def api_get_state(request: Request):
state = get_session_state(request)
return JSONResponse(state)
@app.post("/api/reset")
async def api_reset_state(request: Request):
request.session["escape"] = {
"session_id": str(uuid.uuid4()),
"broom_solved": False,
"broom_score": 0,
"broom_time": 0,
"bottles_solved": False,
"sliding_solved": False,
"started_at": datetime.datetime.utcnow().isoformat(),
"completed_at": None,
"lang": "de"
}
return JSONResponse({"success": True, "message": "Escape progress reset."})
@app.post("/api/broom/complete")
async def api_broom_complete(request: Request):
try:
data = await request.json()
except Exception:
data = {}
state = get_session_state(request)
state["broom_solved"] = True
state["broom_score"] = int(data.get("score", 100))
state["broom_time"] = int(data.get("time_taken", 30))
request.session["escape"] = state
return JSONResponse({
"success": True,
"message": "Der Schlüssel passt ins Schloss! Die Tür zur Flammenkammer steht offen!",
"state": state
})
@app.post("/api/bottles/drink")
async def api_bottles_drink(request: Request):
"""
Check the bottle the player decided to drink to pass through the flames.
Bottle 3 is Ahead (Black fire -> Success).
Bottle 7 is Back (Purple fire -> Escaped back).
Bottles 1, 4, 5 are Poison (Fizzle/Defeat).
Bottles 2, 6 are Nettle Wine (Harmless wine).
"""
try:
data = await request.json()
except Exception:
data = {}
bottle_index = int(data.get("bottle", 0))
target_gate = data.get("gate", "ahead") # 'ahead' (black fire) or 'back' (purple fire)
lang = data.get("lang", "de")
if bottle_index not in CLASSIC_SOLUTION:
raise HTTPException(status_code=400, detail="Ungültige Flaschennummer.")
content = CLASSIC_SOLUTION[bottle_index]
state = get_session_state(request)
if content == "ahead":
if target_gate == "ahead":
state["bottles_solved"] = True
if state.get("broom_solved"):
state["completed_at"] = datetime.datetime.utcnow().isoformat()
request.session["escape"] = state
msg_de = "Ein eisiger Schauer durchströmt dich! Das schwarze Feuer weicht vor dir zurück. Du trittst siegreich in die Schatzkammer des Steins der Weisen!"
msg_en = "An icy chill flows through you! The black flames retreat. You step victoriously into the chamber of the Philosopher's Stone!"
return JSONResponse({
"success": True,
"result": "success_ahead",
"content": content,
"message": msg_de if lang == "de" else msg_en,
"next_url": "/finale"
})
else:
msg_de = "Dieser Trank schützt dich nur vor dem schwarzen Feuer, nicht vor dem violetten!"
msg_en = "This potion only protects against black fire, not purple fire!"
return JSONResponse({"success": False, "result": "wrong_gate", "content": content, "message": msg_de if lang == "de" else msg_en})
elif content == "back":
if target_gate == "back":
msg_de = "Ein wohliger Kälteschleier hüllt dich ein. Du kannst sicher durch die violetten Flammen zurückweichen."
msg_en = "A protective cold mist envelops you. You can safely retreat through the purple flames."
return JSONResponse({
"success": True,
"result": "success_back",
"content": content,
"message": msg_de if lang == "de" else msg_en,
"next_url": "/broom"
})
else:
msg_de = "Dieser Trank schützt vor den violetten Flammen, führt dich aber nicht vorwärts durch das schwarze Feuer!"
msg_en = "This potion protects against purple flames, but does not allow forward passage through black fire!"
return JSONResponse({"success": False, "result": "wrong_gate", "content": content, "message": msg_de if lang == "de" else msg_en})
elif content == "poison":
msg_de = "💀 Gift! Dein Hals brennt wie Feuer. Ein Zauber bricht die Wirkung gerade noch rechtzeitig ab, doch du musst es erneut versuchen!"
msg_en = "💀 Poison! Your throat burns. A counter-curse breaks the effect just in time, but you must try again!"
return JSONResponse({"success": False, "result": "poison", "content": content, "message": msg_de if lang == "de" else msg_en})
elif content == "wine":
msg_de = "🍷 Milder Brennnesselwein! Köstlich erfrischend, aber ohne jegliche magische Schutzwirkung gegen die verzehrenden Flammen."
msg_en = "🍷 Mild nettle wine! Deliciously refreshing, but offering zero magical protection against the consuming flames."
return JSONResponse({"success": False, "result": "wine", "content": content, "message": msg_de if lang == "de" else msg_en})
@app.post("/api/sliding/complete")
async def api_sliding_complete(request: Request):
state = get_session_state(request)
state["sliding_solved"] = True
request.session["escape"] = state
return JSONResponse({
"success": True,
"message": "Das Alchemie-Schiebesiegel ist gelöst! Die geheime Essenz wurde freigegeben."
})
+590
View File
@@ -0,0 +1,590 @@
:root {
--bg-dark: #0d0c13;
--bg-card: rgba(26, 24, 38, 0.92);
--gold: #e5b948;
--gold-glow: rgba(229, 185, 72, 0.4);
--gold-hover: #f7d274;
--parchment-bg: #f8f1de;
--parchment-border: #8b6d3b;
--parchment-ink: #2b1f14;
--purple-fire: #aa26eb;
--purple-fire-glow: rgba(170, 38, 235, 0.6);
--black-fire: #120d1e;
--black-fire-glow: rgba(67, 18, 114, 0.8);
--poison-green: #2ecc71;
--wine-red: #9e2a2b;
--text-light: #ece8e1;
--text-dim: #a59fa8;
--font-gothic: 'MedievalSharp', cursive, serif;
--font-script: 'Caveat', cursive, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-dark);
background-image:
radial-gradient(circle at 15% 20%, rgba(138, 43, 226, 0.12) 0%, transparent 40%),
radial-gradient(circle at 85% 80%, rgba(229, 185, 72, 0.08) 0%, transparent 40%),
url('/static/img/parchment_texture.jpg');
background-blend-mode: overlay;
color: var(--text-light);
font-family: var(--font-gothic);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Site Header */
.site-header {
background: rgba(13, 12, 19, 0.95);
border-bottom: 2px solid var(--gold);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
position: sticky;
top: 0;
z-index: 1000;
backdrop-filter: blur(10px);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.brand-link {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--gold);
font-size: 1.4rem;
text-shadow: 0 0 10px var(--gold-glow);
transition: transform 0.2s;
}
.brand-link:hover {
transform: scale(1.03);
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-link {
color: var(--text-light);
text-decoration: none;
font-size: 1rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
border: 1px solid transparent;
transition: all 0.2s;
}
.nav-link:hover, .nav-link.active {
color: var(--gold);
border-color: var(--gold);
background: rgba(229, 185, 72, 0.1);
}
/* Control Buttons */
.btn-control {
background: rgba(255, 255, 255, 0.08);
color: var(--gold);
border: 1px solid var(--gold);
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.4rem;
transition: all 0.2s;
}
.btn-control:hover {
background: var(--gold);
color: var(--bg-dark);
box-shadow: 0 0 12px var(--gold-glow);
}
.lang-btn.active {
background: var(--gold);
color: var(--bg-dark);
font-weight: bold;
}
/* Container */
.container {
max-width: 1100px;
margin: 1.5rem auto 3rem auto;
padding: 0 1rem;
flex: 1;
width: 100%;
}
/* Parchment Box */
.parchment-scroll {
background: var(--parchment-bg);
color: var(--parchment-ink);
border: 4px double var(--parchment-border);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), inset 0 0 40px rgba(139, 109, 59, 0.25);
position: relative;
margin-bottom: 2rem;
}
.parchment-title {
font-size: 2rem;
text-align: center;
color: #5c1818;
margin-bottom: 1rem;
text-shadow: 1px 1px 0px rgba(255,255,255,0.7);
}
.riddle-poem {
font-family: var(--font-script);
font-size: 1.5rem;
line-height: 1.8;
text-align: center;
margin: 1.5rem 0;
white-space: pre-line;
font-weight: bold;
}
/* Dungeon Card */
.dungeon-card {
background: var(--bg-card);
border: 1px solid rgba(229, 185, 72, 0.4);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
margin-bottom: 2rem;
}
/* Chamber Overview Grid */
.chamber-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.chamber-card {
background: rgba(20, 18, 30, 0.9);
border: 2px solid rgba(229, 185, 72, 0.3);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.chamber-card:hover {
transform: translateY(-5px);
border-color: var(--gold);
box-shadow: 0 10px 30px var(--gold-glow);
}
.chamber-badge {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(229, 185, 72, 0.2);
border: 1px solid var(--gold);
color: var(--gold);
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border-radius: 20px;
}
.chamber-badge.solved {
background: rgba(46, 204, 113, 0.25);
border-color: #2ecc71;
color: #2ecc71;
}
.chamber-icon {
font-size: 3.5rem;
margin-bottom: 1rem;
text-align: center;
}
.chamber-title {
font-size: 1.5rem;
color: var(--gold);
margin-bottom: 0.75rem;
text-align: center;
}
.chamber-desc {
color: var(--text-dim);
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 1.25rem;
text-align: center;
}
/* Magical Buttons */
.btn-magical {
display: inline-block;
background: linear-gradient(135deg, #b8860b, #d4af37, #f7d274);
color: #1a1505;
text-decoration: none;
font-weight: bold;
font-size: 1.1rem;
padding: 0.8rem 1.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
text-align: center;
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4);
transition: all 0.2s;
text-shadow: 0 1px 1px rgba(255,255,255,0.4);
}
.btn-magical:hover {
transform: translateY(-2px);
box-shadow: 0 6px 22px rgba(212, 175, 55, 0.7);
color: #000;
}
/* Flaming Gates (Bottles Riddle) */
.flame-chamber-arena {
position: relative;
background: radial-gradient(circle at center, #191629 0%, #0c0a14 100%);
border: 2px solid #4a3b63;
border-radius: 16px;
padding: 2rem 1.5rem;
box-shadow: inset 0 0 80px rgba(0,0,0,0.9), 0 10px 40px rgba(0,0,0,0.8);
overflow: hidden;
}
.flame-wall-container {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
position: relative;
}
.flame-gate {
width: 48%;
padding: 1.5rem 1rem;
border-radius: 12px;
text-align: center;
position: relative;
overflow: hidden;
}
.flame-gate.purple {
background: rgba(75, 15, 110, 0.35);
border: 2px solid var(--purple-fire);
box-shadow: 0 0 35px var(--purple-fire-glow);
}
.flame-gate.black {
background: rgba(18, 12, 28, 0.8);
border: 2px solid #5a287a;
box-shadow: 0 0 35px var(--black-fire-glow);
}
.flame-icon-animated {
font-size: 3rem;
animation: flameFlicker 1.5s infinite alternate;
}
@keyframes flameFlicker {
0% { transform: scale(1) rotate(-2deg); opacity: 0.85; }
50% { transform: scale(1.1) rotate(3deg); opacity: 1; filter: drop-shadow(0 0 15px currentColor); }
100% { transform: scale(0.95) rotate(-1deg); opacity: 0.9; }
}
/* 7 Bottles Table */
.bottles-table-wrapper {
margin: 2rem 0;
padding: 1.5rem 1rem;
background: linear-gradient(180deg, #2a201b 0%, #17110e 100%);
border: 3px solid #6b5338;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,0.9);
}
.bottles-table {
display: flex;
justify-content: space-around;
align-items: flex-end;
gap: 0.75rem;
flex-wrap: nowrap;
overflow-x: auto;
padding: 1rem 0;
}
.bottle-slot {
display: flex;
flex-direction: column;
align-items: center;
min-width: 95px;
cursor: pointer;
transition: transform 0.2s;
user-select: none;
}
.bottle-slot:hover {
transform: translateY(-8px);
}
.bottle-slot.selected {
transform: translateY(-12px);
filter: drop-shadow(0 0 15px var(--gold));
}
.bottle-pedestal {
width: 80px;
height: 18px;
background: linear-gradient(180deg, #574635 0%, #2f251c 100%);
border: 2px solid #856a52;
border-radius: 6px;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
color: var(--gold);
font-weight: bold;
}
/* SVG Bottles */
.bottle-svg {
filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6));
transition: filter 0.3s;
}
/* Bottle tags */
.bottle-tag-select {
margin-top: 0.4rem;
background: rgba(13, 12, 19, 0.9);
border: 1px solid var(--gold);
color: var(--text-light);
font-family: inherit;
font-size: 0.75rem;
padding: 0.2rem;
border-radius: 4px;
cursor: pointer;
}
/* Canvas Broom Game */
.canvas-wrapper {
position: relative;
width: 100%;
max-width: 900px;
margin: 0 auto;
border: 3px solid var(--gold);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 0 40px rgba(0,0,0,0.9), 0 0 20px var(--gold-glow);
background: #000;
}
#broomCanvas {
display: block;
width: 100%;
height: 520px;
cursor: crosshair;
}
.game-hud {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
background: rgba(13, 12, 19, 0.9);
border-bottom: 2px solid var(--gold);
font-size: 1.1rem;
color: var(--gold);
}
.mobile-controls {
display: none;
margin-top: 1rem;
justify-content: center;
gap: 1rem;
}
@media (max-width: 768px) {
.mobile-controls {
display: flex;
}
.flame-wall-container {
flex-direction: column;
gap: 1rem;
}
.flame-gate {
width: 100%;
}
#broomCanvas {
height: 380px;
}
}
/* Modal Feedback */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 1rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.modal-backdrop.show {
opacity: 1;
pointer-events: auto;
}
.modal-content {
background: var(--bg-card);
border: 2px solid var(--gold);
border-radius: 14px;
max-width: 550px;
width: 100%;
padding: 2rem;
text-align: center;
box-shadow: 0 0 50px var(--gold-glow);
transform: scale(0.9);
transition: transform 0.3s;
}
.modal-backdrop.show .modal-content {
transform: scale(1);
}
.modal-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.modal-title {
font-size: 1.8rem;
color: var(--gold);
margin-bottom: 1rem;
}
.modal-text {
color: var(--text-light);
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 1.5rem;
}
/* Footer */
.footer {
background: rgba(10, 9, 15, 0.95);
border-top: 1px solid rgba(229, 185, 72, 0.3);
text-align: center;
padding: 1.5rem 1rem;
color: var(--text-dim);
font-size: 0.9rem;
}
.footer a {
color: var(--gold);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Sliding Puzzle (Schieberätsel) */
.sliding-board-container {
max-width: 440px;
margin: 1.5rem auto;
background: #171421;
border: 3px solid var(--gold);
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 0 30px rgba(0,0,0,0.8), inset 0 0 30px rgba(0,0,0,0.6);
}
.sliding-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 10px;
width: 100%;
aspect-ratio: 1 / 1;
}
.sliding-tile {
background: linear-gradient(145deg, #2b253b, #1b1725);
border: 2px solid var(--gold);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
transition: transform 0.15s, background 0.2s;
box-shadow: 0 4px 8px rgba(0,0,0,0.5);
padding: 0.5rem;
}
.sliding-tile:hover {
transform: scale(1.03);
background: #39324e;
}
.sliding-tile.blank {
background: transparent;
border: 2px dashed rgba(229, 185, 72, 0.2);
box-shadow: none;
cursor: default;
}
.tile-icon {
font-size: 2.2rem;
}
.tile-num {
font-size: 0.85rem;
color: var(--gold);
font-weight: bold;
}
.tile-name {
font-size: 0.75rem;
color: var(--text-dim);
text-align: center;
margin-top: 2px;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB

+162
View File
@@ -0,0 +1,162 @@
// Web Audio API Synthesizer for magical escape sounds
class SoundFX {
constructor() {
this.ctx = null;
this.muted = localStorage.getItem('escape_sound_muted') === 'true';
}
init() {
if (!this.ctx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioContext();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
toggleMute() {
this.muted = !this.muted;
localStorage.setItem('escape_sound_muted', this.muted);
return !this.muted;
}
isMuted() {
return this.muted;
}
// Whoosh of flying broomstick
playWhoosh() {
if (this.muted) return;
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
osc.type = 'sawtooth';
filter.type = 'lowpass';
filter.frequency.setValueAtTime(300, this.ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.15);
filter.frequency.exponentialRampToValueAtTime(200, this.ctx.currentTime + 0.35);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.35);
osc.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.36);
}
// Key caught / fluttering chime
playKeyChime() {
if (this.muted) return;
this.init();
const freqs = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
freqs.forEach((freq, idx) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
const startTime = this.ctx.currentTime + idx * 0.05;
gain.gain.setValueAtTime(0.1, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.4);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(startTime);
osc.stop(startTime + 0.41);
});
}
// Clink of glass bottle
playBottleClink() {
if (this.muted) return;
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.16);
}
// Success magical harmony
playMagicSuccess() {
if (this.muted) return;
this.init();
const chords = [440, 554.37, 659.25, 880, 1108.73]; // A Major triumph
chords.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, this.ctx.currentTime + i * 0.06);
gain.gain.setValueAtTime(0.12, this.ctx.currentTime + i * 0.06);
gain.gain.exponentialRampToValueAtTime(0.0001, this.ctx.currentTime + i * 0.06 + 1.2);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(this.ctx.currentTime + i * 0.06);
osc.stop(this.ctx.currentTime + i * 0.06 + 1.25);
});
}
// Poison fizzle
playPoisonSizzle() {
if (this.muted) return;
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(180, this.ctx.currentTime);
osc.frequency.linearRampToValueAtTime(90, this.ctx.currentTime + 0.5);
gain.gain.setValueAtTime(0.25, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.5);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.51);
}
// Stone slide
playStoneSlide() {
if (this.muted) return;
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(80, this.ctx.currentTime);
gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.21);
}
}
window.soundFX = new SoundFX();
function updateAudioButtonUI() {
const btn = document.getElementById('audioToggleBtn');
if (!btn) return;
const isMuted = window.soundFX.isMuted();
btn.innerHTML = isMuted ? '🔇 <span class="audio-lbl">Ton: Aus</span>' : '🔊 <span class="audio-lbl">Ton: An</span>';
}
function toggleSound() {
window.soundFX.toggleMute();
updateAudioButtonUI();
}
document.addEventListener('DOMContentLoaded', () => {
updateAudioButtonUI();
document.body.addEventListener('click', () => {
window.soundFX.init();
}, { once: true });
});
+181
View File
@@ -0,0 +1,181 @@
// Snape's 7 Potions Riddle & Interactive Bottle Board
(function() {
let selectedBottle = null;
const slots = document.querySelectorAll('.bottle-slot');
const selectedInfo = document.getElementById('selectedBottleInfo');
const btnDrinkAhead = document.getElementById('btnDrinkAhead');
const btnDrinkBack = document.getElementById('btnDrinkBack');
const modalBackdrop = document.getElementById('modalBackdrop');
const modalTitle = document.getElementById('modalTitle');
const modalText = document.getElementById('modalText');
const modalBtn = document.getElementById('modalBtn');
const modalIcon = document.getElementById('modalIcon');
// Bottle Descriptions based on Book Clues
const bottleDescriptions = {
1: "Eine dunkelbraune Phiole mittlerer Größe. Auf der linken Seite von Flasche 2.",
2: "Eine schlanke, grüne Glasflasche. Schmeckt laut Hinweis 4 exakt wie Flasche 6!",
3: "🔍 Die kleinste Flasche von allen (der 'Zwerg')! Sie enthält kaum mehr als einen einzigen Schluck.",
4: "Ein eigenartiger, kantiger Glaskolben mit trüber, unruhiger Flüssigkeit.",
5: "Eine bauchige Flasche aus Rauchglas. Steht direkt links von Flasche 6.",
6: "Eine elegante Flasche, die Zwillingsschwester von Flasche 2, auch wenn sie äußerlich verschieden wirken.",
7: "🏺 Die größte Flasche in der Reihe (der 'Riese')! Ein schwerer, bauchiger Dekanter."
};
slots.forEach(slot => {
slot.addEventListener('click', (e) => {
if (e.target.tagName === 'SELECT') return; // Don't trigger on tag dropdown
selectSlot(slot);
});
// Drag & Drop / Reordering support
slot.setAttribute('draggable', 'true');
slot.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', slot.dataset.index);
if (window.soundFX) window.soundFX.playStoneSlide();
});
slot.addEventListener('dragover', (e) => {
e.preventDefault();
slot.classList.add('drag-over');
});
slot.addEventListener('dragleave', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', (e) => {
e.preventDefault();
slot.classList.remove('drag-over');
const sourceIndex = e.dataTransfer.getData('text/plain');
const sourceSlot = document.querySelector(`.bottle-slot[data-index="${sourceIndex}"]`);
if (sourceSlot && sourceSlot !== slot) {
swapSlots(sourceSlot, slot);
}
});
});
function swapSlots(slotA, slotB) {
if (window.soundFX) {
window.soundFX.playStoneSlide();
window.soundFX.playBottleClink();
}
// Swap inner contents except the pedestal number
const tempHTML = slotA.innerHTML;
slotA.innerHTML = slotB.innerHTML;
slotB.innerHTML = tempHTML;
// Restore event listeners on newly swapped selects
attachTagListeners();
selectSlot(slotB);
}
function selectSlot(slot) {
slots.forEach(s => s.classList.remove('selected'));
slot.classList.add('selected');
selectedBottle = parseInt(slot.dataset.index, 10);
if (window.soundFX) window.soundFX.playBottleClink();
if (btnDrinkAhead) btnDrinkAhead.disabled = false;
if (btnDrinkBack) btnDrinkBack.disabled = false;
if (selectedInfo) {
const desc = bottleDescriptions[selectedBottle] || "Eine geheimnisvolle Flasche.";
selectedInfo.innerHTML = `Gewählt: <strong>Flasche ${selectedBottle}</strong> — <em>${desc}</em>`;
}
}
function attachTagListeners() {
document.querySelectorAll('.bottle-tag-select').forEach(sel => {
sel.addEventListener('change', () => {
if (window.soundFX) window.soundFX.playStoneSlide();
});
});
}
attachTagListeners();
// Drink to pass Ahead (Black Fire)
if (btnDrinkAhead) {
btnDrinkAhead.addEventListener('click', () => {
if (!selectedBottle) return;
drinkBottle(selectedBottle, 'ahead');
});
}
// Drink to pass Back (Purple Fire)
if (btnDrinkBack) {
btnDrinkBack.addEventListener('click', () => {
if (!selectedBottle) return;
drinkBottle(selectedBottle, 'back');
});
}
async function drinkBottle(bottleIdx, gate) {
if (window.soundFX) window.soundFX.playBottleClink();
try {
const res = await fetch('/api/bottles/drink', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bottle: bottleIdx, gate: gate, lang: 'de' })
});
const data = await res.json();
if (data.result === 'success_ahead') {
if (window.soundFX) window.soundFX.playMagicSuccess();
modalIcon.innerText = "❄️⚡";
modalTitle.innerText = "Hervorragend kombiniert!";
modalTitle.style.color = "#ffd700";
modalText.innerHTML = `Du hast die <strong>kleinste Flasche (Flasche 3, den 'Zwerg')</strong> getrunken!<br><br>
${data.message}<br><br>
Das schwarze Feuer teilt sich mit einem frostigen Zischen. Der Weg zum Stein der Weisen liegt frei vor dir!`;
modalBtn.innerText = "Zum Finale & Escape-Triumph 🏆";
modalBtn.onclick = () => { window.location.href = data.next_url; };
modalBackdrop.classList.add('show');
} else if (data.result === 'success_back') {
if (window.soundFX) window.soundFX.playMagicSuccess();
modalIcon.innerText = "🟣";
modalTitle.innerText = "Rückzug gesichert!";
modalText.innerHTML = `Du hast die <strong>größte Flasche (Flasche 7, den 'Riesen')</strong> getrunken.<br><br>
${data.message}<br><br>
Du kannst sicher durch die violetten Flammen zurückweichen, um deinen Gefährten zu helfen oder den Besenflug nochmals zu betreten.`;
modalBtn.innerText = "Zurück zum Besenflug 🧹";
modalBtn.onclick = () => { window.location.href = data.next_url; };
modalBackdrop.classList.add('show');
} else if (data.result === 'poison') {
if (window.soundFX) window.soundFX.playPoisonSizzle();
modalIcon.innerText = "💀";
modalTitle.innerText = "Tödliches Gift!";
modalTitle.style.color = "#e74c3c";
modalText.innerHTML = `${data.message}<br><br>
<strong>Snapes Rätsel-Hinweis:</strong><br>
<em>"So schlau sich das Gift auch zu tarnen mag,<br>
stets steht es zur Linken vom Wein im Gelag."</em>`;
modalBtn.innerText = "Erneut analysieren 📜";
modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); };
modalBackdrop.classList.add('show');
} else if (data.result === 'wine') {
if (window.soundFX) window.soundFX.playPoisonSizzle();
modalIcon.innerText = "🍷";
modalTitle.innerText = "Harmloser Brennnesselwein";
modalTitle.style.color = "#e67e22";
modalText.innerHTML = `${data.message}<br><br>
Du spürst keinerlei Schutz vor der Hitze der Flammen. Wenn du jetzt hineintrittst, verbrennst du! Lies die Verse auf dem Pergament noch einmal gründlich.`;
modalBtn.innerText = "Verstanden 📜";
modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); };
modalBackdrop.classList.add('show');
} else {
if (window.soundFX) window.soundFX.playPoisonSizzle();
modalIcon.innerText = "⚠️";
modalTitle.innerText = "Falsche Flamme!";
modalText.innerText = data.message;
modalBtn.innerText = "Zurück";
modalBtn.onclick = () => { modalBackdrop.classList.remove('show'); };
modalBackdrop.classList.add('show');
}
} catch (e) {
console.error(e);
}
}
})();
+425
View File
@@ -0,0 +1,425 @@
// 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');
}
})();
+147
View File
@@ -0,0 +1,147 @@
// Alchemy Potion Sliding Block Puzzle (Schieberätsel)
(function() {
const gridEl = document.getElementById('slidingGrid');
if (!gridEl) return;
const movesVal = document.getElementById('slidingMoves');
const timerVal = document.getElementById('slidingTimer');
const modalBackdrop = document.getElementById('modalBackdrop');
const modalTitle = document.getElementById('modalTitle');
const modalText = document.getElementById('modalText');
const modalBtn = document.getElementById('modalBtn');
const btnScramble = document.getElementById('btnScramble');
const SIZE = 3; // 3x3 sliding puzzle (8 tiles + 1 blank)
let tiles = [];
let blankIndex = 8;
let moves = 0;
let timerInterval = null;
let secondsElapsed = 0;
let isSolved = false;
// Tile images / potion runes
const tileSymbols = [
{ id: 1, icon: "🧪", name: "Alraune", color: "#2ecc71" },
{ id: 2, icon: "🍷", name: "Brennnesselwein", color: "#e74c3c" },
{ id: 3, icon: "⚡", name: "Blitzphiole", color: "#f1c40f" },
{ id: 4, icon: "🔮", name: "Phönix-Träne", color: "#9b59b6" },
{ id: 5, icon: "🐉", name: "Drachenblut", color: "#e67e22" },
{ id: 6, icon: "💀", name: "Tobsuchtsgift", color: "#1abc9c" },
{ id: 7, icon: "💎", name: "Elixier des Lebens", color: "#3498db" },
{ id: 8, icon: "🕯️", name: "Ewige Flamme", color: "#d35400" },
{ id: 0, icon: "", name: "Leerer Runen-Sockel", color: "transparent" } // Blank
];
function init() {
tiles = [1, 2, 3, 4, 5, 6, 7, 8, 0];
blankIndex = 8;
moves = 0;
secondsElapsed = 0;
isSolved = false;
if (movesVal) movesVal.innerText = moves;
if (timerVal) timerVal.innerText = '0s';
if (timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(() => {
if (!isSolved) {
secondsElapsed++;
if (timerVal) timerVal.innerText = secondsElapsed + 's';
}
}, 1000);
scramble();
render();
}
function scramble() {
// Perform 60 random valid moves from blank to ensure solvability
for (let i = 0; i < 60; i++) {
const neighbors = getNeighbors(blankIndex);
const randomNeighbor = neighbors[Math.floor(Math.random() * neighbors.length)];
tiles[blankIndex] = tiles[randomNeighbor];
tiles[randomNeighbor] = 0;
blankIndex = randomNeighbor;
}
moves = 0;
if (movesVal) movesVal.innerText = moves;
}
function getNeighbors(idx) {
const row = Math.floor(idx / SIZE);
const col = idx % SIZE;
const res = [];
if (row > 0) res.push(idx - SIZE); // Up
if (row < SIZE - 1) res.push(idx + SIZE); // Down
if (col > 0) res.push(idx - 1); // Left
if (col < SIZE - 1) res.push(idx + 1); // Right
return res;
}
function render() {
gridEl.innerHTML = '';
tiles.forEach((val, idx) => {
const tile = document.createElement('div');
tile.className = 'sliding-tile' + (val === 0 ? ' blank' : '');
tile.dataset.index = idx;
if (val !== 0) {
const info = tileSymbols.find(s => s.id === val);
tile.innerHTML = `
<span class="tile-icon">${info.icon}</span>
<span class="tile-num">${val}</span>
<span class="tile-name">${info.name}</span>
`;
tile.style.borderColor = info.color;
}
tile.addEventListener('click', () => onTileClick(idx));
gridEl.appendChild(tile);
});
}
function onTileClick(idx) {
if (isSolved) return;
const neighbors = getNeighbors(idx);
if (neighbors.includes(blankIndex)) {
// Swap with blank
tiles[blankIndex] = tiles[idx];
tiles[idx] = 0;
blankIndex = idx;
moves++;
if (movesVal) movesVal.innerText = moves;
if (window.soundFX) window.soundFX.playStoneSlide();
render();
checkWin();
}
}
function checkWin() {
const winning = [1, 2, 3, 4, 5, 6, 7, 8, 0];
const match = tiles.every((v, i) => v === winning[i]);
if (match) {
isSolved = true;
clearInterval(timerInterval);
if (window.soundFX) window.soundFX.playMagicSuccess();
fetch('/api/sliding/complete', { method: 'POST' }).catch(console.error);
modalTitle.innerText = "✨ Schieberätsel gelöst!";
modalText.innerHTML = `Mit geschicktem Schieben hast du alle 8 alchemistischen Phiolen in die perfekte harmonische Ordnung gebracht!<br><br>
<strong>Züge:</strong> ${moves}<br>
<strong>Zeit:</strong> ${secondsElapsed} Sekunden<br><br>
Die Runenplatte leuchtet golden auf und der Weg zur Flammenkammer öffnet sich!`;
modalBtn.innerText = "Zur Flammenkammer 🧪";
modalBtn.onclick = () => { window.location.href = '/bottles'; };
modalBackdrop.classList.add('show');
}
}
if (btnScramble) {
btnScramble.addEventListener('click', () => {
if (window.soundFX) window.soundFX.playStoneSlide();
init();
});
}
init();
})();
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="{{ lang or 'de' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
<title>{% block title %}Hogwarts Escape Adventure{% endblock %} · bootle</title>
<link rel="icon" href="/static/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=MedievalSharp&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/escape.css?v=1.0">
{% block head %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a href="/" class="brand-link">
<span>🏰⚡</span>
<span class="brand-title">Hogwarts Escape</span>
</a>
<nav class="nav-links">
<a href="/" class="nav-link {% if request.url.path == '/' %}active{% endif %}">🚪 Kammern</a>
<a href="/broom" class="nav-link {% if request.url.path == '/broom' %}active{% endif %}">🧹 Besenflug</a>
<a href="/bottles" class="nav-link {% if request.url.path == '/bottles' %}active{% endif %}">🧪 Snapes Flaschen</a>
<a href="/sliding" class="nav-link {% if request.url.path == '/sliding' %}active{% endif %}">🧩 Schieberätsel</a>
<a href="https://quiz.dev.waiswuaschd.de" class="nav-link" target="_blank" rel="noopener">📜 Hogwarts-Quiz ↗</a>
</nav>
<div class="header-right">
<button type="button" id="audioToggleBtn" class="btn-control" onclick="toggleSound()">
🔊 <span class="audio-lbl">Ton: An</span>
</button>
</div>
</div>
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<!-- Global Modal Backdrop -->
<div id="modalBackdrop" class="modal-backdrop">
<div class="modal-content">
<div id="modalIcon" class="modal-icon">🏆</div>
<h2 id="modalTitle" class="modal-title">Prüfung bestanden!</h2>
<p id="modalText" class="modal-text">Details zur Lösung...</p>
<button id="modalBtn" class="btn-magical">Weiter</button>
</div>
</div>
<footer class="footer">
<p>⚡ <strong>Hogwarts Escape Adventure</strong> · Teil des Magischen Zauberer-Kosmos ⚡</p>
<p style="margin-top: 0.5rem;">
<a href="/">Startseite</a> ·
<a href="/broom">Der Besenflug</a> ·
<a href="/bottles">Snapes Flaschenrätsel</a> ·
<a href="/sliding">Alchemie-Schieberätsel</a> ·
<a href="https://quiz.dev.waiswuaschd.de" target="_blank" rel="noopener">Hogwarts Quiz Portal</a>
</p>
</footer>
<script src="/static/js/audio.js?v=1.0"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+242
View File
@@ -0,0 +1,242 @@
{% extends "base.html" %}
{% block title %}Kammer II: Snapes Flaschenrätsel & Flammenkammer · Hogwarts Escape{% endblock %}
{% block content %}
<div class="parchment-scroll">
<h1 class="parchment-title">🧪 Kammer II: Snapes Flaschenrätsel 🔥</h1>
<div class="riddle-poem">
"Gefahr liegt vor euch, die Rettung zurück,
Zwei von uns helfen euch, habt ihr das Glück,
Eine von sieben führt euch hindurch,
Eine bringt den, der sie trinkt, sicher zurück,
Zwei von ihnen enthalten nur Brennnesselwein,
Drei sind tödlich, schenkt ihr sie ein.
Wählt oder bleibt auf ewig hier stehen,
Vier Hinweise helfen euch, alles zu sehen:
1. So schlau sich das Gift auch zu tarnen mag,
stets steht es zur Linken vom Wein im Gelag.
2. Verschieden sind jene am Ende der Reih',
doch wollt ihr nach vorn, ist keines getreu.
3. Wie ihr deutlich seht, sind alle verschieden groß,
weder Zwerg noch Riese birgt des Todes Los.
4. Die Zweite von links und die Zweite von rechts
sind Zwillinge im Geschmack des Geflechts,
obwohl sie dem Auge ganz ungleich erscheinen."
</div>
</div>
<div class="flame-chamber-arena">
<!-- The Two Flaming Portals -->
<div class="flame-wall-container">
<!-- Purple Fire (Retreat) -->
<div class="flame-gate purple">
<div class="flame-icon-animated" style="color: #c77dff;">🟣🔥</div>
<h3 style="color: #e0aaff; margin: 0.5rem 0;">Violette Flammen</h3>
<p style="font-size: 0.85rem; color: #d8bbff;">Der Rückweg in den Besenraum</p>
</div>
<!-- Black Fire (Advance to the Stone) -->
<div class="flame-gate black">
<div class="flame-icon-animated" style="color: #7b2cbf;">🖤🔥</div>
<h3 style="color: #ffd700; margin: 0.5rem 0;">Schwarze Flammen</h3>
<p style="font-size: 0.85rem; color: #c4b5fd;">Das Tor zur Kammer des Steins der Weisen!</p>
</div>
</div>
<!-- The 7 Bottles Table -->
<div class="bottles-table-wrapper">
<h4 style="text-align: center; color: var(--gold); margin-bottom: 0.5rem;">
🖐️ Wähle oder schiebe die Flaschen auf den Sockeln (Drag & Drop / Klick):
</h4>
<p style="text-align: center; font-size: 0.85rem; color: var(--text-dim); margin-bottom: 1.25rem;">
Nutze die Notiz-Dropdowns unter jeder Flasche, um deine logische Deduktion festzuhalten!
</p>
<div class="bottles-table" id="bottlesTable">
<!-- Bottle 1 (Poison) -->
<div class="bottle-slot" data-index="1">
<svg class="bottle-svg" width="46" height="90" viewBox="0 0 50 100">
<!-- Amber Medium Bottle -->
<rect x="20" y="5" width="10" height="15" fill="#5c4033" rx="2" />
<rect x="18" y="0" width="14" height="6" fill="#8B4513" rx="2" />
<path d="M15,25 Q5,35 5,55 L5,90 Q5,98 15,98 L35,98 Q45,98 45,90 L45,55 Q45,35 35,25 Z" fill="#964B00" opacity="0.85"/>
<path d="M15,25 Q5,35 5,55 L5,90 Q5,98 15,98 L35,98 Q45,98 45,90 L45,55 Q45,35 35,25 Z" stroke="#e5b948" stroke-width="1.5" fill="none"/>
<ellipse cx="25" cy="65" rx="8" ry="12" fill="#582900" opacity="0.6"/>
</svg>
<div class="bottle-pedestal">I</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 2 (Nettle Wine) -->
<div class="bottle-slot" data-index="2">
<svg class="bottle-svg" width="42" height="110" viewBox="0 0 50 120">
<!-- Green Slim Flacon -->
<rect x="20" y="5" width="10" height="20" fill="#5c4033" rx="2" />
<rect x="17" y="0" width="16" height="6" fill="#8B4513" rx="2" />
<path d="M18,30 L10,50 L10,110 Q10,118 25,118 Q40,118 40,110 L40,50 L32,30 Z" fill="#2d6a4f" opacity="0.85"/>
<path d="M18,30 L10,50 L10,110 Q10,118 25,118 Q40,118 40,110 L40,50 L32,30 Z" stroke="#e5b948" stroke-width="1.5" fill="none"/>
<ellipse cx="25" cy="75" rx="6" ry="18" fill="#1b4332" opacity="0.6"/>
</svg>
<div class="bottle-pedestal">II</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 3 (AHEAD - The Dwarf / Smallest!) -->
<div class="bottle-slot" data-index="3">
<svg class="bottle-svg" width="36" height="58" viewBox="0 0 40 65">
<!-- Tiny Cobalt Vial (The Dwarf!) -->
<rect x="16" y="4" width="8" height="10" fill="#c0c0c0" rx="1" />
<path d="M14,16 Q8,24 8,36 L8,55 Q8,62 20,62 Q32,62 32,55 L32,36 Q32,24 26,16 Z" fill="#0077b6" opacity="0.9"/>
<path d="M14,16 Q8,24 8,36 L8,55 Q8,62 20,62 Q32,62 32,55 L32,36 Q32,24 26,16 Z" stroke="#00ffff" stroke-width="2" fill="none"/>
<circle cx="20" cy="40" r="5" fill="#90e0ef" opacity="0.8"/>
</svg>
<div class="bottle-pedestal" style="border-color: #00ffff; color: #00ffff;">III (Zwerg)</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 4 (Poison) -->
<div class="bottle-slot" data-index="4">
<svg class="bottle-svg" width="48" height="96" viewBox="0 0 50 100">
<!-- Triangular Potion Flask -->
<rect x="20" y="5" width="10" height="15" fill="#4a3525" rx="2" />
<polygon points="20,22 5,88 45,88 30,22" fill="#52b788" opacity="0.85"/>
<polygon points="20,22 5,88 45,88 30,22" stroke="#e5b948" stroke-width="1.5" fill="none"/>
<circle cx="25" cy="60" r="7" fill="#2d6a4f" opacity="0.7"/>
</svg>
<div class="bottle-pedestal">IV</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 5 (Poison) -->
<div class="bottle-slot" data-index="5">
<svg class="bottle-svg" width="50" height="102" viewBox="0 0 60 110">
<!-- Bulbous Violet Bottle -->
<rect x="25" y="5" width="10" height="20" fill="#3b2b1e" rx="2" />
<circle cx="30" cy="65" r="25" fill="#7209b7" opacity="0.85"/>
<circle cx="30" cy="65" r="25" stroke="#e5b948" stroke-width="1.5" fill="none"/>
<circle cx="30" cy="65" r="10" fill="#3a0ca3" opacity="0.6"/>
</svg>
<div class="bottle-pedestal">V</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 6 (Nettle Wine) -->
<div class="bottle-slot" data-index="6">
<svg class="bottle-svg" width="44" height="114" viewBox="0 0 50 120">
<!-- Tall Slender Green Bottle (Twin of 2) -->
<rect x="20" y="5" width="10" height="18" fill="#5c4033" rx="2" />
<rect x="17" y="0" width="16" height="6" fill="#8B4513" rx="2" />
<path d="M19,25 Q12,45 12,80 L12,110 Q12,116 25,116 Q38,116 38,110 L38,80 Q38,45 31,25 Z" fill="#2d6a4f" opacity="0.85"/>
<path d="M19,25 Q12,45 12,80 L12,110 Q12,116 25,116 Q38,116 38,110 L38,80 Q38,45 31,25 Z" stroke="#e5b948" stroke-width="1.5" fill="none"/>
<ellipse cx="25" cy="80" rx="7" ry="20" fill="#1b4332" opacity="0.6"/>
</svg>
<div class="bottle-pedestal">VI</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
<!-- Bottle 7 (BACK - The Giant / Largest!) -->
<div class="bottle-slot" data-index="7">
<svg class="bottle-svg" width="60" height="135" viewBox="0 0 70 140">
<!-- Giant Round Carafe (The Giant!) -->
<rect x="27" y="8" width="16" height="25" fill="#8B4513" rx="3" />
<rect x="24" y="0" width="22" height="10" fill="#b08968" rx="3" />
<path d="M22,35 Q5,55 5,95 Q5,135 35,135 Q65,135 65,95 Q65,55 48,35 Z" fill="#b5179e" opacity="0.85"/>
<path d="M22,35 Q5,55 5,95 Q5,135 35,135 Q65,135 65,95 Q65,55 48,35 Z" stroke="#f72585" stroke-width="2" fill="none"/>
<ellipse cx="35" cy="95" rx="16" ry="22" fill="#560bad" opacity="0.6"/>
</svg>
<div class="bottle-pedestal" style="border-color: #f72585; color: #ff70a6;">VII (Riese)</div>
<select class="bottle-tag-select">
<option value="">? Notiz</option>
<option value="poison">💀 Gift</option>
<option value="wine">🍷 Wein</option>
<option value="ahead">🔥 Vorwärts</option>
<option value="back">🟣 Zurück</option>
</select>
</div>
</div>
</div>
<!-- Selection & Drinking Actions -->
<div style="text-align: center; margin-top: 1.5rem;">
<div id="selectedBottleInfo" style="font-size: 1.15rem; color: var(--gold); margin-bottom: 1.25rem;">
👆 <em>Klicke auf eine der 7 Flaschen auf dem Altar, um sie zu prüfen und zu trinken.</em>
</div>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<button id="btnDrinkAhead" class="btn-magical" disabled style="background: linear-gradient(135deg, #1b092b, #431272, #7209b7); color: #fff; border: 1px solid #ffd700;">
🖤 Trank trinken & durch das SCHWARZE FEUER (Vorwärts zum Stein)
</button>
<button id="btnDrinkBack" class="btn-magical" disabled style="background: linear-gradient(135deg, #2b0938, #5e1178, #9d4edd); color: #fff; border: 1px solid #c77dff;">
🟣 Trank trinken & durch die VIOLETTEN FLAMMEN (Zurück)
</button>
</div>
</div>
</div>
<!-- Hermine's Logic Notebook (Collapsible Clues) -->
<div class="dungeon-card" style="margin-top: 2rem;">
<details>
<summary style="cursor: pointer; font-size: 1.2rem; color: var(--gold); font-weight: bold;">
📖 Hermines Logik-Spickzettel & Denkhilfe öffnen (Hier klicken)
</summary>
<div style="margin-top: 1rem; line-height: 1.8; color: var(--text-light); font-size: 1rem;">
<p><strong>Die 4 genialen Schlussfolgerungen aus Snapes Versen:</strong></p>
<ul style="margin-left: 1.5rem; margin-top: 0.5rem;">
<li><strong>Hinweis 4:</strong> Flasche II und VI schmecken identisch (Zwillinge). Da es nur 2 Brennnesselweine gibt, sind II und VI zwingend der Wein!</li>
<li><strong>Hinweis 1:</strong> Links neben jedem Wein steht immer Gift. Daher müssen Flasche I (links von II) und Flasche V (links von VI) Gift sein!</li>
<li><strong>Hinweis 2:</strong> Flasche I und VII sind unterschiedlich. Da I Gift ist, kann VII kein Gift sein. Außerdem bringt keines der beiden Enden vorwärts.</li>
<li><strong>Hinweis 3:</strong> Weder der <strong>Zwerg (Flasche III)</strong> noch der <strong>Riese (Flasche VII)</strong> bergen Gift. Da Flasche III die Vorwärts-Flasche ist, führt nur sie durch das schwarze Feuer! Flasche VII bringt dich durch die violetten Flammen sicher zurück.</li>
</ul>
</div>
</details>
</div>
<div style="text-align: center; margin-top: 1.5rem;">
<a href="/sliding" class="btn-control" style="display: inline-flex; margin: 0 auto;">
🧩 Zum mechanischen Alchemie-Schieberätsel ➔
</a>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/bottles.js?v=1.0"></script>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Kammer I: Der Flug der geflügelten Schlüssel · Hogwarts Escape{% endblock %}
{% block content %}
<div class="parchment-scroll">
<h1 class="parchment-title">🧹 Kammer I: Der Besenflug 🗝️</h1>
<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>
Die schwere Kerkertür am anderen Ende ist fest verschlossen.<br>
Nur ein einziger Schlüssel passt: Ein alter, großer Silberschlüssel mit geknicktem blauem Flügel!"
</p>
</div>
<div class="canvas-wrapper">
<div class="game-hud">
<div>⏱️ Zeit: <strong id="timerVal">0s</strong></div>
<div>✨ Punkte: <strong id="scoreVal">0</strong></div>
<div>🎯 Mission: <span id="missionText">Fange den Schlüssel mit geknicktem Flügel!</span></div>
</div>
<canvas id="broomCanvas"></canvas>
</div>
<!-- Mobile Touch Controls -->
<div class="mobile-controls">
<button id="mBtnUp" class="btn-control" style="font-size: 1.2rem; padding: 0.8rem 1.4rem;">▲ Hoch</button>
<button id="mBtnBoost" class="btn-control" style="font-size: 1.2rem; padding: 0.8rem 1.4rem; background: var(--gold); color: #000;">⚡ Turbo</button>
<button id="mBtnDown" class="btn-control" style="font-size: 1.2rem; padding: 0.8rem 1.4rem;">▼ Runter</button>
</div>
<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;">
🎮 <strong>Steuerung:</strong> Pfeiltasten / WASD / Mausbewegung zum Fliegen · <strong>Leertaste / Klick</strong> für Besen-Turbo!
</p>
<p style="text-align: center; color: var(--text-dim); font-size: 0.95rem;">
Fliege durch den Raum, jage den ausweichenden Silberschlüssel mit dem verletzten Flügel und fliege nach dem Fang zur Tür ganz rechts!
</p>
</div>
<div style="text-align: center; margin-top: 1rem;">
<a href="/bottles" class="nav-link" style="color: var(--text-dim);">Direkt weiter zu Snapes Flaschenkammer 🧪 ➔</a>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/broom.js?v=1.0"></script>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Escape Gemeistert! Der Stein der Weisen ist in Sicherheit!{% endblock %}
{% block content %}
<div class="parchment-scroll" style="text-align: center;">
<h1 class="parchment-title" style="font-size: 2.3rem;">⚡ ESCAPE GEMEISTERT! ⚡</h1>
<div style="font-size: 5rem; margin: 1rem 0; filter: drop-shadow(0 0 25px #ff0055); animation: stonePulse 2s infinite alternate;">
💎
</div>
<style>
@keyframes stonePulse {
0% { transform: scale(1); filter: drop-shadow(0 0 15px #e63946); }
100% { transform: scale(1.12); filter: drop-shadow(0 0 35px #ff0055); }
}
</style>
<p class="riddle-poem" style="font-size: 1.4rem;">
"Der eisige Kälteschleier der dritten Phiole trug dich unbeschadet durch das schwarze Feuer.<br>
Dahinter stand der goldene Spiegel Nerhegeb...<br>
Und tief in deiner Tasche spürst du plötzlich ein schweres, pulsierendes Gewicht:<br>
<strong>Der Stein der Weisen ist in Sicherheit!</strong>"
</p>
</div>
<div class="dungeon-card" style="text-align: center; max-width: 650px; margin: 0 auto 2rem auto;">
<h2 style="color: var(--gold); margin-bottom: 1rem;">📜 Hogwarts Escape Urkunde</h2>
<p style="color: var(--text-dim); line-height: 1.7; margin-bottom: 1.5rem;">
Du hast bewiesen, dass Mut, Flugkunst auf dem Besen und unbestechliche Logik stärker sind als jede dunkle Magie.
Professor Dumbledore und das gesamte Kollegium von Hogwarts sprechen dir ihre höchste Anerkennung aus!
</p>
<div style="background: rgba(229, 185, 72, 0.1); border: 2px dashed var(--gold); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.5rem;">
<div style="font-size: 1.2rem; color: var(--gold); font-weight: bold; margin-bottom: 0.5rem;">
Auszeichnung: Retter des Steins der Weisen
</div>
<div style="color: var(--text-light); font-size: 0.95rem;">
Besenflug gemeistert: <strong>{% if state.broom_solved %}Ja ({{ state.broom_score }} Pkt){% else %}Übersprungen{% endif %}</strong><br>
Snapes Flammenkammer gelöst: <strong>Ja (Flasche III)</strong>
</div>
</div>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap;">
<a href="https://quiz.dev.waiswuaschd.de" class="btn-magical" target="_blank" rel="noopener">
📜 Zum großen Hogwarts-Quiz ↗
</a>
<button onclick="restartEscape()" class="btn-control" style="font-size: 1rem; padding: 0.6rem 1.2rem;">
🔄 Nochmal spielen
</button>
</div>
</div>
<script>
async function restartEscape() {
await fetch('/api/reset', { method: 'POST' });
window.location.href = '/';
}
</script>
{% endblock %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Die Prüfungen des Steins der Weisen · Hogwarts Escape{% endblock %}
{% block content %}
<div class="parchment-scroll">
<h1 class="parchment-title">⚡ Die Kammern des Steins der Weisen ⚡</h1>
<p class="riddle-poem" style="font-size: 1.3rem; margin: 0.5rem 0;">
"Die Falltür unter Fluffys Pfoten hat sich geöffnet.<br>
Tief unter den Kerkern von Hogwarts wachen uralte Zauber über das größte Geheimnis der Alchemie.<br>
Beweise deinen Scharfsinn, dein Geschick auf dem Besen und deine eiserne Logik!"
</p>
</div>
<div class="dungeon-card">
<h2 style="color: var(--gold); text-align: center; margin-bottom: 0.5rem; font-size: 1.8rem;">
Wähle deine Prüfung im Escape Adventure
</h2>
<p style="text-align: center; color: var(--text-dim); margin-bottom: 1.5rem;">
Du kannst die Kammern einzeln erkunden oder die Prüfungen nacheinander bezwingen:
</p>
<div class="chamber-grid">
<!-- Chamber 1: Winged Keys -->
<div class="chamber-card">
<span class="chamber-badge {% if state.broom_solved %}solved{% endif %}">
{% if state.broom_solved %}✅ Gemeistert{% else %}🔓 Bereit{% endif %}
</span>
<div class="chamber-icon">🧹🗝️</div>
<h3 class="chamber-title">Kammer I: Der Besenflug</h3>
<p class="chamber-desc">
Professor Flitwicks verzauberte Kammer. Schwärme geflügelter Schlüssel surren unter der hohen Gewölbedecke.
Finde den großen, alten Silberschlüssel mit dem geknickten Flügel und schließe die Tür auf!
</p>
<a href="/broom" class="btn-magical">Auf den Besen schwingen 🧹</a>
</div>
<!-- Chamber 2: Snape's Potions -->
<div class="chamber-card">
<span class="chamber-badge {% if state.bottles_solved %}solved{% endif %}">
{% if state.bottles_solved %}✅ Gemeistert{% else %}🔥 Bereit{% endif %}
</span>
<div class="chamber-icon">🧪🔥</div>
<h3 class="chamber-title">Kammer II: Snapes Flaschenrätsel</h3>
<p class="chamber-desc">
Schwarze Flammen versperren den Weg zum Stein, violette Flammen den Rückweg.
Auf dem Tisch stehen 7 Flaschen und Snapes unerbittliches Logik-Gedicht.
Schiebe, etikettiere und wähle die richtige Phiole!
</p>
<a href="/bottles" class="btn-magical">Flammenkammer betreten 🧪</a>
</div>
<!-- Bonus: Alchemy Sliding Puzzle -->
<div class="chamber-card">
<span class="chamber-badge {% if state.sliding_solved %}solved{% endif %}">
{% if state.sliding_solved %}✅ Gelöst{% else %}🧩 Zusatz-Rätsel{% endif %}
</span>
<div class="chamber-icon">🧩⚗️</div>
<h3 class="chamber-title">Alchemie-Schieberätsel</h3>
<p class="chamber-desc">
Das mechanische Runen-Schiebesiegel auf der Steintafel.
Verschiebe die alchemistischen Elixier-Kacheln, um die verborgene Essenz der Alraune und des Lebenselixiers freizuschalten.
</p>
<a href="/sliding" class="btn-magical">Schieberätsel lösen 🧩</a>
</div>
<!-- Quiz Portal Link -->
<div class="chamber-card">
<span class="chamber-badge" style="background: rgba(212, 175, 55, 0.25); border-color: var(--gold); color: var(--gold);">
✨ Quiz Portal
</span>
<div class="chamber-icon">🏰📜</div>
<h3 class="chamber-title">Die Große Halle: Quiz</h3>
<p class="chamber-desc">
Möchtest du dein gesamtes theoretisches Wissen über die Zaubererwelt auf die Probe stellen?
Hunderte Fragen aus allen 7 Schuljahren warten im Hogwarts-Quiz!
</p>
<a href="https://quiz.dev.waiswuaschd.de" class="btn-magical" target="_blank" rel="noopener">Zum Hogwarts-Quiz ↗</a>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn-control" onclick="resetProgress()" style="margin: 0 auto; display: inline-flex;">
🔄 Fortschritt zurücksetzen
</button>
</div>
<script>
async function resetProgress() {
if (confirm("Möchtest du deinen bisherigen Escape-Fortschritt wirklich zurücksetzen?")) {
await fetch('/api/reset', { method: 'POST' });
window.location.reload();
}
}
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Alchemie-Schieberätsel · Hogwarts Escape{% endblock %}
{% block content %}
<div class="parchment-scroll">
<h1 class="parchment-title">🧩 Das Alchemie-Schieberätsel ⚗️</h1>
<p class="riddle-poem" style="font-size: 1.25rem; margin: 0.5rem 0;">
"Auf dem antiken Steinsockel ruht ein uralter Verschlussmechanismus.<br>
Acht alchemistische Essenzen sind durcheinandergeraten.<br>
Schiebe die Phiolen und Runen auf die freien Felder, bis alle Plätze von 1 bis 8 geordnet sind!"
</p>
</div>
<div class="dungeon-card" style="text-align: center;">
<div style="display: flex; justify-content: center; gap: 2rem; margin-bottom: 1rem; font-size: 1.2rem; color: var(--gold);">
<div>⏱️ Zeit: <strong id="slidingTimer">0s</strong></div>
<div>🔄 Züge: <strong id="slidingMoves">0</strong></div>
</div>
<div class="sliding-board-container">
<div id="slidingGrid" class="sliding-grid">
<!-- Tiles rendered via sliding.js -->
</div>
</div>
<div style="margin-top: 1.5rem; display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap;">
<button id="btnScramble" class="btn-control" style="font-size: 1rem; padding: 0.6rem 1.2rem;">
🎲 Neu mischen
</button>
<a href="/bottles" class="btn-magical">
🧪 Zu Snapes Flaschenrätsel
</a>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/sliding.js?v=1.0"></script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
server {
listen 80;
listen [::]:80;
server_name bootle.dev.waiswuaschd.de;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name bootle.dev.waiswuaschd.de;
ssl_certificate /etc/nginx/ssl/bootle.dev.waiswuaschd.de/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/bootle.dev.waiswuaschd.de/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /static/ {
alias /home/maxi/Projekt/bootle.waiswuaschd.de/app/static/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:8002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Executable
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Magisches Hogwarts Escape Adventure (FastAPI)
After=network.target
[Service]
Type=simple
User=maxi
Group=maxi
WorkingDirectory=/home/maxi/Projekt/bootle.waiswuaschd.de
Environment="PATH=/home/maxi/Projekt/bootle.waiswuaschd.de/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/home/maxi/Projekt/bootle.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8002 --workers 2
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.110.0
uvicorn>=0.29.0
jinja2>=3.1.3
python-dotenv>=1.0.1
Executable
+63
View File
@@ -0,0 +1,63 @@
import sys
from starlette.testclient import TestClient
from app.main import app
def test_endpoints():
client = TestClient(app)
# Test pages
res = client.get("/")
assert res.status_code == 200, f"Index failed: {res.status_code}"
assert "Die Kammern des Steins der Weisen" in res.text
res = client.get("/broom")
assert res.status_code == 200
assert "Der Besenflug" in res.text
res = client.get("/bottles")
assert res.status_code == 200
assert "Snapes Flaschenrätsel" in res.text
res = client.get("/sliding")
assert res.status_code == 200
assert "Alchemie-Schieberätsel" in res.text
res = client.get("/finale")
assert res.status_code == 200
assert "ESCAPE GEMEISTERT" in res.text
# Test API Broom complete
res = client.post("/api/broom/complete", json={"score": 500, "time_taken": 25})
assert res.status_code == 200
data = res.json()
assert data["success"] is True
# Test API Bottles drink: Poison (Bottle 1)
res = client.post("/api/bottles/drink", json={"bottle": 1, "gate": "ahead"})
assert res.status_code == 200
assert res.json()["result"] == "poison"
# Test API Bottles drink: Wine (Bottle 2)
res = client.post("/api/bottles/drink", json={"bottle": 2, "gate": "ahead"})
assert res.status_code == 200
assert res.json()["result"] == "wine"
# Test API Bottles drink: Back (Bottle 7)
res = client.post("/api/bottles/drink", json={"bottle": 7, "gate": "back"})
assert res.status_code == 200
assert res.json()["result"] == "success_back"
# Test API Bottles drink: Ahead / Black fire (Bottle 3 - Dwarf)
res = client.post("/api/bottles/drink", json={"bottle": 3, "gate": "ahead"})
assert res.status_code == 200
assert res.json()["result"] == "success_ahead"
# Test API Reset
res = client.post("/api/reset")
assert res.status_code == 200
assert res.json()["success"] is True
print("ALL TESTS PASSED SUCCESSFULLY! 🧙‍♂️✨")
if __name__ == "__main__":
test_endpoints()