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