5

Picross 10×10

tap to fill · long-press to mark blank

A 10×10 nonogram (picross). Each row and column carries a hint listing the lengths of contiguous filled runs in order — for example, means a run of three filled cells, then at least one gap, then one filled cell, another gap, and a run of two. Your job is to recover the hidden picture using only those hints. Tap (or left-click) an empty cell to fill it; long-press (~450 ms) on touch or toggle the "Mark" button on desktop to drop an X for cells you've ruled out. Hint numbers turn green when that line matches your current filled cells, but matching every line isn't enough — "Verify" highlights cells where what you've filled disagrees with the unique generated solution. Each puzzle is a fresh random binary image at density smoothed for shape; rows and columns are guaranteed non-empty. Solve one and a new board rolls in.

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.