diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 76dbb9c..dd4e293 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -31,7 +31,6 @@ jobs: - uses: bahmutov/npm-install@v1.4.3 - - run: ( cd packages/compute ; yarn benchmark ) - run: ( cd packages/gif-creator ; yarn benchmark ) test-action: diff --git a/README.md b/README.md index 1060c1b..c824d13 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,6 @@ Generates a snake game from a github user contributions grid and output a screen capture as gif -- [demo](https://platane.github.io/snk/interactive.html) +- [demo](https://platane.github.io/snk) - [github action](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid) diff --git a/packages/compute/README.md b/packages/compute/README.md deleted file mode 100644 index 0726ddb..0000000 --- a/packages/compute/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# implementation - -## target - -The goal is have the stack of eaten color as sorted as possible. - -The number of step is not very optimized as for now. - -## algorithm - -- for each type of color in the grid - - - determine all the "free" cell of that color. - - > a free cell can be reached by going through only empty cell ( or cell of the same color ) - > - > basically, grabbing those cells have no penalty since we don't touch other color to get to the cell and to leave the cell - - - eat all the free cells (without optimizing the path for the sake of performance) - - - repeat for the next color, consider the current color as the same color - -## future - -- have an intermediate phase where we eat the remaining cell that are not free, to get rid of them before the next "eat free cells" phase - -- use a better heuristic to allows to optimize the number of steps in the "eat free cells" phase diff --git a/packages/compute/__tests__/benchmark.ts b/packages/compute/__tests__/benchmark.ts deleted file mode 100644 index dcc8c11..0000000 --- a/packages/compute/__tests__/benchmark.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { realistic as grid } from "@snk/types/__fixtures__/grid"; -import { snake3 } from "@snk/types/__fixtures__/snake"; -import { performance } from "perf_hooks"; -import { getAvailableRoutes } from "../getAvailableRoutes"; -import { getBestRoute } from "../getBestRoute"; - -{ - const m = 100; - const s = performance.now(); - for (let k = m; k--; ) { - const solutions = []; - - getAvailableRoutes(grid, snake3, (snakes) => { - solutions.push(snakes); - return false; - }); - } - console.log("getAvailableRoutes", (performance.now() - s) / m, "ms"); -} - -{ - const m = 10; - const s = performance.now(); - for (let k = m; k--; ) { - getBestRoute(grid, snake3); - } - - console.log("getBestRoute", (performance.now() - s) / m, "ms"); -} diff --git a/packages/compute/__tests__/getBestRoute-fuzz.spec.ts b/packages/compute/__tests__/getBestRoute-fuzz.spec.ts index 5a5ff07..cf3a58b 100644 --- a/packages/compute/__tests__/getBestRoute-fuzz.spec.ts +++ b/packages/compute/__tests__/getBestRoute-fuzz.spec.ts @@ -1,21 +1,25 @@ import { getBestRoute } from "../getBestRoute"; -import { Color, createEmptyGrid } from "@snk/types/grid"; import { snake3 } from "@snk/types/__fixtures__/snake"; -import { randomlyFillGrid } from "@snk/types/randomlyFillGrid"; -import ParkMiller from "park-miller"; +import { + getHeadX, + getHeadY, + Snake, + snakeWillSelfCollide, +} from "@snk/types/snake"; +import { createFromSeed } from "@snk/types/__fixtures__/createFromSeed"; const n = 1000; const width = 5; const height = 5; it(`should find solution for ${n} ${width}x${height} generated grids`, () => { const results = Array.from({ length: n }, (_, seed) => { - const grid = createEmptyGrid(width, height); - const pm = new ParkMiller(seed); - const random = pm.integerInRange.bind(pm); - randomlyFillGrid(grid, { colors: [1, 2] as Color[], emptyP: 2 }, random); + const grid = createFromSeed(seed, width, height); try { - getBestRoute(grid, snake3); + const chain = getBestRoute(grid, snake3); + + assertValidPath(chain); + return { seed }; } catch (error) { return { seed, error }; @@ -24,3 +28,15 @@ it(`should find solution for ${n} ${width}x${height} generated grids`, () => { expect(results.filter((x) => x.error)).toEqual([]); }); + +const assertValidPath = (chain: Snake[]) => { + for (let i = 0; i < chain.length - 1; i++) { + const dx = getHeadX(chain[i + 1]) - getHeadX(chain[i]); + const dy = getHeadY(chain[i + 1]) - getHeadY(chain[i]); + + if (!((Math.abs(dx) === 1 && dy == 0) || (Math.abs(dy) === 1 && dx == 0))) + throw new Error(`unexpected direction ${dx},${dy}`); + + if (snakeWillSelfCollide(chain[i], dx, dy)) throw new Error(`self collide`); + } +}; diff --git a/packages/compute/__tests__/getBestRoute.spec.ts b/packages/compute/__tests__/getBestRoute.spec.ts index c97daa0..fd249c3 100644 --- a/packages/compute/__tests__/getBestRoute.spec.ts +++ b/packages/compute/__tests__/getBestRoute.spec.ts @@ -15,7 +15,7 @@ it("should find best route", () => { const chain = getBestRoute(grid, createSnakeFromCells(snk0))!; - expect(snakeToCells(chain[0])[1]).toEqual({ x: 0, y: 0 }); + expect(snakeToCells(chain[1])[1]).toEqual({ x: 0, y: 0 }); expect(snakeToCells(chain[chain.length - 1])[0]).toEqual({ x: 3, y: 3 }); }); diff --git a/packages/compute/cleanColoredLayer.ts b/packages/compute/cleanColoredLayer.ts new file mode 100644 index 0000000..e0b0286 --- /dev/null +++ b/packages/compute/cleanColoredLayer.ts @@ -0,0 +1,147 @@ +import { Color, getColor, isEmpty, setColorEmpty } from "@snk/types/grid"; +import { + getHeadX, + getHeadY, + getSnakeLength, + nextSnake, +} from "@snk/types/snake"; +import type { Snake } from "@snk/types/snake"; +import type { Grid } from "@snk/types/grid"; +import type { Point } from "@snk/types/point"; +import { getBestTunnel, trimTunnelEnd, trimTunnelStart } from "./getBestTunnel"; +import { getPathTo } from "./getPathTo"; +import { getTunnels } from "./getTunnels"; + +/** + * eat all the cell for which the color is smaller or equals to color and are reachable without going though cells with color+1 or higher + * attempt to eat the smaller color first + */ +export const cleanColoredLayer = (grid: Grid, snake0: Snake, color: Color) => { + const chain: Snake[] = [snake0]; + + const snakeN = getSnakeLength(snake0); + + const tunnels = getTunnels(grid, getSnakeLength(snake0), color) + .map((tunnel) => ({ tunnel, f: tunnelScore(grid, color, tunnel) })) + .sort((a, b) => a.f - b.f); + + while (tunnels.length) { + // get the best candidates + const candidates = tunnels.filter((a, _, [a0]) => a.f === a0.f); + + // get the closest one + { + const x = getHeadX(chain[0]); + const y = getHeadY(chain[0]); + + candidates.sort( + ({ tunnel: [a] }, { tunnel: [b] }) => + distanceSq(x, y, a.x, a.y) - distanceSq(x, y, b.x, b.y) + ); + } + + // pick tunnel and recompute it + // it might not be relevant since the grid changes + // in some edge case, it could lead to the snake reaching the first cell from the initial exit side + // causing it to self collide when on it's way through the tunnel + const { tunnel: tunnelCandidate } = candidates[0]; + const tunnel = getBestTunnel( + grid, + tunnelCandidate[0].x, + tunnelCandidate[0].y, + color, + snakeN + )!; + + // move to the start of the tunnel + chain.unshift(...getPathTo(grid, chain[0], tunnel[0].x, tunnel[0].y)!); + + // move into the tunnel + chain.unshift(...getTunnelPath(chain[0], tunnel)); + + // update grid + for (const { x, y } of tunnel) setColorEmpty(grid, x, y); + + // update other tunnels + // eventually remove the ones made empty + for (let i = tunnels.length; i--; ) { + updateTunnel(grid, tunnels[i].tunnel, tunnel); + + if (tunnels[i].tunnel.length === 0) tunnels.splice(i, 1); + else tunnels[i].f = tunnelScore(grid, color, tunnels[i].tunnel); + } + tunnels.sort((a, b) => a.f - b.f); + } + + return chain.slice(0, -1); +}; + +/** + * get the score of the tunnel + * prioritize tunnel with maximum color smaller than and with minimum + * with some tweaks + */ +const tunnelScore = (grid: Grid, color: Color, tunnel: Point[]) => { + let nColor = 0; + let nLess = 0; + let nLessLead = -1; + + for (const { x, y } of tunnel) { + const c = getColor(grid, x, y); + + if (!isEmpty(c)) { + if (c === color) { + nColor++; + if (nLessLead === -1) nLessLead = nLess; + } else nLess += color - c; + } + } + + if (nLess === 0) return 999999; + + return -nLessLead * 100 + (1 - nLess / nColor); +}; + +const distanceSq = (ax: number, ay: number, bx: number, by: number) => + (ax - bx) ** 2 + (ay - by) ** 2; + +/** + * get the sequence of snake to cross the tunnel + */ +const getTunnelPath = (snake0: Snake, tunnel: Point[]) => { + const chain: Snake[] = []; + let snake = snake0; + + for (let i = 1; i < tunnel.length; i++) { + const dx = tunnel[i].x - getHeadX(snake); + const dy = tunnel[i].y - getHeadY(snake); + snake = nextSnake(snake, dx, dy); + chain.unshift(snake); + } + + return chain; +}; + +/** + * assuming the grid change and the colors got deleted, update the tunnel + */ +const updateTunnel = (grid: Grid, tunnel: Point[], toDelete: Point[]) => { + trimTunnelStart(grid, tunnel); + trimTunnelEnd(grid, tunnel); + + while (tunnel.length) { + const { x, y } = tunnel[0]; + if (toDelete.some((p) => p.x === x && p.y === y)) { + tunnel.shift(); + trimTunnelStart(grid, tunnel); + } else break; + } + + while (tunnel.length) { + const { x, y } = tunnel[tunnel.length - 1]; + if (toDelete.some((p) => p.x === x && p.y === y)) { + tunnel.pop(); + trimTunnelEnd(grid, tunnel); + } else break; + } +}; diff --git a/packages/compute/cleanIntermediateLayer.ts b/packages/compute/cleanIntermediateLayer.ts deleted file mode 100644 index e59058a..0000000 --- a/packages/compute/cleanIntermediateLayer.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { getColor, isEmpty, setColorEmpty } from "@snk/types/grid"; -import { - getHeadX, - getHeadY, - getSnakeLength, - nextSnake, -} from "@snk/types/snake"; -import { getPathTo } from "./getPathTo"; -import { getBestTunnel, trimTunnelEnd, trimTunnelStart } from "./getBestTunnel"; -import type { Snake } from "@snk/types/snake"; -import type { Color, Grid } from "@snk/types/grid"; -import type { Point } from "@snk/types/point"; - -/** - * - list the cells lesser than that are reachable going through - * - for each cell of the list - * compute the best tunnel to get to the cell and back to the outside ( best = less usage of ) - * - for each tunnel* - * make the snake go to the start of the tunnel from where it was, traverse the tunnel - * repeat - * - * *sort the tunnel: - * - first one to go is the tunnel with the longest line on less than - * - then the ones with the best ratio ( N of less than ) / ( N of ) - */ -export const cleanIntermediateLayer = ( - grid: Grid, - color: Color, - snake0: Snake -) => { - const tunnels: Point[][] = []; - const chain: Snake[] = [snake0]; - - for (let x = grid.width; x--; ) - for (let y = grid.height; y--; ) { - const c = getColor(grid, x, y); - - if (!isEmpty(c) && c < color) { - const tunnel = getBestTunnel(grid, x, y, color, getSnakeLength(snake0)); - if (tunnel) tunnels.push(tunnel); - } - } - - // find the best first tunnel - let i = -1; - for (let j = tunnels.length; j--; ) - if ( - i === -1 || - scoreFirst(grid, color, tunnels[i]) < scoreFirst(grid, color, tunnels[j]) - ) - i = j; - - while (i >= 0) { - const [tunnel] = tunnels.splice(i, 1); - - // push to chain - // 1\ the path to the start on the tunnel - const path = getPathTo(grid, chain[0], tunnel[0].x, tunnel[0].y)!; - chain.unshift(...path); - // 2\ the path into the tunnel - for (let i = 1; i < tunnel.length; i++) { - const dx = tunnel[i].x - getHeadX(chain[0]); - const dy = tunnel[i].y - getHeadY(chain[0]); - const snake = nextSnake(chain[0], dx, dy); - chain.unshift(snake); - } - - // mutate grid - for (const { x, y } of tunnel) setColorEmpty(grid, x, y); - - // remove the cell that we eat - for (let j = tunnels.length; j--; ) { - updateTunnel(grid, tunnels[j], tunnel); - if (!tunnels[j].length) tunnels.splice(j, 1); - } - - // select the next one - i = -1; - for (let j = tunnels.length; j--; ) - if ( - i === -1 || - score(grid, color, tunnels[i]) < score(grid, color, tunnels[j]) - ) - i = j; - } - - return chain; -}; - -const scoreFirst = (grid: Grid, color: Color, tunnel: Point[]) => - tunnel.findIndex(({ x, y }) => getColor(grid, x, y) === color); - -const score = (grid: Grid, color: Color, tunnel: Point[]) => { - let nColor = 0; - let nLessColor = 0; - - for (let i = 0; i < tunnel.length; i++) { - const { x, y } = tunnel[i]; - - const j = tunnel.findIndex((u) => x === u.x && y === u.y); - - if (i !== j) { - const c = getColor(grid, x, y); - if (c === color) nColor++; - else if (!isEmpty(c)) nLessColor++; - } - } - - return nLessColor / nColor; -}; - -/** - * assuming the grid change and the colors got deleted, update the tunnel - */ -const updateTunnel = (grid: Grid, tunnel: Point[], toDelete: Point[]) => { - while (tunnel.length) { - const { x, y } = tunnel[0]; - if (toDelete.some((p) => p.x === x && p.y === y)) { - tunnel.shift(); - trimTunnelStart(grid, tunnel); - } else break; - } - - while (tunnel.length) { - const { x, y } = tunnel[tunnel.length - 1]; - if (toDelete.some((p) => p.x === x && p.y === y)) { - tunnel.pop(); - trimTunnelEnd(grid, tunnel); - } else break; - } -}; diff --git a/packages/compute/cleanLayer-monobranch.ts b/packages/compute/cleanLayer-monobranch.ts deleted file mode 100644 index eed0357..0000000 --- a/packages/compute/cleanLayer-monobranch.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * clean layer is too expensive with solution branching - * do not branch for faster result ( at the cost of finding a minimal step number ) - */ - -import { copyGrid, setColorEmpty } from "@snk/types/grid"; -import { getHeadX, getHeadY } from "@snk/types/snake"; -import { getAvailableRoutes } from "./getAvailableRoutes"; -import type { Snake } from "@snk/types/snake"; -import type { Grid } from "@snk/types/grid"; -import type { Point } from "@snk/types/point"; - -export const getAvailableWhiteListedRoute = ( - grid: Grid, - snake: Snake, - whiteList: Point[] -) => { - let solution: Snake[] | null; - - getAvailableRoutes(grid, snake, (chain) => { - const hx = getHeadX(chain[0]); - const hy = getHeadY(chain[0]); - - if (!whiteList.some(({ x, y }) => hx === x && hy === y)) return false; - - solution = chain; - - return true; - }); - - // @ts-ignore - return solution; -}; - -export const cleanLayer = (grid0: Grid, snake0: Snake, chunk0: Point[]) => { - const chunk = chunk0.slice(); - const grid = copyGrid(grid0); - let snake = snake0; - const chain: Snake[] = []; - - while (chunk.length) { - const chainN = getAvailableWhiteListedRoute(grid, snake, chunk); - - if (!chainN) throw new Error("some cells are unreachable"); - - chain.unshift(...chainN); - snake = chain[0]; - - const x = getHeadX(snake); - const y = getHeadY(snake); - - setColorEmpty(grid, x, y); - - const i = chunk.findIndex((c) => c.x === x && c.y === y); - chunk.splice(i, 1); - } - - return chain; -}; diff --git a/packages/compute/cleanLayer.ts b/packages/compute/cleanLayer.ts deleted file mode 100644 index 571c006..0000000 --- a/packages/compute/cleanLayer.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { copyGrid, isEmpty, setColorEmpty } from "@snk/types/grid"; -import { getHeadX, getHeadY, snakeEquals } from "@snk/types/snake"; -import { sortPush } from "./utils/sortPush"; -import { arrayEquals } from "./utils/array"; -import { getAvailableRoutes } from "./getAvailableRoutes"; -import type { Snake } from "@snk/types/snake"; -import type { Grid } from "@snk/types/grid"; -import type { Point } from "@snk/types/point"; - -type M = { - snake: Snake; - chain: Snake[]; - chunk: Point[]; - grid: Grid; - parent: M | null; - w: number; - h: number; - f: number; -}; -const unwrap = (o: M | null): Snake[] => - !o ? [] : [...o.chain, ...unwrap(o.parent)]; - -const createGetHeuristic = (grid: Grid, chunk0: Point[]) => { - const n = grid.data.reduce((sum, x: any) => sum + +!isEmpty(x), 0); - const area = grid.width * grid.height; - - const k = - Math.sqrt((2 * area) / chunk0.length) * 1 + (n - chunk0.length) / area; - - return (chunk: any[]) => chunk.length * k; -}; - -export const cleanLayer = (grid0: Grid, snake0: Snake, chunk0: Point[]) => { - const getH = createGetHeuristic(grid0, chunk0); - - const next = { - grid: grid0, - snake: snake0, - chain: [snake0], - chunk: chunk0, - parent: null, - h: getH(chunk0), - f: getH(chunk0), - w: 0, - }; - - const openList: M[] = [next]; - const closeList: M[] = [next]; - - while (openList.length) { - const o = openList.shift()!; - - if (o.chunk.length === 0) return unwrap(o).slice(0, -1); - - const chains = getAvailableWhiteListedRoutes(o.grid, o.snake, o.chunk); - - for (const chain of chains) { - const snake = chain[0]; - const x = getHeadX(snake); - const y = getHeadY(snake); - - const chunk = o.chunk.filter((u) => u.x !== x || u.y !== y); - - if ( - !closeList.some( - (u) => snakeEquals(u.snake, snake) && arrayEquals(u.chunk, chunk) - ) - ) { - const grid = copyGrid(o.grid); - setColorEmpty(grid, x, y); - - const h = getH(chunk); - const w = o.w + chain.length; - const f = h + w; - - const next = { snake, chain, chunk, grid, parent: o, h, w, f }; - sortPush(openList, next, (a, b) => a.f - b.f); - closeList.push(next); - } - } - } - - throw new Error("some cells are unreachable"); -}; - -export const getAvailableWhiteListedRoutes = ( - grid: Grid, - snake: Snake, - whiteList0: Point[], - n = 3 -) => { - const whiteList = whiteList0.slice(); - const solutions: Snake[][] = []; - - getAvailableRoutes(grid, snake, (chain) => { - const hx = getHeadX(chain[0]); - const hy = getHeadY(chain[0]); - - const i = whiteList.findIndex(({ x, y }) => hx === x && hy === y); - - if (i >= 0) { - whiteList.splice(i, 1); - solutions.push(chain); - - if (solutions.length >= n || whiteList.length === 0) return true; - } - - return false; - }); - - return solutions; -}; diff --git a/packages/compute/getAvailableRoutes.ts b/packages/compute/getAvailableRoutes.ts deleted file mode 100644 index 00f9067..0000000 --- a/packages/compute/getAvailableRoutes.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isInsideLarge, getColor, isInside, isEmpty } from "@snk/types/grid"; -import { around4 } from "@snk/types/point"; -import { - getHeadX, - getHeadY, - nextSnake, - snakeEquals, - snakeWillSelfCollide, -} from "@snk/types/snake"; -import { sortPush } from "./utils/sortPush"; -import type { Snake } from "@snk/types/snake"; -import type { Grid, Color } from "@snk/types/grid"; - -/** - * get routes leading to non-empty cells until onSolution returns true - */ -export const getAvailableRoutes = ( - grid: Grid, - snake0: Snake, - onSolution: (snakes: Snake[], color: Color) => boolean -) => { - const openList: Snake[][] = [[snake0]]; - const closeList: Snake[] = []; - - while (openList.length) { - const c = openList.shift()!; - const [snake] = c; - - const cx = getHeadX(snake); - const cy = getHeadY(snake); - - for (let i = 0; i < around4.length; i++) { - const { x: dx, y: dy } = around4[i]; - - const nx = cx + dx; - const ny = cy + dy; - - if ( - isInsideLarge(grid, 2, nx, ny) && - !snakeWillSelfCollide(snake, dx, dy) - ) { - const nsnake = nextSnake(snake, dx, dy); - - if (!closeList.some((s) => snakeEquals(nsnake, s))) { - const color = isInside(grid, nx, ny) && getColor(grid, nx, ny); - - if (!color || isEmpty(color)) { - sortPush(openList, [nsnake, ...c], (a, b) => a.length - b.length); - closeList.push(nsnake); - } else { - if (onSolution([nsnake, ...c.slice(0, -1)], color)) return; - } - } - } - } - } -}; diff --git a/packages/compute/getBestRoute.ts b/packages/compute/getBestRoute.ts index b034bfe..9149d2e 100644 --- a/packages/compute/getBestRoute.ts +++ b/packages/compute/getBestRoute.ts @@ -1,30 +1,16 @@ -import { copyGrid } from "@snk/types/grid"; -import { pruneLayer } from "./pruneLayer"; -import { cleanLayer } from "./cleanLayer-monobranch"; -import { getSnakeLength, Snake } from "@snk/types/snake"; -import { cleanIntermediateLayer } from "./cleanIntermediateLayer"; -import type { Color, Grid } from "@snk/types/grid"; +import { Color, copyGrid } from "@snk/types/grid"; +import type { Grid } from "@snk/types/grid"; +import { cleanColoredLayer } from "./cleanColoredLayer"; +import type { Snake } from "@snk/types/snake"; export const getBestRoute = (grid0: Grid, snake0: Snake) => { const grid = copyGrid(grid0); - const colors = extractColors(grid0); - const snakeN = getSnakeLength(snake0); - const chain: Snake[] = [snake0]; - for (const color of colors) { - const gridN = copyGrid(grid); + for (const color of extractColors(grid)) + chain.unshift(...cleanColoredLayer(grid, chain[0], color)); - // clear the free colors - const chunk = pruneLayer(grid, color, snakeN); - chain.unshift(...cleanLayer(gridN, chain[0], chunk)); - - // clear the remaining colors, allowing to eat color+1 - const nextColor = (color + 1) as Color; - chain.unshift(...cleanIntermediateLayer(grid, nextColor, chain[0])); - } - - return chain.reverse().slice(1); + return chain.reverse(); }; const extractColors = (grid: Grid): Color[] => { diff --git a/packages/compute/getBestTunnel.ts b/packages/compute/getBestTunnel.ts index 65acc6d..f701e69 100644 --- a/packages/compute/getBestTunnel.ts +++ b/packages/compute/getBestTunnel.ts @@ -28,9 +28,6 @@ type M = { f: number; }; -const unwrap = (m: M | null): Snake[] => - m ? [m.snake, ...unwrap(m.parent)] : []; - /** * returns the path to reach the outside which contains the least color cell */ @@ -56,8 +53,8 @@ const getSnakeEscapePath = (grid0: Grid, snake0: Snake, color: Color) => { let e: M["parent"] = o; while (e) { points.unshift({ - y: getHeadY(e.snake), x: getHeadX(e.snake), + y: getHeadY(e.snake), }); e = e.parent; } @@ -97,6 +94,8 @@ const getSnakeEscapePath = (grid0: Grid, snake0: Snake, color: Color) => { /** * compute the best tunnel to get to the cell and back to the outside ( best = less usage of ) + * + * notice that it's one of the best tunnels, more with the same score could exist */ export const getBestTunnel = ( grid: Grid, @@ -120,18 +119,18 @@ export const getBestTunnel = ( // remove from the grid the colors that one eat const gridI = copyGrid(grid); - for (const { x, y } of one) setColorEmpty(gridI, x, y); + for (const { x, y } of one) + if (isInside(grid, x, y)) setColorEmpty(gridI, x, y); const two = getSnakeEscapePath(gridI, snakeI, color); if (!two) return null; + one.shift(); one.reverse(); - one.pop(); - - trimTunnelStart(grid, one); - trimTunnelEnd(grid, two); one.push(...two); + trimTunnelStart(grid, one); + trimTunnelEnd(grid, one); return one; }; @@ -152,8 +151,14 @@ export const trimTunnelStart = (grid: Grid, tunnel: Point[]) => { */ export const trimTunnelEnd = (grid: Grid, tunnel: Point[]) => { while (tunnel.length) { - const { x, y } = tunnel[tunnel.length - 1]; - if (!isInside(grid, x, y) || isEmpty(getColor(grid, x, y))) tunnel.pop(); + const i = tunnel.length - 1; + const { x, y } = tunnel[i]; + if ( + !isInside(grid, x, y) || + isEmpty(getColor(grid, x, y)) || + tunnel.findIndex((p) => p.x === x && p.y === y) < i + ) + tunnel.pop(); else break; } }; diff --git a/packages/compute/getTunnels.ts b/packages/compute/getTunnels.ts new file mode 100644 index 0000000..eb0e592 --- /dev/null +++ b/packages/compute/getTunnels.ts @@ -0,0 +1,21 @@ +import { Color, getColor, isEmpty } from "@snk/types/grid"; +import type { Grid } from "@snk/types/grid"; +import type { Point } from "@snk/types/point"; +import { getBestTunnel } from "./getBestTunnel"; + +/** + * get all the tunnels for all the cells accessible + */ +export const getTunnels = (grid: Grid, snakeN: number, color: Color) => { + const tunnels: Point[][] = []; + for (let x = grid.width; x--; ) + for (let y = grid.height; y--; ) { + const c = getColor(grid, x, y); + if (!isEmpty(c) && c <= color) { + const tunnel = getBestTunnel(grid, x, y, color, snakeN); + if (tunnel) tunnels.push(tunnel); + } + } + + return tunnels; +}; diff --git a/packages/compute/package.json b/packages/compute/package.json index dfe8c5d..2cc6f94 100644 --- a/packages/compute/package.json +++ b/packages/compute/package.json @@ -2,10 +2,6 @@ "name": "@snk/compute", "version": "1.0.0", "devDependencies": { - "@zeit/ncc": "0.22.3", "park-miller": "1.1.0" - }, - "scripts": { - "benchmark": "ncc run __tests__/benchmark.ts --quiet" } } diff --git a/packages/compute/pruneLayer.ts b/packages/compute/pruneLayer.ts deleted file mode 100644 index 3c71f6e..0000000 --- a/packages/compute/pruneLayer.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { getColor, isEmpty, isInside, setColorEmpty } from "@snk/types/grid"; -import { around4 } from "@snk/types/point"; -import { sortPush } from "./utils/sortPush"; -import type { Color, Grid } from "@snk/types/grid"; -import type { Point } from "@snk/types/point"; -import { - createSnakeFromCells, - getHeadX, - getHeadY, - nextSnake, - Snake, - snakeEquals, - snakeWillSelfCollide, -} from "@snk/types/snake"; - -type M = Point & { parent: M | null; h: number }; - -const unwrap = (m: M | null): Point[] => (m ? [...unwrap(m.parent), m] : []); - -const getEscapePath = (grid: Grid, x: number, y: number, color: Color) => { - const openList: M[] = [{ x, y, h: 0, parent: null }]; - const closeList: Point[] = []; - - while (openList.length) { - const c = openList.shift()!; - - if (c.y === -1 || c.y === grid.height) return unwrap(c); - - for (const a of around4) { - const x = c.x + a.x; - const y = c.y + a.y; - - if (!isInside(grid, x, y)) return unwrap({ x, y, parent: c } as any); - - const u = getColor(grid, x, y); - - if ( - (isEmpty(u) || u <= color) && - !closeList.some((cl) => cl.x === x && cl.y === y) - ) { - const h = Math.abs(grid.height / 2 - y); - const o = { x, y, parent: c, h }; - - sortPush(openList, o, (a, b) => a.h - b.h); - closeList.push(o); - } - } - } - - return null; -}; - -/** - * returns true if the snake can reach outside from it's location - */ -const snakeCanEscape = (grid: Grid, snake: Snake, color: Color) => { - const openList: Snake[] = [snake]; - const closeList: Snake[] = []; - - while (openList.length) { - const s = openList.shift()!; - - for (const a of around4) { - if (!snakeWillSelfCollide(s, a.x, a.y)) { - const x = getHeadX(s) + a.x; - const y = getHeadY(s) + a.y; - - if (!isInside(grid, x, y)) return true; - - const u = getColor(grid, x, y); - - if (isEmpty(u) || u <= color) { - const sn = nextSnake(s, a.x, a.y); - - if (!closeList.some((s0) => snakeEquals(s0, sn))) { - openList.push(sn); - closeList.push(sn); - } - } - } - } - } - - return false; -}; - -/** - * returns true if the cell can be reached by the snake from outside, and the snake can go back outside - */ -const isFree = ( - grid: Grid, - x: number, - y: number, - color: Color, - snakeN: number -) => { - // get the first path to escape - const firstPath = getEscapePath(grid, x, y, color); - - if (!firstPath) return false; - - // build a snake from the path - // /!\ it might be not a valid snake as we stack up the queue if the path is too short - const s = firstPath.slice(0, snakeN); - while (s.length < snakeN) s.push(s[s.length - 1]); - const snake1 = createSnakeFromCells(s); - - // check for a second route, considering snake collision - return snakeCanEscape(grid, snake1, color); -}; - -/** - * returns free cells - * and removes them from the grid - */ -export const pruneLayer = (grid: Grid, color: Color, snakeN: number) => { - const chunk: Point[] = []; - - for (let x = grid.width; x--; ) - for (let y = grid.height; y--; ) { - const c = getColor(grid, x, y); - - if (!isEmpty(c) && c <= color && isFree(grid, x, y, color, snakeN)) { - setColorEmpty(grid, x, y); - chunk.push({ x, y }); - } - } - - return chunk; -}; diff --git a/packages/demo/canvas.ts b/packages/demo/canvas.ts index 5b43e0d..09f77ad 100644 --- a/packages/demo/canvas.ts +++ b/packages/demo/canvas.ts @@ -34,6 +34,7 @@ export const createCanvas = ({ canvas.style.width = w + "px"; canvas.style.height = h + "px"; canvas.style.display = "block"; + canvas.style.pointerEvents = "none"; document.body.appendChild(canvas); diff --git a/packages/demo/demo.getAvailableRoutes.ts b/packages/demo/demo.getAvailableRoutes.ts deleted file mode 100644 index b2faf02..0000000 --- a/packages/demo/demo.getAvailableRoutes.ts +++ /dev/null @@ -1,87 +0,0 @@ -import "./menu"; -import { createCanvas } from "./canvas"; -import { getHeadX, getHeadY, snakeToCells } from "@snk/types/snake"; -import { grid, snake } from "./sample"; -import { getColor } from "@snk/types/grid"; -import { getAvailableRoutes } from "@snk/compute/getAvailableRoutes"; -import type { Snake } from "@snk/types/snake"; -import type { Color, Empty } from "@snk/types/grid"; - -// -// compute - -const solutions: { - x: number; - y: number; - chain: Snake[]; - color: Color | Empty; -}[] = []; -getAvailableRoutes(grid, snake, (chain) => { - const x = getHeadX(chain[0]); - const y = getHeadY(chain[0]); - - if (!solutions.some((s) => x === s.x && y === s.y)) - solutions.push({ - x, - y, - chain: [snake, ...chain.slice().reverse()], - color: getColor(grid, x, y), - }); - - return false; -}); -solutions.sort((a, b) => a.color - b.color); - -const { canvas, ctx, draw, highlightCell } = createCanvas(grid); -document.body.appendChild(canvas); - -let k = 0; -let i = solutions[k].chain.length - 1; - -const onChange = () => { - const { chain } = solutions[k]; - - ctx.clearRect(0, 0, 9999, 9999); - - draw(grid, chain[i], []); - - chain - .map(snakeToCells) - .flat() - .forEach(({ x, y }) => highlightCell(x, y)); -}; - -onChange(); - -const inputK = document.createElement("input") as any; -inputK.type = "range"; -inputK.value = 0; -inputK.step = 1; -inputK.min = 0; -inputK.max = solutions.length - 1; -inputK.style.width = "90%"; -inputK.style.padding = "20px 0"; -inputK.addEventListener("input", () => { - k = +inputK.value; - i = inputI.value = inputI.max = solutions[k].chain.length - 1; - onChange(); -}); -document.body.append(inputK); - -const inputI = document.createElement("input") as any; -inputI.type = "range"; -inputI.value = inputI.max = solutions[k].chain.length - 1; -inputI.step = 1; -inputI.min = 0; -inputI.style.width = "90%"; -inputI.style.padding = "20px 0"; -inputI.addEventListener("input", () => { - i = +inputI.value; - onChange(); -}); -document.body.append(inputI); - -window.addEventListener("click", (e) => { - if (e.target === document.body || e.target === document.body.parentElement) - inputK.focus(); -}); diff --git a/packages/demo/demo.getBestRoute.ts b/packages/demo/demo.getBestRoute.ts index 9fd4b31..9a4d83e 100644 --- a/packages/demo/demo.getBestRoute.ts +++ b/packages/demo/demo.getBestRoute.ts @@ -4,55 +4,35 @@ import { getBestRoute } from "@snk/compute/getBestRoute"; import { Color, copyGrid } from "@snk/types/grid"; import { grid, snake } from "./sample"; import { step } from "@snk/compute/step"; -import { isStableAndBound, stepSpring } from "./springUtils"; -const chain = [snake, ...getBestRoute(grid, snake)!]; +const chain = getBestRoute(grid, snake)!; // // draw +let k = 0; -const spring = { x: 0, v: 0, target: 0 }; -const springParams = { tension: 120, friction: 20, maxVelocity: 50 }; -let animationFrame: number; - -const { canvas, drawLerp } = createCanvas(grid); +const { canvas, draw } = createCanvas(grid); document.body.appendChild(canvas); -const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); +const onChange = () => { + const gridN = copyGrid(grid); + const stack: Color[] = []; + for (let i = 0; i <= k; i++) step(gridN, stack, chain[i]); -const loop = () => { - cancelAnimationFrame(animationFrame); - - stepSpring(spring, springParams, spring.target); - const stable = isStableAndBound(spring, spring.target); - - const grid0 = copyGrid(grid); - const stack0: Color[] = []; - for (let i = 0; i < Math.min(chain.length, spring.x); i++) - step(grid0, stack0, chain[i]); - - const snake0 = chain[clamp(Math.floor(spring.x), 0, chain.length - 1)]; - const snake1 = chain[clamp(Math.ceil(spring.x), 0, chain.length - 1)]; - const k = spring.x % 1; - - drawLerp(grid0, snake0, snake1, stack0, k); - - if (!stable) animationFrame = requestAnimationFrame(loop); + draw(gridN, chain[k], stack); }; - -loop(); +onChange(); const input = document.createElement("input") as any; input.type = "range"; input.value = 0; input.step = 1; input.min = 0; -input.max = chain.length; +input.max = chain.length - 1; input.style.width = "90%"; input.addEventListener("input", () => { - spring.target = +input.value; - cancelAnimationFrame(animationFrame); - animationFrame = requestAnimationFrame(loop); + k = +input.value; + onChange(); }); document.body.append(input); window.addEventListener("click", (e) => { diff --git a/packages/demo/demo.getBestRoundTrip.ts b/packages/demo/demo.getBestTunnel.ts similarity index 100% rename from packages/demo/demo.getBestRoundTrip.ts rename to packages/demo/demo.getBestTunnel.ts diff --git a/packages/demo/demo.interactive.ts b/packages/demo/demo.interactive.ts index 9c4f118..dcbd25b 100644 --- a/packages/demo/demo.interactive.ts +++ b/packages/demo/demo.interactive.ts @@ -122,6 +122,7 @@ const createViewer = ({ const h = (height / width) * w; canvas.style.width = w + "px"; canvas.style.height = h + "px"; + canvas.style.pointerEvents = "none"; document.body.appendChild(canvas); @@ -205,7 +206,7 @@ const onSubmit = async (userName: string) => { const snake = snake3; const grid = userContributionToGrid(cells); - const chain = [snake, ...getBestRoute(grid, snake)!]; + const chain = getBestRoute(grid, snake)!; dispose(); createViewer({ grid0: grid, chain, drawOptions }); diff --git a/packages/demo/demo.json b/packages/demo/demo.json index bbef4db..35a0494 100644 --- a/packages/demo/demo.json +++ b/packages/demo/demo.json @@ -1,8 +1 @@ -[ - "getAvailableRoutes", - "pruneLayer", - "getBestRoute", - "getBestRoundTrip", - "getPathTo", - "interactive" -] +["interactive", "getBestTunnel", "getBestRoute", "getPathTo"] diff --git a/packages/demo/demo.pruneLayer.ts b/packages/demo/demo.pruneLayer.ts deleted file mode 100644 index f75faf6..0000000 --- a/packages/demo/demo.pruneLayer.ts +++ /dev/null @@ -1,51 +0,0 @@ -import "./menu"; -import { createCanvas } from "./canvas"; -import { Color, copyGrid } from "@snk/types/grid"; -import { grid, snake } from "./sample"; -import { pruneLayer } from "@snk/compute/pruneLayer"; -import { getSnakeLength } from "@snk/types/snake"; - -const colors = [1, 2, 3] as Color[]; - -const snakeN = getSnakeLength(snake); - -const layers = [{ grid, chunk: [] as { x: number; y: number }[] }]; -let grid0 = copyGrid(grid); -for (const color of colors) { - const chunk = pruneLayer(grid0, color, snakeN); - layers.push({ chunk, grid: copyGrid(grid0) }); -} - -const { canvas, ctx, highlightCell, draw } = createCanvas(grid); -document.body.appendChild(canvas); - -let k = 0; - -const loop = () => { - const { grid, chunk } = layers[k]; - - draw(grid, snake, []); - - ctx.fillStyle = "orange"; - chunk.forEach(({ x, y }) => highlightCell(x, y)); -}; - -loop(); - -const input = document.createElement("input") as any; -input.type = "range"; -input.value = 0; -input.step = 1; -input.min = 0; -input.max = layers.length - 1; -input.style.width = "90%"; -input.addEventListener("input", () => { - k = +input.value; - loop(); -}); -document.body.append(input); - -window.addEventListener("click", (e) => { - if (e.target === document.body || e.target === document.body.parentElement) - input.focus(); -}); diff --git a/packages/demo/webpack.config.ts b/packages/demo/webpack.config.ts index 7a78e64..2d4a8fb 100644 --- a/packages/demo/webpack.config.ts +++ b/packages/demo/webpack.config.ts @@ -40,6 +40,11 @@ const config: Configuration = { chunks: [demo], }) ), + new HtmlWebpackPlugin({ + title: "snk - " + demos[0], + filename: `index.html`, + chunks: [demos[0]], + }), ], devtool: false, diff --git a/packages/types/__fixtures__/createFromAscii.ts b/packages/types/__fixtures__/createFromAscii.ts new file mode 100644 index 0000000..f64d64e --- /dev/null +++ b/packages/types/__fixtures__/createFromAscii.ts @@ -0,0 +1,19 @@ +import { Color, createEmptyGrid, setColor } from "../grid"; + +export const createFromAscii = (ascii: string) => { + const a = ascii.split("\n"); + if (a[0] === "") a.shift(); + const height = a.length; + const width = Math.max(...a.map((r) => r.length)); + + const grid = createEmptyGrid(width, height); + for (let x = width; x--; ) + for (let y = height; y--; ) { + const c = a[y][x]; + const color = + (c === "#" && 3) || (c === "@" && 2) || (c === "." && 1) || +c; + if (c) setColor(grid, x, y, color as Color); + } + + return grid; +}; diff --git a/packages/types/__fixtures__/createFromSeed.ts b/packages/types/__fixtures__/createFromSeed.ts new file mode 100644 index 0000000..bdf74ce --- /dev/null +++ b/packages/types/__fixtures__/createFromSeed.ts @@ -0,0 +1,11 @@ +import ParkMiller from "park-miller"; +import { Color, createEmptyGrid } from "../grid"; +import { randomlyFillGrid } from "../randomlyFillGrid"; + +export const createFromSeed = (seed: number, width = 5, height = 5) => { + const grid = createEmptyGrid(width, height); + const pm = new ParkMiller(seed); + const random = pm.integerInRange.bind(pm); + randomlyFillGrid(grid, { colors: [1, 2] as Color[], emptyP: 2 }, random); + return grid; +}; diff --git a/packages/types/__fixtures__/grid.ts b/packages/types/__fixtures__/grid.ts index e8f8add..f46e8e5 100644 --- a/packages/types/__fixtures__/grid.ts +++ b/packages/types/__fixtures__/grid.ts @@ -119,7 +119,7 @@ setColor(closedU, 2 + 10, 3 + 10, 1 as Color); setColor(closedU, 1 + 10, 3 + 10, 1 as Color); setColor(closedU, 2 + 10, 4 + 10, 1 as Color); -const create = (width: number, height: number, emptyP: number) => { +const createRandom = (width: number, height: number, emptyP: number) => { const grid = createEmptyGrid(width, height); const pm = new ParkMiller(10); const random = pm.integerInRange.bind(pm); @@ -128,10 +128,10 @@ const create = (width: number, height: number, emptyP: number) => { }; // small realistic -export const small = create(10, 7, 3); -export const smallPacked = create(10, 7, 1); -export const smallFull = create(10, 7, 0); +export const small = createRandom(10, 7, 3); +export const smallPacked = createRandom(10, 7, 1); +export const smallFull = createRandom(10, 7, 0); // small realistic -export const realistic = create(52, 7, 3); -export const realisticFull = create(52, 7, 0); +export const realistic = createRandom(52, 7, 3); +export const realisticFull = createRandom(52, 7, 0);