- Tabellone grande (fino a 600x600) con frecce-snake orizzontali/verticali - Punta allineata all'ultimo tratto del corpo, nessun incrocio tra linee - Livello sempre risolvibile (clearPath garantito in generazione) - Animazione di uscita step-by-step (crawl) veloce - Pan, zoom con rotella/pinch/pulsanti, minimappa cliccabile - HUD italiano: vite, hint, mosse, undo, livelli - Server Python su porta 1234 (HTTP + LAN)
597 lines
24 KiB
HTML
597 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<title>Arrow Puzzle</title>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
body{
|
||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||
background:#1a1a1a;color:#ccc;overflow:hidden;
|
||
height:100dvh;width:100vw;
|
||
-webkit-tap-highlight-color:transparent;
|
||
user-select:none;-webkit-user-select:none;
|
||
touch-action:none;
|
||
}
|
||
#board{display:block;position:absolute;inset:0;width:100%;height:100%}
|
||
|
||
.hud{
|
||
position:fixed;top:16px;left:0;right:0;display:flex;
|
||
justify-content:space-between;align-items:center;
|
||
padding:0 12px;z-index:10;pointer-events:none;gap:6px;
|
||
}
|
||
.hud>*{pointer-events:auto}
|
||
.hud-item{
|
||
font-weight:700;font-size:13px;
|
||
background:rgba(10,10,20,0.85);color:#ddd;
|
||
padding:5px 12px;border-radius:14px;
|
||
backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,0.08);
|
||
white-space:nowrap;
|
||
}
|
||
.hearts{display:flex;gap:1px}
|
||
.heart{font-size:15px;transition:all 0.3s}
|
||
.heart.lost{opacity:0.15;transform:scale(0.7)}
|
||
.zoom-label{color:#888;font-size:11px}
|
||
.overlay-back{background:rgba(255,200,50,0.15)!important;border-color:rgba(255,200,50,0.4)!important;color:#ffc832!important}
|
||
|
||
.controls{
|
||
position:fixed;bottom:16px;left:50%;transform:translateX(-50%);
|
||
display:flex;gap:8px;z-index:10;flex-wrap:wrap;justify-content:center;
|
||
}
|
||
.btn{
|
||
padding:9px 16px;border-radius:18px;border:1px solid rgba(255,255,255,0.12);
|
||
font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;
|
||
background:rgba(10,10,20,0.85);color:#bbb;
|
||
backdrop-filter:blur(8px);
|
||
transition:all 0.15s;display:flex;align-items:center;gap:4px;
|
||
}
|
||
.btn:active{transform:scale(0.94);background:rgba(255,255,255,0.1)}
|
||
.btn-hint{border-color:rgba(255,200,50,0.35);color:#ffc832}
|
||
.hint-dot{width:5px;height:5px;border-radius:50%;background:currentColor;opacity:0.5;display:inline-block;margin:0 1px}
|
||
.hint-dot.used{opacity:0.08}
|
||
|
||
.overlay{
|
||
position:fixed;inset:0;background:rgba(0,0,0,0.7);
|
||
display:flex;align-items:center;justify-content:center;
|
||
z-index:100;animation:ovIn 0.3s ease;
|
||
}
|
||
@keyframes ovIn{from{opacity:0}to{opacity:1}}
|
||
.overlay-card{
|
||
background:#16162a;border-radius:20px;padding:28px 24px;text-align:center;
|
||
max-width:300px;width:90%;box-shadow:0 16px 48px rgba(0,0,0,0.6);
|
||
animation:pop 0.35s cubic-bezier(0.175,0.885,0.32,1.275);
|
||
border:1px solid rgba(255,255,255,0.06);
|
||
}
|
||
@keyframes pop{from{transform:scale(0.8);opacity:0}to{transform:scale(1);opacity:1}}
|
||
.overlay-emoji{font-size:48px;margin-bottom:8px}
|
||
.overlay-title{font-size:20px;font-weight:800;color:#eee;margin-bottom:4px}
|
||
.overlay-subtitle{color:#888;font-size:13px;margin-bottom:16px}
|
||
.btn-big{padding:12px 28px;font-size:15px;width:100%;justify-content:center;border-radius:22px}
|
||
.btn-win{background:#1b4332;color:#b7e4c7;border-color:rgba(64,145,108,0.3)}
|
||
.btn-retry{background:#590d22;color:#ffb3c1;border-color:rgba(255,77,109,0.3)}
|
||
|
||
.toast{
|
||
position:fixed;bottom:110px;left:50%;transform:translateX(-50%);
|
||
background:rgba(30,30,50,0.9);color:#eee;backdrop-filter:blur(10px);
|
||
padding:8px 20px;border-radius:20px;font-size:13px;font-weight:600;
|
||
animation:toastIn 0.3s ease,toastOut 0.3s ease 1.5s forwards;
|
||
pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.1);
|
||
}
|
||
@keyframes toastIn{from{opacity:0;transform:translateX(-50%) translateY(10px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
|
||
@keyframes toastOut{from{opacity:1}to{opacity:0}}
|
||
|
||
.minimap{
|
||
position:fixed;bottom:100px;right:12px;width:100px;height:100px;
|
||
background:rgba(10,10,20,0.8);border:1px solid rgba(255,255,255,0.1);
|
||
border-radius:8px;z-index:10;overflow:hidden;cursor:pointer;
|
||
}
|
||
.minimap canvas{width:100%;height:100%}
|
||
|
||
.zoom-ctrl{
|
||
position:fixed;right:16px;top:50%;transform:translateY(-50%);
|
||
display:flex;flex-direction:column;gap:6px;z-index:10;
|
||
}
|
||
.zoom-ctrl button{
|
||
width:42px;height:42px;border-radius:12px;border:1px solid rgba(255,255,255,0.12);
|
||
background:rgba(10,10,20,0.85);color:#ddd;font-size:22px;font-weight:700;cursor:pointer;
|
||
backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;
|
||
transition:all 0.15s;
|
||
}
|
||
.zoom-ctrl button:active{transform:scale(0.92);background:rgba(255,255,255,0.14)}
|
||
.zoom-ctrl button:last-child{font-size:16px}
|
||
|
||
@media(max-width:400px){.btn{padding:7px 12px;font-size:11px}.hud-item{padding:4px 10px;font-size:12px}.minimap{width:70px;height:70px;bottom:90px;right:6px}.zoom-ctrl{right:6px}.zoom-ctrl button{width:36px;height:36px;font-size:18px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="board"></canvas>
|
||
<div class="minimap" id="minimapWrap"><canvas id="minimap"></canvas></div>
|
||
|
||
<div class="hud">
|
||
<span class="hud-item" id="levelBadge">Livello 1</span>
|
||
<span class="hud-item" id="arrowCount">Frecce: 0</span>
|
||
<span class="hud-item zoom-label" id="zoomLabel">100%</span>
|
||
<div class="hearts" id="heartsContainer"></div>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<button class="btn" id="btnUndo">↩ Annulla</button>
|
||
<button class="btn" id="btnFit">⊞ Vista completa</button>
|
||
<button class="btn btn-hint" id="btnHint">💡 <span id="hintDots"></span></button>
|
||
<button class="btn" id="btnReset">🔄 Riprova</button>
|
||
</div>
|
||
|
||
<div class="zoom-ctrl">
|
||
<button id="btnZoomIn" title="Zoom avanti">+</button>
|
||
<button id="btnZoomOut" title="Zoom indietro">−</button>
|
||
<button id="btnZoomFit" title="Vista completa">⤢</button>
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
const $=id=>document.getElementById(id);
|
||
const canvas=$('board'), ctx=canvas.getContext('2d');
|
||
const mini=$('minimap'), mctx=mini.getContext('2d');
|
||
|
||
const DIR_VEC={U:[0,-1],R:[1,0],D:[0,1],L:[-1,0]};
|
||
const OPP={U:'D',R:'L',D:'U',L:'R'};
|
||
// Colors per direction - glowing neon on dark bg
|
||
const COLORS=[
|
||
{main:'#ffffff',glow:'rgba(255,255,255,0.45)'}, // white
|
||
{main:'#ff3333',glow:'rgba(255,51,51,0.45)'}, // red
|
||
{main:'#33cc33',glow:'rgba(51,204,51,0.45)'}, // green
|
||
{main:'#3388ff',glow:'rgba(51,136,255,0.45)'}, // blue
|
||
{main:'#ffcc00',glow:'rgba(255,204,0,0.45)'}, // yellow
|
||
{main:'#cc33ff',glow:'rgba(204,51,255,0.45)'}, // purple
|
||
{main:'#ff6600',glow:'rgba(255,102,0,0.45)'}, // orange
|
||
{main:'#00cccc',glow:'rgba(0,204,204,0.45)'}, // cyan
|
||
];
|
||
|
||
let gridW, gridH, arrows, lives=3, maxLives=3, level=1, hints=3, moves=0, solved=false;
|
||
let history=[], selected=null, combo=0;
|
||
let camX=0, camY=0, zoom=1, tZoom=1;
|
||
let cellPx, minZoom, maxZoom;
|
||
let animList=[], animId=null;
|
||
|
||
// ─── Arrow helpers ───────────────────────────────────
|
||
function headCell(a){return a.cells[a.cells.length-1]}
|
||
function clearPath(a) {
|
||
const [hc,hr]=headCell(a); const [dc,dr]=DIR_VEC[a.dir];
|
||
let c=hc+dc, r=hr+dr;
|
||
while(c>=0&&c<gridW&&r>=0&&r<gridH){
|
||
for(const b of arrows){if(b===a)continue;for(const [bc,br] of b.cells)if(bc===c&&br===r)return false}
|
||
c+=dc;r+=dr
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ─── Level gen (snake arrows with turns) ────────────
|
||
function gen(lvl) {
|
||
gridW=Math.min(50+Math.floor(lvl*3), 600);
|
||
gridH=gridW;
|
||
const tgt=Math.min(15+lvl*5, 400);
|
||
const dirs=['U','R','D','L'];
|
||
arrows=[]; const occ=new Set(); const k=(c,r)=>c+','+r;
|
||
let att=0;
|
||
|
||
while(arrows.length<tgt && att<tgt*40){
|
||
att++;
|
||
const hc=2+Math.floor(Math.random()*(gridW-4));
|
||
const hr=2+Math.floor(Math.random()*(gridH-4));
|
||
if(occ.has(k(hc,hr)))continue;
|
||
|
||
const shf=[...dirs].sort(()=>Math.random()-0.5);
|
||
for(const d of shf){
|
||
const bl=4+Math.floor(Math.random()*Math.min(30,10+Math.floor(lvl/2)));
|
||
const cells=[]; let c=hc,r=hr,ok=true;
|
||
let backDir=OPP[d];
|
||
let[bdc,bdr]=DIR_VEC[backDir];
|
||
|
||
for(let i=0;i<bl+1;i++){
|
||
if(c<0||c>=gridW||r<0||r>=gridH||occ.has(k(c,r))){ok=false;break}
|
||
cells.push([c,r]);
|
||
if(i<bl){
|
||
c+=bdc;r+=bdr;
|
||
// Randomly turn 90° (like Snake game)
|
||
if(Math.random()<0.25&&i>1&&i<bl-2){
|
||
const perp=backDir==='U'||backDir==='D'?['L','R']:['U','D'];
|
||
backDir=perp[Math.floor(Math.random()*2)];
|
||
[bdc,bdr]=DIR_VEC[backDir];
|
||
}
|
||
}
|
||
}
|
||
if(!ok||cells.length<4)continue;
|
||
const ci=Math.floor(Math.random()*COLORS.length);
|
||
// Arrowhead direction = direction of the LAST body segment (natural continuation of the line)
|
||
const pc=cells[cells.length-2],lc=cells[cells.length-1];
|
||
const ddx=lc[0]-pc[0],ddy=lc[1]-pc[1];
|
||
const dir=ddx===1?'R':ddx===-1?'L':ddy===1?'D':'U';
|
||
const a={dir,cells,colorIdx:ci};
|
||
arrows.push(a); for(const[cc,rr]of cells)occ.add(k(cc,rr));
|
||
// Solvability guarantee: the NEW arrow must have a clear exit path now,
|
||
// so the level is always solvable (remove arrows in reverse insertion order).
|
||
if(!clearPath(a)){arrows.pop();for(const[cc,rr]of cells)occ.delete(k(cc,rr));continue}
|
||
break
|
||
}
|
||
}
|
||
if(arrows.length<3)return gen(lvl);
|
||
moves=0;combo=0;solved=false;history=[];selected=null;animList=[];
|
||
if(animId){cancelAnimationFrame(animId);animId=null}
|
||
updateUI();fit();draw()
|
||
}
|
||
|
||
// ─── Render ──────────────────────────────────────────
|
||
function resize() {
|
||
canvas.width=window.innerWidth;
|
||
canvas.height=window.innerHeight;
|
||
cellPx=Math.max(1,Math.min(canvas.width*0.9/gridW,canvas.height*0.9/gridH));
|
||
minZoom=0.03;
|
||
maxZoom=16;
|
||
if(zoom<minZoom)zoom=tZoom=minZoom;
|
||
if(zoom>maxZoom)zoom=tZoom=maxZoom;
|
||
mini.width=100;mini.height=100
|
||
}
|
||
|
||
function world(c,r){return[c*cellPx*zoom+camX,r*cellPx*zoom+camY]}
|
||
|
||
function draw() {
|
||
resize();
|
||
const w=canvas.width,h=canvas.height;
|
||
ctx.clearRect(0,0,w,h);
|
||
|
||
// BG
|
||
ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,w,h);
|
||
|
||
// Board area - dark grey/black
|
||
const bx=camX,by=camY,bw=gridW*cellPx*zoom,bh=gridH*cellPx*zoom;
|
||
ctx.fillStyle='#1a1a1a';ctx.fillRect(bx,by,bw,bh);
|
||
|
||
// Grid (only when zoomed in enough)
|
||
const cs=cellPx*zoom;
|
||
if(cs>8){
|
||
ctx.strokeStyle='rgba(255,255,255,0.02)';ctx.lineWidth=0.5;
|
||
// Only draw visible grid lines
|
||
const c0=Math.max(0,Math.floor(-camX/cs)-1);
|
||
const c1=Math.min(gridW,Math.ceil((w-camX)/cs)+1);
|
||
const r0=Math.max(0,Math.floor(-camY/cs)-1);
|
||
const r1=Math.min(gridH,Math.ceil((h-camY)/cs)+1);
|
||
for(let c=c0;c<=c1;c++){const x=bx+c*cs;ctx.beginPath();ctx.moveTo(x,by);ctx.lineTo(x,by+bh);ctx.stroke()}
|
||
for(let r=r0;r<=r1;r++){const y=by+r*cs;ctx.beginPath();ctx.moveTo(bx,y);ctx.lineTo(bx+bw,y);ctx.stroke()}
|
||
}
|
||
|
||
// Arrows
|
||
for(const a of arrows){
|
||
if(animList.some(x=>x.arrow===a))continue;
|
||
const isBlocked=a===selected&&!clearPath(a);
|
||
drawArrow(a,0,0,1,isBlocked);
|
||
}
|
||
// Crawling snakes (step-by-step)
|
||
for(const anim of animList){
|
||
const cs=cellPx*zoom;
|
||
const pts=[];
|
||
const n=anim.n;
|
||
for(let i=0;i<n;i++){
|
||
const fi=anim.t+i;
|
||
const idx=Math.min(anim.path.length-1,Math.floor(fi));
|
||
const nx=Math.min(anim.path.length-1,idx+1);
|
||
const frac=fi-idx;
|
||
const c0=anim.path[idx],c1=anim.path[nx];
|
||
const px=c0[0]+(c1[0]-c0[0])*frac,py=c0[1]+(c1[1]-c0[1])*frac;
|
||
const[p,q]=world(px,py);
|
||
pts.push([p+cs/2,q+cs/2]);
|
||
}
|
||
drawSnakePts(pts,anim.dir,anim.colorIdx,1,false);
|
||
}
|
||
|
||
// Minimap
|
||
drawMinimap();
|
||
}
|
||
|
||
function drawMinimap() {
|
||
const mw=mini.width,mh=mini.height;
|
||
mctx.clearRect(0,0,mw,mh);
|
||
mctx.fillStyle='rgba(10,10,25,0.9)';mctx.fillRect(0,0,mw,mh);
|
||
|
||
const sx=mw/gridW,sy=mh/gridH;
|
||
for(const a of arrows){
|
||
const c=COLORS[a.colorIdx];
|
||
const pts=a.cells.map(([c,r])=>[c*sx,r*sy]);
|
||
mctx.strokeStyle=c.main;mctx.lineWidth=0.5;
|
||
mctx.beginPath();mctx.moveTo(pts[0][0],pts[0][1]);
|
||
for(let i=1;i<pts.length;i++)mctx.lineTo(pts[i][0],pts[i][1]);
|
||
mctx.stroke();
|
||
}
|
||
// Viewport rect
|
||
const vx=(-camX/(cellPx*zoom))/gridW*mw;
|
||
const vy=(-camY/(cellPx*zoom))/gridH*mh;
|
||
const vw=canvas.width/(cellPx*zoom*gridW)*mw;
|
||
const vh=canvas.height/(cellPx*zoom*gridH)*mh;
|
||
mctx.strokeStyle='rgba(255,255,255,0.5)';mctx.lineWidth=1;
|
||
mctx.strokeRect(vx,vy,vw,vh);
|
||
}
|
||
|
||
function drawArrow(a,ox,oy,alpha,blocked){
|
||
const cs=cellPx*zoom;
|
||
const pts=a.cells.map(([c,r])=>{const[p,q]=world(c,r);return[p+cs/2+ox,q+cs/2+oy]});
|
||
drawSnakePts(pts,a.dir,a.colorIdx,alpha,blocked);
|
||
}
|
||
|
||
function drawSnakePts(pts,dir,colorIdx,alpha,blocked){
|
||
const c=COLORS[colorIdx];const cs=cellPx*zoom;
|
||
const head=pts[pts.length-1];
|
||
|
||
// Tail glow (long range) - subtle
|
||
ctx.save();ctx.globalAlpha=alpha*0.15;
|
||
ctx.strokeStyle=c.glow;ctx.lineWidth=cs*0.45;ctx.lineCap='round';ctx.lineJoin='round';
|
||
ctx.shadowColor=c.main;ctx.shadowBlur=cs*1.5;
|
||
ctx.beginPath();ctx.moveTo(pts[0][0],pts[0][1]);
|
||
for(let i=1;i<pts.length;i++)ctx.lineTo(pts[i][0],pts[i][1]);
|
||
ctx.stroke();ctx.restore();
|
||
|
||
// Body line - thin and clean
|
||
ctx.save();ctx.globalAlpha=alpha;
|
||
ctx.strokeStyle=blocked?'#ff4444':c.main;
|
||
ctx.lineWidth=cs*(blocked?0.35:0.14);ctx.lineCap='round';ctx.lineJoin='round';
|
||
ctx.shadowColor=blocked?'#ff4444':c.main;
|
||
ctx.shadowBlur=blocked?15:4;
|
||
ctx.beginPath();ctx.moveTo(pts[0][0],pts[0][1]);
|
||
for(let i=1;i<pts.length;i++)ctx.lineTo(pts[i][0],pts[i][1]);
|
||
ctx.stroke();ctx.restore();
|
||
|
||
// Head triangle - large and clearly visible
|
||
const[dc,dr]=DIR_VEC[dir];
|
||
const headSz=cs*0.4;
|
||
const tipX=head[0]+dc*headSz,tipY=head[1]+dr*headSz;
|
||
const wing=headSz*0.7;
|
||
|
||
// Bright white core
|
||
ctx.save();ctx.globalAlpha=alpha;
|
||
ctx.fillStyle='#ffffff';
|
||
ctx.shadowColor='#ffffff';
|
||
ctx.shadowBlur=headSz*0.8;
|
||
ctx.beginPath();
|
||
switch(dir){
|
||
case'U':ctx.moveTo(tipX,tipY);ctx.lineTo(head[0]-wing,head[1]+headSz*0.3);ctx.lineTo(head[0]+wing,head[1]+headSz*0.3);break;
|
||
case'D':ctx.moveTo(tipX,tipY);ctx.lineTo(head[0]-wing,head[1]-headSz*0.3);ctx.lineTo(head[0]+wing,head[1]-headSz*0.3);break;
|
||
case'L':ctx.moveTo(tipX,tipY);ctx.lineTo(head[0]+headSz*0.3,head[1]-wing);ctx.lineTo(head[0]+headSz*0.3,head[1]+wing);break;
|
||
case'R':ctx.moveTo(tipX,tipY);ctx.lineTo(head[0]-headSz*0.3,head[1]-wing);ctx.lineTo(head[0]-headSz*0.3,head[1]+wing);break;
|
||
}
|
||
ctx.closePath();ctx.fill();
|
||
|
||
// Colored outline
|
||
ctx.strokeStyle=blocked?'#ff4444':c.main;
|
||
ctx.lineWidth=Math.max(2,cs*0.06);
|
||
ctx.shadowColor=blocked?'#ff4444':c.main;
|
||
ctx.shadowBlur=blocked?15:10;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// ─── Anim ────────────────────────────────────────────
|
||
function animLoop(){
|
||
let d=false;
|
||
for(const a of animList){
|
||
a.t+=0.8;d=true;
|
||
// head reaches the end of the path -> whole snake is off-grid
|
||
if(a.t>=a.path.length-a.n)a.done=true;
|
||
}
|
||
animList=animList.filter(a=>!a.done);
|
||
if(Math.abs(zoom-tZoom)>0.0005)clump();
|
||
if(d||Math.abs(zoom-tZoom)>0.0005)draw();
|
||
if(animList.length>0||Math.abs(zoom-tZoom)>0.0005){
|
||
animId=requestAnimationFrame(animLoop)
|
||
}else{
|
||
animId=null;draw();
|
||
if(!solved&&arrows.length===0){solved=true;setTimeout(showWin,300)}
|
||
}
|
||
}
|
||
function fly(a){
|
||
const[dc,dr]=DIR_VEC[a.dir];
|
||
// path = snake cells + straight continuation in the head direction
|
||
const path=a.cells.map(c=>[...c]);
|
||
const n=a.cells.length;
|
||
let off=0;
|
||
while(off<n+2){
|
||
const last=path[path.length-1];
|
||
const nx=[last[0]+dc,last[1]+dr];
|
||
path.push(nx);
|
||
if(nx[0]<0||nx[0]>=gridW||nx[1]<0||nx[1]>=gridH)off++;
|
||
}
|
||
animList.push({arrow:a,path,n,colorIdx:a.colorIdx,dir:a.dir,t:0,done:false});
|
||
if(!animId)animId=requestAnimationFrame(animLoop)
|
||
}
|
||
|
||
// ─── Hit test ────────────────────────────────────────
|
||
function hit(px,py){
|
||
const cs=cellPx*zoom;
|
||
for(const a of arrows){
|
||
for(const [c,r] of a.cells){
|
||
const[cx,cy]=world(c,r);
|
||
const dx=px-(cx+cs/2),dy=py-(cy+cs/2);
|
||
// Larger hit radius for easier tapping
|
||
if(dx*dx+dy*dy<(cs*0.55)*(cs*0.55))return a
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ─── Move ────────────────────────────────────────────
|
||
function tryMove(a){
|
||
if(solved||lives<=0)return;
|
||
if(clearPath(a)){
|
||
combo++;moves++;
|
||
history.push({arrow:a,cells:a.cells.map(c=>[...c]),dir:a.dir,ci:a.colorIdx});
|
||
fly(a);arrows=arrows.filter(x=>x!==a);selected=null;updateUI();
|
||
if(combo>=5)showCombo(combo)
|
||
}else{
|
||
combo=0;lives--;selected=a;updateUI();draw();
|
||
if(lives<=0)setTimeout(showGameOver,500)
|
||
}
|
||
}
|
||
|
||
// ─── Undo ────────────────────────────────────────────
|
||
function undo(){
|
||
if(!history.length||solved)return;
|
||
const r=history.pop();
|
||
arrows.push({dir:r.dir,cells:r.cells,colorIdx:r.ci});
|
||
moves=Math.max(0,moves-1);
|
||
animList=animList.filter(x=>x.arrow!==r.arrow);
|
||
updateUI();draw()
|
||
}
|
||
|
||
// ─── Hint ────────────────────────────────────────────
|
||
function useHint(){
|
||
if(hints<=0||solved||lives<=0)return;
|
||
for(const a of arrows){if(clearPath(a)){hints--;selected=a;updateUI();draw();setTimeout(()=>{if(selected===a){selected=null;draw()}},1500);return}}
|
||
}
|
||
|
||
// ─── Camera ──────────────────────────────────────────
|
||
function clump(){
|
||
zoom+=(tZoom-zoom)*0.18;
|
||
const bw=gridW*cellPx*zoom,bh=gridH*cellPx*zoom;
|
||
camX=Math.min(canvas.width*0.15,Math.max(canvas.width-bw-canvas.width*0.15,camX));
|
||
camY=Math.min(canvas.height*0.15,Math.max(canvas.height-bh-canvas.height*0.15,camY));
|
||
$('zoomLabel').textContent=Math.round(zoom*100)+'%';
|
||
}
|
||
|
||
function fit(){
|
||
resize();tZoom=1;zoom=1;
|
||
camX=(canvas.width-gridW*cellPx)/2;
|
||
camY=(canvas.height-gridH*cellPx)/2;
|
||
draw()
|
||
}
|
||
|
||
// ─── UI ──────────────────────────────────────────────
|
||
function updateUI(){
|
||
$('levelBadge').textContent='Livello '+level;
|
||
$('arrowCount').textContent='Frecce: '+arrows.length;
|
||
let hd='';for(let i=0;i<3;i++)hd+='<span class="hint-dot'+(i>=hints?' used':'')+'"></span>';
|
||
$('hintDots').innerHTML=hd;
|
||
const hc=$('heartsContainer');hc.innerHTML='';
|
||
for(let i=0;i<maxLives;i++){const h=document.createElement('span');h.className='heart'+(i>=lives?' lost':'');h.textContent='❤️';hc.appendChild(h)}
|
||
}
|
||
|
||
function showCombo(n){
|
||
const el=document.createElement('div');
|
||
el.style.cssText='position:fixed;pointer-events:none;font-weight:800;font-size:18px;color:#ffc832;left:'+(window.innerWidth/2)+'px;top:'+(window.innerHeight/2)+'px;z-index:200;animation:cb 0.9s ease-out forwards;text-shadow:0 0 12px rgba(255,200,50,0.5)';
|
||
el.textContent=n+'x';document.body.appendChild(el);
|
||
const s=document.createElement('style');s.textContent='@keyframes cb{0%{opacity:1;transform:translate(-50%,0) scale(1)}100%{opacity:0;transform:translate(-50%,-50px) scale(1.3)}}';
|
||
document.head.appendChild(s);
|
||
el.addEventListener('animationend',()=>{el.remove();s.remove()},{once:true});
|
||
}
|
||
|
||
function showWin(){
|
||
const ov=document.createElement('div');ov.className='overlay';
|
||
ov.innerHTML='<div class="overlay-card"><div class="overlay-emoji">🎉</div><div class="overlay-title">Livello '+level+' Completato!</div><div class="overlay-subtitle">'+moves+' mosse — '+arrows.length+' frecce rimosse</div><button class="btn btn-big btn-win" id="btnNext">▶ Prossimo Livello</button></div>';
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#btnNext').onclick=()=>{ov.remove();level++;lives=maxLives;hints=3;gen(level)};
|
||
}
|
||
|
||
function showGameOver(){
|
||
const ov=document.createElement('div');ov.className='overlay';
|
||
ov.innerHTML='<div class="overlay-card"><div class="overlay-emoji">💔</div><div class="overlay-title">Game Over</div><div class="overlay-subtitle">Rimangono '+arrows.length+' frecce</div><button class="btn btn-big btn-retry" id="btnRetry">🔄 Riprova</button></div>';
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#btnRetry').onclick=()=>{ov.remove();lives=maxLives;hints=3;gen(level)};
|
||
}
|
||
|
||
function showToast(m){
|
||
const el=document.createElement('div');el.className='toast';el.textContent=m;document.body.appendChild(el);
|
||
el.addEventListener('animationend',()=>el.remove(),{once:true});
|
||
}
|
||
|
||
// ─── Input ───────────────────────────────────────────
|
||
let pd=null,ds=null,isDrag=false,lt=0;
|
||
|
||
canvas.addEventListener('pointerdown',e=>{
|
||
if(solved||lives<=0)return;
|
||
pd={x:e.clientX,y:e.clientY,t:Date.now()};ds={x:e.clientX,y:e.clientY,cx:camX,cy:camY};isDrag=false
|
||
});
|
||
|
||
canvas.addEventListener('pointermove',e=>{
|
||
if(!pd)return;
|
||
const dx=e.clientX-pd.x,dy=e.clientY-pd.y;
|
||
if(Math.abs(dx)>4||Math.abs(dy)>4){isDrag=true;camX=ds.cx+dx;camY=ds.cy+dy;draw()}
|
||
});
|
||
|
||
canvas.addEventListener('pointerup',e=>{
|
||
if(!pd)return;const dt=Date.now()-pd.t;const dx=Math.abs(e.clientX-pd.x);const dy=Math.abs(e.clientY-pd.y);pd=null;
|
||
if(!isDrag&&dt<300&&dx<10&&dy<10){
|
||
const a=hit(e.clientX,e.clientY);
|
||
if(a)tryMove(a);else{selected=null;draw()}
|
||
if(dt<250&&Date.now()-lt<300){
|
||
if(Math.abs(zoom-1)<0.05)tZoom=Math.min(6,maxZoom);else tZoom=1;
|
||
if(!animId)animId=requestAnimationFrame(animLoop)
|
||
}
|
||
lt=Date.now()
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('wheel',e=>{
|
||
e.preventDefault();
|
||
const f=e.deltaY>0?0.7:1.4;
|
||
const nz=Math.min(maxZoom,Math.max(minZoom,tZoom*f));
|
||
const r=nz/tZoom;camX=e.clientX-(e.clientX-camX)*r;camY=e.clientY-(e.clientY-camY)*r;
|
||
tZoom=nz;if(!animId)animId=requestAnimationFrame(animLoop);draw()
|
||
},{passive:false});
|
||
|
||
let lpd=null,psz=1;
|
||
canvas.addEventListener('touchstart',e=>{
|
||
if(e.touches.length===2){lpd=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);psz=zoom;pd=null}
|
||
});
|
||
canvas.addEventListener('touchmove',e=>{
|
||
if(e.touches.length===2&&lpd){e.preventDefault();
|
||
const d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
|
||
tZoom=Math.min(maxZoom,Math.max(minZoom,psz*(d/lpd)));
|
||
if(!animId)animId=requestAnimationFrame(animLoop);draw()}
|
||
},{passive:false});
|
||
canvas.addEventListener('touchend',()=>{lpd=null});
|
||
|
||
// ─── Buttons & keys ─────────────────────────────────
|
||
function zoomCenter(f){
|
||
const nz=Math.min(maxZoom,Math.max(minZoom,tZoom*f));
|
||
const r=nz/tZoom;
|
||
camX=canvas.width/2-(canvas.width/2-camX)*r;
|
||
camY=canvas.height/2-(canvas.height/2-camY)*r;
|
||
tZoom=nz;if(!animId)animId=requestAnimationFrame(animLoop);draw()
|
||
}
|
||
$('btnZoomIn').addEventListener('click',()=>zoomCenter(1.25));
|
||
$('btnZoomOut').addEventListener('click',()=>zoomCenter(0.8));
|
||
$('btnZoomFit').addEventListener('click',fit);
|
||
$('btnHint').addEventListener('click',useHint);
|
||
$('btnReset').addEventListener('click',()=>{lives=maxLives;hints=3;gen(level)});
|
||
$('btnUndo').addEventListener('click',undo);
|
||
$('btnFit').addEventListener('click',fit);
|
||
document.addEventListener('keydown',e=>{
|
||
if(e.key==='h')useHint();
|
||
if(e.key==='r'&&!e.ctrlKey&&!e.metaKey)gen(level);
|
||
if(e.key==='z'&&(e.ctrlKey||e.metaKey)){e.preventDefault();undo()}
|
||
if(e.key==='0')fit();
|
||
if(e.key==='+'||e.key==='='){tZoom=Math.min(maxZoom,tZoom*1.25);if(!animId)animId=requestAnimationFrame(animLoop);draw()}
|
||
if(e.key==='-'){tZoom=Math.max(minZoom,tZoom*0.8);if(!animId)animId=requestAnimationFrame(animLoop);draw()}
|
||
});
|
||
|
||
// Click minimap -> jump viewport to that area
|
||
$('minimapWrap').addEventListener('click',e=>{
|
||
const r=$('minimapWrap').getBoundingClientRect();
|
||
const mx=(e.clientX-r.left)/r.width,my=(e.clientY-r.top)/r.height;
|
||
camX=canvas.width/2-mx*gridW*cellPx*zoom;
|
||
camY=canvas.height/2-my*gridH*cellPx*zoom;
|
||
draw()
|
||
});
|
||
|
||
window.addEventListener('resize',()=>{resize();draw()});
|
||
|
||
// ─── Init ────────────────────────────────────────────
|
||
gen(level);
|
||
setTimeout(()=>{
|
||
if(!localStorage.getItem('arrowHelpShown')){
|
||
localStorage.setItem('arrowHelpShown','1');
|
||
showToast('🖱️ Trascina per muoverti • ⤢ per vista completa • clicca la minimappa');
|
||
}
|
||
},800);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|