diff --git a/package.json b/package.json
index d5e687e..20a16cf 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"astro": "^2.3.3",
"boardgame.io": "^0.50.2",
"sass": "^1.62.1",
- "solid-js": "^1.4.3",
+ "solid-js": "^1.7.8",
"@shadryx/pptk": "latest"
}
}
diff --git a/public/tile-laser.svg b/public/tile-laser.svg
new file mode 100644
index 0000000..e8bc12d
--- /dev/null
+++ b/public/tile-laser.svg
@@ -0,0 +1,167 @@
+
+
+
+
diff --git a/server.ts b/server.ts
new file mode 100644
index 0000000..f9ac4d5
--- /dev/null
+++ b/server.ts
@@ -0,0 +1,9 @@
+import { Server, Origins } from "boardgame.io/server";
+import { AcrossTheHex } from "./src/game.js";
+
+const server = Server({
+ games: [AcrossTheHex],
+ origins: [Origins.LOCALHOST]
+});
+
+server.run(3100);
diff --git a/src/clone.ts b/src/clone.ts
new file mode 100644
index 0000000..c2cedd1
--- /dev/null
+++ b/src/clone.ts
@@ -0,0 +1,16 @@
+function isPlainObject(value: unknown): value is Record {
+ if (!value) return false;
+ return [undefined, Object as any].includes(value.constructor);
+}
+
+export default function clone(value: T): T {
+ if (typeof value !== "object" || value === null) return value;
+ else if (Array.isArray(value)) {
+ return value.map(clone) as T;
+ } else if (isPlainObject(value)) {
+ return Object.fromEntries(Object.entries(value).map(([key, value]) => [key, clone(value)])) as T;
+ } else {
+ console.warn(`Cannot clone ${value}!`);
+ return value;
+ }
+}
diff --git a/src/components/Game.tsx b/src/components/Game.tsx
index 4e6000f..7302e17 100644
--- a/src/components/Game.tsx
+++ b/src/components/Game.tsx
@@ -1,33 +1,56 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
import { Client } from "boardgame.io/client";
-import { Local } from "boardgame.io/multiplayer";
-import { AcrossTheHex, Buildings, Cell, Moves, State, adjacentCells, canAttack, canAttackFrom, canPlaceBuilding, cellIn, getCell, getLocalState, getResourceGain, iterateCells, remainingResources } from "../game.js";
-// import type { State as GIOState } from "boardgame.io";
+import { Local, SocketIO } from "boardgame.io/multiplayer";
+import * as game from "../game.js";
import { createStore, reconcile } from "solid-js/store";
import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
import Toolbar, { Tool } from "./Toolbar.jsx";
-import { GHOST_COLORS, PLAYER_COLORS } from "../consts.js";
+import { GHOST_COLORS, PLAYER_COLORS, SOCKETIO_SERVER } from "../consts.js";
import { TileImages, loadTileImages } from "./tiles.js";
+import clone from "../clone.js";
-export type GameProps = {
+export type GameProps = ({
playerID: string,
- solo?: boolean,
+ solo: true,
+} | {
+ playerID: string,
+ solo?: false,
+ matchID: string,
+} | {
+ playerID?: undefined,
+ solo?: false,
+ matchID: string,
+}) & {
+ debug?: boolean
};
export default function Game(props: GameProps) {
const client = Client({
- game: AcrossTheHex,
- multiplayer: Local(),
- playerID: props.playerID
+ game: game.AcrossTheHex,
+ multiplayer: props.solo ? Local() : SocketIO({
+ server: SOCKETIO_SERVER
+ }),
+ debug: props.debug ?? false,
+ ...("matchID" in props ? { matchID: props.matchID } : {}),
+ ...(props.playerID ? { playerID: props.playerID } : {})
});
- const [state, setState] = createStore(client.getState()?.G ?? {
+ const [state, setState] = createStore(clone(client.getState()?.G) ?? {
resources: {},
cells: {},
moves: {}
});
const [soloTurn, setSoloTurn] = createSignal("");
const [stage, setStage] = createSignal();
- const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
+
+ const cells = createMemo(() => {
+ if ("cells" in state && props.playerID) {
+ return game.getLocalState(state, props.playerID);
+ } else if ("cells" in state) {
+ return state.cells;
+ } else {
+ return {};
+ }
+ });
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
const [selectedTool, setSelectedTool] = createSignal({
type: "placeBuilding",
@@ -42,18 +65,18 @@ export default function Game(props: GameProps) {
const targetTiles = createMemo(() => {
const tool = selectedTool();
- if (tool.type !== "attack" || !tool.selected) return [];
+ if (tool.type !== "attack" || !tool.selected || !props.playerID) return [];
- const building = getCell(state.cells, tool.selected.x, tool.selected.y);
+ const building = game.getCell(state.cells, tool.selected.x, tool.selected.y);
if (building?.owner !== props.playerID) return [];
- const buildingRules = Buildings[building.building];
+ const buildingRules = game.Buildings[building.building];
if (!("attack" in buildingRules)) return [];
return buildingRules.attack.targets(state.cells, tool.selected.x, tool.selected.y)
- .filter(([x, y]) => cellIn(state.cells, x, y));
+ .filter(([x, y]) => game.cellIn(state.cells, x, y));
});
- const moves = () => client.moves as Moves;
+ const moves = () => client.moves as game.Moves;
const getTransform = createMemo(() => {
if (!("cells" in state)) return {
@@ -89,13 +112,15 @@ export default function Game(props: GameProps) {
const images = tileImages();
if (!ctx || !("cells" in state) || !images) return;
+ ctx.imageSmoothingQuality = "high";
+
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())) {
+ for (const [x, y, cell] of game.iterateCells(cells())) {
drawCell(ctx, cell, x, y, images);
}
@@ -104,25 +129,27 @@ export default function Game(props: GameProps) {
const tool = selectedTool();
let valid = true;
- if (tool.type === "placeBuilding") {
- valid = canPlaceBuilding(
- state.cells,
- remainingResources(state.resources, state.moves, props.playerID),
- props.playerID,
- x,
- y,
- tool.building
- );
- } else if (tool.type === "attack" && tool.selected) {
- valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y);
- } else if (tool.type === "attack" && !tool.selected) {
- valid = canAttackFrom(
- state.cells,
- remainingResources(state.resources, state.moves, props.playerID),
- props.playerID,
- x,
- y
- );
+ if (props.playerID) {
+ if (tool.type === "placeBuilding") {
+ valid = game.canPlaceBuilding(
+ state.cells,
+ game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
+ props.playerID,
+ x,
+ y,
+ tool.building
+ );
+ } else if (tool.type === "attack" && tool.selected) {
+ valid = !!targetTiles().find(([x2, y2]) => x2 === x && y2 === y);
+ } else if (tool.type === "attack" && !tool.selected) {
+ valid = game.canAttackFrom(
+ state.cells,
+ game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
+ props.playerID,
+ x,
+ y
+ );
+ }
}
drawCellOutline(ctx, x, y, valid);
}
@@ -133,15 +160,17 @@ export default function Game(props: GameProps) {
}
function clickTile(x: number, y: number) {
+ if (!props.playerID) return;
+
const tool = selectedTool();
if (tool.type === "placeBuilding") {
moves().placeBuilding?.(x, y, tool.building);
} else if (tool.type === "attack") {
if (tool.selected) {
- if (canAttack(
+ if (game.canAttack(
state.cells,
- remainingResources(state.resources, state.moves, props.playerID),
+ game.remainingResources(state.cells, state.resources, state.moves, props.playerID),
props.playerID,
tool.selected.x,
tool.selected.y,
@@ -165,17 +194,21 @@ export default function Game(props: GameProps) {
}
}
}
-
client.start();
client.subscribe((state) => {
if (state) {
- setState(reconcile(state.G));
- setStage(state.ctx.activePlayers![props.playerID]);
+ // For some ungodly reason, I need to clone the state, because otherwise the frozenness sporadically gets
+ // saved in the store, triggering various issues later down the line.
+ // My guess for as to why is that `state.G` can be mutated and some of its properties are passed to Object.freeze
+ setState(reconcile(clone(state.G)));
+ if (props.playerID) {
+ setStage(state.ctx.activePlayers?.[props.playerID] ?? "connecting");
+ }
if (props.solo) {
const activePlayers = Object.entries(state.ctx.activePlayers ?? {})
.flatMap(([player, state]) => state === "prepare" ? [player] : [])
.sort((a, b) => +b - +a);
- setSoloTurn(activePlayers[activePlayers.length - 1] ?? "");
+ setSoloTurn(activePlayers[activePlayers.length - 1] ?? "0");
}
}
});
@@ -197,7 +230,7 @@ export default function Game(props: GameProps) {
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)) {
+ if ("cells" in state && game.cellIn(state.cells, hexX, hexY)) {
return [hexX, hexY];
} else {
return null;
@@ -235,19 +268,23 @@ export default function Game(props: GameProps) {
draw();
}}
style={{
- width: "400px",
- height: "400px",
+ width: "600px",
+ height: "600px",
}}
/>
- "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0}
- resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0}
- setReady={() => moves().setReady?.()}
- ready={() => stage() === "ready"}
- />
+
+ {(playerID) => {
+ return "resources" in state ? game.remainingResources(state.cells, state.resources, state.moves, playerID()) : 0}
+ resourceGain={() => "cells" in state ? game.getResourceGain(state.cells, playerID()) : 0}
+ setReady={() => moves().setReady?.()}
+ ready={() => stage() === "ready"}
+ />;
+ }}
+
;
}
@@ -288,7 +325,7 @@ function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number,
let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const;
- for (const [neighborX, neighborY] of adjacentCells(guessHexX, guessHexY)) {
+ for (const [neighborX, neighborY] of game.adjacentCells(guessHexX, guessHexY)) {
const dist = distance(neighborX, neighborY);
if (dist < closest[2]) {
closest = [neighborX, neighborY, dist];
@@ -298,7 +335,7 @@ function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number,
return [closest[0], closest[1]];
}
-function getBounds(grid: Record) {
+function getBounds(grid: Record) {
let res = {
minX: Infinity,
maxX: -Infinity,
@@ -306,7 +343,7 @@ function getBounds(grid: Record) {
maxY: -Infinity
};
- for (const [x, y] of iterateCells(grid)) {
+ for (const [x, y] of game.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);
@@ -353,13 +390,13 @@ function drawCellInfoBubble(ctx: CanvasRenderingContext2D, x: number, y: number,
ctx.fill();
ctx.fillStyle = "white";
- ctx.font = `${RADIUS}px sans-serif`;
+ ctx.font = `${RADIUS}px Arial`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
- ctx.fillText(value, dotX, dotY - 0.2);
+ ctx.fillText(value, dotX, dotY);
}
-function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) {
+function drawCell(ctx: CanvasRenderingContext2D, cell: game.Cell, x: number, y: number, tileImages: TileImages) {
const [x2, y2] = getHexPosition(x, y);
let line_offset = (LINE_WIDTH + SPACING) / 2;
@@ -392,7 +429,7 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe
drawCellImage(ctx, x, y, tileImages[cell.building]);
}
- if (cell.owner && !cell.ghost && (cell.hp !== Buildings[cell.building].hp || true)) {
+ if (cell.owner && !cell.ghost && (cell.hp !== game.Buildings[cell.building].hp || true)) {
drawCellInfoBubble(
ctx,
x,
diff --git a/src/components/Multiplayer.astro b/src/components/Multiplayer.astro
new file mode 100644
index 0000000..6a44073
--- /dev/null
+++ b/src/components/Multiplayer.astro
@@ -0,0 +1,26 @@
+---
+import Game from "./Game.tsx";
+
+export type Props = {
+ playerID: string | undefined,
+ matchID: string,
+ debug: boolean,
+};
+
+---
+
+{
+ Astro.props.playerID
+ ?
+ :
+}
+
diff --git a/src/components/Singleplayer.astro b/src/components/Singleplayer.astro
new file mode 100644
index 0000000..0cf4692
--- /dev/null
+++ b/src/components/Singleplayer.astro
@@ -0,0 +1,11 @@
+---
+import Game from "./Game.tsx";
+
+export type Props = {
+ debug: boolean
+};
+
+---
+
+
+
diff --git a/src/consts.ts b/src/consts.ts
index 4d52c56..1e41db4 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -2,3 +2,4 @@
export const PLAYER_COLORS = ["#f06040", "#4050f0"];
export const GHOST_COLORS = ["#c05a47", "#4750c0"];
export const BASE_URL = "/";
+export const SOCKETIO_SERVER = "localhost:3100";
diff --git a/src/game.ts b/src/game.ts
index 2044766..447b21c 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -81,6 +81,28 @@ export const Buildings = {
}
}
},
+ laser: {
+ name: "laser",
+ description: "A long-range defensive unit, able to cause moderate damage to 3 tiles in a row",
+ humanName: "Laser",
+ cost: 5,
+ hp: 2,
+ placedOn: ["road", "defender"],
+ attack: {
+ power: 2,
+ cost: 2,
+ maxMoves: 1,
+ targets(_cells, x, y) {
+ return [...adjacentCells(x, y)];
+ },
+ damageZone(_cells, x, y, targetX, targetY) {
+ const dx = targetX - x;
+ const dy = targetY - y;
+
+ return new Array(3).fill(null).map((_, dist) => [x + dx * (dist + 1), y + dy * (dist + 1)]);
+ }
+ }
+ },
factory: {
name: "factory",
description: "Gives a steady income of resources, but will explode the adjacent tiles if destroyed.",
@@ -118,13 +140,14 @@ export type Moves = Partial void,
+ setConnected: (state: {}) => void,
}>>;
export const AcrossTheHex: Game, {
size?: number,
initialResources?: number
}> = {
- setup({ ctx }, { size = 3, initialResources = 2 } = {}) {
+ setup({ ctx }, { size = 4, initialResources = 2 } = {}) {
const cells = initGrid(size);
const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString());
@@ -146,7 +169,6 @@ export const AcrossTheHex: Game, {
moves: emptyMoves(players),
};
},
-
turn: {
onBegin(ctx) {
// Dispatch all players into the prepare stage
@@ -169,11 +191,20 @@ export const AcrossTheHex: Game, {
prepare: {
next: "ready",
moves: {
- setReady(ctx) {
- ctx.events.endStage();
+ setReady: {
+ move (ctx) {
+ ctx.events.endStage();
+ },
+ ignoreStaleStateID: true,
+ },
+ placeBuilding: {
+ move: placeBuilding,
+ ignoreStaleStateID: true
+ },
+ attack: {
+ move: attack,
+ ignoreStaleStateID: true
},
- placeBuilding,
- attack,
},
},
ready: {
@@ -183,6 +214,20 @@ export const AcrossTheHex: Game, {
}
} as const;
+function getCostForBuilding(
+ existingCell: Cell,
+ building: PlaceableBuildings
+): number {
+ const buildingRules = Buildings[building];
+ if (!existingCell.owner) return buildingRules.cost;
+
+ const existingBuilding = Buildings[existingCell.building];
+
+ if (existingBuilding.name === "road" || !("cost" in existingBuilding)) return buildingRules.cost;
+
+ return buildingRules.cost - existingBuilding.cost;
+}
+
export function canPlaceBuilding(
cells: Record,
resources: number,
@@ -213,7 +258,8 @@ export function canPlaceBuilding(
}
// Cannot place a building without enough resources
- if (resources < buildingRules.cost) return false;
+ const cost = getCostForBuilding(existingCell, buildingRules.name);
+ if (resources < cost) return false;
return true;
}
@@ -226,7 +272,7 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
if (!canPlaceBuilding(
G.cells,
- remainingResources(G.resources, G.moves, playerID),
+ remainingResources(G.cells, G.resources, G.moves, playerID),
playerID,
x,
y,
@@ -292,7 +338,7 @@ export function canAttack(
function attack({ G, playerID }: { G: State, playerID: string }, x: number, y: number, targetX: number, targetY: number) {
if (!canAttack(
G.cells,
- remainingResources(G.resources, G.moves, playerID),
+ remainingResources(G.cells, G.resources, G.moves, playerID),
playerID,
x,
y,
@@ -331,12 +377,15 @@ export function getBuilding(cells: Record, x: number, y: number, e
return Buildings[cell.building];
}
-export function remainingResources(resources: Record, moves: Record, playerID: string): number {
+export function remainingResources(cells: Record, resources: Record, moves: Record, playerID: string): number {
let result = resources[playerID] ?? 0;
for (const move of moves[playerID] ?? []) {
if (move.type === "placeBuilding") {
- result -= Buildings[move.building].cost;
+ const existingCell = getCell(cells, move.x, move.y);
+ if (existingCell) {
+ result -= getCostForBuilding(existingCell, move.building);
+ }
} else if (move.type === "attack") {
const building = Buildings[move.building];
if ("attack" in building) {
@@ -498,7 +547,7 @@ function applyMoves(state: State) {
}
const order = orders[0]!;
- const cost = Buildings[order[1].building].cost;
+ const cost = getCostForBuilding(getCell(previousCells, x, y)!, order[1].building);
setCell(state.cells, x, y, {
owner: order[0],
@@ -587,7 +636,5 @@ export function getLocalState(state: State, playerID: string): State["cells"] {
}
}
- console.log(res);
-
return res;
}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index be88ccd..752804d 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -3,9 +3,9 @@ import Document from "../components/Document.astro";
import Game from "../components/Game.tsx";
export const prerender = true;
+
---
-
-
+ Across the Hex
diff --git a/src/pages/play.astro b/src/pages/play.astro
new file mode 100644
index 0000000..5b98bac
--- /dev/null
+++ b/src/pages/play.astro
@@ -0,0 +1,14 @@
+---
+import Document from "../components/Document.astro";
+import Multiplayer from "../components/Multiplayer.astro";
+import Singleplayer from "../components/Singleplayer.astro";
+
+const playerID = Astro.url.searchParams.get("player") ?? undefined;
+const matchID = Astro.url.searchParams.get("match");
+const debug = Astro.url.searchParams.has("debug");
+
+---
+
+
+ {matchID ? : }
+
diff --git a/tsconfig.json b/tsconfig.json
index 7cd0fd7..dfef6c9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,7 @@
"strictNullChecks": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
- "moduleResolution": "NodeNext"
+ "moduleResolution": "NodeNext",
+ "allowImportingTsExtensions": true
}
}