diff --git a/public/tile-base.svg b/public/tile-base.svg new file mode 100644 index 0000000..1b039a5 --- /dev/null +++ b/public/tile-base.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + diff --git a/public/tile-factory.svg b/public/tile-factory.svg new file mode 100644 index 0000000..68c97c5 --- /dev/null +++ b/public/tile-factory.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/public/tile-pawn.svg b/public/tile-pawn.svg new file mode 100644 index 0000000..3a15f3c --- /dev/null +++ b/public/tile-pawn.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + diff --git a/public/tile-player0.svg b/public/tile-player0.svg new file mode 100644 index 0000000..a40c76b --- /dev/null +++ b/public/tile-player0.svg @@ -0,0 +1,60 @@ + + + + + + + + + + diff --git a/src/components/Document.astro b/src/components/Document.astro index 2b617f3..1938a86 100644 --- a/src/components/Document.astro +++ b/src/components/Document.astro @@ -19,6 +19,7 @@ export type Props = { min-height: 100svh; overflow-x: hidden; background: #202020; + color: white; } diff --git a/src/components/Game.tsx b/src/components/Game.tsx index 8d6ee68..bc0e4c7 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -1,27 +1,37 @@ -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { createEffect, createMemo, createResource, 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 { AcrossTheHex, Cell, State, adjacentCells, canPlaceBuilding, cellIn, getLocalState, getResourceGain, iterateCells, remainingResources } from "../game.js"; // import type { State as GIOState } from "boardgame.io"; import { createStore, reconcile } from "solid-js/store"; import { PixelPerfectCanvas } from "@shadryx/pptk/solid"; +import Toolbar, { Tool } from "./Toolbar.jsx"; +import { PLAYER_COLORS } from "../consts.js"; +import { TileImages, loadTileImages } from "./tiles.js"; 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(client.getState() ?? {}); + const [state, setState] = createStore(client.getState()?.G ?? { + resources: {}, + cells: {}, + moves: {} + }); const [stage, setStage] = createSignal(); const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {}); const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null); + const [selectedTool, setSelectedTool] = createSignal({ + type: "placeBuilding", + building: "road" + }); + const [tileImages] = createResource(loadTileImages); const [canvas, setCanvas] = createSignal(); @@ -59,7 +69,9 @@ export default function Game(props: GameProps) { function draw() { const ctx = canvas()?.getContext("2d"); - if (!ctx || !("cells" in state)) return; + const images = tileImages(); + if (!ctx || !("cells" in state) || !images) return; + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const transform = getTransform(); @@ -67,17 +79,34 @@ export default function Game(props: GameProps) { 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); + drawCell(ctx, cell, x, y, images); } if (hoveredCell()) { const [x, y] = hoveredCell()!; - drawCellOutline(ctx, x, y); + 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 + ); + } + drawCellOutline(ctx, x, y, valid); } } function clickTile(x: number, y: number) { - client.moves.placeBuilding?.(x, y, "pawn"); + const tool = selectedTool(); + + if (tool.type === "placeBuilding") { + client.moves.placeBuilding?.(x, y, tool.building); + } } client.start(); @@ -146,7 +175,16 @@ export default function Game(props: GameProps) { height: "400px", }} /> - client.moves.setReady?.()}>{stage() === "ready" ? "Waiting for opponent" : "Done"} + Resources + "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0} + resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0} + setReady={() => client.moves.setReady?.()} + ready={() => stage() === "ready"} + /> ; } @@ -228,7 +266,7 @@ function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_o ctx.closePath(); } -function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number) { +function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) { const [x2, y2] = getHexPosition(x, y); const LINE_WIDTH = 0.2; const SPACING = 0.2; @@ -251,13 +289,24 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe ctx.fill(); ctx.stroke(); } + + if (cell.owner && cell.building !== "road") { + const img = tileImages[cell.building]; + ctx.drawImage( + img, + x2 + HEX_BOUNDS.left, + y2 + HEX_BOUNDS.top, + HEX_BOUNDS.right - HEX_BOUNDS.left, + HEX_BOUNDS.bottom - HEX_BOUNDS.top, + ); + } } -function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number) { +function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number, valid: boolean) { const [x2, y2] = getHexPosition(x, y); ctx.lineWidth = 0.05; - ctx.strokeStyle = "white"; - drawHexagon(ctx, x2, y2, 0.05); + ctx.strokeStyle = valid ? "white" : "#d08080"; + drawHexagon(ctx, x2, y2, (0.2 - 0.05) / 2); ctx.stroke(); } diff --git a/src/components/Toolbar.module.css b/src/components/Toolbar.module.css new file mode 100644 index 0000000..4d83ec1 --- /dev/null +++ b/src/components/Toolbar.module.css @@ -0,0 +1,61 @@ +.toolbar { + padding-inline: 2em; +} + +.toolbar > ul { + list-style: none; + padding-left: 0; + display: flex; + justify-content: flex-start; + /* display: grid; + grid-template-columns: 1fr 1fr 1fr; + max-width: calc(3 * 100px); */ + gap: 1em; + width: 100%; +} + +.tool { + width: 100px; + height: 100px; + opacity: 0.6; + cursor: pointer; + position: relative; +} + +.tool:focus { + outline: none; +} + +.tool:not(.selected):hover, .tool:not(.selected):focus { + opacity: 0.8; +} + +.tool.selected { + opacity: 1; +} + +.tool .background { + position: absolute; + inset: 0; +} + +.tool .background > svg, .tool .only-background > svg { + width: 100%; + height: 100%; +} + +.tool.selected:focus .background, .tool.selected:focus .only-background { + filter: drop-shadow(0px 0px 4px white); +} + +.tool .icon, .tool .only-background { + position: relative; + user-select: none; + pointer-events: none; + width: 100%; + height: 100%; +} + +.description { + +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx new file mode 100644 index 0000000..c1a85d0 --- /dev/null +++ b/src/components/Toolbar.tsx @@ -0,0 +1,175 @@ +import { PLAYER_COLORS } from "../consts.js"; +import classes from "./Toolbar.module.css"; +import PlayerTile from "../tile-player-any.svg?raw"; +import { Buildings } from "../game.js"; +import { For, Match, Switch } from "solid-js"; + +const WIDTH = 173.2; +const HEIGHT = 200; + +export type Tool = { + type: "placeBuilding", + building: keyof typeof Buildings, +}; + +export type ToolbarProps = { + playerID: string, + selectedTool: () => Tool, + setSelectedTool: (tool: Tool) => void, + resources: () => number, + resourceGain: () => number, + setReady: (ready: boolean) => void, + ready: () => boolean, +}; + +export default function Toolbar(props: ToolbarProps) { + function isPlacingBuilding(building: keyof typeof Buildings) { + const tool = props.selectedTool(); + if (tool.type !== "placeBuilding") return false; + return tool.building === building; + } + + function selectBuilding(building: keyof typeof Buildings) { + props.setSelectedTool({ + type: "placeBuilding", + building, + }); + } + + return + + isPlacingBuilding("road")} + onSelect={() => selectBuilding("road")} + /> + isPlacingBuilding("pawn")} + onSelect={() => selectBuilding("pawn")} + /> + isPlacingBuilding("factory")} + onSelect={() => selectBuilding("factory")} + /> + + Resources: {props.resources()} (+{props.resourceGain()}) + + props.setReady(!props.ready())}> + {props.ready() ? "Waiting..." : "Ready"} + + + + + ; +} + + +function ToolbarItem(props: { + playerID: string, + type: keyof typeof Buildings, + selected: () => boolean, + onSelect: () => void, +}) { + const color = PLAYER_COLORS[+props.playerID % PLAYER_COLORS.length]; + + return { + if (event.key === "Enter") props.onSelect(); + }} + role="button" + > + + { + props.type !== "road" && + + } + ; +} + +const TILE_DESCRIPTIONS = { + road: "The most basic tile, can be placed on any empty tile adjacent to one of your tiles. Allows buildings to be constructed on it.", + base: "Your HQ, if you lose it, you lose the game!", + factory: "Gives a steady income of resources, but will explode the adjacent tiles if destroyed.", + pawn: "Your first line of defense: has good HP and can attack a single adjacent tile to prevent enemy expansion to the border of your territory." +} satisfies Record; + +const TILE_NAMES = { + road: "Road", + base: "HQ", + factory: "Factory", + pawn: "Defender" +} satisfies Record; + +type KeyOf = T extends object ? keyof T : never; +type TryIndex = T extends Record ? T[K] : never; + +type Stats = { + [K in KeyOf]: + (value: TryIndex) => string +} + +const STATS = { + cost: (cost: number) => `Cost: ${cost}`, + gain: (gain: number) => `Resource gain: ${gain}/turn`, + placedOn: (tiles: readonly (keyof typeof Buildings)[]) => { + const tileNames = tiles.map(tile => TILE_NAMES[tile]); + if (tileNames.length <= 2) { + return `Must be placed on ${tileNames.join(" or ")}`; + } else { + return `Must be placed on ${tileNames.slice(0, -1).join(", ")} or ${tileNames[tiles.length - 1]}`; + } + }, +} satisfies Stats; + +function Description(props: { + selectedTool: () => Tool +}) { + const stats = (building: keyof typeof Buildings) => { + const keys = Object.keys(STATS) as (keyof typeof STATS)[]; + const buildingRules = Buildings[building]; + + return keys.flatMap((key) => { + if (key in buildingRules) { + // SAFETY: can be trivially proven by applying decidability on `key`. + return [STATS[key]((buildingRules as Record)[key])] + } else { + return []; + } + }) + }; + + return + No tool selected}> + + {(_) => { + const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building; + return (<> + {TILE_NAMES[building()]} + {TILE_DESCRIPTIONS[building()]} + + + {(item, index) => { + return {item}; + }} + + + >); + }} + + + ; +} diff --git a/src/components/tiles.ts b/src/components/tiles.ts new file mode 100644 index 0000000..a581236 --- /dev/null +++ b/src/components/tiles.ts @@ -0,0 +1,27 @@ +import { BASE_URL } from "../consts.js"; +import { Buildings } from "../game.js"; + +export type NonRoads = Exclude; +export type TileImages = Record; + +export async function loadTileImages(): Promise { + const tiles = await Promise.all( + (Object.keys(Buildings) as (keyof typeof Buildings)[]) + .flatMap((building): Promise<[NonRoads, HTMLImageElement]>[] => { + if (building === "road") return []; + return [new Promise((resolve, reject) => { + const svg = document.createElement("img"); + svg.src = BASE_URL + `./tile-${building}.svg`; + svg.addEventListener("load", () => { + resolve([building, svg]); + }); + svg.addEventListener("error", (err) => { + console.error(`Error while loading image ${svg.src}: ${err}`); + reject(err); + }); + })]; + }) + ); + + return Object.fromEntries(tiles) as TileImages; +} diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..b7946ae --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,3 @@ + +export const PLAYER_COLORS = ["#f06040", "#4050f0"]; +export const BASE_URL = "/"; diff --git a/src/game.ts b/src/game.ts index f218f18..04a268f 100644 --- a/src/game.ts +++ b/src/game.ts @@ -28,16 +28,29 @@ export const Buildings = { cost: Infinity, gain: 3, }, - pawn: { + road: { cost: 1, + }, + pawn: { + cost: 2, + placedOn: ["road"] + }, + factory: { + cost: 3, + gain: 2, + placedOn: ["road"] } -} as const; +} as const satisfies Readonly>>; export const AcrossTheHex: Game, { size?: number, initialResources?: number }> = { - setup({ ctx }, { size = 3, initialResources = 1 } = {}) { + setup({ ctx }, { size = 3, initialResources = 2 } = {}) { const cells = initGrid(size); const players = new Array(ctx.numPlayers).fill(null).map((_, id) => id.toString()); @@ -93,22 +106,54 @@ export const AcrossTheHex: Game, { } } as const; -function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) { +export function canPlaceBuilding( + cells: Record, + resources: number, + playerID: string, + x: number, + y: number, + building: keyof typeof Buildings +): boolean { // Cannot place a building outside the field - if (!cellIn(G.cells, x, y)) return INVALID_MOVE; + if (!cellIn(cells, x, y)) return false; // 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; + if (!hasAdjacentBuilding(cells, x, y, playerID)) return false; + + // Cannot place a building on an opponent's building + const existingCell = getCell(cells, x, y)!; + if (existingCell.owner && existingCell.owner !== playerID) return false; + + const buildingRules = Buildings[building]; + if (!("placedOn" in buildingRules)) { + // Cannot place a building on an existing building without a placedOn property + if (existingCell.owner) return false; + } else { + // Can only place a building on an existing building that is a member of placedOn + if (!existingCell.owner) return false; + if (!(buildingRules.placedOn as readonly string[]).includes(existingCell.building)) return false; + } + // Cannot place a building without enough resources - if (remainingResources(playerID, G.resources, G.moves) < Buildings[building].cost) return INVALID_MOVE; + if (resources < buildingRules.cost) return false; + + return true; +} - // Cannot place two buildings at the same place +function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: number, y: number, building: keyof typeof Buildings) { + // Prevent placing 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; + if (!canPlaceBuilding( + G.cells, + remainingResources(G.resources, G.moves, playerID), + playerID, + x, + y, + building + )) return INVALID_MOVE; + G.moves[playerID]!.push({ type: "placeBuilding", x, @@ -124,7 +169,7 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe return; } -function remainingResources(playerID: string, resources: Record, moves: Record): number { +export function remainingResources(resources: Record, moves: Record, playerID: string): number { let result = resources[playerID] ?? 0; for (const move of moves[playerID] ?? []) { @@ -207,6 +252,21 @@ function areAllPlayersReady(activePlayers: Record): boolean { return true; } +export function getResourceGain(cells: Record, player: string) { + let res = 0; + + for (const [_x, _y, cell] of iterateCells(cells)) { + if (cell.owner === player) { + const building = Buildings[cell.building]; + if ("gain" in building) { + res += building.gain; + } + } + } + + return res; +} + export function* adjacentCells(x: number, y: number): Iterable<[x: number, y: number]> { yield [x - 1, y - 1]; yield [x, y - 1]; @@ -228,6 +288,7 @@ export function* iterateCells(grid: Record) { function applyMoves(state: State) { const players = Object.keys(state.moves); + const previousCells = {...state.cells}; const moves = state.moves; state.moves = emptyMoves(players); @@ -258,8 +319,14 @@ function applyMoves(state: State) { 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 + if (!canPlaceBuilding( + previousCells, + state.resources[order[0]]!, + order[0], + x, + y, + order[1].building + )) continue; setCell(state.cells, x, y, { owner: order[0], diff --git a/src/tile-player-any.svg b/src/tile-player-any.svg new file mode 100644 index 0000000..22ba4c8 --- /dev/null +++ b/src/tile-player-any.svg @@ -0,0 +1,59 @@ + + + + + + + + + +
{TILE_DESCRIPTIONS[building()]}