Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1898ec16e4 | ||
|
|
fd9d7dadf6 | ||
|
|
73bfce908e | ||
|
|
dd23c1630e | ||
|
|
7377068a9a |
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# snk
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
Generates a snake game from a github user contributions grid and output a screen capture as gif
|
||||
|
||||
- [demo](https://platane.github.io/snk/index.html)
|
||||
|
||||
- [github action](https://github.com/marketplace/actions/generate-snake-game-from-github-contribution-grid)
|
||||
@@ -37,9 +37,12 @@ export const generateContributionSnake = async (userName: string) => {
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
const gameOptions = {
|
||||
maxSnakeLength: 5,
|
||||
colors: Array.from({ length: colorScheme.length - 1 }, (_, i) => i + 1),
|
||||
};
|
||||
|
||||
const gifOptions = { delay: 10 };
|
||||
const gifOptions = { delay: 3 };
|
||||
|
||||
const commands = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Grid, Color } from "./grid";
|
||||
const rand = (a: number, b: number) => Math.floor(Math.random() * (b - a)) + a;
|
||||
|
||||
export const generateEmptyGrid = (width: number, height: number) =>
|
||||
generateGrid(width, height, { colors: [], emptyP: 1 });
|
||||
generateRandomGrid(width, height, { colors: [], emptyP: 1 });
|
||||
|
||||
export const generateGrid = (
|
||||
export const generateRandomGrid = (
|
||||
width: number,
|
||||
height: number,
|
||||
options: { colors: Color[]; emptyP: number } = {
|
||||
|
||||
@@ -1,44 +1,174 @@
|
||||
import { Grid, Color, copyGrid, isInsideLarge } from "./grid";
|
||||
import { Grid, Color, copyGrid, isInsideLarge, getColor } from "./grid";
|
||||
import { Point, around4 } from "./point";
|
||||
import { stepSnake, step } from "./step";
|
||||
import { copySnake, snakeSelfCollide } from "./snake";
|
||||
import { step } from "./step";
|
||||
import { copySnake, snakeSelfCollide, Snake } from "./snake";
|
||||
|
||||
const isGridEmpty = (grid: Grid) => grid.data.every((x) => x === null);
|
||||
|
||||
export const computeBestRun = (
|
||||
grid: Grid,
|
||||
snake: Point[],
|
||||
options: { maxSnakeLength: number }
|
||||
const createComputeHeuristic = (
|
||||
grid0: Grid,
|
||||
_snake0: Snake,
|
||||
colors: Color[]
|
||||
) => {
|
||||
const g = copyGrid(grid);
|
||||
const s = copySnake(snake);
|
||||
const q: Color[] = [];
|
||||
|
||||
const commands: Point[] = [];
|
||||
|
||||
let u = 500;
|
||||
|
||||
while (!isGridEmpty(g) && u-- > 0) {
|
||||
let direction;
|
||||
|
||||
for (let k = 10; k--; ) {
|
||||
direction = around4[Math.floor(Math.random() * around4.length)];
|
||||
|
||||
const sn = copySnake(s);
|
||||
stepSnake(sn, direction, options);
|
||||
|
||||
if (isInsideLarge(g, 1, sn[0].x, sn[0].y) && !snakeSelfCollide(sn)) {
|
||||
break;
|
||||
} else {
|
||||
direction = undefined;
|
||||
}
|
||||
const colorCount: Record<Color, number> = {};
|
||||
for (let x = grid0.width; x--; )
|
||||
for (let y = grid0.height; y--; ) {
|
||||
const c = getColor(grid0, x, y);
|
||||
if (c !== null) colorCount[c] = 1 + (colorCount[c] || 0);
|
||||
}
|
||||
|
||||
if (direction !== undefined) {
|
||||
step(g, s, q, direction, options);
|
||||
commands.push(direction);
|
||||
const values = colors
|
||||
.map((k) => Array.from({ length: colorCount[k] }, () => k))
|
||||
.flat();
|
||||
const weights = colors
|
||||
.map((k) =>
|
||||
Array.from({ length: colorCount[k] }).map(
|
||||
(_, i, arr) => i / (arr.length - 1)
|
||||
)
|
||||
)
|
||||
.flat();
|
||||
|
||||
return (_grid: Grid, _snake: Snake, stack: Color[]) => {
|
||||
let score = 0;
|
||||
|
||||
for (let i = 0; i < stack.length; i++) {
|
||||
const u = stack[i] - values[i];
|
||||
|
||||
if (u !== 0) debugger;
|
||||
|
||||
if (u > 0) score -= 100 * u * (1 + 1 - weights[i]);
|
||||
else if (u < 0) score -= 100 * -u * (1 + weights[i]);
|
||||
else score += 100;
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
};
|
||||
|
||||
const computeKey = (grid: Grid, snake: Snake, stack: Color[]) =>
|
||||
grid.data.map((x) => x || 0).join("") +
|
||||
"|" +
|
||||
snake.map((p) => p.x + "." + p.y).join(",") +
|
||||
"|" +
|
||||
stack.join("");
|
||||
|
||||
const createCell = (
|
||||
key: string,
|
||||
grid: Grid,
|
||||
snake: Snake,
|
||||
stack: Color[],
|
||||
parent: any | null,
|
||||
heuristic: number
|
||||
) => ({
|
||||
key,
|
||||
parent,
|
||||
grid,
|
||||
snake,
|
||||
stack,
|
||||
weight: 1 + (parent?.weight || 0),
|
||||
f: heuristic - 0 * (1 + (parent?.weight || 0)),
|
||||
});
|
||||
|
||||
const unwrap = (c: ReturnType<typeof createCell> | null): Point[] =>
|
||||
c && c.parent
|
||||
? [
|
||||
...unwrap(c.parent),
|
||||
{ x: c.snake[0].x - c.snake[1].x, y: c.snake[0].y - c.snake[1].y },
|
||||
]
|
||||
: [];
|
||||
|
||||
export const computeBestRun = (
|
||||
grid0: Grid,
|
||||
snake0: Snake,
|
||||
options: { maxSnakeLength: number; colors: Color[] }
|
||||
) => {
|
||||
// const grid = copyGrid(grid0);
|
||||
// const snake = copySnake(snake0);
|
||||
// const stack: Color[] = [];
|
||||
|
||||
const computeHeuristic = createComputeHeuristic(
|
||||
grid0,
|
||||
snake0,
|
||||
options.colors
|
||||
);
|
||||
|
||||
const closeList: any = {};
|
||||
const openList = [
|
||||
createCell(
|
||||
computeKey(grid0, snake0, []),
|
||||
grid0,
|
||||
snake0,
|
||||
[],
|
||||
null,
|
||||
computeHeuristic(grid0, snake0, [])
|
||||
),
|
||||
];
|
||||
|
||||
let u = 8000;
|
||||
|
||||
let best = openList[0];
|
||||
|
||||
while (openList.length && u-- > 0) {
|
||||
openList.sort((a, b) => b.f - a.f);
|
||||
const c = openList.shift()!;
|
||||
|
||||
closeList[c.key] = true;
|
||||
|
||||
if (isGridEmpty(c.grid)) return unwrap(c);
|
||||
|
||||
if (c.f > best.f) best = c;
|
||||
|
||||
for (const direction of around4) {
|
||||
const snake = copySnake(c.snake);
|
||||
const stack = c.stack.slice();
|
||||
const grid = copyGrid(c.grid);
|
||||
|
||||
step(grid, snake, stack, direction, options);
|
||||
|
||||
const key = computeKey(grid, snake, stack);
|
||||
|
||||
if (
|
||||
!closeList[key] &&
|
||||
isInsideLarge(grid, 1, snake[0].x, snake[0].y) &&
|
||||
!snakeSelfCollide(snake)
|
||||
) {
|
||||
openList.push(
|
||||
createCell(
|
||||
key,
|
||||
grid,
|
||||
snake,
|
||||
stack,
|
||||
c,
|
||||
computeHeuristic(grid, snake, stack)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commands;
|
||||
return unwrap(best);
|
||||
|
||||
// while (!isGridEmpty(g) && u-- > 0) {
|
||||
// let direction;
|
||||
|
||||
// for (let k = 10; k--; ) {
|
||||
// direction = around4[Math.floor(Math.random() * around4.length)];
|
||||
|
||||
// const sn = copySnake(s);
|
||||
// stepSnake(sn, direction, options);
|
||||
|
||||
// if (isInsideLarge(g, 1, sn[0].x, sn[0].y) && !snakeSelfCollide(sn)) {
|
||||
// break;
|
||||
// } else {
|
||||
// direction = undefined;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (direction !== undefined) {
|
||||
// step(g, s, q, direction, options);
|
||||
// commands.push(direction);
|
||||
// }
|
||||
// }
|
||||
|
||||
// return commands;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Point } from "./point";
|
||||
|
||||
export type Snake = Point[];
|
||||
|
||||
export const snakeSelfCollideNext = (
|
||||
snake: Point[],
|
||||
snake: Snake,
|
||||
direction: Point,
|
||||
options: { maxSnakeLength: number }
|
||||
) => {
|
||||
@@ -14,11 +16,11 @@ export const snakeSelfCollideNext = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const snakeSelfCollide = (snake: Point[]) => {
|
||||
export const snakeSelfCollide = (snake: Snake) => {
|
||||
for (let i = 1; i < snake.length; i++)
|
||||
if (snake[i].x === snake[0].x && snake[i].y === snake[0].y) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
export const copySnake = (x: Snake) => x.map((p) => ({ ...p }));
|
||||
|
||||
@@ -1,81 +1,91 @@
|
||||
// import { generateGrid } from "@snk/compute/generateGrid";
|
||||
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { generateRandomGrid } from "@snk/compute/generateGrid";
|
||||
import { Color, copyGrid } from "@snk/compute/grid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
import { step } from "@snk/compute/step";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { Point } from "@snk/compute/point";
|
||||
import { copySnake } from "@snk/compute/snake";
|
||||
|
||||
const copySnake = (x: Point[]) => x.map((p) => ({ ...p }));
|
||||
|
||||
export const run = async () => {
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
|
||||
const grid0 = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const stack0: Color[] = [];
|
||||
|
||||
const chain = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = drawOptions.sizeCell * (grid0.width + 4);
|
||||
canvas.height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const update = (n: number) => {
|
||||
const snake = copySnake(snake0);
|
||||
const stack = stack0.slice();
|
||||
const grid = copyGrid(grid0);
|
||||
|
||||
for (let i = 0; i < n; i++) step(grid, snake, stack, chain[i], gameOptions);
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
const input: any = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.style.width = "100%";
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.step = 1;
|
||||
input.value = 0;
|
||||
input.addEventListener("input", () => update(+input.value));
|
||||
document.addEventListener("click", () => input.focus());
|
||||
|
||||
document.body.appendChild(input);
|
||||
|
||||
update(+input.value);
|
||||
|
||||
// while (chain.length) {
|
||||
// await wait(100);
|
||||
|
||||
// step(grid, snake, stack, chain.shift()!, gameOptions);
|
||||
|
||||
// ctx.clearRect(0, 0, 9999, 9999);
|
||||
// drawWorld(ctx, grid, snake, stack, options);
|
||||
// }
|
||||
|
||||
// const wait = (delay = 0) => new Promise((r) => setTimeout(r, delay));
|
||||
const drawOptions = {
|
||||
sizeBorderRadius: 2,
|
||||
sizeCell: 16,
|
||||
sizeDot: 12,
|
||||
colorBorder: "#1b1f230a",
|
||||
colorDots: { 1: "#9be9a8", 2: "#40c463", 3: "#30a14e", 4: "#216e39" },
|
||||
colorEmpty: "#ebedf0",
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
run();
|
||||
const gameOptions = { colors: [1, 2, 3], maxSnakeLength: 5 };
|
||||
|
||||
const grid0 = generateRandomGrid(18, 7, { ...gameOptions, emptyP: 2 });
|
||||
|
||||
const snake0 = [
|
||||
{ x: 4, y: -1 },
|
||||
{ x: 3, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
{ x: 0, y: -1 },
|
||||
];
|
||||
const stack0: Color[] = [];
|
||||
|
||||
const chain = computeBestRun(grid0, snake0, gameOptions);
|
||||
|
||||
//
|
||||
// draw
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = drawOptions.sizeCell * (grid0.width + 4);
|
||||
canvas.height = drawOptions.sizeCell * (grid0.height + 4) + 100;
|
||||
document.body.appendChild(canvas);
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
const update = (n: number) => {
|
||||
const snake = copySnake(snake0);
|
||||
const stack = stack0.slice();
|
||||
const grid = copyGrid(grid0);
|
||||
|
||||
for (let i = 0; i < n; i++) step(grid, snake, stack, chain[i], gameOptions);
|
||||
|
||||
ctx.clearRect(0, 0, 9999, 9999);
|
||||
drawWorld(ctx, grid, snake, stack, drawOptions);
|
||||
};
|
||||
|
||||
//
|
||||
// controls
|
||||
|
||||
const input: any = document.createElement("input");
|
||||
input.type = "range";
|
||||
input.style.width = "100%";
|
||||
input.min = 0;
|
||||
input.max = chain.length;
|
||||
input.step = 1;
|
||||
input.value = 0;
|
||||
input.addEventListener("input", () => {
|
||||
setAutoPlay(false);
|
||||
update(+input.value);
|
||||
});
|
||||
document.addEventListener("click", () => input.focus());
|
||||
|
||||
document.body.appendChild(input);
|
||||
|
||||
const autoplayButton = document.createElement("button");
|
||||
let cancel: any;
|
||||
const loop = () => {
|
||||
input.value = (+input.value + 1) % +input.max;
|
||||
update(+input.value);
|
||||
cancelAnimationFrame(cancel);
|
||||
cancel = requestAnimationFrame(loop);
|
||||
};
|
||||
const setAutoPlay = (a: boolean) => {
|
||||
autoplayButton.innerHTML = a ? "pause" : "play";
|
||||
if (a) loop();
|
||||
else cancelAnimationFrame(cancel);
|
||||
};
|
||||
autoplayButton.addEventListener("click", () => {
|
||||
debugger;
|
||||
setAutoPlay(autoplayButton.innerHTML === "play");
|
||||
});
|
||||
document.body.appendChild(autoplayButton);
|
||||
|
||||
setAutoPlay(true);
|
||||
update(+input.value);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "@snk/demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@snk/compute": "1.0.0"
|
||||
"@snk/compute": "1.0.0",
|
||||
"@snk/draw": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "4.43.0",
|
||||
|
||||
@@ -27,7 +27,6 @@ const config: Configuration = {
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
// game
|
||||
new HtmlWebpackPlugin({
|
||||
title: "demo",
|
||||
filename: "index.html",
|
||||
@@ -39,9 +38,6 @@ const config: Configuration = {
|
||||
|
||||
devtool: false,
|
||||
stats: "errors-only",
|
||||
|
||||
// @ts-ignore
|
||||
devServer: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -45,7 +45,7 @@ export const drawWorld = (
|
||||
) => {
|
||||
ctx.save();
|
||||
|
||||
ctx.translate(2 * o.sizeCell, 2 * o.sizeCell);
|
||||
ctx.translate(1 * o.sizeCell, 2 * o.sizeCell);
|
||||
drawGrid(ctx, grid, o);
|
||||
drawSnake(ctx, snake, o);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createGif } from "..";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { generateRandomGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
|
||||
const drawOptions = {
|
||||
@@ -12,12 +12,12 @@ const drawOptions = {
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
const gameOptions = { maxSnakeLength: 5, colors: [1, 2, 3, 4] };
|
||||
|
||||
const gifOptions = { delay: 200 };
|
||||
|
||||
it("should generate gif", async () => {
|
||||
const grid = generateGrid(14, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
const grid = generateRandomGrid(14, 7, { ...gameOptions, emptyP: 3 });
|
||||
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createGif } from "..";
|
||||
import { generateGrid } from "@snk/compute/generateGrid";
|
||||
import { generateRandomGrid } from "@snk/compute/generateGrid";
|
||||
import { computeBestRun } from "@snk/compute";
|
||||
|
||||
const drawOptions = {
|
||||
@@ -12,11 +12,11 @@ const drawOptions = {
|
||||
colorSnake: "purple",
|
||||
};
|
||||
|
||||
const gameOptions = { maxSnakeLength: 5 };
|
||||
const gameOptions = { maxSnakeLength: 5, colors: [1, 2, 3, 4] };
|
||||
|
||||
const gifOptions = { delay: 20 };
|
||||
|
||||
const grid = generateGrid(42, 7, { colors: [1, 2, 3, 4], emptyP: 3 });
|
||||
const grid = generateRandomGrid(42, 7, { ...gameOptions, emptyP: 3 });
|
||||
|
||||
const snake = [
|
||||
{ x: 4, y: -1 },
|
||||
|
||||
@@ -7,7 +7,6 @@ import { copySnake } from "@snk/compute/snake";
|
||||
import { drawWorld } from "@snk/draw/drawWorld";
|
||||
import { step } from "@snk/compute/step";
|
||||
import * as tmp from "tmp";
|
||||
// @ts-ignore
|
||||
import * as execa from "execa";
|
||||
|
||||
export const createGif = async (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getGithubUserContribution } from "..";
|
||||
it("should get user contribution", async () => {
|
||||
const { cells, colorScheme } = await getGithubUserContribution("platane");
|
||||
|
||||
expect(cells).toBeDefined();
|
||||
expect(cells.length).toBeGreaterThan(300);
|
||||
expect(colorScheme).toEqual([
|
||||
"#ebedf0",
|
||||
"#9be9a8",
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
// import * as https from "https";
|
||||
|
||||
// @ts-ignore
|
||||
// import * as cheerio from "cheerio";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
/**
|
||||
* get the contribution grid from a github user page
|
||||
*
|
||||
* @param userName
|
||||
*/
|
||||
export const getGithubUserContribution = async (userName: string) => {
|
||||
// const content: string = await new Promise((resolve, reject) => {
|
||||
// const req = https.request(`https://github.com/${userName}`, (res) => {
|
||||
// let data = "";
|
||||
|
||||
// res.on("error", reject);
|
||||
// res.on("data", (chunk) => (data += chunk));
|
||||
// res.on("end", () => resolve(data));
|
||||
// });
|
||||
|
||||
// req.on("error", reject);
|
||||
// req.end();
|
||||
// });
|
||||
|
||||
// const dom = new JSDOM(content);
|
||||
|
||||
const dom = await JSDOM.fromURL(`https://github.com/${userName}`);
|
||||
|
||||
const colorScheme = Array.from(
|
||||
@@ -34,14 +19,14 @@ export const getGithubUserContribution = async (userName: string) => {
|
||||
dom.window.document.querySelectorAll(".js-calendar-graph-svg > g > g")
|
||||
)
|
||||
.map((column, x) =>
|
||||
Array.from(column.querySelectorAll("rect")).map((element, y) => ({
|
||||
x,
|
||||
y,
|
||||
count: element.getAttribute("data-count"),
|
||||
date: element.getAttribute("data-date"),
|
||||
color: element.getAttribute("fill"),
|
||||
k: colorScheme.indexOf(element.getAttribute("fill")!),
|
||||
}))
|
||||
Array.from(column.querySelectorAll("rect")).map((element, y) => {
|
||||
const count = +element.getAttribute("data-count")!;
|
||||
const date = element.getAttribute("data-date")!;
|
||||
const color = element.getAttribute("fill")!;
|
||||
const k = colorScheme.indexOf(color);
|
||||
|
||||
return { x, y, count, date, color, k };
|
||||
})
|
||||
)
|
||||
.flat();
|
||||
|
||||
@@ -53,9 +38,3 @@ type ThenArg<T> = T extends PromiseLike<infer U> ? U : T;
|
||||
export type Cell = ThenArg<
|
||||
ReturnType<typeof getGithubUserContribution>
|
||||
>["cells"][number];
|
||||
|
||||
// "#ebedf0";
|
||||
// "#9be9a8";
|
||||
// "#40c463";
|
||||
// "#30a14e";
|
||||
// "#216e39";
|
||||
|
||||
Reference in New Issue
Block a user