192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
import { createRng, shuffle } from "../rng";
|
||
|
||
// Legacy export — board still imports it but we no longer use it for hints
|
||
export type ShapeHint = "free";
|
||
|
||
export interface Region {
|
||
id: number;
|
||
cells: [number, number][]; // all cells in grid
|
||
hintCell: [number, number]; // topmost-leftmost cell (always in solution)
|
||
relCells: [number, number][]; // shape normalised to (0,0) origin
|
||
previewRows: number;
|
||
previewCols: number;
|
||
size: number;
|
||
color: string;
|
||
}
|
||
|
||
export interface PatchesPuzzle {
|
||
size: number; // 6
|
||
regions: Region[];
|
||
grid: number[][]; // grid[r][c] = regionId
|
||
}
|
||
|
||
export const PATCH_COLORS = [
|
||
"#e8b040", // gold
|
||
"#4db86e", // green
|
||
"#4a9fd4", // blue
|
||
"#e05050", // red
|
||
"#e07830", // orange
|
||
"#30b8b0", // teal
|
||
"#9060c8", // purple
|
||
"#d06898", // pink
|
||
];
|
||
|
||
/** Place N seeds spread across the grid (no two adjacent). */
|
||
function placeSeeds(size: number, n: number, rng: () => number): [number, number][] {
|
||
const all: [number, number][] = [];
|
||
for (let r = 0; r < size; r++)
|
||
for (let c = 0; c < size; c++)
|
||
all.push([r, c]);
|
||
const pool = shuffle(all, rng);
|
||
|
||
const seeds: [number, number][] = [];
|
||
const banned = new Set<string>();
|
||
|
||
for (const [r, c] of pool) {
|
||
if (seeds.length >= n) break;
|
||
if (banned.has(`${r},${c}`)) continue;
|
||
seeds.push([r, c]);
|
||
// Ban cell + its orthogonal neighbours (avoid touching seeds)
|
||
for (const [dr, dc] of [[0,0],[-1,0],[1,0],[0,-1],[0,1]])
|
||
banned.add(`${r+dr},${c+dc}`);
|
||
}
|
||
return seeds;
|
||
}
|
||
|
||
/**
|
||
* Grow N polyomino regions from seeds, then flood-fill any remaining cells.
|
||
* Returns a size×size grid where grid[r][c] = regionId.
|
||
*/
|
||
function growPolyominos(size: number, seeds: [number, number][], rng: () => number): number[][] {
|
||
const grid: number[][] = Array.from({ length: size }, () => Array(size).fill(-1));
|
||
const sizes: number[] = Array(seeds.length).fill(0);
|
||
const n = seeds.length;
|
||
const target = Math.round((size * size) / n);
|
||
|
||
// Place seeds
|
||
for (let i = 0; i < n; i++) {
|
||
grid[seeds[i][0]][seeds[i][1]] = i;
|
||
sizes[i] = 1;
|
||
}
|
||
|
||
// BFS frontier per region
|
||
const ADJ: [number, number][] = [[-1,0],[1,0],[0,-1],[0,1]];
|
||
|
||
// Build initial frontiers
|
||
const frontiers: Set<string>[] = Array.from({ length: n }, (_, id) => {
|
||
const s = new Set<string>();
|
||
const [sr, sc] = seeds[id];
|
||
for (const [dr, dc] of ADJ) {
|
||
const nr = sr+dr, nc = sc+dc;
|
||
if (nr>=0 && nr<size && nc>=0 && nc<size && grid[nr][nc] === -1)
|
||
s.add(`${nr},${nc}`);
|
||
}
|
||
return s;
|
||
});
|
||
|
||
// Grow until each region hits its target (randomised round-robin)
|
||
let anyGrew = true;
|
||
while (anyGrew) {
|
||
anyGrew = false;
|
||
const order = shuffle(Array.from({ length: n }, (_, i) => i), rng);
|
||
for (const id of order) {
|
||
if (sizes[id] >= target) continue;
|
||
// Pick a random free frontier cell
|
||
const candidates = [...frontiers[id]].filter(k => {
|
||
const [r, c] = k.split(",").map(Number);
|
||
return grid[r][c] === -1;
|
||
});
|
||
if (!candidates.length) continue;
|
||
const k = candidates[Math.floor(rng() * candidates.length)];
|
||
const [nr, nc] = k.split(",").map(Number);
|
||
if (grid[nr][nc] !== -1) { frontiers[id].delete(k); continue; } // race condition
|
||
grid[nr][nc] = id;
|
||
sizes[id]++;
|
||
frontiers[id].delete(k);
|
||
anyGrew = true;
|
||
// Expand frontier
|
||
for (const [dr, dc] of ADJ) {
|
||
const nnr = nr+dr, nnc = nc+dc;
|
||
if (nnr>=0 && nnr<size && nnc>=0 && nnc<size && grid[nnr][nnc] === -1)
|
||
frontiers[id].add(`${nnr},${nnc}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Flood-fill unassigned cells → smallest adjacent region
|
||
let changed = true;
|
||
while (changed) {
|
||
changed = false;
|
||
for (let r = 0; r < size; r++)
|
||
for (let c = 0; c < size; c++) {
|
||
if (grid[r][c] !== -1) continue;
|
||
let best = -1, bestSz = Infinity;
|
||
for (const [dr, dc] of ADJ) {
|
||
const nr = r+dr, nc = c+dc;
|
||
if (nr<0 || nr>=size || nc<0 || nc>=size) continue;
|
||
const id = grid[nr][nc];
|
||
if (id === -1) continue;
|
||
if (sizes[id] < bestSz) { bestSz = sizes[id]; best = id; }
|
||
}
|
||
if (best !== -1) { grid[r][c] = best; sizes[best]++; changed = true; }
|
||
}
|
||
}
|
||
|
||
return grid;
|
||
}
|
||
|
||
export function generatePatches(date: string): PatchesPuzzle {
|
||
const seed = date.split("-").reduce((a, n) => a * 1000 + parseInt(n), 0) + 333;
|
||
const rng = createRng(seed);
|
||
const size = 6;
|
||
|
||
// 7 or 8 regions → avg 4.5–5 cells each, with interesting shapes
|
||
const n = 7 + Math.floor(rng() * 2);
|
||
|
||
const seeds = placeSeeds(size, n, rng);
|
||
const grid = growPolyominos(size, seeds, rng);
|
||
|
||
const numRegions = Math.max(...grid.flat()) + 1;
|
||
|
||
// Collect cells per region
|
||
const regionCells = new Map<number, [number, number][]>();
|
||
for (let r = 0; r < size; r++)
|
||
for (let c = 0; c < size; c++) {
|
||
const id = grid[r][c];
|
||
if (!regionCells.has(id)) regionCells.set(id, []);
|
||
regionCells.get(id)!.push([r, c]);
|
||
}
|
||
|
||
const colors = shuffle([...PATCH_COLORS], rng);
|
||
|
||
const regions: Region[] = [];
|
||
for (let id = 0; id < numRegions; id++) {
|
||
const cells = regionCells.get(id) ?? [];
|
||
if (!cells.length) continue;
|
||
|
||
// Hint cell = topmost row, then leftmost col
|
||
const hintCell = cells.reduce((best, cur) =>
|
||
cur[0] < best[0] || (cur[0] === best[0] && cur[1] < best[1]) ? cur : best
|
||
);
|
||
|
||
// Normalised shape (origin at 0,0)
|
||
const minR = Math.min(...cells.map(([r]) => r));
|
||
const minC = Math.min(...cells.map(([, c]) => c));
|
||
const relCells: [number, number][] = cells.map(([r, c]) => [r - minR, c - minC]);
|
||
const previewRows = Math.max(...relCells.map(([r]) => r)) + 1;
|
||
const previewCols = Math.max(...relCells.map(([, c]) => c)) + 1;
|
||
|
||
regions.push({
|
||
id,
|
||
cells,
|
||
hintCell,
|
||
relCells,
|
||
previewRows,
|
||
previewCols,
|
||
size: cells.length,
|
||
color: colors[id % colors.length],
|
||
});
|
||
}
|
||
|
||
return { size, regions, grid };
|
||
}
|