Files
bootle/app/static/js/duel.js
T

534 lines
18 KiB
JavaScript
Executable File

// 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() {
let newItem = null;
try {
const res = await fetch('/api/duel/complete', { method: 'POST' });
const data = await res.json();
if (data.new_item) newItem = data.new_item;
} catch (e) {
console.error(e);
}
if (!newItem) {
newItem = {
id: "duel_badge",
name: "Duellanten-Abzeichen & Kessel-Rezept",
icon: "⚡",
desc: "Ein goldenes Abzeichen und Snapes geheime Zaubertrank-Formel für die Brauküche."
};
}
if (window.showChamberVictory) {
window.showChamberVictory({
chamberBadge: "Kammer II: Das Spruch-Duell",
title: "⚡ Duell Gemeistert!",
statsHtml: `<span>🪄 Gesten: <strong>4 Sprüche</strong></span><span>🛡️ Rüstung: <strong>Bezwungen!</strong></span>`,
item: newItem,
storyText: "Die verzauberte Rüstung bricht klirrend in sich zusammen! In ihrem Brustharnisch liegt ein versiegeltes Pergament: <strong>Snapes geheime Rezeptur</strong> für die Kessel-Brauküche. Das schwere Fallgitter zur <strong>Zaubertrank-Brauküche</strong> hebt sich!",
nextUrl: "/brew",
nextBtnText: "➔ Weiter zu Kammer III: Brauküche ⚗️"
});
} else {
modalTitle.innerText = "⚡ Duell Gemeistert!";
modalText.innerHTML = `Mit meisterhafter Zauberstabführung hast du alle Sprüche entfesselt und die Rüstung bezwungen!`;
modalBtn.innerText = "Weiter zur 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);
})();