// Wand Spell Tracing & Dueling Mini-Game (Das Zauberspruch-Duell - Mobile First) (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 = 400; function resize() { const rect = canvas.getBoundingClientRect(); width = Math.round(rect.width || canvas.parentElement.clientWidth || 700); // Slightly taller on mobile so stars are large and easy to tap const isMobile = width < 500; height = Math.round(isMobile ? Math.min(360, Math.max(300, width * 0.85)) : Math.min(440, Math.max(320, width * 0.58))); 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: 'Tippe oder ziehe die 4 Sterne von 1 bis 4 im Zickzack-Muster!', color: '#00f2fe', beamColor: '#4facfe', points: [ { x: 0.18, y: 0.28 }, { x: 0.52, y: 0.28 }, { x: 0.20, y: 0.72 }, { x: 0.54, y: 0.72 } ] }, { id: 'incendio', name: 'Incendio 🔥', incantation: '"Incendio!" (Feuer-Zauber)', desc: 'Tippe oder ziehe die Sterne im Dreieck (1 ➔ 2 ➔ 3 ➔ 4), um Feuer zu entfachen!', color: '#ff4500', beamColor: '#ff8c00', points: [ { x: 0.36, y: 0.22 }, { x: 0.54, y: 0.74 }, { x: 0.18, y: 0.74 }, { x: 0.36, y: 0.22 } ] }, { id: 'alohomora', name: 'Alohomora 🗝️', incantation: '"Alohomora!" (Entriegelungszauber)', desc: 'Verbinde die Sterne von 1 bis 4 in der Schlüsselloch-Kurve!', color: '#ffd700', beamColor: '#fff275', points: [ { x: 0.32, y: 0.22 }, { x: 0.48, y: 0.36 }, { x: 0.34, y: 0.52 }, { x: 0.34, y: 0.80 } ] }, { id: 'expecto_patronum', name: 'Expecto Patronum 🦌', incantation: '"Expecto Patronum!" (Meister-Schutzzauber)', desc: 'Verbinde die 5 Sterne des Lichts im Pentagramm (1 ➔ 2 ➔ 3 ➔ 4 ➔ 5), um die Rüstung endgültig zu bannen!', color: '#ffffff', beamColor: '#bbf2f6', points: [ { x: 0.35, y: 0.18 }, { x: 0.50, y: 0.65 }, { x: 0.18, y: 0.35 }, { x: 0.52, y: 0.35 }, { x: 0.20, y: 0.65 } ] } ]; 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; let resetTimer = null; 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: width < 500 ? 24 : 20, hit: idx < nextNodeIdx })); } window.addEventListener('resize', resize); window.addEventListener('orientationchange', () => setTimeout(resize, 200)); 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(); 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 checkNodeHit(x, y) { if (nextNodeIdx >= spellNodes.length) return; const targetNode = spellNodes[nextNodeIdx]; const dist = Math.hypot(x - targetNode.x, y - targetNode.y); // Generous, thumb-friendly hit radius: 52px on mobile, 44px desktop const hitRadius = width < 500 ? 52 : 44; if (dist < hitRadius) { targetNode.hit = true; nextNodeIdx++; if (window.navigator && window.navigator.vibrate) { window.navigator.vibrate(30); } if (window.soundFX) window.soundFX.playKeyChime(); addWandSpark(targetNode.x, targetNode.y, '#ffd700'); // Clear any pending idle reset clearTimeout(resetTimer); // Connect path to the node exactly userPath.push({ x: targetNode.x, y: targetNode.y }); // All nodes connected for this spell! if (nextNodeIdx >= spellNodes.length) { castSpellSuccess(); } } } function handleInputStart(x, y) { if (gameWon) return; isDrawing = true; // If starting fresh or continuing if (nextNodeIdx === 0) { userPath = [{ x, y }]; } else { userPath.push({ 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; // MOBILE-FIRST IMPROVEMENT: // DO NOT wipe nextNodeIdx on finger release! // This allows players to either swipe continuously OR tap stars 1 -> 2 -> 3 -> 4! // Only set an idle reset timeout if they abandon the spell for 10 seconds. clearTimeout(resetTimer); if (nextNodeIdx > 0 && nextNodeIdx < spellNodes.length) { resetTimer = setTimeout(() => { if (nextNodeIdx < spellNodes.length && !gameWon) { nextNodeIdx = 0; userPath = []; setupSpellNodes(); } }, 10000); } } // Touch events with passive false and 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 }); canvas.addEventListener('touchcancel', e => { e.preventDefault(); handleInputEnd(); }, { passive: false }); // Pointer events for desktop mouse 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 = 22; if (window.soundFX) window.soundFX.playMagicSuccess(); if (window.navigator && window.navigator.vibrate) { window.navigator.vibrate([40, 60, 80]); } // Explosion on target dummy const dummyX = width * 0.82; 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 🗝️

Die alte Rüstung verneigt sich ehrfürchtig. Das Tor öffnet sich und gibt den Weg zur dampfenden Zaubertrank-Brauküche frei!

🎒 Das goldene Duellanten-Abzeichen wurde deinem Zauberbeutel hinzugefügt! `; modalBtn.innerText = "Weiter zur Zaubertrank-Brauküche ⚗️ ➔"; modalBtn.onclick = () => { window.location.href = '/brew'; }; 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 = 16 * spellCastAnim; ctx.shadowColor = spell.color; ctx.shadowBlur = 30; ctx.beginPath(); ctx.moveTo(width * 0.45, height * 0.5); ctx.lineTo(width * 0.82, height * 0.5); ctx.stroke(); ctx.restore(); spellCastAnim -= 0.05; } // Draw Enchanted Target Dummy (Right side) ctx.save(); const dummyBaseX = width * 0.82 + (dummyShake > 0 ? (Math.random() - 0.5) * dummyShake : 0); const dummyBaseY = height * 0.5; if (dummyShake > 0) dummyShake *= 0.85; // Dummy wooden post ctx.fillStyle = '#4a3319'; ctx.fillRect(dummyBaseX - 6, dummyBaseY - 50, 12, 100); // Armor Breastplate ctx.fillStyle = '#6c757d'; ctx.strokeStyle = '#e5b948'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(dummyBaseX, dummyBaseY - 10, 28, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Armor Helmet ctx.fillStyle = '#495057'; ctx.beginPath(); ctx.arc(dummyBaseX, dummyBaseY - 45, 18, 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, 38 + Math.sin(Date.now() * 0.008) * 4, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); // Draw Guide Lines between all nodes if (spellNodes.length > 1 && !gameWon) { ctx.save(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; ctx.setLineDash([6, 6]); 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 Completed Connected Lines between hit nodes if (nextNodeIdx > 0 && !gameWon) { ctx.save(); ctx.strokeStyle = '#2ecc71'; ctx.lineWidth = 5; ctx.shadowColor = '#2ecc71'; ctx.shadowBlur = 15; ctx.beginPath(); ctx.moveTo(spellNodes[0].x, spellNodes[0].y); for (let i = 1; i < nextNodeIdx; i++) { ctx.lineTo(spellNodes[i].x, spellNodes[i].y); } ctx.stroke(); ctx.restore(); } // Draw Active Player Finger Path 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 (Stars with large numbers) if (!gameWon) { spellNodes.forEach((node, idx) => { ctx.save(); const isNext = (idx === nextNodeIdx); const isHit = node.hit; ctx.shadowColor = isHit ? '#2ecc71' : (isNext ? spell.color : '#666'); ctx.shadowBlur = isNext ? 22 : 8; ctx.fillStyle = isHit ? '#2ecc71' : (isNext ? '#ffffff' : '#221a30'); ctx.strokeStyle = isHit ? '#2ecc71' : (isNext ? spell.color : '#8c6e3b'); ctx.lineWidth = isNext ? 4 : 2; const r = node.radius + (isNext ? Math.sin(Date.now() * 0.012) * 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 16px 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); })();