diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index fa83743..e2c6f9c 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index ff80055..25a206c 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/pipepuzzle/PipePuzzleArt.js b/src/games/pipepuzzle/PipePuzzleArt.js new file mode 100644 index 0000000..6455237 --- /dev/null +++ b/src/games/pipepuzzle/PipePuzzleArt.js @@ -0,0 +1,346 @@ +// 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) +// • 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 } 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 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'; + const straight = (sockets & N && sockets & S) || (sockets & E && sockets & W); + return straight ? 'pp-tile-straight' : 'pp-tile-elbow'; +} + +// Rotation (degrees, clockwise) for a tile painted in its canonical pose. +export function angleFor(sockets) { + if (sockets === (N | E)) return 0; + if (sockets === (E | S)) return 90; + if (sockets === (S | W)) return 180; + if (sockets === (W | N)) return 270; + if (sockets === (E | W)) return 0; + if (sockets === (N | S)) return 90; + if (sockets === N) return 0; + if (sockets === E) return 90; + if (sockets === S) return 180; + if (sockets === W) return 270; + return 0; +} + +// 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-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); + } +} diff --git a/src/games/pipepuzzle/PipePuzzleGame.js b/src/games/pipepuzzle/PipePuzzleGame.js new file mode 100644 index 0000000..42a5213 --- /dev/null +++ b/src/games/pipepuzzle/PipePuzzleGame.js @@ -0,0 +1,591 @@ +// 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 dashed stream. Win = every +// cell connected to the faucet with zero leaks. + +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, + cellRC, matchedDirs, neighborOf, + generatePuzzle, rotateAt, isSolved, wetOrder, + 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: 45, medium: 90, hard: 180, legendary: 360 }; + +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, path } + 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, + 'Route the water: spin the pipes so every tile flows from the faucet to the drain — no leaks.', + { 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-straight', angle: 0 }, + { tex: 'pp-tile-source', angle: 180 }, + { tex: 'pp-tile-drain', angle: 180 }, + ]; + + 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, legendary: 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 layer (under tiles? no — over tiles, low alpha so pipes show through). + this._waterGfx = this.add.graphics().setDepth(D.water); + this._flashGfx = this.add.graphics().setDepth(D.flash); + sc.add([this._waterGfx, this._flashGfx]); + + // ── 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 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) + .setInteractive({ useHandCursor: true }); + img._idx = i; + img._targetAngle = angleFor(this._board.sockets[i]); + 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); + } + + // ── 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 - 760); + this._movesText = makeStat('MOVES', '0', GAME_WIDTH - 660); + const conn = makeStat('CONNECTED', '0 / ' + n * n, GAME_WIDTH - 520); + const leak = makeStat('LEAKS', '0', GAME_WIDTH - 390); + this._connText = conn; this._leakText = leak; + + // 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) { + if (this._screen !== 'play' || this._won) return; + 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 (isSolved(this._board)) { + this._won = true; + this._winSequence(); + } + } + + _updateStats() { + const { n, sockets, source } = this._board; + const wet = wetOrder(sockets, n, source).length; + let leaks = 0; + const wetSet = new Set(wetOrder(sockets, n, source)); + for (const i of wetSet) { + const [r, c] = cellRC(i, n); + 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 = r + dr, nc = c + dc; + if (nr < 0 || nr >= n || nc < 0 || nc >= n) { leaks++; continue; } + if (!(sockets[nr * n + nc] & OPP[d])) leaks++; + } + } + this._connText.setText(`${wet} / ${n * 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. + for (const i of order) { + const x = bx + (i % n) * CELL, y = by + Math.floor(i / n) * CELL; + const a = 0.16 + Math.min(0.3, boost * 0.25); + gfx.fillStyle(0x3fa0dc, a); + gfx.fillRoundedRect(x + 7, y + 7, CELL - 14, CELL - 14, 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 EDGE2 = { [N]: [0, -1], [S]: [0, 1], [E]: [1, 0], [W]: [-1, 0] }; + const period = CELL * 1.4; + const off = (this.time.now * 0.12) % period; + const baseA = 0.42 + Math.min(0.25, boost * 0.25); + const pulseA = 0.85; + 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] = EDGE2[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(10, CELL * 0.24), 0x2f7fb8, baseA * 0.8); + 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(6, CELL * 0.14), 0xaee6ff, pulseA); + gfx.lineBetween(px - (x1 - x0) * 0.10, py - (y1 - y0) * 0.10, px + (x1 - x0) * 0.10, py + (y1 - y0) * 0.10); + } + } + + // 3) Leak droplets (wet cells with an open end) — pulsing red. + const pulse = 0.55 + 0.35 * Math.sin(this.time.now * 0.008); + for (const i of order) { + const [r, c] = cellRC(i, n); + for (const d of DIRS) { + if (!(sockets[i] & d)) continue; + const [dr, dc] = EDGE[d]; + const nr = r + dr, nc = c + dc; + if (nr >= 0 && nr < n && nc >= 0 && nc < n && (sockets[nr * n + nc] & OPP[d])) continue; + const x = cx(i) + dc * CELL * 0.5; + const y = cy(i) + dr * CELL * 0.5; + gfx.fillStyle(0xff5a5a, pulse * 0.9); + gfx.fillCircle(x, y, CELL * 0.075); + gfx.fillStyle(0x7a1d24, pulse); + gfx.fillCircle(x, y, CELL * 0.035); + } + } + } + // ── 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]); + } +} diff --git a/src/games/pipepuzzle/PipePuzzleLogic.js b/src/games/pipepuzzle/PipePuzzleLogic.js new file mode 100644 index 0000000..86707c1 --- /dev/null +++ b/src/games/pipepuzzle/PipePuzzleLogic.js @@ -0,0 +1,260 @@ +// Pipe Puzzle — pure game logic (no Phaser, runs in Node for verification). +// +// Strict "all-tiles-connected" variant: +// • The board is an N×N grid, **every cell holds a pipe tile**. +// • Exactly two special 1-socket tiles: a SOURCE (faucet) and a DRAIN. +// • Every other tile is a 2-socket STRAIGHT or ELBOW. +// • The solved state is a single continuous, leak-free pipe path that runs +// from the faucet to the drain and visits every cell exactly once — a +// Hamiltonian path. So "every tile is connected" and "no leaks" fall out +// of one clean condition. +// • The puzzle is generated by building a random Hamiltonian path, orienting +// every tile along it (the solution), then randomly rotating the tiles. +// It is therefore always solvable. +// +// Directions / sockets +// Bit flags per socket: N=1, E=2, S=4, W=8. A tile's `sockets` value is the +// OR of the sockets it currently has. `rotateSockets` turns it clockwise. + +// ── 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] }; + +// Tile kinds (used by the renderer for art; the socket mask is the source of +// truth for connectivity). +export const TILE = { SOURCE: 'source', DRAIN: 'drain', STRAIGHT: 'straight', ELBOW: 'elbow' }; + +// ── 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); +} +// 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; +} +export const cellKey = (r, c, n) => r * n + c; +export function gridAdjacent(a, b, n) { + const [ar, ac] = cellRC(a, n), [br, bc] = cellRC(b, n); + return Math.abs(ar - br) + Math.abs(ac - bc) === 1; +} +// Direction bit from cell a toward adjacent cell b. +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; +} + +// ── 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; +} + +// ── Hamiltonian path generation ────────────────────────────────────────────── +// A guaranteed-valid snake (boustrophedon) path covering every cell. +function snakePath(n) { + const horizontal = Math.random() < 0.5; + const path = []; + if (horizontal) { + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) path.push(cellKey(r, (r % 2 === 0) ? c : n - 1 - c, n)); + } + } else { + for (let c = 0; c < n; c++) { + for (let r = 0; r < n; r++) path.push(cellKey((c % 2 === 0) ? r : n - 1 - r, c, n)); + } + } + if (Math.random() < 0.5) path.reverse(); + return path; +} + +// Randomize a Hamiltonian path with "2-switch" (detour) moves. +// +// Pick two path edges (a→b) and (c→d) with a non-trivial segment between +// them; if a~c and b~d are both valid grid adjacencies, reroute to +// a→c … d→b by reversing the middle segment. This is the standard +// Hamiltonian-path improvement move: it keeps the path a permutation of all +// cells (nothing is duplicated or dropped) and preserves every adjacency, +// so the result is always a valid Hamiltonian path — the puzzle stays +// solvable by construction. +// +// On a snake this produces detours that weave between rows/columns, giving +// each puzzle a distinct shape and distinct source/drain cells. +function randomizePath(path, n, attempts = 600) { + const len = path.length; + for (let t = 0; t < attempts; t++) { + let p = randInt(len - 1); + let q = randInt(len - 1); + if (p > q) [p, q] = [q, p]; + if (q - p < 1) continue; // need at least one cell between the edges + const a = path[p], b = path[p + 1], c = path[q], d = path[q + 1]; + if (gridAdjacent(a, c, n) && gridAdjacent(b, d, n)) { + const left = path.slice(0, p + 1); // … a + const mid = path.slice(p + 1, q + 1); // b … c + const right = path.slice(q + 1); // d … + path = left.concat(mid.reverse(), right); + } + } + if (Math.random() < 0.5) path.reverse(); + return path; +} + +export function randomHamiltonianPath(n) { + return randomizePath(snakePath(n), n); +} + +// ── Puzzle construction ────────────────────────────────────────────────────── +// Orient every tile along the path (the solution), then scramble. +export function generatePuzzle(n) { + const path = randomHamiltonianPath(n); + const total = path.length; + + // Solution: sockets per cell, following the path. + const solution = new Array(total).fill(0); + for (let i = 0; i < total; i++) { + let sock = 0; + if (i > 0) sock |= dirBetween(path[i], path[i - 1], n); + if (i < total - 1) sock |= dirBetween(path[i], path[i + 1], n); + solution[path[i]] = sock; + } + + const source = path[0]; + const drain = path[total - 1]; + + // Scramble: random rotation of every tile (source & drain included). + const sockets = solution.map((s) => (s === 0 ? 0 : rotateSockets(s, randInt(4)))); + + // If the scramble happened to produce the solved board (only possible for a + // 1-socket/2-socket board when rotations coincide), nudge one interior tile. + if (isSolved({ n, sockets, source, drain })) { + for (let i = 0; i < total; i++) { + if (i === source || i === drain) continue; + if (sockets[i] !== 0 && sockets[i] !== solution[i]) break; + // rotate a tile whose solution orientation is not its only option + if (countBits(sockets[i]) === 2) { sockets[i] = rotateSockets(sockets[i], 1); break; } + } + } + + return { n, sockets, solution, source, drain, path }; +} + +// ── Board queries ──────────────────────────────────────────────────────────── +function countBits(x) { let c = 0; while (x) { x &= x - 1; c++; } return c; } + +export function isSpecial(i, source, drain) { return i === source || i === drain; } + +// Set of cell indices reachable from `start` through *matched* sockets +// (a socket counts only when the neighbor opens back). This is the wet set. +export function wetCells(sockets, n, start) { + return new Set(wetOrder(sockets, n, start)); +} + +// BFS order of wet cells from the source — used for the win "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; +} + +// True if cell i has at least one socket that is a leak (points off the board +// or at a neighbor that does not open back). +export function tileHasLeak(sockets, n, i) { + 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) return true; + if (!(sockets[nr * n + nc] & OPP[d])) return true; + } + return false; +} + +// Any leak on the board? +export function boardHasLeak(sockets, n) { + for (let i = 0; i < sockets.length; i++) if (sockets[i] !== 0 && tileHasLeak(sockets, n, i)) return true; + return false; +} + +// Solved = every cell is wet (connected to the faucet) AND there are no leaks. +export function isSolved(board) { + const { n, sockets, source } = board; + if (wetCells(sockets, n, source).size !== n * n) return false; + if (boardHasLeak(sockets, n)) return false; + return true; +} + +// Rotate the tile at index i one step clockwise (specials rotate visually too). +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 ───────────────────────────────────────────────────────── +export const DIFFICULTIES = [ + { key: 'easy', label: 'Easy', n: 4, blurb: '4 × 4 grid' }, + { key: 'medium', label: 'Medium', n: 5, blurb: '5 × 5 grid' }, + { key: 'hard', label: 'Hard', n: 6, blurb: '6 × 6 grid' }, + { key: 'legendary', label: 'Legendary', n: 8, blurb: '8 × 8 grid' }, +]; +export function difficultyByKey(key) { + return DIFFICULTIES.find((d) => d.key === key) ?? DIFFICULTIES[0]; +} diff --git a/src/games/pipepuzzle/tutorial.md b/src/games/pipepuzzle/tutorial.md new file mode 100644 index 0000000..3b971b2 --- /dev/null +++ b/src/games/pipepuzzle/tutorial.md @@ -0,0 +1,62 @@ +# Pipe Puzzle + +A single continuous pipe network must carry water from the **faucet** to the +**drain** — and it has to use *every* pipe on the board. No leaks, no dead +ends. + +## The Goal + +- Turn the pipes until **every tile is connected** to the faucet through + open, matched pipe ends. +- The whole board must form **one** unbroken pipe: in the solved state the + water flows from the faucet, through every single tile, and out of the + drain. +- An open pipe 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 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) is where water enters; the iron grate is the + drain. Both must be part of the final pipe. + +## Reading the Board + +- **Straight pipes** (two flanged collars) carry water in a line. +- **Elbow pipes** (the corner collar with three bolts) turn water 90°. +- **Water** (the blue glow) shows everything currently connected to the + faucet — the animated dashes show the direction the water is flowing. +- The **CONNECTED** counter tracks how many tiles the faucet reaches; the + **LEAKS** counter tracks open ends. Solved = connected count is the whole + board and leaks are 0. + +## Difficulty + +| Tier | Grid | Notes | +|-----------|------|------------------------------------------------| +| Easy | 4 × 4| A gentle introduction — short pipe to trace. | +| Medium | 5 × 5| The classic feel; a single meandering run. | +| Hard | 6 × 6| Longer path, more turns to line up. | +| Legendary | 8 × 8| 64 pipes, one unbroken route. 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 + +- Work **outward from the faucet**: keep the wet region growing instead of + chasing pieces at random. +- The board is one long pipe — if you can trace a continuous route from the + faucet covering every tile, you've found the solution; you're just looking + for the rotations that make it happen. +- Corners (elbows) are the constraints. If an elbow's two openings can't + both point at pipes you need, rotate its neighbor instead of itself. +- Dead ends near the edge are usually the last two moves. diff --git a/src/main.js b/src/main.js index 63b813a..2d6ab4f 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 4e9ba63..f154daa 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -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 = { diff --git a/tools/smokePipePuzzle.cjs b/tools/smokePipePuzzle.cjs new file mode 100644 index 0000000..690fd17 --- /dev/null +++ b/tools/smokePipePuzzle.cjs @@ -0,0 +1,114 @@ +// 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 tile to its solution orientation (all synchronous, no await). + const rot = await page.evaluate(() => { + const sc = window.game.scene.getScene('PipePuzzleGame'); + const { n, solution } = 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++) { + 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); }); diff --git a/tools/verifyPipePuzzle.js b/tools/verifyPipePuzzle.js new file mode 100644 index 0000000..34f40d6 --- /dev/null +++ b/tools/verifyPipePuzzle.js @@ -0,0 +1,93 @@ +// Headless verification for Pipe Puzzle. +// node tools/verifyPipePuzzle.js +// Exits non-zero on any failure. +// +// 1. Fixture tests: hand-built boards, rotation algebra, win check. +// 2. Generation invariant sweep: for many random puzzles at every +// difficulty, the generated path is a valid Hamiltonian path and the +// solution board is solved while the scrambled board is not. + +import { + N, E, S, W, DIRS, OPP, DELTA, + rotateSockets, generatePuzzle, isSolved, wetCells, + boardHasLeak, tileHasLeak, randomHamiltonianPath, gridAdjacent, + cellRC, 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)); + +// A solved 2×2 board (row-major: 0=(0,0) 1=(0,1) / 2=(1,0) 3=(1,1)): +// source cell0 → cell1 → cell3 → drain cell2. +// Sockets (solution): cell0 = E (source, 1 socket), cell1 = W|S, cell2 = E +// (drain, 1 socket), cell3 = N|W. +{ + const n = 2; + const sockets = [E, W | S, E, N | W]; + const board = { n, sockets, source: 0, drain: 2 }; + check('fixture 2×2 solved', isSolved(board) === true); + check('fixture 2×2 wet = all cells', wetCells(sockets, n, 0).size === 4); + check('fixture 2×2 no leaks', boardHasLeak(sockets, n) === false); + + // Rotate the source: E → S. Now it points at cell2 (which has only E), + // so the source leaks and the board is unsolved. + board.sockets = [S, W | S, E, N | W]; + check('fixture 2×2 after rotation not solved', isSolved(board) === false); + check('fixture 2×2 after rotation has leak', boardHasLeak(board.sockets, n) === true); +} + +// A leaked board: single socket pointing at wall +{ + const n = 3; + const sockets = [N, 0, 0, 0, 0, 0, 0, 0, 0]; + check('wall socket is a leak', tileHasLeak(sockets, n, 0) === true); +} + +// ── 2. Generation invariant sweep ─────────────────────────────────────────── +console.log('\n— Generation invariants —'); +for (const diff of DIFFICULTIES) { + const n = diff.n; + const samples = 40; + let pathValid = 0, solutionSolved = 0, scrambledUnsolved = 0, tileCounts = 0; + + for (let s = 0; s < samples; s++) { + const path = randomHamiltonianPath(n); + const total = n * n; + const isPerm = new Set(path).size === total && path.length === total && + path.every((i) => Number.isInteger(i) && i >= 0 && i < total); + const adjacent = path.slice(0, -1).every((c, i) => gridAdjacent(c, path[i + 1], n)); + if (isPerm && adjacent) pathValid++; + + const p = generatePuzzle(n); + // Solution board must be solved. + if (isSolved({ n, sockets: p.solution, source: p.source, drain: p.drain })) solutionSolved++; + // Scrambled board must not already be solved. + if (!isSolved({ n, sockets: p.sockets, source: p.source, drain: p.drain })) scrambledUnsolved++; + // Tile socket counts: 1 for source/drain, 2 for the rest. + const countsOk = p.sockets.every((sk, i) => { + const bits = (x) => { let c = 0; while (x) { x &= x - 1; c++; } return c; }; + if (i === p.source || i === p.drain) return bits(sk) === 1; + return bits(sk) === 2; + }); + if (countsOk) tileCounts++; + } + + check(`${diff.key} (${n}×${n}): path is a valid Hamiltonian path (${pathValid}/${samples})`, pathValid === samples); + check(`${diff.key} (${n}×${n}): solution board is solved (${solutionSolved}/${samples})`, solutionSolved === samples); + check(`${diff.key} (${n}×${n}): scrambled board starts unsolved (${scrambledUnsolved}/${samples})`, scrambledUnsolved === samples); + check(`${diff.key} (${n}×${n}): socket counts 1/1/2…/2 (${tileCounts}/${samples})`, tileCounts === samples); +} + +console.log(failures === 0 ? '\nAll Pipe Puzzle checks passed.' : `\n${failures} check(s) FAILED.`); +process.exit(failures === 0 ? 0 : 1);