Initial commit: Harry Potter Escape Adventure on bootle.dev.waiswuaschd.de
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
PORT=8002
|
||||
SECRET_KEY=hogwarts_magic_bootle_potion_key_2026_escape
|
||||
@@ -0,0 +1,3 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Executable
BIN
Binary file not shown.
Executable
+230
@@ -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."
|
||||
})
|
||||
Executable
+590
@@ -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;
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 751 KiB |
Executable
+162
@@ -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 });
|
||||
});
|
||||
Executable
+181
@@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
Executable
+425
@@ -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');
|
||||
}
|
||||
})();
|
||||
Executable
+147
@@ -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();
|
||||
})();
|
||||
Executable
+66
@@ -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>
|
||||
Executable
+242
@@ -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 %}
|
||||
Executable
+48
@@ -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 %}
|
||||
Executable
+58
@@ -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 %}
|
||||
Executable
+97
@@ -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 %}
|
||||
Executable
+40
@@ -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 %}
|
||||
Executable
+39
@@ -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
@@ -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
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.110.0
|
||||
uvicorn>=0.29.0
|
||||
jinja2>=3.1.3
|
||||
python-dotenv>=1.0.1
|
||||
Executable
+63
@@ -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()
|
||||
Reference in New Issue
Block a user