Home

Put the illustration back together.

Drag a piece onto another to swap them.

Time
00:00
Moves
0

puzzlescript
(function(){
function init(){
var root = document.getElementById('pzh');
if (!root || root.dataset.ready) return;
root.dataset.ready = '1';
var IMG = root.getAttribute('data-img');
// on narrow screens hard mode uses fewer pieces
var small = window.matchMedia('(max-width:600px)');
function levels(){ return { easy:{cols:3,rows:3}, hard: small.matches ? {cols:4,rows:3} : {cols:5,rows:4} }; }
var board = root.querySelector('.pzh-board');
var timeEl = root.querySelector('.pzh-time');
var movesEl = root.querySelector('.pzh-moves');
var bestEl = root.querySelector('.pzh-best');
var winText = root.querySelector('.pzh-win-text');
var live = root.querySelector('.pzh-sr');
var hardBtn = root.querySelector('[data-act="hard"]');
var peekBtn = root.querySelector('[data-act="peek"]');
root.style.setProperty('--img', 'url("' + IMG + '")');
// read proportions from the image
var probe = new Image();
probe.onload = function(){ if (probe.naturalWidth) root.style.setProperty('--ratio', probe.naturalWidth + ' / ' + probe.naturalHeight); };
probe.onerror = function(){
var msg = document.createElement('p');
msg.style.cssText = 'position:absolute;inset:0;margin:0;display:grid;place-items:center;padding:16px;text-align:center;font-size:1rem;background:#fff;z-index:10;';
msg.textContent = 'Image not found: ' + IMG;
board.appendChild(msg);
};
probe.src = IMG;
var level = 'easy', cols = 3, rows = 3;
var order = [], pieces = [];
var moves = 0, startAt = 0, tick = null, solved = false, selected = null;
function fmt(ms){ var s = Math.floor(ms/1000); return String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
function getBest(){ try { return JSON.parse(localStorage.getItem('pzh-best-' + cols + 'x' + rows)); } catch(e){ return null; } }
function setBest(v){ try { localStorage.setItem('pzh-best-' + cols + 'x' + rows, JSON.stringify(v)); } catch(e){} }
function showBest(){
var b = getBest();
if (b && b.ms){ bestEl.hidden = false; bestEl.textContent = 'Best: ' + fmt(b.ms) + ', ' + b.moves + ' moves'; }
else bestEl.hidden = true;
}
function shuffled(n){
var a;
do {
a = []; for (var i = 0; i < n; i++) a.push(i);
for (var k = n-1; k > 0; k--){ var j = Math.floor(Math.random()*(k+1)); var t = a[k]; a[k] = a[j]; a[j] = t; }
} while (a.filter(function(v,i){ return v === i; }).length > Math.floor(n/5));
return a;
}
function build(){
var L = levels()[level]; cols = L.cols; rows = L.rows;
root.style.setProperty('--cols', cols);
root.style.setProperty('--rows', rows);
pieces.forEach(function(p){ p.remove(); });
pieces = [];
for (var id = 0; id < cols*rows; id++){
var c = id % cols, r = Math.floor(id / cols);
var el = document.createElement('button');
el.type = 'button'; el.className = 'pzh-piece'; el.dataset.id = id;
var face = document.createElement('span');
face.className = 'pzh-face';
face.style.setProperty('--bx', (cols > 1 ? c/(cols-1)*100 : 0) + '%');
face.style.setProperty('--by', (rows > 1 ? r/(rows-1)*100 : 0) + '%');
el.appendChild(face);
bindPiece(el);
board.appendChild(el);
pieces.push(el);
}
reset();
}
function place(){
order.forEach(function(id, cell){
var el = pieces[id];
el.style.setProperty('--c', cell % cols);
el.style.setProperty('--r', Math.floor(cell / cols));
el.setAttribute('aria-label', 'Piece in row ' + (Math.floor(cell/cols)+1) + ', column ' + (cell%cols+1));
});
}
function reset(){
clearInterval(tick); tick = null; startAt = 0; moves = 0; solved = false; clearSelection();
root.classList.remove('is-solved');
pieces.forEach(function(p){ p.disabled = false; });
order = shuffled(cols*rows); place();
timeEl.textContent = '00:00'; movesEl.textContent = '0';
showBest();
}
function startClock(){
if (tick) return;
startAt = Date.now();
tick = setInterval(function(){ timeEl.textContent = fmt(Date.now() - startAt); }, 250);
}
function swapCells(a, b){
if (a === b) return;
startClock();
var t = order[a]; order[a] = order[b]; order[b] = t;
moves++; movesEl.textContent = moves;
place();
if (order.every(function(v,i){ return v === i; })) win();
}
function win(){
solved = true; clearInterval(tick); tick = null;
var ms = Date.now() - startAt;
timeEl.textContent = fmt(ms);
root.classList.add('is-solved');
pieces.forEach(function(p){ p.disabled = true; });
var prev = getBest(), isBest = !prev || ms < prev.ms;
if (isBest) setBest({ms:ms, moves:moves});
winText.textContent = 'Solved in ' + fmt(ms) + ' with ' + moves + ' moves' + (isBest && prev ? ', new best' : '') + '.';
live.textContent = winText.textContent;
showBest();
}
function cellOf(el){ return order.indexOf(+el.dataset.id); }
function bindPiece(el){
el.addEventListener('pointerdown', function(e){
if (solved || e.button > 0) return;
e.preventDefault();
el.setPointerCapture(e.pointerId);
var rect = board.getBoundingClientRect();
var sx = e.clientX, sy = e.clientY, moved = false, target = null;
function onMove(ev){
var dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!moved && Math.hypot(dx,dy) < 5) return;
if (!moved){ moved = true; el.classList.add('is-dragging'); clearSelection(); }
el.style.setProperty('--dx', dx + 'px');
el.style.setProperty('--dy', dy + 'px');
var cx = Math.min(cols-1, Math.max(0, Math.floor((ev.clientX - rect.left) / (rect.width/cols))));
var cy = Math.min(rows-1, Math.max(0, Math.floor((ev.clientY - rect.top) / (rect.height/rows))));
var t = pieces[order[cy*cols + cx]];
if (target && target !== t) target.classList.remove('is-target');
target = (t !== el) ? t : null;
if (target) target.classList.add('is-target');
}
function onUp(){
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
if (target) target.classList.remove('is-target');
if (!moved){ tapSelect(el); return; }
var from = cellOf(el);
el.classList.remove('is-dragging');
el.style.removeProperty('--dx'); el.style.removeProperty('--dy');
if (target) swapCells(from, cellOf(target));
}
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
el.addEventListener('keydown', function(e){
if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); tapSelect(el); }
if (e.key === 'Escape') clearSelection();
});
}
function tapSelect(el){
if (solved) return;
if (!selected){ selected = el; el.classList.add('is-selected'); live.textContent = 'Piece selected. Choose where to move it.'; return; }
if (selected === el){ clearSelection(); return; }
var a = cellOf(selected), b = cellOf(el);
clearSelection(); swapCells(a, b);
}
function clearSelection(){ if (selected) selected.classList.remove('is-selected'); selected = null; }
root.querySelector('[data-act="shuffle"]').addEventListener('click', reset);
root.querySelector('[data-act="again"]').addEventListener('click', reset);
hardBtn.addEventListener('click', function(){
level = level === 'easy' ? 'hard' : 'easy';
hardBtn.setAttribute('aria-pressed', level === 'hard');
build();
});
if (small.addEventListener) small.addEventListener('change', function(){ if (level === 'hard') build(); });
function peekOn(){ if (!solved) root.classList.add('is-peeking'); }
function peekOff(){ root.classList.remove('is-peeking'); }
peekBtn.addEventListener('pointerdown', peekOn);
['pointerup','pointerleave','pointercancel','blur'].forEach(function(ev){ peekBtn.addEventListener(ev, peekOff); });
peekBtn.addEventListener('keydown', function(e){ if (e.key === ' ' || e.key === 'Enter'){ e.preventDefault(); peekOn(); } });
peekBtn.addEventListener('keyup', peekOff);
build();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();