5
Picross 10×10
tap to fill · long-press to mark blank
idle
398 lines · vanilla
view source
// Picross / Nonogram 10x10.
//
// Auto-generates a solvable board: random binary image with density ~0.35,
// derive row/column run-length hints from it. User fills cells (tap / left
// click) or marks them blank (long-press / right-button via mode toggle).
// "Verify" highlights mismatches. Winning the board auto-rolls a new puzzle.
const N = 10;
let solution; // Uint8Array of N*N: 1 = filled, 0 = empty
let state; // Uint8Array: 0 = unknown, 1 = filled, 2 = marked-blank
let rowHints; // array of arrays of run-lengths
let colHints; // array of arrays of run-lengths
let rowDone; // bool per row
let colDone; // bool per column
let won = false;
let winFlash = 0;
let verifyFlash = 0; // when >0 we briefly highlight wrong cells
let errorCells; // Uint8Array marking cells that violate solution during verify
let elapsed = 0;
let solveTime = 0;
// Input bookkeeping
let pressR = -1, pressC = -1; // grid cell currently being pressed
let pressStart = 0; // ms since sim start when press began
let pressedConsumed = false; // true if press already turned into "blank" via long-hold
let blankMode = false; // desktop toggle: tap = blank when on
let lastMouseDown = false;
let pressX = 0, pressY = 0; // initial press position (for slop check)
const LONG_PRESS_MS = 450;
const SLOP_PX = 14;
// ---------- puzzle generation ----------
function genSolution() {
// 1) build a random binary image with density ~0.35, ensure each row and
// column has at least one filled cell so hints aren't degenerate.
const sol = new Uint8Array(N * N);
const density = 0.32 + Math.random() * 0.10;
for (let i = 0; i < sol.length; i++) sol[i] = Math.random() < density ? 1 : 0;
// Smooth a touch: bias towards neighbours of filled cells so the image
// tends to form contiguous shapes rather than salt-and-pepper noise.
// Three passes of a "majority of self+random neighbour" sweep.
for (let pass = 0; pass < 2; pass++) {
const next = new Uint8Array(sol);
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
let n = 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const xx = x + dx, yy = y + dy;
if (xx < 0 || xx >= N || yy < 0 || yy >= N) continue;
n += sol[yy * N + xx];
}
if (sol[y * N + x] === 1) {
if (n <= 1 && Math.random() < 0.6) next[y * N + x] = 0;
} else {
if (n >= 4 && Math.random() < 0.5) next[y * N + x] = 1;
}
}
}
sol.set(next);
}
// ensure non-empty rows/cols
for (let y = 0; y < N; y++) {
let any = false;
for (let x = 0; x < N; x++) if (sol[y * N + x]) { any = true; break; }
if (!any) sol[y * N + ((Math.random() * N) | 0)] = 1;
}
for (let x = 0; x < N; x++) {
let any = false;
for (let y = 0; y < N; y++) if (sol[y * N + x]) { any = true; break; }
if (!any) sol[((Math.random() * N) | 0) * N + x] = 1;
}
return sol;
}
function runsOf(arr) {
const runs = [];
let cur = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i]) cur++;
else if (cur) { runs.push(cur); cur = 0; }
}
if (cur) runs.push(cur);
if (!runs.length) runs.push(0);
return runs;
}
function hintsFromSolution(sol) {
const rows = [];
const cols = [];
for (let y = 0; y < N; y++) {
const row = new Uint8Array(N);
for (let x = 0; x < N; x++) row[x] = sol[y * N + x];
rows.push(runsOf(row));
}
for (let x = 0; x < N; x++) {
const col = new Uint8Array(N);
for (let y = 0; y < N; y++) col[y] = sol[y * N + x];
cols.push(runsOf(col));
}
return { rows, cols };
}
function lineMatches(line, hint) {
// Does the filled pattern in this line match the given hint?
// Only the FILLED cells (state===1) count as filled; unknown counts as empty.
const arr = new Uint8Array(line.length);
for (let i = 0; i < line.length; i++) arr[i] = line[i] === 1 ? 1 : 0;
const r = runsOf(arr);
if (r.length === 1 && r[0] === 0 && hint.length === 1 && hint[0] === 0) return true;
if (r.length !== hint.length) return false;
for (let i = 0; i < r.length; i++) if (r[i] !== hint[i]) return false;
return true;
}
function refreshLineFlags() {
// mark rows/cols that match their hints with the user's filled cells
for (let y = 0; y < N; y++) {
const line = new Uint8Array(N);
for (let x = 0; x < N; x++) line[x] = state[y * N + x];
rowDone[y] = lineMatches(line, rowHints[y]);
}
for (let x = 0; x < N; x++) {
const line = new Uint8Array(N);
for (let y = 0; y < N; y++) line[y] = state[y * N + x];
colDone[x] = lineMatches(line, colHints[x]);
}
}
function isSolved() {
// Solved when every FILLED cell in state equals every filled cell in
// the solution AND no incorrect cells are filled.
for (let i = 0; i < N * N; i++) {
const want = solution[i];
const have = state[i] === 1 ? 1 : 0;
if (want !== have) return false;
}
return true;
}
function newPuzzle() {
solution = genSolution();
const h = hintsFromSolution(solution);
rowHints = h.rows;
colHints = h.cols;
state = new Uint8Array(N * N);
rowDone = new Array(N).fill(false);
colDone = new Array(N).fill(false);
errorCells = new Uint8Array(N * N);
won = false;
winFlash = 0;
verifyFlash = 0;
blankMode = false;
pressR = -1;
elapsed = 0;
solveTime = 0;
refreshLineFlags();
}
// ---------- layout ----------
let W = 0, H = 0;
let originX = 0, originY = 0;
let cell = 24;
let hintLeftW = 0, hintTopH = 0;
let verifyBtn = { x: 0, y: 0, w: 0, h: 0 };
let newBtn = { x: 0, y: 0, w: 0, h: 0 };
let modeBtn = { x: 0, y: 0, w: 0, h: 0 };
function layout() {
// Reserve room for hints. Each row hint area on the left is ~5 numbers wide
// worst case; max run count for a 10-cell line is 5. Each column hint area
// is similarly up to 5 numbers tall.
// Choose cell size that fits: total grid = N*cell. Left/top hints take
// 5 * cell * 0.45 of width/height (numbers smaller than cells).
const padTop = 50; // for header buttons
const padBottom = 12;
const padX = 8;
// Available area for hints + grid
const availW = W - padX * 2;
const availH = H - padTop - padBottom;
// Solve cell so that (5*0.5 + N) * cell <= availW and similarly height
const cw = availW / (N + 5 * 0.55);
const ch = availH / (N + 5 * 0.55);
cell = Math.max(12, Math.floor(Math.min(cw, ch)));
hintLeftW = Math.ceil(cell * 5 * 0.55);
hintTopH = Math.ceil(cell * 5 * 0.55);
const gridW = N * cell;
const gridH = N * cell;
const totalW = hintLeftW + gridW;
const totalH = hintTopH + gridH;
originX = Math.floor((W - totalW) / 2) + hintLeftW;
originY = padTop + Math.floor((availH - totalH) / 2) + hintTopH;
if (originY < padTop + hintTopH) originY = padTop + hintTopH;
// Header buttons
const btnH = 30;
const btnW = Math.min(96, Math.floor((W - 24) / 3));
const baseY = 10;
newBtn = { x: 8, y: baseY, w: btnW, h: btnH };
modeBtn = { x: 8 + btnW + 6, y: baseY, w: btnW, h: btnH };
verifyBtn = { x: 8 + (btnW + 6) * 2, y: baseY, w: btnW, h: btnH };
}
function init({ width, height }) {
W = width; H = height;
newPuzzle();
layout();
}
// ---------- input ----------
function cellAt(x, y) {
if (x < originX || y < originY) return null;
const cx = Math.floor((x - originX) / cell);
const cy = Math.floor((y - originY) / cell);
if (cx < 0 || cy < 0 || cx >= N || cy >= N) return null;
return { r: cy, c: cx };
}
function hitButton(b, x, y) {
return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
}
function applyMark(r, c, mark) {
// mark: 1 = fill toggle, 2 = blank toggle
if (won) return;
const i = r * N + c;
if (state[i] === mark) state[i] = 0;
else state[i] = mark;
errorCells[i] = 0;
refreshLineFlags();
if (isSolved()) {
won = true;
winFlash = 1;
solveTime = elapsed;
}
}
function handleInput(input, now) {
// Buttons via consumeClicks (fires on mouseup, doesn't conflict with press tracking)
const clicks = input.consumeClicks();
for (const c of clicks) {
if (hitButton(newBtn, c.x, c.y)) { newPuzzle(); layout(); return; }
if (hitButton(modeBtn, c.x, c.y)) { blankMode = !blankMode; continue; }
if (hitButton(verifyBtn, c.x, c.y)) {
// Highlight mismatches: cells the user filled that aren't in solution,
// OR cells they marked blank that ARE filled in the solution.
let anyErr = false;
for (let i = 0; i < N * N; i++) {
const want = solution[i];
const have = state[i];
let bad = false;
if (have === 1 && want === 0) bad = true;
else if (have === 2 && want === 1) bad = true;
errorCells[i] = bad ? 1 : 0;
if (bad) anyErr = true;
}
verifyFlash = anyErr ? 1.5 : 0.8;
continue;
}
// If clicks fall inside the grid AND we never had a corresponding
// mousedown press (e.g. tap-with-touch fast path), use the click as a fill.
// Normal flow uses press release below; this is a safety net.
}
// Track press lifecycle for long-press detection.
if (input.mouseDown && !lastMouseDown) {
pressX = input.mouseX;
pressY = input.mouseY;
const hit = cellAt(input.mouseX, input.mouseY);
if (hit) {
pressR = hit.r;
pressC = hit.c;
pressStart = now;
pressedConsumed = false;
} else {
pressR = -1;
}
} else if (input.mouseDown && pressR >= 0) {
// Cancel if the user dragged off the starting cell
const dx = input.mouseX - pressX, dy = input.mouseY - pressY;
if (dx * dx + dy * dy > SLOP_PX * SLOP_PX) {
pressR = -1;
} else if (!pressedConsumed && now - pressStart >= LONG_PRESS_MS) {
// Long press fires while still holding: mark blank.
applyMark(pressR, pressC, 2);
pressedConsumed = true;
}
} else if (!input.mouseDown && lastMouseDown) {
// Release
if (pressR >= 0 && !pressedConsumed) {
const mark = blankMode ? 2 : 1;
applyMark(pressR, pressC, mark);
}
pressR = -1;
pressedConsumed = false;
}
lastMouseDown = input.mouseDown;
}
// ---------- drawing ----------
function fmtTime(t) {
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
return `${m}:${s < 10 ? "0" + s : s}`;
}
function drawButton(ctx, b, label, active) {
ctx.fillStyle = active ? "#3b82f6" : "#1f2937";
ctx.strokeStyle = active ? "#60a5fa" : "#374151";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(b.x + 0.5, b.y + 0.5, b.w, b.h);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#e5e7eb";
ctx.font = `bold 13px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, b.x + b.w / 2, b.y + b.h / 2);
}
function drawHints(ctx) {
// Row hints — right-aligned just left of the grid
ctx.textBaseline = "middle";
ctx.font = `${Math.max(10, Math.floor(cell * 0.42))}px ui-monospace, monospace`;
for (let y = 0; y < N; y++) {
const cy = originY + y * cell + cell / 2;
const hints = rowHints[y];
const allZero = hints.length === 1 && hints[0] === 0;
ctx.fillStyle = rowDone[y] && !allZero ? "#6ee7b7" : (allZero ? "#4b5563" : "#cbd5e1");
ctx.textAlign = "right";
let x = originX - 4;
for (let i = hints.length - 1; i >= 0; i--) {
ctx.fillText(String(hints[i]), x, cy);
x -= Math.floor(cell * 0.42);
}
}
// Column hints — bottom-aligned just above the grid
for (let xCol = 0; xCol < N; xCol++) {
const cx = originX + xCol * cell + cell / 2;
const hints = colHints[xCol];
const allZero = hints.length === 1 && hints[0] === 0;
ctx.fillStyle = colDone[xCol] && !allZero ? "#6ee7b7" : (allZero ? "#4b5563" : "#cbd5e1");
ctx.textAlign = "center";
let y = originY - 4;
for (let i = hints.length - 1; i >= 0; i--) {
ctx.fillText(String(hints[i]), cx, y);
y -= Math.floor(cell * 0.5);
}
}
}
function drawGrid(ctx) {
// Background panel for grid
ctx.fillStyle = "#0f172a";
ctx.fillRect(originX, originY, N * cell, N * cell);
// Cells
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const i = y * N + x;
const px = originX + x * cell;
const py = originY + y * cell;
const v = state[i];
// Light/dark 5x5 group alternation for readability
const grpDark = (((Math.floor(x / 5)) + (Math.floor(y / 5))) % 2) === 1;
ctx.fillStyle = grpDark ? "#111827" : "#1e293b";
ctx.fillRect(px + 1, py + 1, cell - 1, cell - 1);
if (v === 1) {
ctx.fillStyle = errorCells[i] && verifyFlash > 0 ? "#ef4444" : "#e5e7eb";
ctx.fillRect(px + 2, py + 2, cell - 3, cell - 3);
} else if (v === 2) {
// blank marker: small X
ctx.strokeStyle = "#64748b";
ctx.lineWidth = Math.max(1.5, cell * 0.07);
const m = Math.floor(cell * 0.28);
ctx.beginPath();
ctx.moveTo(px + m, py + m);
ctx.lineTo(px + cell - m, py + cell - m);
ctx.moveTo(px + cell - m, py + m);
ctx.lineTo(px + m, py + cell - m);
ctx.stroke();
if (errorCells[i] && verifyFlash > 0) {
ctx.fillStyle = "rgba(239,68,68,0.25)";
ctx.fillRect(px + 1, py + 1, cell - 1, cell - 1);
}
}
// Hover/press highlight
if (pressR === y && pressC === x) {
ctx.fillStyle = "rgba(96,165,250,0.18)";
ctx.fillRect(px + 1, py + 1, cell - 1, cell - 1);
}
}
}
// Grid lines
ctx.strokeStyle = "#334155";
ctx.lineWidth = 1;
for (let i = 0; i <= N; i++) {
const offX = originX + i * cell + 0.5;
const offY = originY + i * cell + 0.5;
ctx.beginPath();
ctx.moveTo(offX, originY);
ctx.lineTo(offX, originY + N * cell);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(originX, offY);
ctx.lineTo(originX + N * cell, offY);
ctx.stroke();
}
// Heavier lines every 5
ctx.strokeStyle = "#94a3b8";
ctx.lineWidth = 2;
for (let i = 0; i <= N; i += 5) {
const offX = originX + i * cell + 0.5;
const offY = originY + i * cell + 0.5;
ctx.beginPath();
ctx.moveTo(offX, originY);
ctx.lineTo(offX, originY + N * cell);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(originX, offY);
ctx.lineTo(originX + N * cell, offY);
ctx.stroke();
}
}
function tick({ ctx, dt, width, height, input, time }) {
if (W !== width || H !== height) {
W = width; H = height;
layout();
}
if (!won) elapsed += dt;
if (verifyFlash > 0) verifyFlash = Math.max(0, verifyFlash - dt);
if (winFlash > 0) winFlash = Math.max(0, winFlash - dt * 0.6);
// Use sim time (seconds) for press timing. Convert to ms for thresholds.
handleInput(input, time * 1000);
// Background
ctx.fillStyle = "#020617";
ctx.fillRect(0, 0, W, H);
// Header buttons
drawButton(ctx, newBtn, "New", false);
drawButton(ctx, modeBtn, blankMode ? "Mark: X" : "Mark: fill", blankMode);
drawButton(ctx, verifyBtn, "Verify", verifyFlash > 0);
// Timer / status
ctx.font = "12px ui-monospace, monospace";
ctx.fillStyle = "#94a3b8";
ctx.textAlign = "right";
ctx.textBaseline = "top";
ctx.fillText(won ? `Solved in ${fmtTime(solveTime)}` : fmtTime(elapsed), W - 10, 16);
// Grid + hints
drawHints(ctx);
drawGrid(ctx);
// Win overlay
if (won) {
const a = 0.55 + 0.25 * Math.sin(time * 3);
ctx.fillStyle = `rgba(110,231,183,${0.18 + 0.10 * Math.sin(time * 3)})`;
ctx.fillRect(originX, originY, N * cell, N * cell);
ctx.fillStyle = "#ecfdf5";
ctx.font = "bold 22px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Solved!", originX + N * cell / 2, originY + N * cell / 2 - 14);
ctx.font = "13px system-ui, sans-serif";
ctx.fillText(`Time ${fmtTime(solveTime)} — tap "New" for another`, originX + N * cell / 2, originY + N * cell / 2 + 12);
// auto-roll after a few seconds
if (winFlash <= 0) {
newPuzzle();
layout();
}
}
// Help line at bottom
ctx.font = "11px system-ui, sans-serif";
ctx.fillStyle = "#64748b";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const tip = blankMode
? "Mark mode: tap to mark blank (X). Toggle off to fill."
: "Tap to fill — long-press a cell to mark blank.";
ctx.fillText(tip, W / 2, H - 6);
}
Comments (0)
Log in to comment.