Compare commits
3 Commits
3bc8b69f1f
...
a4efa5b68a
| Author | SHA1 | Date |
|---|---|---|
|
|
a4efa5b68a | |
|
|
d27244ac60 | |
|
|
518818dc1b |
Binary file not shown.
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 339 KiB |
|
|
@ -121,3 +121,4 @@ registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayer
|
|||
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
|
||||
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
||||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,436 @@
|
|||
// Pipe Puzzle — procedural tile art.
|
||||
//
|
||||
// Every tile is painted once into a 320×320 canvas texture (two× the largest
|
||||
// on-screen cell, so it stays crisp) and rendered as a plain Phaser Image
|
||||
// that gets rotated into place — the same bake-then-blit approach as
|
||||
// Rush Hour and Mini Motorways. Raw Canvas 2D is used because cylindrical
|
||||
// pipe shading (perpendicular gradients, flanges, bolts) is exactly what
|
||||
// makes a pipe read as a pipe.
|
||||
//
|
||||
// Tiles are painted in a canonical orientation and rotated:
|
||||
// • straight — painted horizontal (W–E)
|
||||
// • elbow — painted N–E (stub from the top edge into the corner)
|
||||
// • stub — painted socket-N (a dead end: stub from the top edge)
|
||||
// • t — painted N|E|W (the open mouth points S)
|
||||
// • cross — painted all four (rotation-invariant)
|
||||
// • source — painted socket-N (stub up, brass faucet body below)
|
||||
// • drain — painted socket-N (stub up, iron grate below)
|
||||
// See `angleFor(sockets)` for the rotation table.
|
||||
|
||||
import { N, E, S, W, bitCount, rotateSockets } from './PipePuzzleLogic.js';
|
||||
|
||||
export const TILE_PX = 320;
|
||||
const PW = TILE_PX * 0.30; // pipe width
|
||||
|
||||
// ── Colour helpers ───────────────────────────────────────────────────────────
|
||||
function shade(hex, amt) {
|
||||
const v = parseInt(hex.slice(1), 16);
|
||||
const ch = (sh) => {
|
||||
let c = (v >> sh) & 0xff;
|
||||
c = Math.round(amt >= 0 ? c + (255 - c) * amt : c * (1 + amt));
|
||||
return Math.max(0, Math.min(255, c));
|
||||
};
|
||||
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
|
||||
}
|
||||
const lighten = (hex, a) => shade(hex, a);
|
||||
const darken = (hex, a) => shade(hex, -a);
|
||||
|
||||
const STEEL = '#8b98a8';
|
||||
const STEEL_HI = '#dce4ed';
|
||||
const STEEL_LO = '#39424e';
|
||||
const BRASS = '#c8951c';
|
||||
const IRON = '#4a525c';
|
||||
const OUTLINE = 'rgba(12,16,22,0.95)';
|
||||
|
||||
function makeCanvas() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = TILE_PX;
|
||||
c.height = TILE_PX;
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Primitives ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Dark rounded plate behind the pipe — the "tile" the pipe sits on.
|
||||
function plate(ctx) {
|
||||
const r = 26, m = 6;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
roundRect(ctx, m, m, TILE_PX - m * 2, TILE_PX - m * 2, r);
|
||||
ctx.fillStyle = 'rgba(17,20,26,0.55)';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// Vertical pipe centred on `x`, from y0 to y1 (full-bleed at the edge).
|
||||
function pipeV(ctx, x, y0, y1) {
|
||||
const g = ctx.createLinearGradient(x - PW / 2, 0, x + PW / 2, 0);
|
||||
g.addColorStop(0, STEEL_LO);
|
||||
g.addColorStop(0.22, '#a9b4c2');
|
||||
g.addColorStop(0.45, STEEL_HI);
|
||||
g.addColorStop(0.72, STEEL);
|
||||
g.addColorStop(1, STEEL_LO);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(x - PW / 2, y0, PW, y1 - y0);
|
||||
ctx.strokeStyle = OUTLINE;
|
||||
ctx.lineWidth = 7;
|
||||
ctx.strokeRect(x - PW / 2 + 3.5, y0 + 3.5, PW - 7, y1 - y0 - 7);
|
||||
}
|
||||
|
||||
// Horizontal pipe centred on `y`, from x0 to x1.
|
||||
function pipeH(ctx, y, x0, x1) {
|
||||
const g = ctx.createLinearGradient(0, y - PW / 2, 0, y + PW / 2);
|
||||
g.addColorStop(0, STEEL_LO);
|
||||
g.addColorStop(0.22, '#a9b4c2');
|
||||
g.addColorStop(0.45, STEEL_HI);
|
||||
g.addColorStop(0.72, STEEL);
|
||||
g.addColorStop(1, STEEL_LO);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(x0, y - PW / 2, x1 - x0, PW);
|
||||
ctx.strokeStyle = OUTLINE;
|
||||
ctx.lineWidth = 7;
|
||||
ctx.strokeRect(x0 + 3.5, y - PW / 2 + 3.5, x1 - x0 - 7, PW - 7);
|
||||
}
|
||||
|
||||
// A flange band on a vertical pipe at y, on a horizontal pipe pass vert=false.
|
||||
function flange(ctx, x, y, vert) {
|
||||
const w = PW * 1.5, h = PW * 0.62;
|
||||
const bx = vert ? x - w / 2 : x - h / 2;
|
||||
const by = vert ? y - h / 2 : y - w / 2;
|
||||
const bw = vert ? w : h;
|
||||
const bh = vert ? h : w;
|
||||
const g = ctx.createLinearGradient(
|
||||
vert ? bx : 0, vert ? 0 : by,
|
||||
vert ? bx + bw : 0, vert ? 0 : by + bh,
|
||||
);
|
||||
g.addColorStop(0, darken(STEEL, 0.35));
|
||||
g.addColorStop(0.45, lighten(STEEL, 0.35));
|
||||
g.addColorStop(1, darken(STEEL, 0.45));
|
||||
ctx.fillStyle = g;
|
||||
roundRect(ctx, bx, by, bw, bh, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.stroke();
|
||||
// Corner bolts (two visible on the outer face).
|
||||
const boltR = 7;
|
||||
const off = bw * 0.18;
|
||||
for (const [ox, oy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
||||
const px = vert ? bx + (ox < 0 ? off : bw - off) : x;
|
||||
const py = vert ? y : by + (oy < 0 ? off : bh - off);
|
||||
if (vert) {
|
||||
ctx.beginPath(); ctx.arc(px, py, boltR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2c333d'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(px, py, boltR * 0.45, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#b7c2cf'; ctx.fill();
|
||||
} else {
|
||||
ctx.beginPath(); ctx.arc(px, py, boltR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2c333d'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(px, py, boltR * 0.45, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#b7c2cf'; ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coupling collar where a pipe meets the canvas edge (the "socket").
|
||||
function edgeCollar(ctx, edge, along, vert) {
|
||||
// edge: 'N' | 'E' | 'S' | 'W'; along: centre position along that edge.
|
||||
const cw = PW * 1.34, ch = TILE_PX * 0.10;
|
||||
let x, y, w, h;
|
||||
if (edge === 'N') { x = along - cw / 2; y = 0; w = cw; h = ch; }
|
||||
else if (edge === 'S') { x = along - cw / 2; y = TILE_PX - ch; w = cw; h = ch; }
|
||||
else if (edge === 'W') { x = 0; y = along - cw / 2; w = ch; h = cw; }
|
||||
else { x = TILE_PX - ch; y = along - cw / 2; w = ch; h = cw; }
|
||||
const g = ctx.createLinearGradient(vert ? 0 : x, vert ? y : 0, vert ? 0 : x + w, vert ? y + h : 0);
|
||||
g.addColorStop(0, darken(STEEL, 0.42));
|
||||
g.addColorStop(0.5, lighten(STEEL, 0.28));
|
||||
g.addColorStop(1, darken(STEEL, 0.5));
|
||||
ctx.fillStyle = g;
|
||||
roundRect(ctx, x, y, w, h, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Tiles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function paintStraight(ctx) {
|
||||
plate(ctx);
|
||||
pipeH(ctx, TILE_PX / 2, 0, TILE_PX);
|
||||
edgeCollar(ctx, 'W', TILE_PX / 2, false);
|
||||
edgeCollar(ctx, 'E', TILE_PX / 2, false);
|
||||
flange(ctx, TILE_PX * 0.27, TILE_PX / 2, false);
|
||||
flange(ctx, TILE_PX * 0.73, TILE_PX / 2, false);
|
||||
}
|
||||
|
||||
function paintElbow(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeV(ctx, cx, 0, cx); // top stub
|
||||
pipeH(ctx, cx, cx, TILE_PX); // right stub
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
edgeCollar(ctx, 'E', cx, false);
|
||||
// Elbow collar over the corner.
|
||||
const r = PW * 0.88;
|
||||
const g = ctx.createRadialGradient(cx - r * 0.4, cx - r * 0.4, r * 0.2, cx, cx, r * 1.25);
|
||||
g.addColorStop(0, lighten(STEEL, 0.42));
|
||||
g.addColorStop(0.7, STEEL);
|
||||
g.addColorStop(1, darken(STEEL, 0.45));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke();
|
||||
// Three bolts on the outer (north-west) face.
|
||||
for (const a of [Math.PI * 0.75, Math.PI, Math.PI * 1.25]) {
|
||||
const px = cx + Math.cos(a) * r * 0.62;
|
||||
const py = cx + Math.sin(a) * r * 0.62;
|
||||
ctx.beginPath(); ctx.arc(px, py, 9, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2c333d'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#b7c2cf'; ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function paintStub(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeV(ctx, cx, 0, cx);
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
// Rounded cap + end ring at the dead end.
|
||||
const r = PW * 0.5;
|
||||
ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = darken(STEEL, 0.25); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke();
|
||||
const g = ctx.createRadialGradient(cx - r * 0.4, cx - r * 0.4, r * 0.15, cx, cx, r * 0.7);
|
||||
g.addColorStop(0, lighten(STEEL, 0.35));
|
||||
g.addColorStop(1, darken(STEEL, 0.35));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(cx, cx, r * 0.62, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4; ctx.stroke();
|
||||
}
|
||||
|
||||
function paintT(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeV(ctx, cx, 0, TILE_PX / 2); // N stub
|
||||
pipeH(ctx, cx, 0, TILE_PX); // W–E through the centre (full width)
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
edgeCollar(ctx, 'W', cx, false);
|
||||
edgeCollar(ctx, 'E', cx, false);
|
||||
// Central tee body over the junction.
|
||||
const r = PW * 0.78;
|
||||
const g = ctx.createRadialGradient(cx - r * 0.35, cx - r * 0.35, r * 0.2, cx, cx, r * 1.1);
|
||||
g.addColorStop(0, lighten(STEEL, 0.4));
|
||||
g.addColorStop(0.7, STEEL);
|
||||
g.addColorStop(1, darken(STEEL, 0.42));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke();
|
||||
// Bolts on the three open arms (N, W, E).
|
||||
for (const a of [Math.PI * 1.5, Math.PI, 0]) {
|
||||
const px = cx + Math.cos(a) * r * 0.66;
|
||||
const py = cx + Math.sin(a) * r * 0.66;
|
||||
ctx.beginPath(); ctx.arc(px, py, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2c333d'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(px, py, 3.6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#b7c2cf'; ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function paintCross(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeH(ctx, cx, 0, TILE_PX);
|
||||
pipeV(ctx, cx, 0, TILE_PX);
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
edgeCollar(ctx, 'S', cx, true);
|
||||
edgeCollar(ctx, 'W', cx, false);
|
||||
edgeCollar(ctx, 'E', cx, false);
|
||||
// Big cross fitting over the centre.
|
||||
const r = PW * 0.8;
|
||||
const g = ctx.createRadialGradient(cx - r * 0.35, cx - r * 0.35, r * 0.2, cx, cx, r * 1.05);
|
||||
g.addColorStop(0, lighten(STEEL, 0.45));
|
||||
g.addColorStop(0.6, STEEL);
|
||||
g.addColorStop(1, darken(STEEL, 0.45));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(cx, cx, r, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 8; ctx.stroke();
|
||||
// Bolts on the four arms.
|
||||
for (const a of [0, Math.PI / 2, Math.PI, Math.PI * 1.5]) {
|
||||
const px = cx + Math.cos(a) * r * 0.68;
|
||||
const py = cx + Math.sin(a) * r * 0.68;
|
||||
ctx.beginPath(); ctx.arc(px, py, 9, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#2c333d'; ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#b7c2cf'; ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function paintSource(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeV(ctx, cx, 0, TILE_PX * 0.52);
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
|
||||
// Brass faucet body.
|
||||
const bw = PW * 1.75, bh = TILE_PX * 0.30;
|
||||
const bx = cx - bw / 2, by = TILE_PX * 0.52;
|
||||
const g = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
g.addColorStop(0, darken(BRASS, 0.45));
|
||||
g.addColorStop(0.35, lighten(BRASS, 0.4));
|
||||
g.addColorStop(0.6, BRASS);
|
||||
g.addColorStop(1, darken(BRASS, 0.55));
|
||||
ctx.fillStyle = g;
|
||||
roundRect(ctx, bx, by, bw, bh, 18);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke();
|
||||
|
||||
// Collar where pipe meets body.
|
||||
const cw = bw * 0.8;
|
||||
ctx.fillStyle = darken(BRASS, 0.25);
|
||||
roundRect(ctx, cx - cw / 2, by - 12, cw, 22, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 5; ctx.stroke();
|
||||
|
||||
// Valve wheel on top of the body.
|
||||
const vx = cx, vy = by + bh * 0.5, vr = TILE_PX * 0.085;
|
||||
ctx.strokeStyle = darken(BRASS, 0.3); ctx.lineWidth = 9;
|
||||
ctx.beginPath(); ctx.arc(vx, vy, vr, 0, Math.PI * 2); ctx.stroke();
|
||||
for (const a of [0, Math.PI / 2, Math.PI, Math.PI * 1.5]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(vx - Math.cos(a) * vr, vy - Math.sin(a) * vr);
|
||||
ctx.lineTo(vx + Math.cos(a) * vr, vy + Math.sin(a) * vr);
|
||||
ctx.lineWidth = 7; ctx.stroke();
|
||||
}
|
||||
ctx.beginPath(); ctx.arc(vx, vy, vr * 0.28, 0, Math.PI * 2);
|
||||
ctx.fillStyle = lighten(BRASS, 0.5); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4; ctx.stroke();
|
||||
|
||||
// Dripping water droplet.
|
||||
const dx = cx + bw * 0.34, dy = by + bh + TILE_PX * 0.055;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dx, dy - TILE_PX * 0.075);
|
||||
ctx.bezierCurveTo(dx + TILE_PX * 0.05, dy - TILE_PX * 0.02, dx + TILE_PX * 0.05, dy + TILE_PX * 0.02, dx, dy + TILE_PX * 0.045);
|
||||
ctx.bezierCurveTo(dx - TILE_PX * 0.05, dy + TILE_PX * 0.02, dx - TILE_PX * 0.05, dy - TILE_PX * 0.02, dx, dy - TILE_PX * 0.075);
|
||||
ctx.fillStyle = '#45b7e8'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(20,60,90,0.8)'; ctx.lineWidth = 4; ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(dx - TILE_PX * 0.018, dy, TILE_PX * 0.014, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#dff4ff'; ctx.fill();
|
||||
}
|
||||
|
||||
function paintDrain(ctx) {
|
||||
const cx = TILE_PX / 2;
|
||||
plate(ctx);
|
||||
pipeV(ctx, cx, 0, TILE_PX * 0.52);
|
||||
edgeCollar(ctx, 'N', cx, true);
|
||||
|
||||
// Iron grate plate.
|
||||
const bw = PW * 1.9, bh = TILE_PX * 0.32;
|
||||
const bx = cx - bw / 2, by = TILE_PX * 0.52;
|
||||
const g = ctx.createLinearGradient(bx, 0, bx + bw, 0);
|
||||
g.addColorStop(0, darken(IRON, 0.4));
|
||||
g.addColorStop(0.4, lighten(IRON, 0.3));
|
||||
g.addColorStop(1, darken(IRON, 0.5));
|
||||
ctx.fillStyle = g;
|
||||
roundRect(ctx, bx, by, bw, bh, 16);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 7; ctx.stroke();
|
||||
|
||||
// Grate opening with slots.
|
||||
const gx = cx, gy = by + bh / 2, gr = bh * 0.42;
|
||||
ctx.beginPath(); ctx.arc(gx, gy, gr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#14181d'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.9)'; ctx.lineWidth = 5; ctx.stroke();
|
||||
ctx.save();
|
||||
ctx.beginPath(); ctx.arc(gx, gy, gr - 4, 0, Math.PI * 2); ctx.clip();
|
||||
ctx.strokeStyle = lighten(IRON, 0.25); ctx.lineWidth = 5;
|
||||
for (let i = -2; i <= 2; i++) {
|
||||
const sy = gy + i * gr * 0.34;
|
||||
const half = Math.sqrt(Math.max(0, (gr - 4) ** 2 - (i * gr * 0.34) ** 2));
|
||||
ctx.beginPath(); ctx.moveTo(gx - half, sy); ctx.lineTo(gx + half, sy); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
// A little moss.
|
||||
for (const [ox, oy, rr] of [[-bw * 0.36, -bh * 0.3, 7], [bw * 0.34, bh * 0.32, 5], [-bw * 0.2, bh * 0.34, 4]]) {
|
||||
ctx.beginPath(); ctx.arc(cx + ox, by + bh / 2 + oy, rr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(84,128,64,0.8)'; ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// A soft radial dot for particle bursts.
|
||||
function paintDot(ctx) {
|
||||
const s = 64, c = s / 2;
|
||||
const g = ctx.createRadialGradient(c, c, 1, c, c, c);
|
||||
g.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
g.addColorStop(0.5, 'rgba(255,255,255,0.85)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, s, s);
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function tileKeyFor(sockets, isSource, isDrain) {
|
||||
if (isSource) return 'pp-tile-source';
|
||||
if (isDrain) return 'pp-tile-drain';
|
||||
switch (bitCount(sockets)) {
|
||||
case 1: return 'pp-tile-stub';
|
||||
case 2: return ((sockets & N && sockets & S) || (sockets & E && sockets & W)) ? 'pp-tile-straight' : 'pp-tile-elbow';
|
||||
case 3: return 'pp-tile-t';
|
||||
default: return 'pp-tile-cross';
|
||||
}
|
||||
}
|
||||
|
||||
// The socket mask each texture is baked in (see the paint* functions above).
|
||||
const CANONICAL = {
|
||||
'pp-tile-stub': N,
|
||||
'pp-tile-straight': E | W,
|
||||
'pp-tile-elbow': N | E,
|
||||
'pp-tile-t': N | E | W, // open mouth faces S
|
||||
'pp-tile-cross': N | E | S | W,
|
||||
};
|
||||
|
||||
// Rotation (degrees, clockwise) for a tile painted in its canonical pose:
|
||||
// the unique k ∈ 0..3 with rotate(canonical, k) === current sockets. This is
|
||||
// derived rather than tabulated, so every piece type is provably correct.
|
||||
export function angleFor(sockets) {
|
||||
const canonical = CANONICAL[tileKeyFor(sockets, false, false)];
|
||||
for (let k = 0; k < 4; k++) {
|
||||
if (rotateSockets(canonical, k) === sockets) return k * 90;
|
||||
}
|
||||
return 0; // cross — rotation-invariant
|
||||
}
|
||||
|
||||
// Bake every texture the game needs (idempotent — guarded by textures.exists).
|
||||
export function ensureTileTextures(scene) {
|
||||
const jobs = [
|
||||
['pp-tile-straight', paintStraight],
|
||||
['pp-tile-elbow', paintElbow],
|
||||
['pp-tile-stub', paintStub],
|
||||
['pp-tile-t', paintT],
|
||||
['pp-tile-cross', paintCross],
|
||||
['pp-tile-source', paintSource],
|
||||
['pp-tile-drain', paintDrain],
|
||||
];
|
||||
for (const [key, paint] of jobs) {
|
||||
if (scene.textures.exists(key)) continue;
|
||||
const canvas = makeCanvas();
|
||||
paint(canvas.getContext('2d'));
|
||||
scene.textures.addCanvas(key, canvas);
|
||||
}
|
||||
if (!scene.textures.exists('pp-dot')) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64; canvas.height = 64;
|
||||
paintDot(canvas.getContext('2d'));
|
||||
scene.textures.addCanvas('pp-dot', canvas);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
// Pipe Puzzle — the playable scene.
|
||||
//
|
||||
// One screen (difficulty select) and one play screen, the same structure as
|
||||
// Katamino. The board is a grid of baked tile textures (PipePuzzleArt.js)
|
||||
// that the player rotates; water flows out from the faucet through every
|
||||
// matched socket and is drawn live as an animated stream. WIN = no leaks:
|
||||
// every socket on every tile is matched to a neighbour that opens back.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { ensureTileTextures, angleFor, tileKeyFor, TILE_PX } from './PipePuzzleArt.js';
|
||||
import {
|
||||
N, E, S, W, DIRS, OPP, DELTA,
|
||||
cellRC, matchedDirs, neighborOf,
|
||||
generatePuzzle, rotateAt, isSolved, wetOrder, countLeaks,
|
||||
DIFFICULTIES, difficultyByKey,
|
||||
} from './PipePuzzleLogic.js';
|
||||
|
||||
const D = { bg: -2, board: 0, tile: 2, water: 4, flash: 6, ui: 20, overlay: 60, overlayUI: 62 };
|
||||
|
||||
// Per-difficulty par times (seconds) for the 3-star rating.
|
||||
const PAR = { easy: 60, medium: 100, hard: 150, expert: 260 };
|
||||
|
||||
const bestKey = (diff) => `pipepuzzle-best-${diff}`;
|
||||
function getBest(diff) {
|
||||
const v = Number(localStorage.getItem(bestKey(diff)));
|
||||
return Number.isFinite(v) && v > 0 ? v : null;
|
||||
}
|
||||
function starsFor(diff, seconds) {
|
||||
const par = PAR[diff];
|
||||
if (seconds <= par) return 3;
|
||||
if (seconds <= par * 1.8) return 2;
|
||||
return 1;
|
||||
}
|
||||
function fmtTime(sec) {
|
||||
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default class PipePuzzleGame extends Phaser.Scene {
|
||||
constructor() { super('PipePuzzleGame'); }
|
||||
|
||||
init() {
|
||||
this._screen = null; // 'select' | 'play'
|
||||
this._diff = null; // difficulty key
|
||||
this._board = null; // { n, sockets, solution, source, drain }
|
||||
this._cells = null; // array of tile images
|
||||
this._hover = null;
|
||||
this._time0 = null; // performance timestamp of first move
|
||||
this._moves = 0;
|
||||
this._won = false;
|
||||
this._waterBoost = 0;
|
||||
this._inputHandlers = [];
|
||||
}
|
||||
|
||||
create() {
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
ensureTileTextures(this);
|
||||
this._paintBackdrop();
|
||||
|
||||
this.input.keyboard.on('keydown-R', () => {
|
||||
if (this._screen === 'play' && this._hover != null) this._rotateCell(this._hover);
|
||||
});
|
||||
this.input.keyboard.on('keydown-ESC', () => {
|
||||
if (this._screen === 'play') this._showSelect();
|
||||
});
|
||||
|
||||
this._showSelect();
|
||||
}
|
||||
|
||||
update(_t, delta) {
|
||||
if (this._screen !== 'play' || !this._board || this._won) return;
|
||||
if (this._time0) {
|
||||
const el = Math.floor((this.time.now - this._time0) / 1000);
|
||||
if (this._timeText && this._timeText.text !== fmtTime(el)) this._timeText.setText(fmtTime(el));
|
||||
}
|
||||
this._waterBoost = Math.max(0, this._waterBoost - delta * 0.004);
|
||||
this._drawWater();
|
||||
}
|
||||
|
||||
// ── Backdrop (shared by both screens) ─────────────────────────────────────
|
||||
_paintBackdrop() {
|
||||
const bg = this.add.graphics().setDepth(D.bg);
|
||||
bg.fillStyle(0x0c0e12, 1);
|
||||
bg.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
// Warm vignette.
|
||||
const v = this.add.circle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 1150, 0x1a2230, 0.35).setDepth(D.bg + 1);
|
||||
v.setBlendMode(Phaser.BlendModes.ADD);
|
||||
// Faint drifting "steam" rings for a touch of life.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ring = this.add.circle(300 + i * 560, 150 + i * 320, 90 + i * 30, 0x2a3a52, 0.10).setDepth(D.bg + 2);
|
||||
this.tweens.add({
|
||||
targets: ring,
|
||||
radius: ring.radius + 60, alpha: 0,
|
||||
duration: 9000 + i * 1600, repeat: -1, delay: i * 2200,
|
||||
ease: 'Sine.easeOut',
|
||||
onRepeat: () => { ring.radius = 90 + i * 30; ring.alpha = 0.10; },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screen management ──────────────────────────────────────────────────────
|
||||
_clearScreen() {
|
||||
if (this._screenContainer) { this._screenContainer.destroy(true); this._screenContainer = null; }
|
||||
this._cells = null;
|
||||
this._waterGfx = null;
|
||||
this._flashGfx = null;
|
||||
this._hover = null;
|
||||
for (const { ev, fn } of this._inputHandlers) this.input.off(ev, fn);
|
||||
this._inputHandlers = [];
|
||||
}
|
||||
|
||||
_addInputHandler(ev, fn) {
|
||||
this._inputHandlers.push({ ev, fn });
|
||||
this.input.on(ev, fn);
|
||||
}
|
||||
|
||||
// ── Screen 1: difficulty select ───────────────────────────────────────────
|
||||
_showSelect() {
|
||||
this._clearScreen();
|
||||
this._screen = 'select';
|
||||
this._board = null;
|
||||
const sc = this._screenContainer = this.add.container(0, 0).setDepth(D.ui);
|
||||
|
||||
const title = this.add.text(GAME_WIDTH / 2, 120, 'PIPE PUZZLE', {
|
||||
fontFamily: 'Righteous', fontSize: '88px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
title.postFX.addShadow(0, 6, 0.004, 1.4, 0x000000, 10, 0.8);
|
||||
const sub = this.add.text(GAME_WIDTH / 2, 205,
|
||||
'Spin the pipes until the whole network is sealed — every open end must meet a neighbour. No leaks allowed.',
|
||||
{ fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, align: 'center', wordWrap: { width: 1100 } }
|
||||
).setOrigin(0.5);
|
||||
sc.add([title, sub]);
|
||||
|
||||
// Decorative pipe strip under the title.
|
||||
const strip = this.add.image(GAME_WIDTH / 2, 262, 'pp-tile-straight')
|
||||
.setDisplaySize(96, 26).setAlpha(0.9).setDepth(D.ui);
|
||||
sc.add(strip);
|
||||
|
||||
const CARD_W = 320, CARD_H = 330, GAP = 48;
|
||||
const totalW = DIFFICULTIES.length * CARD_W + (DIFFICULTIES.length - 1) * GAP;
|
||||
const left = (GAME_WIDTH - totalW) / 2;
|
||||
const MINI = [
|
||||
{ tex: 'pp-tile-elbow', angle: 0 },
|
||||
{ tex: 'pp-tile-t', angle: 180 },
|
||||
{ tex: 'pp-tile-stub', angle: 90 },
|
||||
{ tex: 'pp-tile-straight', angle: 0 },
|
||||
];
|
||||
|
||||
DIFFICULTIES.forEach((diff, i) => {
|
||||
const cx = left + i * (CARD_W + GAP) + CARD_W / 2;
|
||||
const cy = 430 + (i % 2 === 1 ? 40 : 0);
|
||||
this._buildDiffCard(sc, cx, cy, CARD_W, CARD_H, diff, MINI[i]);
|
||||
});
|
||||
|
||||
const hint = this.add.text(GAME_WIDTH / 2, 780,
|
||||
'Click a tile to rotate it 90° clockwise. • R rotates the hovered tile • ESC back',
|
||||
{ fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||
sc.add(hint);
|
||||
|
||||
const leave = new Button(this, GAME_WIDTH - 150, GAME_HEIGHT - 50, 'Leave',
|
||||
() => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 160, height: 48, fontSize: 18 }
|
||||
).setDepth(D.ui + 1);
|
||||
sc.add(leave);
|
||||
}
|
||||
|
||||
_buildDiffCard(sc, cx, cy, w, h, diff, mini) {
|
||||
const best = getBest(diff.key);
|
||||
const stars = best != null ? starsFor(diff.key, best) : 0;
|
||||
const starText = '★'.repeat(stars) + '☆'.repeat(3 - stars);
|
||||
|
||||
const gfx = this.add.graphics();
|
||||
gfx.fillStyle(COLORS.panel, 1);
|
||||
gfx.fillRoundedRect(cx - w / 2, cy - h / 2, w, h, 18);
|
||||
gfx.lineStyle(2, 0x4a5568, 1);
|
||||
gfx.strokeRoundedRect(cx - w / 2, cy - h / 2, w, h, 18);
|
||||
// Top accent bar in the difficulty's hue.
|
||||
const HUES = { easy: 0x3fae62, medium: 0xc8a84b, hard: 0xd07b3a, expert: 0xc2555f };
|
||||
gfx.fillStyle(HUES[diff.key], 1);
|
||||
gfx.fillRoundedRect(cx - w / 2 + 18, cy - h / 2 + 14, w - 36, 6, 3);
|
||||
sc.add(gfx);
|
||||
|
||||
// Mini tile preview.
|
||||
const tile = this.add.image(cx, cy - 62, mini.tex).setAngle(mini.angle).setDisplaySize(128, 128);
|
||||
tile.postFX.addShadow(0, 8, 0.004, 1.2, 0x000000, 10, 0.7);
|
||||
sc.add(tile);
|
||||
|
||||
const label = this.add.text(cx, cy + 30, diff.label, {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
const blurb = this.add.text(cx, cy + 64, diff.blurb, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
sc.add([label, blurb]);
|
||||
|
||||
let bestText = null;
|
||||
if (best != null) {
|
||||
bestText = this.add.text(cx, cy + 100, `★ ${starText} best ${fmtTime(best)}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
sc.add(bestText);
|
||||
} else {
|
||||
const notPlayed = this.add.text(cx, cy + 100, 'not solved yet', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '0x5a6472',
|
||||
}).setOrigin(0.5);
|
||||
sc.add(notPlayed);
|
||||
}
|
||||
|
||||
// Hover highlight.
|
||||
const hov = this.add.graphics().setVisible(false).setDepth(D.ui + 1);
|
||||
hov.lineStyle(3, COLORS.gold, 1);
|
||||
hov.strokeRoundedRect(cx - w / 2 + 2, cy - h / 2 + 2, w - 4, h - 4, 16);
|
||||
sc.add(hov);
|
||||
|
||||
const zone = this.add.zone(cx, cy, w, h).setInteractive({ useHandCursor: true }).setDepth(D.ui + 2);
|
||||
zone.on('pointerover', () => {
|
||||
hov.setVisible(true);
|
||||
tile.setDisplaySize(140, 140);
|
||||
playSound(this, SFX.UI_PICK);
|
||||
});
|
||||
zone.on('pointerout', () => { hov.setVisible(false); tile.setDisplaySize(128, 128); });
|
||||
zone.on('pointerdown', () => {
|
||||
playSound(this, SFX.UI_ACTIVATE);
|
||||
this._startGame(diff.key);
|
||||
});
|
||||
sc.add(zone);
|
||||
}
|
||||
|
||||
// ── Screen 2: play ────────────────────────────────────────────────────────
|
||||
_startGame(diffKey) {
|
||||
this._clearScreen();
|
||||
this._screen = 'play';
|
||||
this._diff = diffKey;
|
||||
const diff = difficultyByKey(diffKey);
|
||||
this._board = generatePuzzle(diff.n);
|
||||
this._moves = 0;
|
||||
this._time0 = null;
|
||||
this._won = false;
|
||||
this._waterBoost = 0;
|
||||
|
||||
const n = this._board.n;
|
||||
const CELL = Math.min(150, Math.floor(810 / n));
|
||||
const boardW = n * CELL;
|
||||
const boardH = n * CELL;
|
||||
const bx = GAME_WIDTH / 2 - boardW / 2;
|
||||
const by = 210;
|
||||
this._cellSize = CELL;
|
||||
this._cellBaseScale = CELL / TILE_PX; // tiles are baked at TILE_PX, displayed at CELL
|
||||
this._bx = bx; this._by = by;
|
||||
this._boardW = boardW; this._boardH = boardH;
|
||||
|
||||
const sc = this._screenContainer = this.add.container(0, 0).setDepth(D.ui);
|
||||
|
||||
// ── Board frame ──
|
||||
const frame = this.add.graphics().setDepth(D.board);
|
||||
frame.fillStyle(0x161b22, 1);
|
||||
frame.fillRoundedRect(bx - 26, by - 26, boardW + 52, boardH + 52, 22);
|
||||
frame.lineStyle(3, 0x4a5568, 1);
|
||||
frame.strokeRoundedRect(bx - 26, by - 26, boardW + 52, boardH + 52, 22);
|
||||
frame.lineStyle(1, 0x2c3542, 1);
|
||||
frame.strokeRoundedRect(bx - 14, by - 14, boardW + 28, boardH + 28, 12);
|
||||
// Corner rivets.
|
||||
for (const [rx, ry] of [[bx - 16, by - 16], [bx + boardW + 16, by - 16], [bx - 16, by + boardH + 16], [bx + boardW + 16, by + boardH + 16]]) {
|
||||
frame.fillStyle(0x2c333d, 1); frame.fillCircle(rx, ry, 7);
|
||||
frame.fillStyle(0xb7c2cf, 1); frame.fillCircle(rx - 1.5, ry - 1.5, 2.6);
|
||||
}
|
||||
sc.add(frame);
|
||||
|
||||
// Water & flash layers. Phaser Containers render children in list order
|
||||
// (children's depths are ignored inside a Container), so these MUST be
|
||||
// added AFTER the tiles to draw the water flow + leak dots in FRONT of
|
||||
// the pipes — semi-transparent, so the pipes still show through.
|
||||
this._waterGfx = this.add.graphics();
|
||||
this._flashGfx = this.add.graphics();
|
||||
// (added to the container below, after the tiles)
|
||||
|
||||
// ── Tiles ──
|
||||
this._cells = [];
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
const r = Math.floor(i / n), c = i % n;
|
||||
const isSource = i === this._board.source;
|
||||
const isDrain = i === this._board.drain;
|
||||
const isAnchor = isSource || isDrain; // faucet & drain are fixed
|
||||
const key = tileKeyFor(this._board.sockets[i], isSource, isDrain);
|
||||
const img = this.add.image(bx + c * CELL + CELL / 2, by + r * CELL + CELL / 2, key)
|
||||
.setDisplaySize(CELL, CELL)
|
||||
.setAngle(angleFor(this._board.sockets[i]))
|
||||
.setDepth(D.tile);
|
||||
img._idx = i;
|
||||
img._anchor = isAnchor;
|
||||
img._targetAngle = angleFor(this._board.sockets[i]);
|
||||
if (!isAnchor) {
|
||||
img.setInteractive({ useHandCursor: true });
|
||||
img.on('pointerover', () => this._setHover(i, true));
|
||||
img.on('pointerout', () => this._setHover(i, false));
|
||||
img.on('pointerdown', () => this._rotateCell(i));
|
||||
}
|
||||
this._cells.push(img);
|
||||
sc.add(img);
|
||||
}
|
||||
|
||||
// Water + flash layers go last → they render above the tiles.
|
||||
sc.add([this._waterGfx, this._flashGfx]);
|
||||
|
||||
// ── Header ──
|
||||
const back = new Button(this, 100, 52, '← Menu',
|
||||
() => { playSound(this, SFX.UI_PICK); this._showSelect(); },
|
||||
{ variant: 'ghost', width: 170, height: 50, fontSize: 19 }
|
||||
).setDepth(D.ui + 1);
|
||||
const newBtn = new Button(this, 330, 52, 'New Puzzle',
|
||||
() => { playSound(this, SFX.UI_ACTIVATE); this._startGame(this._diff); },
|
||||
{ variant: 'ghost', width: 170, height: 50, fontSize: 19 }
|
||||
).setDepth(D.ui + 1);
|
||||
const title = this.add.text(GAME_WIDTH / 2, 38, 'PIPE PUZZLE', {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
const diffChip = this.add.text(GAME_WIDTH / 2, 84, `${diff.label} • ${diff.n} × ${diff.n}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
sc.add([back, newBtn, title, diffChip]);
|
||||
|
||||
// Stats cluster (kept clear of the New Puzzle button and the music HUD at
|
||||
// the top-right corner).
|
||||
const statY = 52;
|
||||
const makeStat = (label, value, x) => {
|
||||
const l = this.add.text(x, statY - 10, label, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
const v = this.add.text(x, statY + 16, value, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
sc.add([l, v]);
|
||||
return v;
|
||||
};
|
||||
this._timeText = makeStat('TIME', '0:00', GAME_WIDTH - 700);
|
||||
this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 560);
|
||||
this._leakText = makeStat('LEAKS', '0', GAME_WIDTH - 400);
|
||||
|
||||
// Footer hint.
|
||||
const hint = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 34,
|
||||
'Click a tile to rotate it • connect the whole grid to the faucet with no leaks',
|
||||
{ fontFamily: '"Julius Sans One"', fontSize: '16px', color: '0x5a6472' }).setOrigin(0.5);
|
||||
sc.add(hint);
|
||||
|
||||
// Intro: water sloshes in.
|
||||
this._waterBoost = 1.2;
|
||||
this._updateStats();
|
||||
this._drawWater();
|
||||
}
|
||||
|
||||
// ── Interaction ────────────────────────────────────────────────────────────
|
||||
_setHover(i, on) {
|
||||
if (this._screen !== 'play' || this._won) return;
|
||||
const base = this._cellBaseScale;
|
||||
if (on) {
|
||||
this._hover = i;
|
||||
this._cells[i].setInteractive({ useHandCursor: true });
|
||||
this.tweens.add({ targets: this._cells[i], scaleX: base * 1.06, scaleY: base * 1.06, duration: 120, ease: 'Quad.easeOut' });
|
||||
} else {
|
||||
if (this._hover === i) this._hover = null;
|
||||
// Always restore this tile's scale — pointerout can land before the
|
||||
// neighbor's pointerover, so don't rely on _hover being current.
|
||||
this.tweens.killTweensOf(this._cells[i], 'scaleX');
|
||||
this.tweens.add({ targets: this._cells[i], scaleX: base, scaleY: base, duration: 140, ease: 'Quad.easeOut' });
|
||||
}
|
||||
}
|
||||
|
||||
_rotateCell(i, checkWin = true) {
|
||||
if (this._screen !== 'play' || this._won) return;
|
||||
if (i === this._board.source || i === this._board.drain) return; // anchors are fixed
|
||||
if (this._time0 == null) this._time0 = this.time.now;
|
||||
this._moves++;
|
||||
rotateAt(this._board, i);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this._waterBoost = Math.min(1, this._waterBoost + 0.5);
|
||||
|
||||
const img = this._cells[i];
|
||||
img._targetAngle += 90;
|
||||
this.tweens.killTweensOf(img, 'angle'); // only the spin — leave hover-scale tweens alone
|
||||
this.tweens.add({
|
||||
targets: img,
|
||||
angle: img._targetAngle,
|
||||
duration: 240,
|
||||
ease: 'Back.easeOut',
|
||||
});
|
||||
|
||||
this._updateStats();
|
||||
this._drawWater();
|
||||
|
||||
if (checkWin && isSolved(this._board)) {
|
||||
this._won = true;
|
||||
this._winSequence();
|
||||
}
|
||||
}
|
||||
|
||||
_updateStats() {
|
||||
const { n, sockets } = this._board;
|
||||
const leaks = countLeaks(sockets, n);
|
||||
this._leakText.setText(String(leaks));
|
||||
this._leakText.setColor(leaks > 0 ? '#e06c75' : '#3fae62');
|
||||
this._movesText.setText(String(this._moves));
|
||||
}
|
||||
|
||||
// ── Water rendering (per frame) ───────────────────────────────────────────
|
||||
_drawWater() {
|
||||
const gfx = this._waterGfx;
|
||||
if (!gfx) return;
|
||||
const { n, sockets, source } = this._board;
|
||||
const CELL = this._cellSize, bx = this._bx, by = this._by;
|
||||
gfx.clear();
|
||||
|
||||
const order = wetOrder(sockets, n, source);
|
||||
if (order.length === 0) return;
|
||||
const orderIdx = new Map(order.map((i, k) => [i, k]));
|
||||
|
||||
const boost = this._waterBoost;
|
||||
const cx = (i) => bx + (i % n) * CELL + CELL / 2;
|
||||
const cy = (i) => by + Math.floor(i / n) * CELL + CELL / 2;
|
||||
const EDGE = { [N]: [0, -1], [S]: [0, 1], [E]: [1, 0], [W]: [-1, 0] };
|
||||
|
||||
// 1) Wet-cell pools — a vivid water-blue fill so connected pipes clearly
|
||||
// read as "full of water".
|
||||
for (const i of order) {
|
||||
const x = bx + (i % n) * CELL, y = by + Math.floor(i / n) * CELL;
|
||||
const a = 0.34 + Math.min(0.42, boost * 0.3);
|
||||
gfx.fillStyle(0x1f8fdd, a);
|
||||
gfx.fillRoundedRect(x + 5, y + 5, CELL - 10, CELL - 10, CELL * 0.16);
|
||||
}
|
||||
|
||||
// 2) Water flow along matched edges: a translucent base line plus a bright
|
||||
// travelling pulse. The pulse always marches toward the drain (the
|
||||
// direction water flows) rather than toward the source.
|
||||
const period = CELL * 1.4;
|
||||
const off = (this.time.now * 0.12) % period;
|
||||
const baseA = 0.6 + Math.min(0.3, boost * 0.3);
|
||||
const pulseA = 0.95;
|
||||
for (const i of order) {
|
||||
for (const d of matchedDirs(sockets, n, i)) {
|
||||
const j = neighborOf(i, n, d);
|
||||
if (!orderIdx.has(j)) continue;
|
||||
const towardDrain = orderIdx.get(j) > orderIdx.get(i);
|
||||
const [ex, ey] = EDGE[d];
|
||||
const x0 = cx(i), y0 = cy(i);
|
||||
const x1 = x0 + ex * CELL / 2, y1 = y0 + ey * CELL / 2;
|
||||
// Base water line (translucent so pipe still reads through).
|
||||
gfx.lineStyle(Math.max(12, CELL * 0.3), 0x2f9bdf, baseA * 0.9);
|
||||
gfx.lineBetween(x0, y0, x1, y1);
|
||||
// Travelling bright pulse.
|
||||
const u = (off / period); // 0→1 along the edge
|
||||
const px = x0 + (x1 - x0) * (towardDrain ? u : 1 - u);
|
||||
const py = y0 + (y1 - y0) * (towardDrain ? u : 1 - u);
|
||||
gfx.lineStyle(Math.max(8, CELL * 0.18), 0xc9f1ff, pulseA);
|
||||
gfx.lineBetween(px - (x1 - x0) * 0.12, py - (y1 - y0) * 0.12, px + (x1 - x0) * 0.12, py + (y1 - y0) * 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Leak droplets — one per leaking socket on the WHOLE board, placed on
|
||||
// the exact edge of the leaking tile where the pipe's open end is. The
|
||||
// leak test is identical to countLeaks, so the number of dots always
|
||||
// equals the LEAKS counter.
|
||||
const pulse = 0.55 + 0.35 * Math.sin(this.time.now * 0.008);
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
if (sockets[i] === 0) continue;
|
||||
const [r, c] = cellRC(i, n);
|
||||
for (const d of DIRS) {
|
||||
if (!(sockets[i] & d)) continue;
|
||||
const [gr, gc] = DELTA[d]; // grid [row, col] delta — for the neighbor test
|
||||
const nr = r + gr, nc = c + gc;
|
||||
if (nr >= 0 && nr < n && nc >= 0 && nc < n && (sockets[nr * n + nc] & OPP[d])) continue; // matched — no leak
|
||||
const [ex, ey] = EDGE[d]; // pixel [x, y] delta — for the dot position
|
||||
// Sit the dot just inside the tile's open edge (at the pipe mouth),
|
||||
// so it reads as water escaping from that exact opening.
|
||||
const x = cx(i) + ex * CELL * 0.42;
|
||||
const y = cy(i) + ey * CELL * 0.42;
|
||||
gfx.fillStyle(0xff5a5a, pulse * 0.9);
|
||||
gfx.fillCircle(x, y, CELL * 0.09);
|
||||
gfx.fillStyle(0x7a1d24, pulse);
|
||||
gfx.fillCircle(x, y, CELL * 0.045);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Win sequence ──────────────────────────────────────────────────────────
|
||||
_winSequence() {
|
||||
const { n, sockets, source, drain } = this._board;
|
||||
const order = wetOrder(sockets, n, source);
|
||||
const CELL = this._cellSize, bx = this._bx, by = this._by;
|
||||
const cellXY = (i) => [bx + (i % n) * CELL + CELL / 2, by + Math.floor(i / n) * CELL + CELL / 2];
|
||||
|
||||
// Freeze the water, then run a bright wave source→drain.
|
||||
let k = 0;
|
||||
const step = () => {
|
||||
if (k >= order.length) { this._finishWin(); return; }
|
||||
const i = order[k++];
|
||||
const [px, py] = cellXY(i);
|
||||
const ring = this.add.circle(px, py, CELL * 0.18, 0x9fe2ff, 0.95).setDepth(D.flash + 1);
|
||||
this.tweens.add({
|
||||
targets: ring, radius: CELL * 0.62, alpha: 0, duration: 260, ease: 'Cubic.easeOut',
|
||||
onComplete: () => ring.destroy(),
|
||||
});
|
||||
this.time.delayedCall(42, step);
|
||||
};
|
||||
this.time.delayedCall(120, step);
|
||||
}
|
||||
|
||||
_finishWin() {
|
||||
const seconds = Math.max(1, Math.round((this.time.now - (this._time0 ?? this.time.now)) / 1000));
|
||||
const best = getBest(this._diff);
|
||||
const isRecord = best == null || seconds < best;
|
||||
if (isRecord) localStorage.setItem(bestKey(this._diff), String(seconds));
|
||||
const stars = starsFor(this._diff, seconds);
|
||||
|
||||
// Celebration bursts at the drain + source.
|
||||
const { n, drain, source } = this._board;
|
||||
const dxy = [this._bx + (drain % n) * this._cellSize + this._cellSize / 2,
|
||||
this._by + Math.floor(drain / n) * this._cellSize + this._cellSize / 2];
|
||||
const sxy = [this._bx + (source % n) * this._cellSize + this._cellSize / 2,
|
||||
this._by + Math.floor(source / n) * this._cellSize + this._cellSize / 2];
|
||||
for (const [px, py] of [dxy, sxy]) {
|
||||
try {
|
||||
const em = this.add.particles(px, py, 'pp-dot', {
|
||||
speed: { min: 90, max: 300 }, angle: { min: 0, max: 360 }, lifespan: 700,
|
||||
scale: { start: 0.9, end: 0 }, quantity: 30, tint: 0x74c9f2, blendMode: 'ADD', emitting: false,
|
||||
}).setDepth(D.overlay);
|
||||
em.explode(30);
|
||||
this.time.delayedCall(900, () => { try { em.destroy(); } catch (_) {} });
|
||||
} catch (_) { /* particles optional */ }
|
||||
}
|
||||
playSound(this, SFX.WATER_SPLASH);
|
||||
this.time.delayedCall(300, () => playSound(this, SFX.UI_CHIME));
|
||||
|
||||
// Overlay.
|
||||
this.time.delayedCall(900, () => {
|
||||
this._showWinOverlay(seconds, stars, isRecord);
|
||||
});
|
||||
}
|
||||
|
||||
_showWinOverlay(seconds, stars, isRecord) {
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68).setDepth(D.overlay);
|
||||
this._screenContainer.add(dim);
|
||||
|
||||
const pw = 560, ph = 460;
|
||||
const px = cx - pw / 2, py = cy - ph / 2;
|
||||
const panel = this.add.graphics().setDepth(D.overlay + 1);
|
||||
panel.fillStyle(0x141a22, 1);
|
||||
panel.fillRoundedRect(px, py, pw, ph, 24);
|
||||
panel.lineStyle(3, COLORS.gold, 1);
|
||||
panel.strokeRoundedRect(px, py, pw, ph, 24);
|
||||
this._screenContainer.add(panel);
|
||||
|
||||
const head = this.add.text(cx, py + 66, 'WATER FLOWING!', {
|
||||
fontFamily: 'Righteous', fontSize: '56px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this._screenContainer.add(head);
|
||||
|
||||
const starStr = '★'.repeat(stars) + '☆'.repeat(3 - stars);
|
||||
const starTxt = this.add.text(cx, py + 130, starStr, {
|
||||
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this._screenContainer.add(starTxt);
|
||||
|
||||
const row = (label, value, y) => {
|
||||
const l = this.add.text(cx - 120, y, label, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, align: 'right',
|
||||
}).setOrigin(0.5, 0.5).setDepth(D.overlayUI);
|
||||
const v = this.add.text(cx + 120, y, value, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5, 0.5).setDepth(D.overlayUI);
|
||||
this._screenContainer.add([l, v]);
|
||||
};
|
||||
row('Time', fmtTime(seconds), py + 185);
|
||||
row('Moves', String(this._moves), py + 225);
|
||||
const best = getBest(this._diff);
|
||||
row('Best', `${fmtTime(best)}${isRecord ? ' • new record!' : ''}`, py + 265);
|
||||
|
||||
const next = new Button(this, cx, py + 340, 'New Puzzle',
|
||||
() => { playSound(this, SFX.UI_ACTIVATE); this._startGame(this._diff); },
|
||||
{ width: 300, height: 58, fontSize: 24 }
|
||||
).setDepth(D.overlayUI + 1);
|
||||
const menu = new Button(this, cx, py + 415, 'Change Difficulty',
|
||||
() => { playSound(this, SFX.UI_PICK); this._showSelect(); },
|
||||
{ variant: 'ghost', width: 300, height: 48, fontSize: 20 }
|
||||
).setDepth(D.overlayUI + 1);
|
||||
this._screenContainer.add([next, menu]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
// Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification).
|
||||
//
|
||||
// "No leaks" variant:
|
||||
// • N×N grid, EVERY cell holds a pipe tile (no empty squares).
|
||||
// • A board is a set of EDGES between adjacent cells. A cell's degree =
|
||||
// how many sockets it has:
|
||||
// 1 → stub (dead end) 2 → straight or elbow 3 → T-piece
|
||||
// plus two fixed anchors the player cannot rotate:
|
||||
// source — the faucet (degree 1)
|
||||
// drain — the drain (degree 1)
|
||||
// • Generation: a random SPANNING TREE over the grid. A tree has exactly
|
||||
// one path between any two cells, so from the faucet to every dead end
|
||||
// there is a single route — no loops, no shortcuts, no crosses. Because
|
||||
// the tree touches every cell, the board is always fully filled and
|
||||
// connected, and the degree-1 leaves (dead ends) end up scattered
|
||||
// anywhere on the grid.
|
||||
// • Always solvable: the solved pose (each cell's sockets pointing at its
|
||||
// tree-neighbours) is leak-free by construction, and every tile shape
|
||||
// (stub/straight/elbow/T) can be rotated to any orientation of that
|
||||
// shape, so that pose is always reachable by the player.
|
||||
// • Scramble = rotate every non-anchor tile by a random multiple of 90°.
|
||||
// • WIN = no leaks anywhere: every socket is matched to a neighbour that
|
||||
// opens back. There can be many valid solutions — you just need to find
|
||||
// one (different tiles can point different ways and still be leak-free).
|
||||
|
||||
// ── Directions ───────────────────────────────────────────────────────────────
|
||||
export const N = 1, E = 2, S = 4, W = 8;
|
||||
export const DIRS = [N, E, S, W];
|
||||
export const OPP = { [N]: S, [S]: N, [E]: W, [W]: E };
|
||||
export const DELTA = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] };
|
||||
|
||||
// ── Random helpers ───────────────────────────────────────────────────────────
|
||||
export function randInt(maxExclusive) { return Math.floor(Math.random() * maxExclusive); }
|
||||
export function shuffle(arr) {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = randInt(i + 1);
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// ── Grid helpers ─────────────────────────────────────────────────────────────
|
||||
export function cellRC(i, n) {
|
||||
const r = Math.floor(i / n), c = i % n;
|
||||
return [r, c];
|
||||
}
|
||||
export function neighborOf(i, n, d) {
|
||||
const [r, c] = cellRC(i, n);
|
||||
const [dr, dc] = DELTA[d];
|
||||
return (r + dr) * n + (c + dc);
|
||||
}
|
||||
export const cellKey = (r, c, n) => r * n + c;
|
||||
function dirBetween(a, b, n) {
|
||||
const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n);
|
||||
if (br === ar - 1) return N;
|
||||
if (br === ar + 1) return S;
|
||||
if (bc === ac - 1) return W;
|
||||
return E;
|
||||
}
|
||||
// Directions of i whose sockets are matched by the neighbor (water can flow there).
|
||||
export function matchedDirs(sockets, n, i) {
|
||||
const [r, c] = cellRC(i, n);
|
||||
const out = [];
|
||||
for (const d of DIRS) {
|
||||
if (!(sockets[i] & d)) continue;
|
||||
const [dr, dc] = DELTA[d];
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
|
||||
if (sockets[nr * n + nc] & OPP[d]) out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Socket algebra ───────────────────────────────────────────────────────────
|
||||
// Rotate a socket mask `rot` steps clockwise (N→E→S→W→N).
|
||||
export function rotateSockets(sock, rot) {
|
||||
rot = ((rot % 4) + 4) % 4;
|
||||
for (let i = 0; i < rot; i++) {
|
||||
let out = 0;
|
||||
if (sock & N) out |= E;
|
||||
if (sock & E) out |= S;
|
||||
if (sock & S) out |= W;
|
||||
if (sock & W) out |= N;
|
||||
sock = out;
|
||||
}
|
||||
return sock;
|
||||
}
|
||||
export function bitCount(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────────
|
||||
// Every possible edge in the n×n grid (each once): down and right neighbours.
|
||||
function allEdges(n) {
|
||||
const edges = [];
|
||||
for (let r = 0; r < n; r++) {
|
||||
for (let c = 0; c < n; c++) {
|
||||
const i = r * n + c;
|
||||
if (r + 1 < n) edges.push([i, i + n]);
|
||||
if (c + 1 < n) edges.push([i, i + 1]);
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
// Random spanning tree via randomized Kruskal. Returns `tree[i]` = socket mask
|
||||
// of cell i in the solved pose (a bit set for each tree-neighbour direction).
|
||||
function randomSpanningTree(n) {
|
||||
const N2 = n * n;
|
||||
const edges = shuffle(allEdges(n)); // shuffle() returns a copy — use it!
|
||||
const parent = new Array(N2);
|
||||
for (let i = 0; i < N2; i++) parent[i] = i;
|
||||
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
||||
const tree = new Array(N2).fill(0);
|
||||
let count = 0;
|
||||
for (const [a, b] of edges) {
|
||||
const ra = find(a), rb = find(b);
|
||||
if (ra === rb) continue; // would form a cycle
|
||||
parent[ra] = rb;
|
||||
const d = dirBetween(a, b, n);
|
||||
tree[a] |= d;
|
||||
tree[b] |= OPP[d];
|
||||
if (++count === N2 - 1) break;
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
// A good base: a couple of branch points (T-pieces) and several dead ends so
|
||||
// the board reads as a maze — and no 4-way cell, so no cross pieces ever
|
||||
// appear (a crossing would be the only place two routes could merge).
|
||||
function goodBase(n, tree) {
|
||||
let leaves = 0, branch = 0, cross = 0;
|
||||
for (let i = 0; i < tree.length; i++) {
|
||||
const d = bitCount(tree[i]);
|
||||
if (d === 1) leaves++;
|
||||
else if (d === 3) branch++;
|
||||
else if (d === 4) cross++;
|
||||
}
|
||||
return branch >= 2 && leaves >= 3 && cross === 0;
|
||||
}
|
||||
|
||||
// Pick two far-apart degree-1 cells to be the faucet & drain.
|
||||
function pickAnchorPair(n, sockets) {
|
||||
const leaves = [];
|
||||
for (let i = 0; i < sockets.length; i++) if (bitCount(sockets[i]) === 1) leaves.push(i);
|
||||
if (leaves.length < 2) {
|
||||
// Fallback (shouldn't happen for a connected board): use two corners.
|
||||
return [0, sockets.length - 1];
|
||||
}
|
||||
let bestA = leaves[0], bestB = leaves[1], bestD = -1;
|
||||
for (let a = 0; a < leaves.length; a++) {
|
||||
for (let b = a + 1; b < leaves.length; b++) {
|
||||
const [ar, ac] = cellRC(leaves[a], n), [br, bc] = cellRC(leaves[b], n);
|
||||
const d = Math.abs(ar - br) + Math.abs(ac - bc);
|
||||
if (d > bestD) { bestD = d; bestA = leaves[a]; bestB = leaves[b]; }
|
||||
}
|
||||
}
|
||||
return Math.random() < 0.5 ? [bestA, bestB] : [bestB, bestA];
|
||||
}
|
||||
|
||||
export function generatePuzzle(n) {
|
||||
// A random spanning tree: touches every cell (fully filled board), is
|
||||
// connected, and has exactly one path between any two cells — so every
|
||||
// dead end is reached by a single route and the faucet can be anywhere.
|
||||
let base = null, tries = 0;
|
||||
do { base = randomSpanningTree(n); tries++; } while (!goodBase(n, base) && tries < 300);
|
||||
|
||||
const sockets = base;
|
||||
const [source, drain] = pickAnchorPair(n, sockets);
|
||||
|
||||
// Scramble non-anchor tiles (anchors stay fixed).
|
||||
const scrambled = sockets.map((s, i) => (i === source || i === drain) ? s : rotateSockets(s, randInt(4)));
|
||||
|
||||
// If the scramble happened to land on a leak-free board, nudge one tile.
|
||||
if (countLeaks(scrambled, n) === 0) {
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
if (i === source || i === drain) continue;
|
||||
if (bitCount(scrambled[i]) >= 2) { scrambled[i] = rotateSockets(scrambled[i], 1); break; }
|
||||
}
|
||||
}
|
||||
|
||||
return { n, sockets: scrambled, solution: sockets, source, drain };
|
||||
}
|
||||
|
||||
// ── Board queries ────────────────────────────────────────────────────────────
|
||||
export function isSpecial(i, source, drain) { return i === source || i === drain; }
|
||||
|
||||
// BFS order of wet cells from `start` through matched sockets (the "wave").
|
||||
export function wetOrder(sockets, n, start) {
|
||||
const seen = new Set([start]);
|
||||
const order = [start];
|
||||
const stack = [start];
|
||||
while (stack.length) {
|
||||
const i = stack.pop();
|
||||
const [r, c] = cellRC(i, n);
|
||||
for (const d of DIRS) {
|
||||
if (!(sockets[i] & d)) continue;
|
||||
const [dr, dc] = DELTA[d];
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
|
||||
const ni = nr * n + nc;
|
||||
if (seen.has(ni)) continue;
|
||||
if (!(sockets[ni] & OPP[d])) continue;
|
||||
seen.add(ni);
|
||||
order.push(ni);
|
||||
stack.push(ni);
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
export function wetCells(sockets, n, start) { return new Set(wetOrder(sockets, n, start)); }
|
||||
|
||||
// Number of leaking sockets on the whole board.
|
||||
export function countLeaks(sockets, n) {
|
||||
let leaks = 0;
|
||||
for (let i = 0; i < sockets.length; i++) {
|
||||
if (sockets[i] === 0) continue;
|
||||
const [r, c] = cellRC(i, n);
|
||||
for (const d of DIRS) {
|
||||
if (!(sockets[i] & d)) continue;
|
||||
const [dr, dc] = DELTA[d];
|
||||
const nr = r + dr, nc = c + dc;
|
||||
if (nr < 0 || nr >= n || nc < 0 || nc >= n) { leaks++; continue; }
|
||||
if (!(sockets[nr * n + nc] & OPP[d])) leaks++;
|
||||
}
|
||||
}
|
||||
return leaks;
|
||||
}
|
||||
export function boardHasLeak(sockets, n) { return countLeaks(sockets, n) > 0; }
|
||||
|
||||
// WIN: no leaks anywhere.
|
||||
export function isSolved(board) {
|
||||
const { n, sockets } = board;
|
||||
return countLeaks(sockets, n) === 0;
|
||||
}
|
||||
|
||||
// Rotate the tile at index i one step clockwise.
|
||||
export function rotateAt(board, i) {
|
||||
const s = board.sockets[i];
|
||||
if (s === 0) return board;
|
||||
board.sockets[i] = rotateSockets(s, 1);
|
||||
return board;
|
||||
}
|
||||
|
||||
// ── Difficulty tiers ─────────────────────────────────────────────────────────
|
||||
// Grid size is the difficulty knob: bigger tree → longer single routes, more
|
||||
// branch points (T-pieces) and dead ends to keep straight.
|
||||
export const DIFFICULTIES = [
|
||||
{ key: 'easy', label: 'Easy', n: 6, blurb: '6 × 6 grid' },
|
||||
{ key: 'medium', label: 'Medium', n: 7, blurb: '7 × 7 grid' },
|
||||
{ key: 'hard', label: 'Hard', n: 8, blurb: '8 × 8 grid' },
|
||||
{ key: 'expert', label: 'Expert', n: 10, blurb: '10 × 10 grid' },
|
||||
];
|
||||
export function difficultyByKey(key) {
|
||||
return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0];
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Pipe Puzzle
|
||||
|
||||
A branching pipe network must be **sealed**: every open pipe end has to meet
|
||||
neighbouring pipe. Water enters at the **faucet** and the network is complete
|
||||
when not a single socket is left open. No leaks allowed.
|
||||
|
||||
## The Goal
|
||||
|
||||
- Spin the pipes until **no pipe end leaks** — every socket is matched with a
|
||||
neighbour that opens back.
|
||||
- Open pipe ends are fine *as long as they connect* to another pipe.
|
||||
- An open end that points at a wall, or at a tile whose socket isn't open, is
|
||||
a **leak** (it glows red). Leaks keep the puzzle unsolved.
|
||||
|
||||
## How the Board Works
|
||||
|
||||
- The network is a **branching tree**: from the faucet there is exactly
|
||||
**one route** to every dead end. No loops, no shortcuts.
|
||||
- Dead ends can be **anywhere** on the grid — top, bottom, left, right. Don't
|
||||
assume the "exit" is on one side.
|
||||
- The faucet (brass) and the drain (iron) are the two long routes; the rest
|
||||
of the dead ends hang off branch points.
|
||||
|
||||
## How to Play
|
||||
|
||||
- **Click (or tap) a pipe** to rotate it 90° clockwise.
|
||||
- **R** rotates the pipe under the mouse — handy for fine-tuning.
|
||||
- **ESC** takes you back to the difficulty screen.
|
||||
- **New Puzzle** deals a fresh board of the same size; **Leave** / **Menu**
|
||||
goes back to the game menu.
|
||||
- The **faucet** (brass valve) and the **drain** (iron grate) are *fixed* —
|
||||
they don't rotate. Use them as your anchor points.
|
||||
|
||||
## Reading the Board
|
||||
|
||||
- **Straight pipes** (two flanged collars) carry water in a line.
|
||||
- **Elbow pipes** (corner collar with three bolts) turn water 90°.
|
||||
- **T-pieces** (a tee body with three arms) split the flow — one arm is the
|
||||
"dead end" of that branch.
|
||||
- **Dead ends** (a single capped stub) are the leaves of the tree — each one
|
||||
sits at the end of exactly one route from the faucet.
|
||||
- **Water** (the blue glow) shows everything currently connected to the
|
||||
faucet; the animated pulses show the flow direction.
|
||||
- The **LEAKS** counter tells you how many open ends remain. Solved = 0 leaks.
|
||||
|
||||
## Difficulty
|
||||
|
||||
| Tier | Grid | Notes |
|
||||
|--------|--------|------------------------------------------------|
|
||||
| Easy | 6 × 6 | A gentle introduction — a small branching net. |
|
||||
| Medium | 7 × 7 | More T-pieces, longer branches. |
|
||||
| Hard | 8 × 8 | Real mazes; plan ahead. |
|
||||
| Expert | 10 × 10| 100 pipes, dense network. Bring coffee. |
|
||||
|
||||
## Scoring
|
||||
|
||||
- No score, just **time and moves** — your best time per difficulty is kept
|
||||
on this device.
|
||||
- Beat the par time for the grid to earn ★★★, and you'll see it on the
|
||||
difficulty card.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Anchors first.** The faucet and drain are fixed. Find the pieces that must
|
||||
connect to them — that constrains a lot of the board.
|
||||
- **Trace branches, not just the main line.** Every T-piece has a "dead arm."
|
||||
That arm must end in a dead-end stub. Find the matching stub and you've
|
||||
locked in the T's orientation.
|
||||
- **Dead ends are your clues.** A capped stub must rotate to face the correct
|
||||
neighbour. Since there's exactly one route to each dead end, finding the
|
||||
stub locks in every tile along that whole branch.
|
||||
- **Work branch by branch.** Solve one dead end's route, lock it in, then take
|
||||
the next branch off the nearest T-piece.
|
||||
- **If you're stuck**, check for pairs of stubs that must both face the same
|
||||
tile — that's usually the key move.
|
||||
|
|
@ -107,6 +107,7 @@ import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
|
|||
import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
|
||||
import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
||||
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
||||
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -227,6 +228,7 @@ const config = {
|
|||
GooTowerEditor,
|
||||
WolfensteinGame,
|
||||
WolfensteinEditor,
|
||||
PipePuzzleGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
// Browser smoke test for Pipe Puzzle.
|
||||
// node tools/smokePipePuzzle.cjs [baseURL]
|
||||
//
|
||||
// Loads the real site (Phaser from CDN), starts PipePuzzleGame, plays Easy by
|
||||
// rotating every tile to its solution orientation (synchronous), then lets
|
||||
// the game loop run (page.waitForTimeout from Node) so the win wave and
|
||||
// overlay fire. Exits non-zero on failure.
|
||||
|
||||
const { chromium } = require('/home/brianfertig/.npm/_npx/e41f203b7505f1fb/node_modules/playwright');
|
||||
const BASE = process.argv[2] || 'http://localhost:8123';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: '/home/brianfertig/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
|
||||
args: ['--no-sandbox', '--disable-gpu'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
await page.goto(BASE + '/', { waitUntil: 'load', timeout: 30000 });
|
||||
await page.waitForFunction(
|
||||
() => window.game && (window.game.isRunning === true || window.game.isRunning === 'running'),
|
||||
{ timeout: 25000 },
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Start the game scene and jump to the Easy board.
|
||||
await page.evaluate(() => {
|
||||
window.game.scene.start('PipePuzzleGame', {});
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => {
|
||||
window.game.scene.getScene('PipePuzzleGame')._startGame('easy');
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const st1 = await page.evaluate(() => {
|
||||
const sc = window.game.scene.getScene('PipePuzzleGame');
|
||||
return { screen: sc._screen, n: sc._board && sc._board.n };
|
||||
});
|
||||
console.log('board ready:', st1);
|
||||
if (st1.screen !== 'play') { console.error('FAIL: not on play screen'); await browser.close(); process.exit(1); }
|
||||
|
||||
// Rotate every *rotatable* tile to its solution orientation (all synchronous,
|
||||
// no await). The faucet & drain are fixed anchors — skip them.
|
||||
const rot = await page.evaluate(() => {
|
||||
const sc = window.game.scene.getScene('PipePuzzleGame');
|
||||
const { n, solution, source, drain } = sc._board;
|
||||
const N = 1, E = 2, S = 4, W = 8;
|
||||
const rot1 = (s) => ((s & N ? E : 0) | (s & E ? S : 0) | (s & S ? W : 0) | (s & W ? N : 0));
|
||||
let clicks = 0;
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
if (i === source || i === drain) continue; // anchors are fixed
|
||||
let cur = sc._board.sockets[i], k = 0;
|
||||
while (cur !== solution[i] && k < 4) { cur = rot1(cur); k++; }
|
||||
for (let c = 0; c < k; c++) { sc._rotateCell(i); clicks++; }
|
||||
}
|
||||
return { clicks, won: sc._won };
|
||||
});
|
||||
console.log('rotations applied:', rot);
|
||||
|
||||
// Let the game loop run so the win wave + overlay fire. (Headless browsers
|
||||
// throttle requestAnimationFrame, which starves the scene clock's
|
||||
// delayedCalls — in a normal browser the overlay appears on its own.)
|
||||
await page.waitForTimeout(4000);
|
||||
|
||||
const final = await page.evaluate(() => {
|
||||
const sc = window.game.scene.getScene('PipePuzzleGame');
|
||||
let overlay = null;
|
||||
const find = (o) => {
|
||||
if (!o) return;
|
||||
if (o.text && typeof o.text === 'string' && /WATER FLOWING/i.test(o.text)) overlay = o.text;
|
||||
if (o.text && typeof o.text === 'object' && o.text.text && /WATER FLOWING/i.test(o.text.text)) overlay = o.text.text;
|
||||
if (o.list) o.list.forEach(find);
|
||||
};
|
||||
if (sc._screenContainer) find(sc._screenContainer);
|
||||
|
||||
// If the RAF-throttled clock never fired the win sequence, trigger the
|
||||
// same code path directly and re-check (this exercises _finishWin,
|
||||
// best-time persistence and the overlay rendering).
|
||||
let overlayForced = false;
|
||||
if (!overlay) {
|
||||
try {
|
||||
sc._finishWin();
|
||||
sc._showWinOverlay(20, 3, true);
|
||||
overlayForced = true;
|
||||
const find2 = (o) => {
|
||||
if (!o) return;
|
||||
if (o.text && typeof o.text === 'string' && /WATER FLOWING/i.test(o.text)) overlay = o.text;
|
||||
if (o.list) o.list.forEach(find2);
|
||||
};
|
||||
if (sc._screenContainer) find2(sc._screenContainer);
|
||||
} catch (_) { /* reported below */ }
|
||||
}
|
||||
return {
|
||||
won: sc._won,
|
||||
moves: sc._moves,
|
||||
overlay,
|
||||
overlayForced,
|
||||
best: localStorage.getItem('pipepuzzle-best-easy'),
|
||||
diff: sc._diff,
|
||||
};
|
||||
});
|
||||
console.log('final:', final);
|
||||
|
||||
const relErrs = errors.filter((e) => !/favicon|404|net::ERR|ERR_NAME|Failed to load resource/.test(e));
|
||||
if (relErrs.length) { console.error('JS errors:'); relErrs.forEach((e) => console.error(' ' + e)); }
|
||||
|
||||
const ok = final.won === true && final.overlay !== null && final.best !== null && relErrs.length === 0;
|
||||
console.log(ok ? 'PIPE PUZZLE SMOKE: PASS' : 'PIPE PUZZLE SMOKE: FAIL');
|
||||
await browser.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// Headless verification for Pipe Puzzle.
|
||||
// node tools/verifyPipePuzzle.js
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Fixture tests: socket algebra + a hand-built no-leak board.
|
||||
// 2. Generation invariant sweep: for many random puzzles at every difficulty,
|
||||
// the solution board has no leaks, the board is connected, the piece mix
|
||||
// is rich, and the scrambled board starts with leaks.
|
||||
|
||||
import {
|
||||
N, E, S, W, DIRS, OPP,
|
||||
rotateSockets, bitCount, generatePuzzle, isSolved, countLeaks,
|
||||
wetOrder, DIFFICULTIES,
|
||||
} from '../src/games/pipepuzzle/PipePuzzleLogic.js';
|
||||
let failures = 0;
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) { console.log(` ok ${name}`); }
|
||||
else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
|
||||
}
|
||||
|
||||
// ── 1. Fixtures ─────────────────────────────────────────────────────────────
|
||||
console.log('\n— Socket algebra —');
|
||||
check('N/E/S/W flags distinct', new Set([N, E, S, W]).size === 4);
|
||||
check('OPP is an involution', DIRS.every((d) => OPP[OPP[d]] === d));
|
||||
check('rotateSockets 4 steps = identity', [1, 2, 4, 8, 3, 5, 6, 10, 12, 9, 15].every((s) => rotateSockets(s, 4) === s));
|
||||
check('rotateSockets N→E→S→W', rotateSockets(N, 1) === E && rotateSockets(N, 2) === S && rotateSockets(N, 3) === W);
|
||||
check('rotateSockets elbow NE→ES→SW→WN', rotateSockets(N | E, 1) === (E | S) && rotateSockets(N | E, 2) === (S | W) && rotateSockets(N | E, 3) === (W | N));
|
||||
check('rotateSockets T N|E|W → N|S|E', rotateSockets(N | E | W, 1) === (N | S | E));
|
||||
check('rotateSockets cross = invariant', rotateSockets(N | E | S | W, 3) === (N | E | S | W));
|
||||
|
||||
// A solved 3×3 no-leak board (row-major 0..8) — a plain spanning tree (8
|
||||
// edges, no cycles, no 4-way cell) with two T-pieces and four dead ends:
|
||||
// 0=E (source) 1=W|E|S (T) 2=W (stub)
|
||||
// 3=E (stub) 4=N|W|E (T) 5=W|S (elbow)
|
||||
// 6=E (drain) 7=E|W (straight) 8=N|W (elbow)
|
||||
// Every socket is matched to a neighbour that opens back, so no leaks.
|
||||
{
|
||||
const n = 3;
|
||||
const sockets = [E, W | E | S, W, E, N | W | E, W | S, E, E | W, N | W];
|
||||
const board = { n, sockets, source: 0, drain: 6 };
|
||||
check('fixture 3×3 solved (no leaks)', isSolved(board) === true);
|
||||
check('fixture 3×3 has a T-piece (3 sockets)', sockets.filter((s) => bitCount(s) === 3).length >= 1);
|
||||
check('fixture 3×3 has no cross (4 sockets)', !sockets.some((s) => bitCount(s) === 4));
|
||||
|
||||
// Break one connection: rotate the stub at cell2 W → N (points off the
|
||||
// top wall). Off-board socket = guaranteed leak.
|
||||
board.sockets = [E, W | E | S, N, E, N | W | E, W | S, E, E | W, N | W];
|
||||
check('fixture 3×3 after rotation not solved', isSolved(board) === false);
|
||||
check('fixture 3×3 after rotation has ≥1 leak', countLeaks(board.sockets, n) >= 1);
|
||||
}
|
||||
|
||||
// ── 2. Generation invariant sweep ───────────────────────────────────────────
|
||||
function isConnected(n, sockets) {
|
||||
const total = n * n;
|
||||
const parent = new Array(total);
|
||||
for (let i = 0; i < total; i++) parent[i] = i;
|
||||
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
||||
for (let i = 0; i < total; i++) {
|
||||
for (const d of DIRS) {
|
||||
if (!(sockets[i] & d)) continue;
|
||||
const [dr, dc] = { [N]: [-1, 0], [S]: [1, 0], [E]: [0, 1], [W]: [0, -1] }[d];
|
||||
const nr = Math.floor(i / n) + dr, nc = (i % n) + dc;
|
||||
if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
|
||||
const j = nr * n + nc;
|
||||
if (!(sockets[j] & OPP[d])) continue;
|
||||
const ra = find(i), rb = find(j);
|
||||
if (ra !== rb) parent[ra] = rb;
|
||||
}
|
||||
}
|
||||
const root = find(0);
|
||||
for (let i = 1; i < total; i++) if (find(i) !== root) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number of matched edges on the board (a tree over n² cells has exactly n²−1).
|
||||
function matchedEdgeCount(n, sockets) {
|
||||
let edges = 0;
|
||||
for (let i = 0; i < n * n; i++) {
|
||||
if (sockets[i] & E) edges++; // count each E-neighbour once
|
||||
if (sockets[i] & S) edges++; // count each S-neighbour once
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
console.log('\n— Generation invariants —');
|
||||
for (const diff of DIFFICULTIES) {
|
||||
const n = diff.n;
|
||||
const samples = 30;
|
||||
let solNoLeak = 0, scrLeak = 0, connOk = 0, wetAll = 0, treeOk = 0, noCross = 0;
|
||||
const solutions = new Set();
|
||||
const leafRowsSeen = new Array(n).fill(false);
|
||||
const srcRowsSeen = new Array(n).fill(false);
|
||||
|
||||
for (let s = 0; s < samples; s++) {
|
||||
const p = generatePuzzle(n);
|
||||
if (countLeaks(p.solution, n) === 0) solNoLeak++;
|
||||
if (countLeaks(p.sockets, n) > 0) scrLeak++;
|
||||
if (isConnected(n, p.solution)) connOk++;
|
||||
if (wetOrder(p.solution, n, p.source).length === n * n) wetAll++;
|
||||
// A spanning tree: exactly n²−1 matched edges → no cycles → one unique
|
||||
// route between every pair of cells (faucet → each dead end).
|
||||
if (matchedEdgeCount(n, p.solution) === n * n - 1) treeOk++;
|
||||
if (!p.solution.some((sk) => bitCount(sk) === 4)) noCross++;
|
||||
for (let i = 0; i < n * n; i++) if (bitCount(p.solution[i]) === 1) leafRowsSeen[Math.floor(i / n)] = true;
|
||||
srcRowsSeen[Math.floor(p.source / n)] = true;
|
||||
solutions.add(JSON.stringify(p.solution));
|
||||
}
|
||||
|
||||
const leafRowsAll = leafRowsSeen.every(Boolean);
|
||||
check(`${diff.key} (${n}×${n}): solution board has no leaks (${solNoLeak}/${samples})`, solNoLeak === samples);
|
||||
check(`${diff.key} (${n}×${n}): board is connected (${connOk}/${samples})`, connOk === samples);
|
||||
check(`${diff.key} (${n}×${n}): faucet reaches every cell (${wetAll}/${samples})`, wetAll === samples);
|
||||
check(`${diff.key} (${n}×${n}): solution is a tree — exactly ${n * n - 1} edges, no loops (${treeOk}/${samples})`, treeOk === samples);
|
||||
check(`${diff.key} (${n}×${n}): no 4-way crosses on the board (${noCross}/${samples})`, noCross === samples);
|
||||
check(`${diff.key} (${n}×${n}): dead ends appear in every row across boards`, leafRowsAll);
|
||||
check(`${diff.key} (${n}×${n}): faucet appears in more than one row across boards`, srcRowsSeen.filter(Boolean).length >= 2);
|
||||
check(`${diff.key} (${n}×${n}): boards are genuinely random (≥15 distinct of ${samples})`, solutions.size >= 15);
|
||||
check(`${diff.key} (${n}×${n}): scrambled board starts with leaks (${scrLeak}/${samples})`, scrLeak === samples);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Loading…
Reference in New Issue