🎉 Setup a hex grid and the turn system

main
Shad Amethyst 11 months ago
commit fce9712698

3
.gitignore vendored

@ -0,0 +1,3 @@
.env
node_modules/
src/env.d.ts

@ -0,0 +1,18 @@
import { fileURLToPath } from "url";
import { defineConfig } from "astro/config";
import solidJs from "@astrojs/solid-js";
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
site: "https://www.shadamethyst.xyz/",
integrations: [solidJs()],
// This seems to still put everything in `workspace:dist/website/dist/`,
// but I can live with the appended `dist` folder
outDir: fileURLToPath(new URL(import.meta.env["OUT_DIR"] ?? "./dist/", import.meta.url)),
output: "server",
adapter: node({
mode: "middleware"
})
});

@ -0,0 +1,23 @@
{
"name": "across-the-hex",
"version": "1.0.0",
"description": "A multiplayer strategy game played on a hex grid, focused on expansion and extermination",
"main": "index.js",
"author": "Amethyst System",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "astro dev",
"lint": "astro check",
"build": "astro build"
},
"dependencies": {
"@astrojs/node": "^5.1.2",
"@astrojs/solid-js": "^2.1.1",
"astro": "^2.3.3",
"boardgame.io": "^0.50.2",
"sass": "^1.62.1",
"solid-js": "^1.4.3",
"@shadryx/pptk": "latest"
}
}

@ -0,0 +1,28 @@
---
export type Props = {
title?: string,
};
---
<html>
<head>
<title>Across the Hex{Astro.props.title ? "- " + Astro.props.title : ""}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body {
margin: 0;
display: flex;
flex-direction: column;
min-height: 100svh;
overflow-x: hidden;
background: #202020;
}
</style>
</head>
<body>
<slot />
</body>
</html>

@ -0,0 +1,271 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { Client } from "boardgame.io/client";
import { Local } from "boardgame.io/multiplayer";
import { AcrossTheHex, Cell, State, adjacentCells, cellIn, getLocalState, iterateCells } from "../game.js";
// import type { State as GIOState } from "boardgame.io";
import { createStore, reconcile } from "solid-js/store";
import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
export type GameProps = {
playerID: string
};
const PLAYER_COLORS = ["#f06040", "#4050f0"];
export default function Game(props: GameProps) {
const client = Client({
game: AcrossTheHex,
multiplayer: Local(),
playerID: props.playerID
});
const [state, setState] = createStore<State | {}>(client.getState() ?? {});
const [stage, setStage] = createSignal<string>();
const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
const [canvas, setCanvas] = createSignal<HTMLCanvasElement>();
const [width, setWidth] = createSignal(0);
const [height, setHeight] = createSignal(0);
const getTransform = createMemo(() => {
if (!("cells" in state)) return {
scale: 1,
sx: 0,
sy: 0,
};
const bounds = getBounds(state.cells);
const scale = Math.min(width() / (bounds.maxX - bounds.minX), height() / (bounds.maxY - bounds.minY));
const sx = width() / 2 - (bounds.maxX - bounds.minX) * scale / 2 - bounds.minX * scale;
const sy = height() / 2 - (bounds.maxY - bounds.minY) * scale / 2 - bounds.minY * scale;
return {
scale,
sx,
sy
};
}, {
scale: 1,
sx: 0,
sy: 0,
},
{
equals(prev, next) {
return prev.scale === next.scale && prev.sx === next.sx && prev.sy === next.sy
}
});
function draw() {
const ctx = canvas()?.getContext("2d");
if (!ctx || !("cells" in state)) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const transform = getTransform();
ctx.setTransform(transform.scale, 0, 0, transform.scale, transform.sx, transform.sy);
for (const [x, y, cell] of iterateCells(cells())) {
drawCell(ctx, cell, x, y);
}
if (hoveredCell()) {
const [x, y] = hoveredCell()!;
drawCellOutline(ctx, x, y);
}
}
function clickTile(x: number, y: number) {
client.moves.placeBuilding?.(x, y, "pawn");
}
client.start();
client.subscribe((state) => {
if (state) {
setState(reconcile(state.G));
setStage(state.ctx.activePlayers![props.playerID]);
}
});
createEffect(() => {
draw();
});
createEffect(() => {
const c = canvas();
if (!c) return;
function getTileBelowMouse(event: MouseEvent): [number, number] | null {
const transform = getTransform();
const bounds = c!.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / window.devicePixelRatio - transform.sx) / transform.scale;
const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale;
const [hexX, hexY] = inverseHexPosition(x, y);
if ("cells" in state && cellIn(state.cells, hexX, hexY)) {
return [hexX, hexY];
} else {
return null;
}
}
function mouseDown(event: MouseEvent) {
const tile = getTileBelowMouse(event);
if (tile) {
clickTile(tile[0], tile[1]);
}
}
function mouseMove(event: MouseEvent) {
const tile = getTileBelowMouse(event);
setHoveredCell(tile);
}
c.addEventListener("mousedown", mouseDown);
c.addEventListener("mousemove", mouseMove);
c.addEventListener("mouseleave", () => setHoveredCell(null));
onCleanup(() => {
c.removeEventListener("mousedown", mouseDown);
c.removeEventListener("mousemove", mouseMove);
});
});
return <div>
<PixelPerfectCanvas
onAttach={setCanvas}
onResize={(_, width, height) => {
setWidth(width);
setHeight(height);
draw();
}}
style={{
width: "400px",
height: "400px",
}}
/>
<button onClick={() => client.moves.setReady?.()}>{stage() === "ready" ? "Waiting for opponent" : "Done"}</button>
</div>;
}
const HEX_BASIS = [
[2 * Math.cos(Math.PI / 6), Math.cos(Math.PI * 5 / 6)],
[0, 1 + Math.sin(Math.PI * 5 / 6)]
] as const;
const INVERSE_HEX_BASIS = inverse2DMatrix(HEX_BASIS);
const HEX_BOUNDS = {
left: 1 + Math.cos(Math.PI * 5 / 6),
top: 0,
bottom: 2,
right: 1 + Math.cos(Math.PI / 6),
};
function getHexPosition(x: number, y: number) {
return [
HEX_BASIS[0][0] * x + HEX_BASIS[0][1] * y,
HEX_BASIS[1][0] * x + HEX_BASIS[1][1] * y,
] as const;
}
function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number, hexY: number] {
// I can't be bothered to properly do this, so this just brute-forces the neighbors to find which is closest,
// using the property that the voronoi cell texture of a 2D close packing is a hex grid
const guessHexX = Math.floor(spatialX * INVERSE_HEX_BASIS[0][0] + spatialY * INVERSE_HEX_BASIS[0][1]);
const guessHexY = Math.floor(spatialX * INVERSE_HEX_BASIS[1][0] + spatialY * INVERSE_HEX_BASIS[1][1]);
const hexCenterX = (HEX_BOUNDS.right + HEX_BOUNDS.left) / 2;
const hexCenterY = (HEX_BOUNDS.top + HEX_BOUNDS.bottom) / 2;
function distance(hexX: number, hexY: number) {
const [x2, y2] = getHexPosition(hexX, hexY);
return (x2 + hexCenterX - spatialX) ** 2 + (y2 + hexCenterY - spatialY) ** 2;
}
let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const;
for (const [neighborX, neighborY] of adjacentCells(guessHexX, guessHexY)) {
const dist = distance(neighborX, neighborY);
if (dist < closest[2]) {
closest = [neighborX, neighborY, dist];
}
}
return [closest[0], closest[1]];
}
function getBounds(grid: Record<string, Cell>) {
let res = {
minX: Infinity,
maxX: -Infinity,
minY: Infinity,
maxY: -Infinity
};
for (const [x, y] of iterateCells(grid)) {
const [x2, y2] = getHexPosition(x, y);
res.minX = Math.min(res.minX, x2 + HEX_BOUNDS.left);
res.maxX = Math.max(res.maxX, x2 + HEX_BOUNDS.right);
res.minY = Math.min(res.minY, y2 + HEX_BOUNDS.top);
res.maxY = Math.max(res.maxY, y2 + HEX_BOUNDS.bottom);
}
return res;
}
function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_offset: number) {
ctx.beginPath();
// Start at top corner, and rotate clockwise
ctx.moveTo(x + 1, y + line_offset);
for (let n = 1; n < 6; n++) {
ctx.lineTo(
x + 1 + Math.cos(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset),
y + 1 + Math.sin(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset),
);
}
ctx.closePath();
}
function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number) {
const [x2, y2] = getHexPosition(x, y);
const LINE_WIDTH = 0.2;
const SPACING = 0.2;
let line_offset = (LINE_WIDTH + SPACING) / 2;
if (cell.owner === null) {
line_offset = LINE_WIDTH / 2;
}
ctx.strokeStyle = "#202020";
ctx.lineWidth = LINE_WIDTH;
drawHexagon(ctx, x2, y2, line_offset);
if (cell.owner === null) {
ctx.fillStyle = "#505050";
ctx.fill();
} else {
ctx.strokeStyle = PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!;
ctx.fillStyle = "#707070";
ctx.fill();
ctx.stroke();
}
}
function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number) {
const [x2, y2] = getHexPosition(x, y);
ctx.lineWidth = 0.05;
ctx.strokeStyle = "white";
drawHexagon(ctx, x2, y2, 0.05);
ctx.stroke();
}
type Matrix2 = readonly [readonly [number, number], readonly [number, number]];
function inverse2DMatrix(matrix: Matrix2): Matrix2 {
const determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];
return [
[matrix[1][1] / determinant, -matrix[0][1] / determinant],
[-matrix[1][0] / determinant, matrix[0][0] / determinant],
];
}

@ -0,0 +1,293 @@
import type { Game } from "boardgame.io";
import { INVALID_MOVE } from "boardgame.io/core";
// TODO: partial information
export type Cell = {
owner: null
} | {
owner: string,
building: keyof typeof Buildings,
};
export type Move = {
type: "placeBuilding",
x: number,
y: number,
building: keyof typeof Buildings,
};
export type State = {
cells: Record<string, Cell>,
resources: Record<string, number>,
moves: Record<string, Move[]>
}
export const Buildings = {
base: {
cost: Infinity,
gain: 3,
},
pawn: {
cost: 1,
}
} as const;
export const AcrossTheHex: Game<State, Record<string, unknown>, {
size?: number,
initialResources?: number
}> = {
setup({ ctx }, { size = 3, initialResources = 1 } = {}) {
const cells = initGrid(size);
const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString());
setCell(cells, 0, 0, {
owner: "0",
building: "base"
});
setCell(cells, size * 2 - 2, size * 2 - 2, {
owner: "1",
building: "base"
});
return {
cells,
resources: initResources(players, initialResources),
moves: emptyMoves(players),
};
},
turn: {
onBegin(ctx) {
// Dispatch all players into the prepare stage
ctx.events.setActivePlayers({
all: "prepare"
});
},
onEnd(ctx) {
if (!ctx.ctx.activePlayers || !areAllPlayersReady(ctx.ctx.activePlayers)) {
throw new Error("Assertion error: not all players were ready when turn ended");
}
applyMoves(ctx.G);
},
endIf(ctx) {
if (!ctx.ctx.activePlayers) return false;
return areAllPlayersReady(ctx.ctx.activePlayers);
},
stages: {
prepare: {
next: "ready",
moves: {
setReady(ctx) {
ctx.events.endStage();
},
placeBuilding,
},
},
ready: {
moves: {}
}
}
}
} as const;
function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) {
// Cannot place a building outside the field
if (!cellIn(G.cells, x, y)) return INVALID_MOVE;
// Must place a building next to an already-established friendly building
if (!hasAdjacentBuilding(G.cells, x, y, playerID)) return INVALID_MOVE;
// Cannot place a building on an existing one
const targetOwner = getCell(G.cells, x, y)?.owner;
if (targetOwner && targetOwner !== playerID) return INVALID_MOVE;
// Cannot place a building without enough resources
if (remainingResources(playerID, G.resources, G.moves) < Buildings[building].cost) return INVALID_MOVE;
// Cannot place two buildings at the same place
if (G.moves[playerID]!.find((move) => {
return move.type === "placeBuilding" && move.x === x && move.y === y;
})) return INVALID_MOVE;
G.moves[playerID]!.push({
type: "placeBuilding",
x,
y,
building,
});
// G.cells[`${y}:${x}`] = {
// owner: playerID
// };
// G.resources[playerID] -= Buildings[building].cost;
return;
}
function remainingResources(playerID: string, resources: Record<string, number>, moves: Record<string, Move[]>): number {
let result = resources[playerID] ?? 0;
for (const move of moves[playerID] ?? []) {
if (move.type === "placeBuilding") {
result -= Buildings[move.building].cost;
}
}
return result;
}
function initGrid(size: number): Record<string, Cell> {
const res: Record<string, Cell> = {};
for (let y = 0; y < size; y++) {
for (let x = 0; x < size + y; x++) {
res[`${y}:${x}`] = emptyCell();
}
}
for (let y = size; y < size * 2 - 1; y++) {
for (let x = y - size + 1; x < size * 2 - 1; x++) {
res[`${y}:${x}`] = emptyCell();
}
}
return res;
}
function emptyCell(): Cell {
return {
owner: null
};
}
function emptyMoves(players: string[]) {
let res: Record<string, Move[]> = {};
for (const player of players) {
res[player] = [];
}
return res;
}
function initResources(players: string[], resources: number = 0): Record<string, number> {
return Object.fromEntries(players.map((id) => [id, resources] as const));
}
export function cellIn(grid: Record<string, Cell>, x: number, y: number): boolean {
if (x < 0 || y < 0 || !Number.isInteger(x) || !Number.isInteger(y)) return false;
return `${y}:${x}` in grid;
}
function getCell(grid: Record<string, Cell>, x: number, y: number): Cell | null {
if (!cellIn(grid, x, y)) return null;
return grid[`${y}:${x}`]!;
}
function setCell(grid: Record<string, Cell>, x: number, y: number, cell: Cell) {
if (!cellIn(grid, x, y)) return;
grid[`${y}:${x}`] = cell;
}
function hasAdjacentBuilding(grid: Record<string, Cell>, x: number, y: number, playerID: string): boolean {
for (const [x2, y2] of adjacentCells(x, y)) {
const cell = getCell(grid, x2, y2);
if (cell?.owner === playerID) return true;
}
return false;
}
function areAllPlayersReady(activePlayers: Record<string, string>): boolean {
for (const player in activePlayers) {
if (activePlayers[player] !== "ready") return false;
}
return true;
}
export function* adjacentCells(x: number, y: number): Iterable<[x: number, y: number]> {
yield [x - 1, y - 1];
yield [x, y - 1];
yield [x - 1, y];
yield [x + 1, y];
yield [x, y + 1];
yield [x + 1, y + 1];
}
export function* iterateCells(grid: Record<string, Cell>) {
const regexp = /^(\d+):(\d+)$/;
for (let key in grid) {
const match = regexp.exec(key);
if (!match) continue;
yield [+match[2]!, +match[1]!, grid[key]!] as const;
}
}
function applyMoves(state: State) {
const players = Object.keys(state.moves);
const moves = state.moves;
state.moves = emptyMoves(players);
const extractedResources = initResources(players, 0);
// Resource extracting step
for (const [_x, _y, cell] of iterateCells(state.cells)) {
if (cell.owner !== null && cell.owner in state.resources) {
const building = Buildings[cell.building];
if ("gain" in building) {
extractedResources[cell.owner] += building.gain;
}
}
}
// Building placement step
const placeBuildingMoves = Object.entries(moves).flatMap(([player, moves]) => moves.map((move) => [player, move] as const));
for (const [x, y, _cell] of iterateCells(state.cells)) {
const orders = placeBuildingMoves.filter(([, move]) => move.x === x && move.y === y);
if (orders.length === 0) continue;
// If two players try to build on the same tile, then nothing happens
if (orders.length > 1) {
continue;
}
const order = orders[0]!;
const cost = Buildings[order[1].building].cost;
if (state.resources[order[0]]! < cost) continue;
// TODO: fully check that the building can be placed
setCell(state.cells, x, y, {
owner: order[0],
building: order[1].building
});
state.resources[order[0]] -= cost;
}
// Resource gathering step
for (const player of players) {
state.resources[player] += extractedResources[player]!;
}
}
export function getLocalState(state: State, playerID: string): State["cells"] {
const res: State["cells"] = {
...state.cells
};
for (const move of state.moves[playerID] ?? []) {
if (move.type === "placeBuilding") {
res[`${move.y}:${move.x}`] = {
owner: playerID,
building: move.building
};
}
}
return res;
}

@ -0,0 +1,11 @@
---
import Document from "../components/Document.astro";
import Game from "../components/Game.tsx";
export const prerender = true;
---
<Document>
<Game playerID="0" client:only />
<Game playerID="1" client:only />
</Document>

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strictest",
"compilerOptions": {
"strictNullChecks": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"moduleResolution": "NodeNext"
}
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save