feat: add Level 8 Trencaclosques (drag-and-drop puzzle)

- Blind base map (grey slots for all comarques)
- Shuffled tray at the bottom with each comarca's shape + name
- Drag-and-drop with pointer events (works on touch + mouse)
- Hit test: SVG isPointInFill (precise) + 65px center fallback for small comarques
- Correct placement: comarca snaps to map with color + label
- Wrong drop: piece shakes and returns to tray (counts errors)
- Score = total pieces - errors; stars via showResult(8)
- Respects active group filter (same piece count as other map levels)
This commit is contained in:
Jaume Garriga Maestre
2026-05-13 22:29:45 +02:00
parent 6a85e97fd1
commit b9404dd5b2

View File

@@ -253,6 +253,34 @@
.avatar-grid{grid-template-columns:repeat(4,1fr);} .avatar-grid{grid-template-columns:repeat(4,1fr);}
} }
/* ── Nivell 8 · Trencaclosques ── */
#screen-puzzle { padding-bottom:20px; }
#puzzle-map-wrap { width:100%; background:#D8EEF8; border-radius:16px;
overflow:hidden; box-shadow:var(--shadow); }
#puzzle-tray-wrap { margin-top:10px; background:#f2ede3; border-radius:14px;
padding:8px 8px 10px; box-shadow:var(--shadow); }
#puzzle-tray-label { font-size:.72rem; font-weight:700; color:#888;
text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px; padding-left:2px; }
#puzzle-tray { display:flex; gap:8px; overflow-x:auto; overflow-y:hidden;
padding:4px 2px 6px; scroll-snap-type:x mandatory; }
#puzzle-tray::-webkit-scrollbar{ height:4px; }
#puzzle-tray::-webkit-scrollbar-thumb{ background:#ccc; border-radius:4px; }
.puzzle-piece { flex:0 0 auto; display:flex; flex-direction:column; align-items:center;
background:#fff; border-radius:12px; padding:5px 7px 4px; cursor:grab;
user-select:none; touch-action:none; border:2px solid #e0d8cc;
scroll-snap-align:start; transition:transform .15s, box-shadow .15s, opacity .2s; }
.puzzle-piece:hover { transform:translateY(-4px); box-shadow:0 5px 14px rgba(0,0,0,.22); }
.puzzle-piece svg { width:80px; height:60px; display:block; }
.puzzle-piece .pp-name { font-size:.6rem; font-weight:700; text-align:center;
margin-top:3px; color:#555; font-family:'Lexend',Arial,sans-serif;
max-width:84px; line-height:1.2; }
#puzzle-drag-ghost { display:none; position:fixed; pointer-events:none; z-index:9999;
transform:translate(-50%,-50%); filter:drop-shadow(0 6px 16px rgba(0,0,0,.45));
transition:none; }
#puzzle-drag-ghost svg { width:110px; height:82px; display:block; }
.puzzle-snap-ok { animation:pSnap .35s ease forwards; }
@keyframes pSnap { 0%{filter:brightness(1.8)} 100%{filter:brightness(1)} }
/* ── Nivell 7 · Milionari ── */ /* ── Nivell 7 · Milionari ── */
#screen-mill { #screen-mill {
background: linear-gradient(180deg, #08082a 0%, #140a3e 100%); background: linear-gradient(180deg, #08082a 0%, #140a3e 100%);
@@ -412,6 +440,11 @@
<div class="lvl-name" style="color:#FFD700;">Nivell 7 · Milionari</div> <div class="lvl-name" style="color:#FFD700;">Nivell 7 · Milionari</div>
<div class="lvl-desc">Qui vol ser milionari? 4 opcions, 10 preguntes!</div> <div class="lvl-desc">Qui vol ser milionari? 4 opcions, 10 preguntes!</div>
</div> </div>
<div class="level-card" onclick="startLevel(8)" style="grid-column:span 2;background:linear-gradient(135deg,#0a2a0a,#1a5c1a);border:2px solid #7CFC00;">
<span class="emoji">🧩</span>
<div class="lvl-name" style="color:#7CFC00;">Nivell 8 · Trencaclosques</div>
<div class="lvl-desc">Arrossega cada comarca fins al seu lloc al mapa!</div>
</div>
</div> </div>
<div class="home-actions"> <div class="home-actions">
<button class="btn btn-green" onclick="goToStats()" id="btn-stats" style="display:none;"> <button class="btn btn-green" onclick="goToStats()" id="btn-stats" style="display:none;">
@@ -552,6 +585,25 @@
</div> </div>
</div> </div>
<!-- ═══════════════════ TRENCACLOSQUES (Level 8) ═══════════════════ -->
<div id="screen-puzzle" class="screen">
<button class="btn btn-back" onclick="goHome()">← Tornar</button>
<div class="map-score-row">
<span id="puzzle-ctr"></span>
<span id="puzzle-score-live"></span>
</div>
<div id="puzzle-map-wrap">
<svg id="puzzle-map-svg" viewBox="0 0 820 600"
xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;display:block;"></svg>
</div>
<div id="puzzle-tray-wrap">
<div id="puzzle-tray-label">🧩 Arrossega cada comarca fins al seu lloc al mapa</div>
<div id="puzzle-tray"></div>
</div>
<!-- Floating drag ghost — follows pointer -->
<div id="puzzle-drag-ghost"></div>
</div>
<!-- ═══════════════════ MILIONARI (Level 7) ═══════════════════ --> <!-- ═══════════════════ MILIONARI (Level 7) ═══════════════════ -->
<div id="screen-mill" class="screen"> <div id="screen-mill" class="screen">
<button class="btn btn-back" onclick="goHome()" style="color:#FFD700;background:rgba(255,215,0,.12);border:1px solid #FFD700;">← Tornar</button> <button class="btn btn-back" onclick="goHome()" style="color:#FFD700;background:rgba(255,215,0,.12);border:1px solid #FFD700;">← Tornar</button>
@@ -878,8 +930,8 @@ const MTN_COLORS = {
"Pallars Jussà":"#D4851F","Alt Urgell":"#E09828","Cerdanya":"#E8A82E", "Pallars Jussà":"#D4851F","Alt Urgell":"#E09828","Cerdanya":"#E8A82E",
"Ripollès":"#C86820","Garrotxa":"#B05818","Berguedà":"#D07B25","Solsonès":"#E8B835", "Ripollès":"#C86820","Garrotxa":"#B05818","Berguedà":"#D07B25","Solsonès":"#E8B835",
}; };
const LEVEL_ICONS = {1:'👀',2:'🧩',3:'🔗',4:'✏️',5:'🗺️',6:'🔮',7:'💰'}; const LEVEL_ICONS = {1:'👀',2:'🧩',3:'🔗',4:'✏️',5:'🗺️',6:'🔮',7:'💰',8:'🧩'};
const LEVEL_NAMES = {1:'Descobreix',2:'Tria',3:'Uneix',4:'Escriu',5:'El Mapa',6:'Mapa Cec',7:'Milionari'}; const LEVEL_NAMES = {1:'Descobreix',2:'Tria',3:'Uneix',4:'Escriu',5:'El Mapa',6:'Mapa Cec',7:'Milionari',8:'Trencaclosques'};
/* ══════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════
ESTAT ESTAT
@@ -1372,7 +1424,8 @@ function startLevel(n){
if(n===1)initL1();else if(n===2)initL2();else if(n===3)initL3(); if(n===1)initL1();else if(n===2)initL2();else if(n===3)initL3();
else if(n===4)initL4();else if(n===5){mapBlind=false;initMap();} else if(n===4)initL4();else if(n===5){mapBlind=false;initMap();}
else if(n===6){mapBlind=true;initMap();} else if(n===6){mapBlind=true;initMap();}
else{initMill();} else if(n===7){initMill();}
else if(n===8){initPuzzle();}
} }
function repeatLevel(){ startLevel(currentLevel); } function repeatLevel(){ startLevel(currentLevel); }
@@ -1860,6 +1913,210 @@ function buildMillSVG(comarca) {
svg.setAttribute('viewBox', '0 0 820 600'); svg.setAttribute('viewBox', '0 0 820 600');
} }
} }
/* ══════════════════════════════════════════════════════
NIVELL 8 · TRENCACLOSQUES
Drag-and-drop: arrossega el contorn de la comarca
fins al seu lloc al mapa cec.
══════════════════════════════════════════════════════ */
let puzzlePlaced = new Set(); // noms de comarques ja col·locades
let puzzleTotal = 0; // total de peces d'aquesta partida
let puzzleWrong = 0; // nombre de deixades incorrectes
let puzzleDrag = null; // {name, trayEl} mentre es fa drag
/** Entry point cridat per startLevel(8). */
function initPuzzle() {
puzzlePlaced = new Set();
puzzleWrong = 0;
puzzleDrag = null;
// questions ja conté les comarques filtrades amb path (assignades a startLevel)
puzzleTotal = questions.length;
showScreen('screen-puzzle');
_buildPuzzleBaseMap();
_buildPuzzleTray();
_updatePuzzleHUD();
}
/** Construeix el mapa de fons: totes les comarques en gris (slots buits). */
function _buildPuzzleBaseMap() {
const svg = document.getElementById('puzzle-map-svg');
svg.innerHTML = '';
// Fons de mar
const sea = document.createElementNS('http://www.w3.org/2000/svg','rect');
sea.setAttribute('width','820'); sea.setAttribute('height','600');
sea.setAttribute('fill','#C8E8F5'); svg.appendChild(sea);
// Totes les comarques amb path: slot gris clar (placa buida)
for (const [name, info] of Object.entries(COMARCA_PATHS)) {
const path = document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('d', info.d);
path.setAttribute('id', 'pslot_'+info.id);
path.setAttribute('data-name', name);
path.setAttribute('fill', '#E2E8E0');
path.setAttribute('stroke', '#B8C8B0');
path.setAttribute('stroke-width', '0.7');
svg.appendChild(path);
}
// Textos de veïns
addTextSVG(svg,420,18,'ANDORRA · FRANÇA',7,'#aaa','middle');
addTextSVG(svg,36,310,'ARAGÓ',7,'#aaa','middle','rotate(-90,36,310)');
addTextSVG(svg,790,430,'Mar Mediterrani',7,'#80AACC','middle','rotate(90,790,430)');
addTextSVG(svg,320,575,'COMUNITAT VALENCIANA',7,'#aaa','middle');
}
/** Construeix la safata inferior amb les peces barrejades. */
function _buildPuzzleTray() {
const tray = document.getElementById('puzzle-tray');
tray.innerHTML = '';
for (const c of questions) { // questions ja és shuffle()
const info = COMARCA_PATHS[c.name];
if (!info) continue;
// viewBox centrat al centroide de la comarca (finestra de 120×90 unitats SVG)
const vx = info.cx - 60, vy = info.cy - 45;
// Color de la peça: igual que al mapa de joc
const fill = MTN_COLORS[c.name] || '#C97D10';
const el = document.createElement('div');
el.className = 'puzzle-piece';
el.dataset.name = c.name;
el.innerHTML = `
<svg viewBox="${vx} ${vy} 120 90" xmlns="http://www.w3.org/2000/svg">
<path d="${info.d}" fill="${fill}" stroke="white" stroke-width="1.8"/>
</svg>
<div class="pp-name">${c.name}</div>`;
el.addEventListener('pointerdown', _puzzleDragStart, {passive:false});
tray.appendChild(el);
}
}
/** Comença el drag en prémer una peça de la safata. */
function _puzzleDragStart(e) {
e.preventDefault();
const trayEl = e.currentTarget;
const name = trayEl.dataset.name;
const info = COMARCA_PATHS[name];
const fill = MTN_COLORS[name] || '#C97D10';
// Atenua la peça original a la safata
trayEl.style.opacity = '0.35';
trayEl.style.pointerEvents = 'none';
// Crea el fantasma flotant
const ghost = document.getElementById('puzzle-drag-ghost');
const vx = info.cx - 60, vy = info.cy - 45;
ghost.innerHTML = `<svg viewBox="${vx} ${vy} 120 90" xmlns="http://www.w3.org/2000/svg">
<path d="${info.d}" fill="${fill}" stroke="white" stroke-width="1.8"/>
</svg>`;
ghost.style.display = 'block';
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
puzzleDrag = {name, trayEl};
document.addEventListener('pointermove', _puzzleDragMove);
document.addEventListener('pointerup', _puzzleDragEnd);
document.addEventListener('pointercancel', _puzzleDragCancel);
}
function _puzzleDragMove(e) {
if (!puzzleDrag) return;
const ghost = document.getElementById('puzzle-drag-ghost');
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
}
function _puzzleDragEnd(e) {
if (!puzzleDrag) return;
_puzzleCleanListeners();
const ghost = document.getElementById('puzzle-drag-ghost');
ghost.style.display = 'none';
const {name, trayEl} = puzzleDrag;
puzzleDrag = null;
// Converteix coordenades pantalla → SVG
const svg = document.getElementById('puzzle-map-svg');
const rect = svg.getBoundingClientRect();
const svgX = (e.clientX - rect.left) / rect.width * 820;
const svgY = (e.clientY - rect.top) / rect.height * 600;
// Hit-test 1: SVG isPointInFill (precís, usa la forma real)
const slot = document.getElementById('pslot_' + COMARCA_PATHS[name].id);
let hit = false;
if (slot) {
try {
const pt = svg.createSVGPoint();
pt.x = svgX; pt.y = svgY;
hit = slot.isPointInFill(pt);
} catch(_) {}
}
// Hit-test 2: fallback per a comarques petites (radi generós 65px SVG)
if (!hit) {
const info = COMARCA_PATHS[name];
hit = Math.hypot(svgX - info.cx, svgY - info.cy) < 65;
}
if (hit) {
_puzzlePlace(name);
trayEl.remove();
} else {
// Deixada incorrecta — torna a la safata
puzzleWrong++;
trayEl.style.opacity = '1';
trayEl.style.pointerEvents = '';
trayEl.style.animation = 'wShake .4s ease';
setTimeout(() => trayEl.style.animation = '', 420);
_updatePuzzleHUD();
}
}
function _puzzleDragCancel() {
if (!puzzleDrag) return;
_puzzleCleanListeners();
document.getElementById('puzzle-drag-ghost').style.display = 'none';
const {trayEl} = puzzleDrag;
puzzleDrag = null;
trayEl.style.opacity = '1';
trayEl.style.pointerEvents = '';
}
function _puzzleCleanListeners() {
document.removeEventListener('pointermove', _puzzleDragMove);
document.removeEventListener('pointerup', _puzzleDragEnd);
document.removeEventListener('pointercancel',_puzzleDragCancel);
}
/** Fixa la comarca al mapa amb color i etiqueta. */
function _puzzlePlace(name) {
puzzlePlaced.add(name);
const info = COMARCA_PATHS[name];
const fill = MTN_COLORS[name] || '#C97D10';
const svg = document.getElementById('puzzle-map-svg');
// Omple el slot del mapa amb color
const slot = document.getElementById('pslot_'+info.id);
if (slot) {
slot.setAttribute('fill', fill);
slot.setAttribute('stroke', 'white');
slot.setAttribute('stroke-width', '1.4');
slot.classList.add('puzzle-snap-ok');
}
// Afegeix l'etiqueta amb el nom de la comarca
const c = questions.find(x => x.name === name);
const small = name.length > 12;
const words = name.split(' ');
if (words.length > 2 && small) {
const mid = Math.ceil(words.length / 2);
addLabel(svg, info.cx, info.cy - 5, words.slice(0, mid).join(' '), true);
addLabel(svg, info.cx, info.cy + 7, words.slice(mid).join(' '), true);
} else {
addLabel(svg, info.cx, info.cy, name, small);
}
_updatePuzzleHUD();
// Fi del joc?
if (puzzlePlaced.size >= puzzleTotal) {
// score = total peces - errors (mínim 0) → % per a showResult
score = Math.max(0, puzzleTotal - puzzleWrong);
questions = {length: puzzleTotal}; // showResult usa questions.length
setTimeout(() => showResult(8), 900);
}
}
function _updatePuzzleHUD() {
document.getElementById('puzzle-ctr').textContent =
`Col·locades: ${puzzlePlaced.size} de ${puzzleTotal}`;
document.getElementById('puzzle-score-live').textContent =
puzzleWrong > 0 ? `${puzzleWrong} errors` : '';
}
</script> </script>
</body> </body>
</html> </html>