5
Lights Out
tap any tile
idle
143 lines · vanilla
view source
// Lights Out — classic 5x5. Tap a tile to toggle it + its 4 orthogonal neighbors.
// Start from a solvable scramble (~10 random clicks from solved). Win = all off.
const N = 5;
let grid; // Uint8Array N*N, 1=on
let moves; // move counter
let won;
let cellSize; // px per tile (recomputed each frame)
let originX, originY; // top-left of grid
let pulse; // last-tapped indicator (i, t)
let resetBtn; // {x,y,w,h} hit rect for mobile reshuffle button
function scramble() {
grid = new Uint8Array(N * N); // start solved (all off)
// Apply ~10 random valid presses — guarantees solvable (involutive)
const presses = 10;
for (let k = 0; k < presses; k++) {
const r = (Math.random() * N) | 0;
const c = (Math.random() * N) | 0;
press(r, c);
}
// Extremely unlikely all-off after scramble; if so, redo
let any = false;
for (let i = 0; i < grid.length; i++) if (grid[i]) { any = true; break; }
if (!any) press(2, 2);
moves = 0;
won = false;
}
function press(r, c) {
toggle(r, c);
toggle(r - 1, c);
toggle(r + 1, c);
toggle(r, c - 1);
toggle(r, c + 1);
}
function toggle(r, c) {
if (r < 0 || r >= N || c < 0 || c >= N) return;
grid[r * N + c] ^= 1;
}
function checkWin() {
for (let i = 0; i < grid.length; i++) if (grid[i]) return false;
return true;
}
function init() {
scramble();
pulse = { i: -1, t: 0 };
resetBtn = { x: 0, y: 0, w: 0, h: 0 };
}
function layout(width, height) {
// Reserve top strip for HUD (24px) and bottom strip for hint (20px)
const padTop = 32, padBot = 24, padSide = 12;
const availW = Math.max(40, width - padSide * 2);
const availH = Math.max(40, height - padTop - padBot);
const side = Math.min(availW, availH);
cellSize = Math.floor(side / N);
const gridSize = cellSize * N;
originX = ((width - gridSize) / 2) | 0;
originY = padTop + (((availH - gridSize) / 2) | 0);
}
function hitTest(x, y) {
const c = Math.floor((x - originX) / cellSize);
const r = Math.floor((y - originY) / cellSize);
if (r < 0 || r >= N || c < 0 || c >= N) return null;
return { r, c };
}
function tick({ ctx, dt, width, height, input }) {
layout(width, height);
const clicks = input.consumeClicks();
if (input.justPressed && input.justPressed("r")) {
scramble();
pulse = { i: -1, t: 0 };
}
for (const click of clicks) {
// Tap the reshuffle button (works on mobile where there's no R key).
if (resetBtn.w > 0
&& click.x >= resetBtn.x && click.x <= resetBtn.x + resetBtn.w
&& click.y >= resetBtn.y && click.y <= resetBtn.y + resetBtn.h) {
scramble();
pulse = { i: -1, t: 0 };
continue;
}
const hit = hitTest(click.x, click.y);
if (!hit) continue;
if (won) { scramble(); pulse = { i: -1, t: 0 }; continue; }
press(hit.r, hit.c);
moves++;
pulse = { i: hit.r * N + hit.c, t: 0.35 };
if (checkWin()) won = true;
}
if (pulse.t > 0) pulse.t = Math.max(0, pulse.t - dt);
draw(ctx, width, height);
}
function draw(ctx, width, height) {
// Background
ctx.fillStyle = "#0a0a14";
ctx.fillRect(0, 0, width, height);
// HUD
ctx.fillStyle = "#9aa0b4";
ctx.font = "13px system-ui, sans-serif";
ctx.textAlign = "left";
ctx.fillText(`Moves ${moves}`, 10, 18);
let lit = 0;
for (let i = 0; i < grid.length; i++) if (grid[i]) lit++;
// Reshuffle button (top-right) — gives mobile a tappable reset.
const btnW = 78, btnH = 22;
resetBtn.x = width - btnW - 8;
resetBtn.y = 4;
resetBtn.w = btnW;
resetBtn.h = btnH;
ctx.fillStyle = "#1f2238";
ctx.fillRect(resetBtn.x, resetBtn.y, btnW, btnH);
ctx.strokeStyle = "#3a3f5c";
ctx.lineWidth = 1;
ctx.strokeRect(resetBtn.x + 0.5, resetBtn.y + 0.5, btnW - 1, btnH - 1);
ctx.fillStyle = "#c9cee0";
ctx.textAlign = "center";
ctx.fillText("Reshuffle", resetBtn.x + btnW / 2, resetBtn.y + 15);
ctx.textAlign = "right";
ctx.fillStyle = "#9aa0b4";
ctx.fillText(`Lit ${lit}/${N * N}`, resetBtn.x - 8, 18);
// Grid
const g = cellSize * N;
ctx.fillStyle = "#15182a";
ctx.fillRect(originX - 2, originY - 2, g + 4, g + 4);
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
const x = originX + c * cellSize;
const y = originY + r * cellSize;
const on = grid[r * N + c] === 1;
const inset = 2;
if (on) {
// Glowy yellow
ctx.fillStyle = "#ffcc00";
ctx.fillRect(x + inset, y + inset, cellSize - inset * 2, cellSize - inset * 2);
// Inner highlight
ctx.fillStyle = "rgba(255,255,255,0.18)";
ctx.fillRect(x + inset, y + inset, cellSize - inset * 2, Math.max(2, (cellSize - inset * 2) * 0.35));
} else {
ctx.fillStyle = "#1f2238";
ctx.fillRect(x + inset, y + inset, cellSize - inset * 2, cellSize - inset * 2);
}
// Pulse ring on last-pressed tile
if (pulse.t > 0 && pulse.i === r * N + c) {
const a = pulse.t / 0.35;
ctx.strokeStyle = `rgba(255,255,255,${a.toFixed(3)})`;
ctx.lineWidth = 2;
ctx.strokeRect(x + inset, y + inset, cellSize - inset * 2, cellSize - inset * 2);
}
}
}
// Hint / win line
ctx.fillStyle = won ? "#34c759" : "#6a6f88";
ctx.font = won ? "bold 14px system-ui, sans-serif" : "12px system-ui, sans-serif";
ctx.textAlign = "center";
const msg = won
? `Solved in ${moves} — tap to reshuffle`
: "tap a tile · tap Reshuffle (or R) to reset";
ctx.fillText(msg, width / 2, height - 8);
}
Comments (2)
Log in to comment.