Compare commits

...

5 Commits

Author SHA1 Message Date
platane
fd9d7dadf6 🚀 smarter snake 2020-07-21 00:34:22 +02:00
platane
73bfce908e 🔨 fix demo 2020-07-20 23:02:23 +02:00
platane
dd23c1630e 🚿 clean up 2020-07-20 22:37:58 +02:00
platane
7377068a9a 📓 add readme 2020-07-20 10:23:32 +02:00
platane
8a06b668cd 🚀 fix action 2020-07-20 10:18:24 +02:00
23 changed files with 344 additions and 190 deletions

View File

@@ -6,22 +6,22 @@ on:
- master
jobs:
test:
deploy-demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
- uses: actions/setup-node@v1.4.2
with:
node-version: 14
- uses: bahmutov/npm-install@v1
- uses: bahmutov/npm-install@v1.4.1
- run: yarn build:demo
env:
BASE_PATHNAME: "snk"
- uses: crazy-max/ghaction-github-pages@068e494
- uses: crazy-max/ghaction-github-pages@v2.1.1
with:
target_branch: gh-pages
build_dir: packages/demo/dist

View File

@@ -9,12 +9,38 @@ jobs:
steps:
- run: sudo apt-get install gifsicle graphicsmagick
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
- uses: actions/setup-node@v1.4.2
with:
node-version: 14
- uses: bahmutov/npm-install@v1
- uses: bahmutov/npm-install@v1.4.1
- run: yarn type
- run: yarn lint
- run: yarn test --ci
- run: yarn build:action
test-action:
runs-on: ubuntu-latest
steps:
- run: mkdir dist
- name: generate-snake-game-from-github-contribution-grid
id: snake-gif
uses: Platane/snk@master
with:
github_user_name: platane
gif_out_path: dist/github-contribution-grid-snake.gif
- name: ensure the generated file exists
run: |
ls -l ${{ steps.snake-gif.outputs.gif_out_path }}
test -f ${{ steps.snake-gif.outputs.gif_out_path }}
- uses: crazy-max/ghaction-github-pages@v2.1.1
with:
target_branch: output
build_dir: dist
env:
GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN_GH_PAGES }}

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
npm-debug.log*
yarn-error.log*
dist
build
build
out.gif

View File

@@ -4,7 +4,16 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends gifsicle graphicsmagick \
&& rm -rf /var/lib/apt/lists/*
COPY packages/action/dist/* ./github-contribution-grid-snake
COPY tsconfig.json package.json yarn.lock /github/snk/
COPY packages /github/snk/packages
CMD ["node", "github-contribution-grid-snake/index.js"]
RUN ( \
cd /github/snk \
&& find . \
&& yarn install --frozen-lockfile \
&& yarn build:action \
&& mv packages/action/dist/* . \
&& rm -rf packages tsconfig.json package.json yarn.lock node_modules \
)
CMD ["node", "/github/snk/index.js"]

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# snk
![type definitions](https://img.shields.io/npm/types/typescript?style=flat-square)
![code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)
![](https://raw.githubusercontent.com/Platane/snk/output/github-contribution-grid-snake.gif)
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)

View File

@@ -1,23 +1,20 @@
name: "github-contribution-grid-snake"
description: ""
name: "generate-snake-game-from-github-contribution-grid"
description: "Generates a snake game from a github user contributions grid and output a screen capture as gif"
author: "platane"
outputs:
gif_out_path:
description: ""
description: "path of the generated gif"
runs:
using: "docker"
image: "Dockerfile"
args:
- ${{ inputs.github_user_name }}
- ${{ inputs.gif_out_path }}
inputs:
github_user_name:
description: ""
description: "github user name"
required: true
gif_out_path:
description: ""
description: "path of the generated gif"
required: false
default: "./github-contribution-grid-snake.gif"

View File

@@ -1,5 +1,6 @@
{
"name": "snk",
"description": "Generates a snake game from a github user contributions grid and output a screen capture as gif",
"version": "1.0.0",
"private": true,
"repository": "github:platane/snk",

View File

@@ -1,3 +0,0 @@
!dist
!dist/build
out.gif

View File

@@ -37,7 +37,10 @@ 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 };

View File

@@ -4,15 +4,14 @@ import { generateContributionSnake } from "./generateContributionSnake";
(async () => {
try {
console.log(core.getInput("user_name"));
console.log(core.getInput("gif_out_path"));
console.log("--");
console.log(process.cwd());
console.log("--");
console.log(fs.readdirSync(process.cwd()));
const userName = core.getInput("github_user_name");
const gifOutPath = core.getInput("gif_out_path");
const buffer = await generateContributionSnake(core.getInput("user_name"));
fs.writeFileSync(core.getInput("gif_out_path"), buffer);
const buffer = await generateContributionSnake(userName);
fs.writeFileSync(gifOutPath, buffer);
console.log(`::set-output name=gif_out_path::${gifOutPath}`);
} catch (e) {
core.setFailed(`Action failed with "${e.message}"`);
}

View File

@@ -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 } = {

View File

@@ -1,44 +1,167 @@
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();
return (_grid: Grid, _snake: Snake, stack: Color[]) => {
let score = 0;
for (let i = 0; i < stack.length; i++) {
const k = Math.abs(stack[i] - values[i]);
score += k === 0 ? 100 : -100 * k;
}
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[],
direction: Point | null,
parent: any | null,
heuristic: number
) => ({
key,
parent,
direction,
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.direction ? [...unwrap(c.parent), c.direction] : [];
// c && c.parent
// ? [
// ...unwrap(c.parent),
// { x: c.snake[1].x - c.snake[0].x, y: c.snake[1].y - c.snake[0].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,
null,
computeHeuristic(grid0, snake0, [])
),
];
let u = 7000;
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,
direction,
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;
};

View File

@@ -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 }));

View File

@@ -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, 4], maxSnakeLength: 5 };
const grid0 = generateRandomGrid(42, 7, { ...gameOptions, 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);
//
// 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);

View File

@@ -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",

View File

@@ -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;

View File

@@ -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);

View File

@@ -1 +0,0 @@
out.gif

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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 (

View File

@@ -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",

View File

@@ -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";