From 0e20549a44ec3faf8aa5e2a120170f01b6b3fc9f Mon Sep 17 00:00:00 2001 From: Maxi Date: Tue, 22 Sep 2026 19:38:57 +0200 Subject: [PATCH] Overhaul: Golden ring flight course and magic spell dueling mini-game with mobile gestures --- .gitignore | 2 +- app/__pycache__/main.cpython-313.pyc | Bin 17427 -> 17274 bytes app/main.py | 83 ++--- app/static/js/broom.js | 473 ++++++++++++++------------- app/static/js/duel.js | 454 +++++++++++++++++++++++++ app/templates/base.html | 19 +- app/templates/broom.html | 23 +- app/templates/duel.html | 43 +++ app/templates/index.html | 29 +- test_app.py | 34 +- 10 files changed, 831 insertions(+), 329 deletions(-) create mode 100755 app/static/js/duel.js create mode 100755 app/templates/duel.html diff --git a/.gitignore b/.gitignore index d0ee3b1..9b5f630 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -venv/ __pycache__/ *.pyc +venv/ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 715350d4c13b3c3595ef32605784f0d2e6c307a7..f6fc3f068d2258230848c4eadea0ac97a707c187 100644 GIT binary patch delta 4797 zcmb7Hdu&_f6~FiT+D@F8^L{5canjUj;$$Ixr0=9jnzl5gxpDePm~reI$Blg*zH2v4 zHs->{CcIW+9&tR1M;A}pFIu92jqkDA^Cpxbl4`6MtOMB zAU`l^=@#XYD(<96n%K&bNsBx>*~}zavP}OW)E10QX3O>-0p;C-JWk7W%>_r(){fDG zuy#Cc?F9XvuDN_-Gat8Ho~+_FCtiYVqA{Vo;K4Nfx9Q(Nha+w6AzCFi7dX?_rs#;+ zT<==f8@I}h@35}F1NB>!49s*IrgO4n)I|$Ri|HQGR2kPYlZNO@A}rnvO()ZE_a$-k z=j)n$X^8G5!s5@+q^99Euj2w4xVbdkmL!fI)+cQzX^5>J-m1|L^{?ndTfyt}5rb4d zLf(0MI;46ORdOptI^hgWDMazoUmHH>?ewy-yCkH#rBmK1;tYBNs>Y;2`mu3=AEOUu zS#8tryiH)MDiNpVRD-|_`a&Mb9U`t7NpsE2D^rTMq2q8L@T!t0;DaL+O|DR%)q)C{aZU%7 zgzZ|;In};jfn`a3y?1-+rofoH%L3GASM%zBA_Zn{#gCyf9jtw`FC*pPJ6YdN2pyO7<2WE+z0^f$S! zd>8#^?q~WR*||{;!m7lq2a(FlS$2L010f)w1#Iz`6F7T!}UmH5l;Ak>SWtQ6*o)j^2w4{YcPMtAWH3^pRr8 zjLT8OOdzBwnpH=}=`V^ab9N#&0a_`(Pdb615|0ve1Qq|B&lyx4t^fqw=^*idNQ*R; zoae*CxT_b*9w3V*FT~zy+f2~!1Me`ErLZ0~Cbljc3BGeQ_u-I2790=`PQOORasLBI z%1~$?OB^_=XmgMQ3Og{VFK!~>G>F=2PLd?TOf}6pJ&J>!PaX#G8Tx+JiOB0^8~Djg zBd16p;AcjXSv=K`WG(6EuBbJ;7 zg%{wb(Uq!kzJgvZs|vGI77ZPcp_=9b;rfM9{eAeY)d8_^688c(ktp#H$Gj}ax*nm4 z+p3%9t>Q2_1t_l#09GNY3lU#52a+}z(%^f#6)R7SAYbOlB;4Yz%qNQ5yO}7ipo1Mw z9s$wM;HQ-Uxy7B~?ije-nkD09c_pXkhS>9OO$5PXIeB~}Tq*GPXZRy`Di93%6z$$F z$z?7{z)2XCF2iR$#cPrNYCS)msaNxP-B9Pgson&fJO+B-gdaJBM2oyqBl4NwFlXAU zI(^E$Rp&61<7o0YDEuw`v=%I;?mDFdgPmj{IR-=%fF$EJ&pGFHV0z)lP{QIJdn(F{N!+2D!?Nnz4{uX;*Rg8{NYo&w14>Gg)E^2%$@FFd!fRC`%} zvwTa$(RhV7plzg!mNi+!Y9W^kLY-ADYj#vTsRh(%_P}YE zGKam8nH|;p0`m%q8?Hx{%*CvDu=fzzdqhRIGw6(Fu^Q*_x>;dkK??R)C*FX40S&$Y zLy;Fi+eeYVG{yL8)?4<%^I+WPy?9U`l0Ed34fTW10vk2@!C0s{)+{oDq9aH~k;q7} zt`j?wlyN*WPQzRU8|aaiQvHj-$LMTJn^0auUu-#TLO-+Q-a@N4-V~2Xqb8h?T39=p z$O9?6Ak9s7LC&%1l2b|(m&FbH={ak4cww5`rK{(rc~$~)hsWa4@FeWK#Xc*BQ`8jn z`V~je2|f50RuVFV1nYPRUwFhxT!9eLB#)Bl`@sgO->XPtID#yO{K*Mmj)Yt@?fp1d zNH7P?1jABbdRkE%ptqo>2v#=s1t3n(A^#i_)(CW7HF#J3XP+?9IB0Sg7J6x{<+ohyse~ke6_Ibwo$A!tZ`zlF~*5{55>ni7->?D@hI~FRRyOo7miE^2#ipRAp+x@)9+W*S)?THKWSd^29rJ% zQ$CDlsmi>=3;h_qImlTP`Fs>TwH{@}iH~2YH!NSV*`(a|695*|U$&QQu4LZyP2jPtc{QXOcnPick(1ydw2r6epj$c;9AN~dfAY*Pvg(Es0xW@beL z?A-v`S~9dX-}O%6Z}L(b*VS1;U2BHAjd!WLr#26inEm|lzZ$p+>_r?s+ErTcpR{7L ziGHW6Dks&bpQcM)meywgJVsc*8U!}pmkuMB${#iz?}_}qtA)2<0fd%>)gx*kiWc$( z6Eh@~3sz1p(ys0|%&fe;ih3>~xj^r9|EUJ&9Y~E#83L9gpKiV(m{UdQ$GAF9-``f< z$r?#U>HeC`vS!CSC8ZTT_3;;FXCJ41J&hJNin3Ho3^5Kj@v5`r3Y=rX3T4pIXlo7q zY0s1Smj+%xuzcXM|AV&vmA3v!+x9k|w?vNb=;XU=pgav_LwOo9KzRc0<^8QgHT-o0 z5B&A)+@VU{^->+KRPe}G0#C2*e9?f@05qw|UE_V{frSd3-XFhO7fP_6CMKg)X^6n) zX?kbZNa41q5YXbyjk!&Z*mnzj_%%F;RT`Wjpx=63!T4DtDDfZ}M}o6B8=u*b%SK5y z*s&>$4NPqIVZ%dIoC|0{)`x35BB@JFt64k!0e8r!>?ZGm1bobC zjr5Z}Tg1aFhGDv4@0+}bez>=yX?nFtTCHfiooCE9#EQ6r>Q(aw_>Z+}o?R`fTdn8< zK{5UB-a7g~Z&~&}-kK+z$&MYPYVX0~TE6+he2jzJrS3a;i|wZG^)BoxU#dA%euKBg zglyh$;piF%w>2Fnbg+e387JtUD1Ws4Y{gB!=(Zs6+fi5?LoSv@pXuwQ{=SX$YTvuv u`Mhz-v&O*)p>E;_>QCJ5`0P_=10=f37g>3L!sF!Rcn@B6!Oxnm6znN?$?PMG2Af0@5H`z{hkey@~U+pG)$X>FKbnzAO zn`i~;?zNCWZ)KB->@O0InrIcL9O$hi2Yajej*Z;IcWQds2Znstq24Ugv(|uYO$O4- zEXHcr;SAcnY#q@0GHCa)n6Wxn&Y&GZwd{h?>k4Mj``I;+S2Ab=%w(!w99kvn0hzWw z$eK;n?0=^0LfxCAsBzVF%xHbDr%PrZ6y{bAEs%?3@HNixu_5z}rdS5YiW!c|_ySGC z8GI{e`Hn5%qZxcnvwZ3TJ}rZ9)hu6P0pI-@e5*sE?>M_{9yg3^V9#2l(j9U%s?eC! zuc#8ylyFQ1lu8Gbu&Tt^?<}8-t?V6Z6Z6^tB@!HrOk7b_B|_yy)XzS$)v%vsogd9s!x3QV8&V!f6hr7cl%ZHSIHZ9T z2}h17RGk$}SYdx^{M<92zW#Wk7x1mX1lz|Isz`E73lAyt%t&%V_j=Cq5I>e*B5)!p z#0`ydOWVM;kP?x=5jycBO$raj{As&#LXL)G3U|!|=1@^wRi$A%ab-Y>NmJuLIFoh+ z2NhNA1E)iS+DH!Y9GB?`dOu9Vv5-O&77+dL{5U#&6wr3C9hFrzjDb-{zr@e|-WBYK z$K;S4iKyt*h@5~(Xi8M-3M+60aA-L==$ATWP3o6b>9`V}7+0iD1yqI<*p0{`CFV`r zb?1h|N>EAU;t}vNCD1i-Wst%VJ~TY_93$D6ODwbkw&_v?%MkbxG$2^PO6;Gwn-FbA zunxg`R^(VJKFZo1pO}9h5V{0N6c5|!bcx?#z0TL!FP%$7CrddSS1y`)dXhj~0kH*A zP&R`nhJZ+1wV;dG=TS{SwX^k$HkG<}r!8<4d03%n`=W8ahiq(7z@mqq{cBNYRR{Xi ziGcgP12OLWPQ+Fq*v7hDyTpy`c~?*NVIbfGQkOHUyWUXZWzBBcT6V)#H|eTl7u-d~ zW7CV2JMZk!q`TsV+c)Xy_O~2Yutqw@~+!!QdPcF?PvXy4lldy_F8(-&fcVV zaiLhkJ!<2I5#SMg7OHcw{W((8HlU;}gYkr_s6Hb-h|8?aJ9qZo-QB*6q%EC$0#J*6 zR=N@8rv-nKvr_D3f5|B_hk^P~^1nIf#2tH)VGDxI07kOIka{QlgIY8KLF3YSKu=qP z@tCHZ&@Ngjo=#9clBe=&#KQ}{IYQ&{DEEd&fs@7&Pylz*$eHPJ*nJY&Gz19%w=MPjDYIuv zz6dO|z<0i`1MDmFelem3>Zu>wuCP$R7R&D1l>)iBPpzv4h z{ZjCl?Jra6@r6(ufqmh8K~l{mNQFQZBo816As7Up zV*Q1O3m@%e&w7`b9|OV-_NMo=v!HZ5>wMM)|E22d1@tZQRNkZGNAn<`A*f8Sj;PL8?3kKapal471ne!`+ZIMeP>YI z8Uqm~%$tfm3lLn$Bt$-YkU%Gjq4+VDEDls9k0#C}rQS zJ;9dUl_fsK-l=F|t#xCKBr9Ox_1r25^#aM>Ck6~8s}_WyrUQl{;QJmy;AT=+SHR?dA9v1-q5s^51+_Nh?Y61<5{H7V%D#0e~9`_18__p|G<$XmG8ibfw zF6@43K-gd`7Y4*N#sSgi+TE=`tVegahMihJVGMO23~1vSdqGM0vbt3^Sk&V-oo|9EvcF^s7dhw zm?FFvP4tX#A6UYQm$t=}<9%W1^t|Hxvgr4LN*_nSi?%Tw)ATgB2WhjnNii#+jqqG} z0=M`ZGXkbReB06zixuu#{Uf_pRm8R~-7fx>{c~A+>ENF{rDKsv&t0j-`JZK(3oW+< zv%@+in9bJDirJp(vbHUD@sZrNwZb{EZH;Nn1lZGU#Xx*(jR^Q#YaLr`#tE|lC=*r@ zahnnFk=%NCPSa&O|E{S~p3-xHZ9pe^VDWK6{9vMq%9^H!aC)NIG4UD=>wh=hD#ZsO4v)E2Gu^jc#P6JMC{AeEeCTzADe>l z=x{`V689WvDZ|)d+qfy56h3m4j)kr}YEHJNN~DwRr~H$q!jv^x4hHfHnDdr z)--Hodm}NegX$u`z@GOvF+=^~7AH?Jn1JRbp9_-DLKg8IH)0vdH@)u4 z!(bQdO+4)5`tngXY9M(e-*1^qI zV&OhQl?zx&@%%Im*cPnKq=fTA34^ogcqv#a1U&iPE~+t<(w!;esia)xKmz`tp;s1vC;W4DI`_j@Qk&y>gqt9Z-H~QU#%@??tCGr zbsggFKd`aJ(x$&>)U~BBCF@fMZw_fw2zxSKCY_rLzRPYlR#u+|VmIZjw-fjGAehTX zJ|u5Wu3k|qR-S|{Xso=mSfC*!BHHx$2-SyAX3wybD=)i|f&Li53kaTMZB2hJIf24v zE9DZz**t?^A;Rppbx*ket+c;0fh?8&oNh* zGsY*@xS_~=8TgK|y&Lu}UXwP&RlQa7kOY#iY%qwUFQHDX=X{VzoBG2+O;^iXJC5gi z3&6>n56gU> dict: "broom_solved": False, "broom_score": 0, "broom_time": 0, - "chess_solved": False, + "duel_solved": False, "bottles_solved": False, "sliding_solved": False, "started_at": datetime.datetime.utcnow().isoformat(), @@ -109,16 +101,14 @@ def get_session_state(request: Request) -> dict: state = request.session["escape"] if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] - if state.get("broom_solved"): - state["inventory"].append("silver_key") - if state.get("chess_solved"): - state["inventory"].append("chess_knight") + if state.get("duel_solved"): + state["inventory"].append("duel_badge") if state.get("bottles_solved"): state["inventory"].append("philosophers_stone") if "house" not in state: state["house"] = "gryffindor" - if "chess_solved" not in state: - state["chess_solved"] = False + if "duel_solved" not in state: + state["duel_solved"] = False request.session["escape"] = state return request.session["escape"] @@ -159,18 +149,23 @@ async def page_broom(request: Request): context=get_template_context(request, state, lang) ) -@app.get("/chess", response_class=HTMLResponse) -async def page_chess(request: Request): +@app.get("/duel", response_class=HTMLResponse) +async def page_duel(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="chess.html", + name="duel.html", context=get_template_context(request, state, lang) ) +@app.get("/chess") +async def redirect_chess(): + # Graceful redirect for old links + return RedirectResponse(url="/duel", status_code=302) + @app.get("/bottles", response_class=HTMLResponse) async def page_bottles(request: Request): state = get_session_state(request) @@ -223,7 +218,7 @@ async def api_get_inventory(request: Request): "house": house_info, "score": state.get("broom_score", 0), "broom_solved": state.get("broom_solved", False), - "chess_solved": state.get("chess_solved", False), + "duel_solved": state.get("duel_solved", False), "bottles_solved": state.get("bottles_solved", False), "sliding_solved": state.get("sliding_solved", False) }) @@ -251,7 +246,7 @@ async def api_reset_state(request: Request): "broom_solved": False, "broom_score": 0, "broom_time": 0, - "chess_solved": False, + "duel_solved": False, "bottles_solved": False, "sliding_solved": False, "started_at": datetime.datetime.utcnow().isoformat(), @@ -270,32 +265,26 @@ async def api_broom_complete(request: Request): state["broom_solved"] = True state["broom_score"] = int(data.get("score", 100)) state["broom_time"] = int(data.get("time_taken", 30)) - # Add Silver Key to Inventory - if "inventory" not in state: - state["inventory"] = ["wand", "broom", "snape_scroll"] - if "silver_key" not in state["inventory"]: - state["inventory"].append("silver_key") request.session["escape"] = state return JSONResponse({ "success": True, - "message": "Der Schlüssel passt ins Schloss! Die Tür zu McGonagalls Zauberschach steht offen!", - "new_item": ALL_ITEMS["silver_key"], + "message": "Parcours gemeistert! Die Tür zum Zauberspruch-Duell steht offen!", "state": state }) -@app.post("/api/chess/complete") -async def api_chess_complete(request: Request): +@app.post("/api/duel/complete") +async def api_duel_complete(request: Request): state = get_session_state(request) - state["chess_solved"] = True + state["duel_solved"] = True if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] - if "chess_knight" not in state["inventory"]: - state["inventory"].append("chess_knight") + if "duel_badge" not in state["inventory"]: + state["inventory"].append("duel_badge") request.session["escape"] = state return JSONResponse({ "success": True, - "message": "Schachmatt! Die weiße Königin senkt ihr Schwert. Der Weg zur Flammenkammer ist frei!", - "new_item": ALL_ITEMS["chess_knight"], + "message": "Zauberspruch-Duell gewonnen! Die Pforte zur Flammenkammer steht weit offen!", + "new_item": ALL_ITEMS["duel_badge"], "state": state }) @@ -318,7 +307,7 @@ async def api_bottles_drink(request: Request): if content == "ahead": if target_gate == "ahead": state["bottles_solved"] = True - if state.get("broom_solved"): + if state.get("broom_solved") or state.get("duel_solved"): state["completed_at"] = datetime.datetime.utcnow().isoformat() if "inventory" not in state: state["inventory"] = ["wand", "broom", "snape_scroll"] @@ -349,7 +338,7 @@ async def api_bottles_drink(request: Request): "result": "success_back", "content": content, "message": msg_de if lang == "de" else msg_en, - "next_url": "/chess" + "next_url": "/duel" }) else: msg_de = "Dieser Trank schützt vor den violetten Flammen, führt dich aber nicht vorwärts durch das schwarze Feuer!" diff --git a/app/static/js/broom.js b/app/static/js/broom.js index ee1d37f..a9bedee 100755 --- a/app/static/js/broom.js +++ b/app/static/js/broom.js @@ -1,4 +1,4 @@ -// Broom flight mini-game: Winged Keys Chamber (Flitwick's Challenge) - Mobile & Touch Optimized +// Broomstick Flight: Golden Ring Course (Quidditch-Flugtraining) - Mobile & Touch Optimized (function() { const canvas = document.getElementById('broomCanvas'); if (!canvas) return; @@ -7,88 +7,90 @@ // UI elements const scoreVal = document.getElementById('scoreVal'); const timerVal = document.getElementById('timerVal'); + const ringsVal = document.getElementById('ringsVal'); + const comboVal = document.getElementById('comboVal'); 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 = 800, height = 480; + let width = 800, height = 460; function resize() { const rect = canvas.getBoundingClientRect(); width = Math.round(rect.width || canvas.parentElement.clientWidth || 800); - height = Math.round(Math.min(520, Math.max(300, width * 0.58))); + height = Math.round(Math.min(500, Math.max(280, width * 0.56))); canvas.width = width; canvas.height = height; canvas.style.height = height + 'px'; - - if (door) { - door.x = width - 55; - door.y = height / 2 - 45; - } } - // Target Door - const door = { - x: width - 55, - y: height / 2 - 45, - w: 42, - h: 90 - }; - - // Game State + // Game Configuration + const TOTAL_RINGS_NEEDED = 12; let gameRunning = true; let startTime = Date.now(); let score = 0; - let keyCaught = false; - let doorUnlocked = false; + let ringsCollected = 0; + let combo = 0; // Player (Broomstick) const player = { - x: 80, - y: 200, + x: 90, + y: 220, + targetY: 220, + targetX: 90, vx: 0, vy: 0, - speed: 6.0, + speed: 6.5, boost: 1.0, angle: 0, trail: [] }; - // Keys Swarm - const keys = []; - const NUM_KEYS = 22; + // Flying Golden Rings + const rings = []; + let lastRingSpawn = Date.now(); - // Master Key (Damaged wing silver key) - const masterKey = { - x: 600, - y: 200, - vx: -2.5, - vy: 1.5, - wingPhase: 0, - caught: false, - size: 17 - }; - - // Initialize Canvas Size - window.addEventListener('resize', resize); - resize(); - - // 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 + function spawnRing() { + // Rings glide from right to left + const ringY = Math.random() * (height - 120) + 60; + const isGoldenBonus = Math.random() > 0.75; + rings.push({ + x: width + 40, + y: ringY, + radius: isGoldenBonus ? 28 : 24, + speed: (Math.random() * 1.5 + 3.2), + golden: isGoldenBonus, + collected: false, + scale: 1.0, + alpha: 1.0, + pulse: 0 }); } - // Keyboard Controls + // Sparkles & Confetti particles + const particles = []; + function spawnSparkles(x, y, color) { + for (let i = 0; i < 18; i++) { + const angle = Math.random() * Math.PI * 2; + const spd = Math.random() * 5 + 2; + particles.push({ + x: x, + y: y, + vx: Math.cos(angle) * spd, + vy: Math.sin(angle) * spd, + color: color || '#ffd700', + size: Math.random() * 4 + 2, + alpha: 1.0 + }); + } + } + + // Initialize Canvas + window.addEventListener('resize', resize); + resize(); + + // Controls: Keyboard const keysDown = {}; window.addEventListener('keydown', e => { keysDown[e.key.toLowerCase()] = true; @@ -102,8 +104,8 @@ if (e.key === ' ' || e.key === 'Shift') player.boost = 1.0; }); - // Touch & Pointer Steering - let pointerTarget = null; + // Touch & Pointer Direct Steering (Smooth Follow-Touch) + let touchSteering = false; function getCanvasCoords(clientX, clientY) { const rect = canvas.getBoundingClientRect(); @@ -113,11 +115,13 @@ }; } - // Direct Touch Listeners to prevent browser scrolling canvas.addEventListener('touchstart', e => { e.preventDefault(); + touchSteering = true; if (e.touches.length > 0) { - pointerTarget = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + const coords = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + player.targetX = coords.x; + player.targetY = coords.y; player.boost = 1.4; if (window.soundFX) window.soundFX.playWhoosh(); } @@ -126,31 +130,38 @@ canvas.addEventListener('touchmove', e => { e.preventDefault(); if (e.touches.length > 0) { - pointerTarget = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + const coords = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + player.targetX = coords.x; + player.targetY = coords.y; } }, { passive: false }); canvas.addEventListener('touchend', e => { e.preventDefault(); - pointerTarget = null; + touchSteering = false; player.boost = 1.0; }, { passive: false }); - // Pointer events for mouse + // Pointer events for Desktop Mouse canvas.addEventListener('pointerdown', e => { if (e.pointerType === 'mouse') { - pointerTarget = getCanvasCoords(e.clientX, e.clientY); + touchSteering = true; + const coords = getCanvasCoords(e.clientX, e.clientY); + player.targetX = coords.x; + player.targetY = coords.y; player.boost = 1.5; if (window.soundFX) window.soundFX.playWhoosh(); } }); canvas.addEventListener('pointermove', e => { - if (e.pointerType === 'mouse' && (e.buttons > 0 || pointerTarget)) { - pointerTarget = getCanvasCoords(e.clientX, e.clientY); + if (touchSteering) { + const coords = getCanvasCoords(e.clientX, e.clientY); + player.targetX = coords.x; + player.targetY = coords.y; } }); canvas.addEventListener('pointerup', () => { - pointerTarget = null; + touchSteering = false; player.boost = 1.0; }); @@ -190,103 +201,115 @@ btnBoost.addEventListener('pointerleave', endBoost); } + // Pre-spawn 3 rings + rings.push({ x: width + 50, y: height * 0.4, radius: 25, speed: 3.5, golden: false, collected: false, scale: 1, alpha: 1, pulse: 0 }); + rings.push({ x: width + 280, y: height * 0.65, radius: 25, speed: 3.6, golden: true, collected: false, scale: 1, alpha: 1, pulse: 0 }); + rings.push({ x: width + 520, y: height * 0.3, radius: 25, speed: 3.7, golden: false, collected: false, scale: 1, alpha: 1, pulse: 0 }); + function update() { if (!gameRunning) return; const elapsedSec = Math.floor((Date.now() - startTime) / 1000); if (timerVal) timerVal.innerText = elapsedSec + 's'; - // Keyboard & D-Pad Movement + // Spawn new rings continuously + if (Date.now() - lastRingSpawn > 1400) { + spawnRing(); + lastRingSpawn = Date.now(); + } + + // Steer player let ax = 0, ay = 0; 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 (Touch / Mouse direct following) - if (pointerTarget) { - const dx = pointerTarget.x - player.x; - const dy = pointerTarget.y - player.y; + if (touchSteering) { + const dx = player.targetX - player.x; + const dy = player.targetY - player.y; const dist = Math.hypot(dx, dy); - if (dist > 12) { + if (dist > 8) { ax = dx / dist; ay = dy / dist; } } const curSpeed = player.speed * player.boost; - player.vx = player.vx * 0.82 + ax * curSpeed * 0.22; - player.vy = player.vy * 0.82 + ay * curSpeed * 0.22; + player.vx = player.vx * 0.82 + ax * curSpeed * 0.24; + player.vy = player.vy * 0.82 + ay * curSpeed * 0.24; player.x += player.vx; player.y += player.vy; - // Bounds - player.x = Math.max(30, Math.min(width - 65, player.x)); - player.y = Math.max(30, Math.min(height - 30, player.y)); + // Keep inside bounds + player.x = Math.max(35, Math.min(width - 55, player.x)); + player.y = Math.max(30, Math.min(height - 35, player.y)); - player.angle = Math.atan2(player.vy, player.vx + 2) * 0.55; + player.angle = Math.atan2(player.vy, player.vx + 3) * 0.5; - // Particles trail - player.trail.push({ x: player.x - 22, y: player.y, alpha: 1, size: Math.random() * 4 + 2 }); - if (player.trail.length > 22) player.trail.shift(); + // Particle trail from broom tail + player.trail.push({ x: player.x - 24, 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.88; p.x -= 2; }); - // Update decoy keys - keys.forEach(k => { - k.wingPhase += k.wingSpeed; - k.x += k.vx; - k.y += k.vy; - if (k.x < 40 || k.x > width - 90) k.vx *= -1; - if (k.y < 30 || k.y > height - 30) k.vy *= -1; - }); + // Update rings + for (let i = rings.length - 1; i >= 0; i--) { + const r = rings[i]; + r.x -= r.speed * (player.boost > 1.2 ? 1.4 : 1.0); + r.pulse += 0.08; - // Update Master Key - if (!masterKey.caught) { - masterKey.wingPhase += 0.35; - const dToPlayer = Math.hypot(player.x - masterKey.x, player.y - masterKey.y); - if (dToPlayer < 150) { - masterKey.vx += (masterKey.x - player.x) * 0.045; - masterKey.vy += (masterKey.y - player.y) * 0.045; + if (!r.collected) { + // Check if player flew through ring + const dist = Math.hypot(player.x - r.x, player.y - r.y); + if (dist < r.radius + 18) { + r.collected = true; + ringsCollected++; + combo++; + const ringPts = (r.golden ? 300 : 150) * Math.min(4, combo); + score += ringPts; + + if (scoreVal) scoreVal.innerText = score; + if (ringsVal) ringsVal.innerText = `${ringsCollected}/${TOTAL_RINGS_NEEDED}`; + if (comboVal) comboVal.innerText = combo > 1 ? `x${combo} Combo!` : ''; + + if (window.soundFX) window.soundFX.playKeyChime(); + spawnSparkles(r.x, r.y, r.golden ? '#ffd700' : '#00ffff'); + + // Check Course Win + if (ringsCollected >= TOTAL_RINGS_NEEDED) { + gameRunning = false; + onCourseWin(elapsedSec); + } + } } else { - masterKey.vx += (Math.random() - 0.5) * 0.9; - masterKey.vy += (Math.random() - 0.5) * 0.9; - } - masterKey.vx = Math.max(-5.5, Math.min(5.5, masterKey.vx * 0.95)); - masterKey.vy = Math.max(-4.5, Math.min(4.5, masterKey.vy * 0.95)); - - masterKey.x += masterKey.vx; - masterKey.y += masterKey.vy; - - masterKey.x = Math.max(90, Math.min(width - 100, masterKey.x)); - masterKey.y = Math.max(40, Math.min(height - 40, masterKey.y)); - - // Catch check (generous hit box for mobile thumbs: 42px) - if (dToPlayer < 42) { - masterKey.caught = true; - keyCaught = true; - score += 500; - if (scoreVal) scoreVal.innerText = score; - if (window.soundFX) window.soundFX.playKeyChime(); - if (window.notifyNewItem) { - window.notifyNewItem({ icon: "🗝️", name: "Geknickter Silberschlüssel" }); - } - if (statusMsg) { - statusMsg.innerHTML = "✨ Schlüssel gefangen & im Beutel! Fliege jetzt zur schweren Holztür rechts!"; - statusMsg.style.color = "#2ecc71"; + // Animation after collection + r.scale += 0.08; + r.alpha -= 0.08; + if (r.alpha <= 0) { + rings.splice(i, 1); + continue; } } - } 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 < 60 && !doorUnlocked) { - doorUnlocked = true; - gameRunning = false; - onGameWin(elapsedSec); + // Remove rings that flew past screen + if (r.x < -60) { + if (!r.collected) { + combo = 0; // Combo reset on miss + if (comboVal) comboVal.innerText = ''; + } + rings.splice(i, 1); + } + } + + // Update sparkles + for (let i = particles.length - 1; i >= 0; i--) { + const p = particles[i]; + p.x += p.vx; + p.y += p.vy; + p.alpha *= 0.92; + if (p.alpha <= 0.02) { + particles.splice(i, 1); } } } @@ -294,121 +317,100 @@ function render() { ctx.clearRect(0, 0, width, height); - // Dungeon stone background & arches - ctx.fillStyle = '#0f0e17'; + // Night sky gradient with Hogwarts towers silhouettes + const skyGrad = ctx.createLinearGradient(0, 0, 0, height); + skyGrad.addColorStop(0, '#0c0b17'); + skyGrad.addColorStop(1, '#1b142e'); + ctx.fillStyle = skyGrad; ctx.fillRect(0, 0, width, height); - // Stone pillars - ctx.fillStyle = 'rgba(25, 22, 36, 0.7)'; - for (let x = 110; x < width - 90; x += 160) { - ctx.fillRect(x, 0, 30, height); - ctx.strokeStyle = '#352e47'; - ctx.lineWidth = 2; - ctx.strokeRect(x, 0, 30, height); + // Stars + ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; + for (let i = 0; i < 25; i++) { + const sx = (i * 37 + (Date.now() * 0.02)) % width; + const sy = (i * 53) % (height * 0.65); + ctx.fillRect(sx, sy, 1.5, 1.5); } - // Vaulted arches at top - ctx.strokeStyle = '#2d273d'; - ctx.lineWidth = 3; + // Hogwarts distant castle spires in background + ctx.fillStyle = 'rgba(15, 12, 25, 0.85)'; ctx.beginPath(); - for (let x = 0; x < width; x += 80) { - ctx.moveTo(x, 0); - ctx.quadraticCurveTo(x + 40, 50, x + 80, 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.moveTo(0, height); + ctx.lineTo(80, height - 90); + ctx.lineTo(120, height - 160); + ctx.lineTo(160, height - 90); + ctx.lineTo(240, height - 120); + ctx.lineTo(310, height - 190); + ctx.lineTo(380, height - 100); + ctx.lineTo(460, height - 140); + ctx.lineTo(550, height - 80); + ctx.lineTo(650, height - 180); + ctx.lineTo(750, height - 110); + ctx.lineTo(width, height - 90); + ctx.lineTo(width, height); + ctx.closePath(); 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 + // Sparkle trail behind broom 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; - // Touch target indicator - if (pointerTarget && gameRunning) { - ctx.strokeStyle = 'rgba(229, 185, 72, 0.5)'; + // Touch steer indicator + if (touchSteering && gameRunning) { + ctx.strokeStyle = 'rgba(229, 185, 72, 0.45)'; ctx.lineWidth = 2; ctx.beginPath(); - ctx.arc(pointerTarget.x, pointerTarget.y, 20 + Math.sin(Date.now() * 0.01) * 4, 0, Math.PI * 2); + ctx.arc(player.targetX, player.targetY, 22 + Math.sin(Date.now() * 0.01) * 3, 0, Math.PI * 2); ctx.stroke(); } - // Decoy keys - keys.forEach(k => { + // Render Golden Rings + rings.forEach(r => { ctx.save(); - ctx.translate(k.x, k.y); - 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(); + ctx.translate(r.x, r.y); + ctx.scale(r.scale, r.scale); + ctx.globalAlpha = r.alpha; - // Key body - ctx.fillStyle = k.gold ? '#e5b948' : '#c0c5cc'; + // Outer golden glow + ctx.shadowColor = r.golden ? '#ffd700' : '#4cc9f0'; + ctx.shadowBlur = r.golden ? 18 : 12; + + // Ring Outer Circle + ctx.strokeStyle = r.golden ? '#ffd700' : '#7209b7'; + ctx.lineWidth = r.golden ? 5 : 4; ctx.beginPath(); - ctx.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.arc(0, 0, r.radius, 0, Math.PI * 2); + ctx.stroke(); + + // Inner Ring Highlights + ctx.strokeStyle = r.golden ? '#fff275' : '#4895ef'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(0, 0, r.radius - 3, 0, Math.PI * 2); + ctx.stroke(); + + // Ring Mount / Pole at bottom + ctx.fillStyle = '#6b5338'; + ctx.fillRect(-2, r.radius, 4, 30); + ctx.restore(); }); - - // Master Key (Damaged Wing) - ctx.save(); - ctx.translate(masterKey.x, masterKey.y); - const mWingY = Math.sin(masterKey.wingPhase) * (masterKey.size * 0.8); - ctx.shadowColor = '#00ffff'; - ctx.shadowBlur = masterKey.caught ? 6 : 14; - - // Right intact wing - ctx.fillStyle = 'rgba(180, 225, 255, 0.85)'; - ctx.beginPath(); - ctx.ellipse(masterKey.size * 0.7, mWingY, masterKey.size * 0.9, masterKey.size * 0.4, -0.3, 0, Math.PI * 2); - ctx.fill(); - - // Left BENT wing - ctx.fillStyle = 'rgba(140, 190, 230, 0.75)'; - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(-masterKey.size * 0.5, mWingY * 0.6); - ctx.lineTo(-masterKey.size * 0.9, mWingY * 0.3); - ctx.lineTo(-masterKey.size * 0.4, mWingY * 0.2); - ctx.closePath(); - ctx.fill(); - - // Heavy antique key body - ctx.fillStyle = '#e0e8f0'; - ctx.beginPath(); - ctx.arc(0, 0, masterKey.size * 0.4, 0, Math.PI * 2); - ctx.fill(); - ctx.fillRect(-2, 0, 4, masterKey.size * 1.3); - ctx.fillRect(0, masterKey.size * 0.8, masterKey.size * 0.5, 3); - ctx.fillRect(0, masterKey.size * 1.05, masterKey.size * 0.4, 3); - ctx.restore(); ctx.shadowBlur = 0; + // Render Sparkles + particles.forEach(p => { + ctx.fillStyle = p.color; + ctx.globalAlpha = p.alpha; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.globalAlpha = 1.0; + // Player (Broom & Wizard) ctx.save(); ctx.translate(player.x, player.y); @@ -444,11 +446,11 @@ ctx.lineTo(2, -26); ctx.closePath(); ctx.fill(); - // Cloak fluttering - ctx.fillStyle = '#7a1f28'; + // Gryffindor / House cloak fluttering + ctx.fillStyle = '#800e13'; ctx.beginPath(); ctx.moveTo(0, -6); - ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.01) * 3); + ctx.lineTo(-20, -10 + Math.sin(Date.now() * 0.012) * 3); ctx.lineTo(-24, 6); ctx.lineTo(10, 0); ctx.closePath(); @@ -457,9 +459,9 @@ // Wand tip spark ctx.fillStyle = '#ffff99'; ctx.shadowColor = '#ffff00'; - ctx.shadowBlur = 10; + ctx.shadowBlur = 12; ctx.beginPath(); - ctx.arc(24, -8, 3, 0, Math.PI * 2); + ctx.arc(24, -8, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; @@ -473,25 +475,32 @@ } requestAnimationFrame(loop); - async function onGameWin(timeTaken) { + async function onCourseWin(elapsedSec) { if (window.soundFX) window.soundFX.playMagicSuccess(); try { - await fetch('/api/broom/complete', { + const res = await fetch('/api/broom/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ score: score, time_taken: timeTaken }) + body: JSON.stringify({ score: score, time_taken: elapsedSec }) }); + const data = await res.json(); + if (data.new_item && window.notifyNewItem) { + window.notifyNewItem(data.new_item); + } } catch (e) { console.error(e); } - modalTitle.innerText = "🏆 Raum 1 gemeistert!"; - modalText.innerHTML = `Mit waghalsigem Flug hast du den großen Silberschlüssel mit dem geknickten Flügel erwischt und das schwere Schloss entriegelt!

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

- Die Tür schwingt knarrend auf... Vor dir liegt McGonagalls gigantisches Zauberschach!`; - modalBtn.innerText = "Weiter zu McGonagalls Zauberschach ♟️"; - modalBtn.onclick = () => { window.location.href = '/chess'; }; + modalTitle.innerText = "🏆 Ring-Parcours Gemeistert!"; + modalText.innerHTML = ` + Mit atemberaubenden Manövern hast du alle ${TOTAL_RINGS_NEEDED} goldenen Ringe durchflogen!

+ Flugzeit: ${elapsedSec} Sekunden
+ Punkte: ${score}

+ Madam Hooch nickt anerkennend und überreicht dir die Flug-Auszeichnung!
+ Das Tor zur nächsten Kammer steht offen! + `; + modalBtn.innerText = "Weiter zum Zauberspruch-Duell 🪄"; + modalBtn.onclick = () => { window.location.href = '/duel'; }; modalBackdrop.classList.add('show'); } })(); diff --git a/app/static/js/duel.js b/app/static/js/duel.js new file mode 100755 index 0000000..0c6ccec --- /dev/null +++ b/app/static/js/duel.js @@ -0,0 +1,454 @@ +// Wand Spell Tracing & Dueling Mini-Game (Das Zauberspruch-Duell) +(function() { + const canvas = document.getElementById('spellCanvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const spellNameEl = document.getElementById('spellName'); + const spellDescEl = document.getElementById('spellDesc'); + const spellProgressEl = document.getElementById('spellProgress'); + const dummyHpEl = document.getElementById('dummyHp'); + const modalBackdrop = document.getElementById('modalBackdrop'); + const modalTitle = document.getElementById('modalTitle'); + const modalText = document.getElementById('modalText'); + const modalBtn = document.getElementById('modalBtn'); + + let width = 700, height = 440; + function resize() { + const rect = canvas.getBoundingClientRect(); + width = Math.round(rect.width || canvas.parentElement.clientWidth || 700); + height = Math.round(Math.min(480, Math.max(280, width * 0.62))); + canvas.width = width; + canvas.height = height; + canvas.style.height = height + 'px'; + setupSpellNodes(); + } + + // 3 Sequential Spells with distinct gestures + const SPELLS = [ + { + id: 'flipendo', + name: 'Flipendo ⚡', + incantation: '"Flipendo!" (Ruck-Schockzauber)', + desc: 'Verbinde die 4 Runensterne im Zickzack-Muster, um den Schockzauber zu entfesseln!', + color: '#00f2fe', + beamColor: '#4facfe', + // Relative coordinates (0 to 1) for responsive scaling + points: [ + { x: 0.22, y: 0.28 }, + { x: 0.52, y: 0.28 }, + { x: 0.25, y: 0.72 }, + { x: 0.55, y: 0.72 } + ] + }, + { + id: 'incendio', + name: 'Incendio 🔥', + incantation: '"Incendio!" (Feuer-Zauber)', + desc: 'Zeichne das lodernde Flammendreieck, um die Zielattrappe in Brand zu setzen!', + color: '#ff4500', + beamColor: '#ff8c00', + points: [ + { x: 0.38, y: 0.22 }, + { x: 0.55, y: 0.74 }, + { x: 0.22, y: 0.74 }, + { x: 0.38, y: 0.22 } + ] + }, + { + id: 'alohomora', + name: 'Alohomora 🗝️', + incantation: '"Alohomora!" (Entriegelungszauber)', + desc: 'Zeichne die magische Schlüsselloch-Kurve, um das verzauberte Siegel zu sprengen!', + color: '#ffd700', + beamColor: '#fff275', + points: [ + { x: 0.38, y: 0.25 }, + { x: 0.48, y: 0.38 }, + { x: 0.42, y: 0.52 }, + { x: 0.42, y: 0.78 } + ] + } + ]; + + let currentSpellIdx = 0; + let spellNodes = []; + let nextNodeIdx = 0; + let isDrawing = false; + let userPath = []; + let wandParticles = []; + let blastParticles = []; + let dummyShake = 0; + let spellCastAnim = 0; + let gameWon = false; + + function setupSpellNodes() { + const spell = SPELLS[currentSpellIdx]; + if (!spell) return; + spellNodes = spell.points.map((pt, idx) => ({ + x: Math.round(pt.x * width), + y: Math.round(pt.y * height), + radius: 20, + hit: idx < nextNodeIdx + })); + } + + window.addEventListener('resize', resize); + resize(); + + function updateSpellUI() { + const spell = SPELLS[currentSpellIdx]; + if (!spell) return; + if (spellNameEl) spellNameEl.innerText = spell.name; + if (spellDescEl) spellDescEl.innerText = spell.desc; + if (spellProgressEl) spellProgressEl.innerText = `Zauberspruch ${currentSpellIdx + 1} von ${SPELLS.length}`; + if (dummyHpEl) dummyHpEl.innerText = `${SPELLS.length - currentSpellIdx} Zauber übrig`; + setupSpellNodes(); + } + updateSpellUI(); + + // Touch & Pointer Coordinates + function getCanvasCoords(clientX, clientY) { + const rect = canvas.getBoundingClientRect(); + return { + x: (clientX - rect.left) * (canvas.width / rect.width), + y: (clientY - rect.top) * (canvas.height / rect.height) + }; + } + + function addWandSpark(x, y, color) { + for (let i = 0; i < 4; i++) { + wandParticles.push({ + x: x, + y: y, + vx: (Math.random() - 0.5) * 3, + vy: (Math.random() - 0.5) * 3, + color: color || '#fff', + size: Math.random() * 4 + 2, + alpha: 1.0 + }); + } + } + + function handleInputStart(x, y) { + if (gameWon) return; + isDrawing = true; + userPath = [{ x, y }]; + checkNodeHit(x, y); + } + + function handleInputMove(x, y) { + if (!isDrawing || gameWon) return; + userPath.push({ x, y }); + addWandSpark(x, y, SPELLS[currentSpellIdx].color); + checkNodeHit(x, y); + } + + function handleInputEnd() { + isDrawing = false; + // If not all nodes hit, reset progress for this spell + if (nextNodeIdx < spellNodes.length) { + nextNodeIdx = 0; + userPath = []; + setupSpellNodes(); + } + } + + function checkNodeHit(x, y) { + if (nextNodeIdx >= spellNodes.length) return; + const targetNode = spellNodes[nextNodeIdx]; + const dist = Math.hypot(x - targetNode.x, y - targetNode.y); + + // Tolerant hit radius for easy finger play: 36px + if (dist < 36) { + targetNode.hit = true; + nextNodeIdx++; + if (window.soundFX) window.soundFX.playKeyChime(); + addWandSpark(targetNode.x, targetNode.y, '#ffd700'); + + // All nodes connected for this spell! + if (nextNodeIdx >= spellNodes.length) { + castSpellSuccess(); + } + } + } + + // Touch events with touch-action none + canvas.addEventListener('touchstart', e => { + e.preventDefault(); + if (e.touches.length > 0) { + const p = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + handleInputStart(p.x, p.y); + } + }, { passive: false }); + + canvas.addEventListener('touchmove', e => { + e.preventDefault(); + if (e.touches.length > 0) { + const p = getCanvasCoords(e.touches[0].clientX, e.touches[0].clientY); + handleInputMove(p.x, p.y); + } + }, { passive: false }); + + canvas.addEventListener('touchend', e => { + e.preventDefault(); + handleInputEnd(); + }, { passive: false }); + + // Pointer events for desktop + canvas.addEventListener('pointerdown', e => { + if (e.pointerType === 'mouse') { + const p = getCanvasCoords(e.clientX, e.clientY); + handleInputStart(p.x, p.y); + } + }); + + canvas.addEventListener('pointermove', e => { + if (e.pointerType === 'mouse' && isDrawing) { + const p = getCanvasCoords(e.clientX, e.clientY); + handleInputMove(p.x, p.y); + } + }); + + canvas.addEventListener('pointerup', () => { + handleInputEnd(); + }); + + function castSpellSuccess() { + const spell = SPELLS[currentSpellIdx]; + spellCastAnim = 1.0; + dummyShake = 18; + if (window.soundFX) window.soundFX.playMagicSuccess(); + + // Explosion on target dummy + const dummyX = width * 0.78; + const dummyY = height * 0.5; + for (let i = 0; i < 40; i++) { + const angle = Math.random() * Math.PI * 2; + const spd = Math.random() * 8 + 3; + blastParticles.push({ + x: dummyX, + y: dummyY, + vx: Math.cos(angle) * spd, + vy: Math.sin(angle) * spd, + color: spell.color, + size: Math.random() * 5 + 3, + alpha: 1.0 + }); + } + + setTimeout(() => { + currentSpellIdx++; + nextNodeIdx = 0; + userPath = []; + + if (currentSpellIdx >= SPELLS.length) { + gameWon = true; + onDuelWon(); + } else { + updateSpellUI(); + } + }, 800); + } + + async function onDuelWon() { + if (window.soundFX) window.soundFX.playMagicSuccess(); + try { + const res = await fetch('/api/duel/complete', { method: 'POST' }); + const data = await res.json(); + if (data.new_item && window.notifyNewItem) { + window.notifyNewItem(data.new_item); + } + } catch (e) { + console.error(e); + } + + modalTitle.innerText = "⚡ Duell Gemeistert!"; + modalText.innerHTML = ` + Mit meisterhafter Zauberstabführung hast du alle 3 Zaubersprüche entfesselt und die verzauberte Rüstung bezwungen!

+ Gelehrte Sprüche: Flipendo ⚡, Incendio 🔥 & Alohomora 🗝️

+ Professor Flitwick applaudiert begeistert. Das uralte Schlosstor schwingt auf... + Dahinter flackern die unheimlichen Flammen von Snapes Flammenkammer!

+ 🎒 Das goldene Duellanten-Abzeichen wurde deinem Zauberbeutel hinzugefügt! + `; + modalBtn.innerText = "Weiter zu Snapes Flaschenrätsel 🧪"; + modalBtn.onclick = () => { window.location.href = '/bottles'; }; + modalBackdrop.classList.add('show'); + } + + function render() { + ctx.clearRect(0, 0, width, height); + + // Dungeon stone chamber background + const bgGrad = ctx.createRadialGradient(width * 0.5, height * 0.5, 30, width * 0.5, height * 0.5, width * 0.6); + bgGrad.addColorStop(0, '#1c172e'); + bgGrad.addColorStop(1, '#0c0a14'); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, width, height); + + // Stone floor tiles + ctx.strokeStyle = '#2d253d'; + ctx.lineWidth = 1; + for (let y = height * 0.75; y < height; y += 25) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(width, y); + ctx.stroke(); + } + + const spell = SPELLS[currentSpellIdx] || SPELLS[SPELLS.length - 1]; + + // Draw Spell Beam if casting + if (spellCastAnim > 0) { + ctx.save(); + ctx.strokeStyle = spell.beamColor; + ctx.lineWidth = 14 * spellCastAnim; + ctx.shadowColor = spell.color; + ctx.shadowBlur = 30; + ctx.beginPath(); + ctx.moveTo(width * 0.45, height * 0.5); + ctx.lineTo(width * 0.78, height * 0.5); + ctx.stroke(); + ctx.restore(); + spellCastAnim -= 0.05; + } + + // Draw Enchanted Target Dummy (Right side) + ctx.save(); + const dummyBaseX = width * 0.78 + (dummyShake > 0 ? (Math.random() - 0.5) * dummyShake : 0); + const dummyBaseY = height * 0.5; + if (dummyShake > 0) dummyShake *= 0.85; + + // Dummy wooden post & shield + ctx.fillStyle = '#4a3319'; + ctx.fillRect(dummyBaseX - 8, dummyBaseY - 60, 16, 120); + + // Armor Breastplate + ctx.fillStyle = '#6c757d'; + ctx.strokeStyle = '#e5b948'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(dummyBaseX, dummyBaseY - 10, 32, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Armor Helmet + ctx.fillStyle = '#495057'; + ctx.beginPath(); + ctx.arc(dummyBaseX, dummyBaseY - 50, 20, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Glowing Rune Shield Aura on Dummy + ctx.strokeStyle = spell.color; + ctx.shadowColor = spell.color; + ctx.shadowBlur = 15; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(dummyBaseX, dummyBaseY - 10, 42 + Math.sin(Date.now() * 0.008) * 4, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + + // Draw Spell Tracing Guide Lines (Left / Center side) + if (spellNodes.length > 1 && !gameWon) { + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.setLineDash([8, 8]); + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(spellNodes[0].x, spellNodes[0].y); + for (let i = 1; i < spellNodes.length; i++) { + ctx.lineTo(spellNodes[i].x, spellNodes[i].y); + } + ctx.stroke(); + ctx.restore(); + } + + // Draw Player's Drawn Line + if (userPath.length > 1) { + ctx.save(); + ctx.strokeStyle = spell.color; + ctx.lineWidth = 6; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.shadowColor = spell.color; + ctx.shadowBlur = 18; + ctx.beginPath(); + ctx.moveTo(userPath[0].x, userPath[0].y); + for (let i = 1; i < userPath.length; i++) { + ctx.lineTo(userPath[i].x, userPath[i].y); + } + ctx.stroke(); + ctx.restore(); + } + + // Draw Spell Nodes (Rune Stars to connect) + if (!gameWon) { + spellNodes.forEach((node, idx) => { + ctx.save(); + const isNext = (idx === nextNodeIdx); + const isHit = node.hit; + + ctx.shadowColor = isHit ? '#2ecc71' : (isNext ? spell.color : '#888'); + ctx.shadowBlur = isNext ? 20 : 8; + + ctx.fillStyle = isHit ? '#2ecc71' : (isNext ? '#ffffff' : '#332a42'); + ctx.strokeStyle = isNext ? spell.color : '#e5b948'; + ctx.lineWidth = isNext ? 4 : 2; + + const r = node.radius + (isNext ? Math.sin(Date.now() * 0.01) * 3 : 0); + ctx.beginPath(); + ctx.arc(node.x, node.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Number label + ctx.fillStyle = isHit ? '#000' : (isNext ? '#000' : '#e5b948'); + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(idx + 1, node.x, node.y); + + ctx.restore(); + }); + } + + // Update & Render Wand Sparks + for (let i = wandParticles.length - 1; i >= 0; i--) { + const p = wandParticles[i]; + p.x += p.vx; + p.y += p.vy; + p.alpha *= 0.90; + if (p.alpha <= 0.02) { + wandParticles.splice(i, 1); + continue; + } + ctx.fillStyle = p.color; + ctx.globalAlpha = p.alpha; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + + // Update & Render Blast Particles + for (let i = blastParticles.length - 1; i >= 0; i--) { + const p = blastParticles[i]; + p.x += p.vx; + p.y += p.vy; + p.alpha *= 0.92; + if (p.alpha <= 0.02) { + blastParticles.splice(i, 1); + continue; + } + ctx.fillStyle = p.color; + ctx.globalAlpha = p.alpha; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1.0; + + requestAnimationFrame(render); + } + requestAnimationFrame(render); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index a298c57..b9de723 100755 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -119,12 +119,12 @@ {{ state.broom_score }} Pkt
- 🧹 Besenflug: + 🧹 Ring-Flug: {% if state.broom_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}
- ♟️ Zauberschach: - {% if state.chess_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %} + 🪄 Spruch-Duell: + {% if state.duel_solved %}✅ Gemeistert{% else %}⏳ Offen{% endif %}
+ 🧩
diff --git a/app/templates/broom.html b/app/templates/broom.html index 8c90b83..3139222 100755 --- a/app/templates/broom.html +++ b/app/templates/broom.html @@ -1,22 +1,23 @@ {% extends "base.html" %} -{% block title %}Kammer I: Der Flug der geflügelten Schlüssel · Hogwarts Escape{% endblock %} +{% block title %}Kammer I: Der Goldene Ring-Flug · Hogwarts Escape{% endblock %} {% block content %}
-

🧹 Kammer I: Der Besenflug 🗝️

+

🧹 Kammer I: Der Goldene Ring-Flug ⭕

- "Hunderte metallischer Flügel schwirren wie Vögel unter der Decke des Gewölbes.
- Die schwere Kerkertür am anderen Ende ist fest verschlossen.
- Nur ein einziger Schlüssel passt: Ein alter, großer Silberschlüssel mit geknicktem blauem Flügel!" + "Schwinge dich auf deinen Rennbesen über den Dächern von Hogwarts!
+ Durchfliege die magisch schwebenden goldenen Ringe, halte die Combo aufrecht
+ und meistere den Parcours von Madam Hooch, um die nächste Pforte zu öffnen!"

⏱️ Zeit: 0s
+
⭕ Ringe: 0/12
✨ Punkte: 0
-
🎯 Ziel: Schlüssel fangen!
+
@@ -39,25 +40,25 @@ ⚡ TURBO
- 💡 Tipp: Du kannst auch direkt mit dem Finger über das Spielfeld wischen! + 💡 Ziehe den Besen einfach mit dem Finger direkt von Ring zu Ring!

- 🎮 Steuerung: Pfeiltasten / WASD / Touch-Wischen zum Fliegen · Leertaste / Turbo für Besen-Beschleunigung! + 🎮 Steuerung: Wische mit dem Finger über das Spielfeld oder nutze Pfeiltasten / D-Pad zum Fliegen · Turbo für rasanten Schub!

- Fange den großen Silberschlüssel mit dem geknickten Flügel und fliege zur schweren Holztür ganz rechts! + Sammle 12 Ringe, um den Parcours abzuschließen. Schnelligkeit und Combos bringen Zusatzpunkte!

{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/app/templates/duel.html b/app/templates/duel.html new file mode 100755 index 0000000..714b019 --- /dev/null +++ b/app/templates/duel.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Kammer II: Das Zauberspruch-Duell · Hogwarts Escape{% endblock %} + +{% block content %} +
+

🪄 Kammer II: Das Zauberspruch-Duell ⚡

+

+ "Eine uralte verzauberte Rüstung bewacht die Pforte zur Flammenkammer.
+ Zücke deinen Zauberstab! Verbinde die leuchtenden Runensterne mit dem Finger oder der Maus,
+ um mächtige Hogwarts-Zaubersprüche zu entfesseln und die Rüstung zu bezwingen!" +

+
+ +
+
+
🪄 Spruch: Flipendo ⚡
+
Zauberspruch 1 von 3
+
🛡️ Ziel: 3 Zauber übrig
+
+ + +
+ +
+

+ Verbinde die leuchtenden Runensterne von 1 bis 4, um den Zauber zu wirken! +

+

+ 💡 Tipp: Ziehe deinen Finger oder die Maus flüssig durch die nummerierten Sterne auf dem Bildschirm. +

+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 23bc6ec..828405a 100755 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -21,34 +21,35 @@

- +
{% if state.broom_solved %}✅ Gemeistert{% else %}🔓 Bereit{% endif %} -
🧹🗝️
-

Kammer I: Der Besenflug

+
🧹⭕
+

Kammer I: Der Ring-Flug

- 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! + Madam Hoochs Quidditch-Parcours! Schwinge dich auf deinen Rennbesen, steuere geschmeidig durch + die schwebenden goldenen Ringe und sammle Punkte und Speed-Combos!

- Auf den Besen schwingen 🧹 + Parcours fliegen 🧹
- +
- - {% if state.chess_solved %}✅ Gemeistert{% else %}⚔️ Bereit{% endif %} + + {% if state.duel_solved %}✅ Gemeistert{% else %}⚡ Bereit{% endif %} -
♟️⚔️
-

Kammer II: McGonagalls Schach

+
🪄⚡
+

Kammer II: Das Spruch-Duell

- Das gigantische, lebendige Zauberschach. Übernimm Rons Rolle als schwarzer Springer, - überliste die weiße Königin und setze den feindlichen König schachmatt! + Zücke deinen Zauberstab! Zeichne die magischen Runen-Gesten (Flipendo, Incendio & Alohomora) + flüssig mit dem Finger nach und zerschmettere die verzauberte Trainingsrüstung!

- Schachbrett betreten ♟️ + Duell beginnen 🪄
+
diff --git a/test_app.py b/test_app.py index 86f92fe..f8199f8 100755 --- a/test_app.py +++ b/test_app.py @@ -13,11 +13,16 @@ def test_endpoints(): res = client.get("/broom") assert res.status_code == 200 - assert "Der Besenflug" in res.text + assert "Der Goldene Ring-Flug" in res.text - res = client.get("/chess") + res = client.get("/duel") assert res.status_code == 200 - assert "McGonagalls Zauberschach" in res.text + assert "Das Zauberspruch-Duell" in res.text + + # Test /chess redirects to /duel + res = client.get("/chess", follow_redirects=False) + assert res.status_code in [301, 302] + assert res.headers["location"] == "/duel" res = client.get("/bottles") assert res.status_code == 200 @@ -43,24 +48,23 @@ def test_endpoints(): assert "snape_scroll" in item_ids # Test House API - res = client.post("/api/player/house", json={"house": "slytherin"}) + res = client.post("/api/player/house", json={"house": "ravenclaw"}) assert res.status_code == 200 - assert res.json()["house"]["name"] == "Slytherin" + assert res.json()["house"]["name"] == "Ravenclaw" - # Test API Broom complete (adds silver_key) - res = client.post("/api/broom/complete", json={"score": 500, "time_taken": 25}) + # Test API Broom complete + res = client.post("/api/broom/complete", json={"score": 850, "time_taken": 22}) assert res.status_code == 200 data = res.json() assert data["success"] is True - assert "silver_key" in data["state"]["inventory"] - # Test API Chess complete (adds chess_knight) - res = client.post("/api/chess/complete") + # Test API Duel complete (adds duel_badge) + res = client.post("/api/duel/complete") assert res.status_code == 200 - chess_data = res.json() - assert chess_data["success"] is True - assert chess_data["new_item"]["id"] == "chess_knight" - assert "chess_knight" in chess_data["state"]["inventory"] + duel_data = res.json() + assert duel_data["success"] is True + assert duel_data["new_item"]["id"] == "duel_badge" + assert "duel_badge" in duel_data["state"]["inventory"] # Test API Bottles drink: Ahead / Black fire (Bottle 3 - Dwarf, adds philosophers_stone) res = client.post("/api/bottles/drink", json={"bottle": 3, "gate": "ahead"}) @@ -75,7 +79,7 @@ def test_endpoints(): assert res.status_code == 200 assert res.json()["success"] is True - print("ALL TESTS (INCLUDING CHESS & MOBILE FLIGHT) PASSED! ♟️🧹✨") + print("ALL TESTS (RING FLIGHT & SPELL DUEL) PASSED! ⭕🪄✨") if __name__ == "__main__": test_endpoints()