Implement attacking

main
Shad Amethyst 1 year ago
parent 59c6fed541
commit 849dbfed20

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="173.2"
height="200"
viewBox="0 0 1.732 2"
version="1.1"
id="svg7723"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="button-attack.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7725"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="2.8284271"
inkscape:cx="51.795572"
inkscape:cy="102.00015"
inkscape:window-width="1920"
inkscape:window-height="1004"
inkscape:window-x="0"
inkscape:window-y="54"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs7720" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
sodipodi:type="star"
style="display:none;fill:#707070;fill-opacity:1;stroke:none;stroke-width:0.132184;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
id="path10510"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="2.6478419"
sodipodi:cy="0.71499872"
sodipodi:r1="0.60318714"
sodipodi:r2="0.5223754"
sodipodi:arg1="0.52359878"
sodipodi:arg2="1.0471976"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 3.1702173,1.0165923 2.6478419,1.3181859 2.1254665,1.0165923 l 0,-0.60318715 0.5223754,-0.30159357 0.5223754,0.30159357 z"
transform="matrix(1.6578603,0,0,1.6578603,-3.5237265,-0.18536797)"
inkscape:label="bg" />
<circle
style="fill:none;fill-opacity:1;stroke:#d54444;stroke-width:0.1;stroke-opacity:1;stroke-dasharray:none"
id="path34661"
cx="0.86602557"
cy="1"
r="0.45018601" />
<path
style="fill:none;stroke:#d54444;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 0.86602557,0.63683924 -1e-8,-0.23780814"
id="path35673"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#d54444;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 1.2291863,1 0.2378082,-1e-8"
id="path35675"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#d54444;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 0.86602557,1.3631608 -1e-8,0.2378081"
id="path35677"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#d54444;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
d="m 0.50286481,1 -0.23780814,-1e-8"
id="path35679"
sodipodi:nodetypes="cc" />
<ellipse
style="fill:#d54444;fill-opacity:1;stroke:none;stroke-width:0.1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
id="path35888"
cx="0.86602557"
cy="1"
rx="0.050000004"
ry="0.050000019" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -1,16 +1,17 @@
import { createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"; import { Show, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js";
import { Client } from "boardgame.io/client"; import { Client } from "boardgame.io/client";
import { Local } from "boardgame.io/multiplayer"; import { Local } from "boardgame.io/multiplayer";
import { AcrossTheHex, Cell, State, adjacentCells, canPlaceBuilding, cellIn, getLocalState, getResourceGain, iterateCells, remainingResources } from "../game.js"; 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 type { State as GIOState } from "boardgame.io";
import { createStore, reconcile } from "solid-js/store"; import { createStore, reconcile } from "solid-js/store";
import { PixelPerfectCanvas } from "@shadryx/pptk/solid"; import { PixelPerfectCanvas } from "@shadryx/pptk/solid";
import Toolbar, { Tool } from "./Toolbar.jsx"; import Toolbar, { Tool } from "./Toolbar.jsx";
import { PLAYER_COLORS } from "../consts.js"; import { GHOST_COLORS, PLAYER_COLORS } from "../consts.js";
import { TileImages, loadTileImages } from "./tiles.js"; import { TileImages, loadTileImages } from "./tiles.js";
export type GameProps = { export type GameProps = {
playerID: string playerID: string,
solo?: boolean,
}; };
export default function Game(props: GameProps) { export default function Game(props: GameProps) {
@ -24,6 +25,7 @@ export default function Game(props: GameProps) {
cells: {}, cells: {},
moves: {} moves: {}
}); });
const [soloTurn, setSoloTurn] = createSignal<string>("");
const [stage, setStage] = createSignal<string>(); const [stage, setStage] = createSignal<string>();
const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {}); const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null); const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
@ -38,6 +40,21 @@ export default function Game(props: GameProps) {
const [width, setWidth] = createSignal(0); const [width, setWidth] = createSignal(0);
const [height, setHeight] = createSignal(0); const [height, setHeight] = createSignal(0);
const targetTiles = createMemo(() => {
const tool = selectedTool();
if (tool.type !== "attack" || !tool.selected) return [];
const building = getCell(state.cells, tool.selected.x, tool.selected.y);
if (building?.owner !== props.playerID) return [];
const buildingRules = 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));
});
const moves = () => client.moves as Moves;
const getTransform = createMemo(() => { const getTransform = createMemo(() => {
if (!("cells" in state)) return { if (!("cells" in state)) return {
scale: 1, scale: 1,
@ -96,16 +113,56 @@ export default function Game(props: GameProps) {
y, y,
tool.building 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
);
} }
drawCellOutline(ctx, x, y, valid); drawCellOutline(ctx, x, y, valid);
} }
for (const [x, y] of targetTiles()) {
drawCellTarget(ctx, x, y, images);
}
} }
function clickTile(x: number, y: number) { function clickTile(x: number, y: number) {
const tool = selectedTool(); const tool = selectedTool();
if (tool.type === "placeBuilding") { if (tool.type === "placeBuilding") {
client.moves.placeBuilding?.(x, y, tool.building); moves().placeBuilding?.(x, y, tool.building);
} else if (tool.type === "attack") {
if (tool.selected) {
if (canAttack(
state.cells,
remainingResources(state.resources, state.moves, props.playerID),
props.playerID,
tool.selected.x,
tool.selected.y,
x,
y
)) {
moves().attack?.(tool.selected.x, tool.selected.y, x, y);
setSelectedTool({
type: "attack",
selected: null
});
}
} else {
setSelectedTool({
type: "attack",
selected: {
x,
y
}
});
}
} }
} }
@ -114,6 +171,12 @@ export default function Game(props: GameProps) {
if (state) { if (state) {
setState(reconcile(state.G)); setState(reconcile(state.G));
setStage(state.ctx.activePlayers![props.playerID]); setStage(state.ctx.activePlayers![props.playerID]);
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] ?? "");
}
} }
}); });
@ -163,28 +226,29 @@ export default function Game(props: GameProps) {
}); });
return <div> return <div>
<PixelPerfectCanvas <Show when={props.solo === true ? soloTurn() === props.playerID : true}>
onAttach={setCanvas} <PixelPerfectCanvas
onResize={(_, width, height) => { onAttach={setCanvas}
setWidth(width); onResize={(_, width, height) => {
setHeight(height); setWidth(width);
draw(); setHeight(height);
}} draw();
style={{ }}
width: "400px", style={{
height: "400px", width: "400px",
}} height: "400px",
/> }}
<div>Resources</div> />
<Toolbar <Toolbar
playerID={props.playerID} playerID={props.playerID}
selectedTool={selectedTool} selectedTool={selectedTool}
setSelectedTool={setSelectedTool} setSelectedTool={setSelectedTool}
resources={() => "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0} resources={() => "resources" in state ? remainingResources(state.resources, state.moves, props.playerID) : 0}
resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0} resourceGain={() => "cells" in state ? getResourceGain(state.cells, props.playerID) : 0}
setReady={() => client.moves.setReady?.()} setReady={() => moves().setReady?.()}
ready={() => stage() === "ready"} ready={() => stage() === "ready"}
/> />
</Show>
</div>; </div>;
} }
@ -253,6 +317,9 @@ function getBounds(grid: Record<string, Cell>) {
return res; return res;
} }
const LINE_WIDTH = 0.2;
const SPACING = 0.2;
function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_offset: number) { function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_offset: number) {
ctx.beginPath(); ctx.beginPath();
// Start at top corner, and rotate clockwise // Start at top corner, and rotate clockwise
@ -266,17 +333,46 @@ function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_o
ctx.closePath(); ctx.closePath();
} }
function drawCellInfoBubble(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string, value: string) {
const [x2, y2] = getHexPosition(x, y);
const RADIUS = 0.25;
const dotX = x2 + 1 + Math.cos(angle) * (1 - SPACING / 2 - LINE_WIDTH);
const dotY = y2 + 1 + Math.sin(angle) * (1 - SPACING / 2 - LINE_WIDTH);
ctx.beginPath();
ctx.ellipse(
dotX,
dotY,
RADIUS,
RADIUS,
0,
0,
Math.PI * 2
);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = "white";
ctx.font = `${RADIUS}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(value, dotX, dotY - 0.2);
}
function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) { function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: number, tileImages: TileImages) {
const [x2, y2] = getHexPosition(x, y); const [x2, y2] = getHexPosition(x, y);
const LINE_WIDTH = 0.2;
const SPACING = 0.2;
let line_offset = (LINE_WIDTH + SPACING) / 2; let line_offset = (LINE_WIDTH + SPACING) / 2;
if (cell.owner === null) { if (cell.owner === null) {
line_offset = LINE_WIDTH / 2; line_offset = LINE_WIDTH / 2;
} else {
if (cell.ghost) {
ctx.lineWidth = LINE_WIDTH / 2;
line_offset = (LINE_WIDTH / 2 + SPACING) / 2;
} else {
ctx.lineWidth = LINE_WIDTH;
}
} }
ctx.strokeStyle = "#202020";
ctx.lineWidth = LINE_WIDTH;
drawHexagon(ctx, x2, y2, line_offset); drawHexagon(ctx, x2, y2, line_offset);
@ -284,24 +380,52 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe
ctx.fillStyle = "#505050"; ctx.fillStyle = "#505050";
ctx.fill(); ctx.fill();
} else { } else {
ctx.strokeStyle = PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!; ctx.strokeStyle = cell.ghost
ctx.fillStyle = "#707070"; ? GHOST_COLORS[+cell.owner % GHOST_COLORS.length]!
: PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!;
ctx.fillStyle = cell.ghost ? "#606060" : "#707070";
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
} }
if (cell.owner && cell.building !== "road") { if (cell.owner && cell.building !== "road") {
const img = tileImages[cell.building]; drawCellImage(ctx, x, y, tileImages[cell.building]);
ctx.drawImage( }
img,
x2 + HEX_BOUNDS.left, if (cell.owner && !cell.ghost && (cell.hp !== Buildings[cell.building].hp || true)) {
y2 + HEX_BOUNDS.top, drawCellInfoBubble(
HEX_BOUNDS.right - HEX_BOUNDS.left, ctx,
HEX_BOUNDS.bottom - HEX_BOUNDS.top, x,
y,
Math.PI / 6,
PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!,
cell.hp.toString()
);
}
if (cell.attacked !== undefined && cell.attacked > 0) {
drawCellInfoBubble(
ctx,
x,
y,
Math.PI * 5 / 6,
"#ff0000",
cell.attacked.toString()
); );
} }
} }
function drawCellImage(ctx: CanvasRenderingContext2D, x: number, y: number, image: HTMLImageElement) {
const [x2, y2] = getHexPosition(x, y);
ctx.drawImage(
image,
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, valid: boolean) { function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number, valid: boolean) {
const [x2, y2] = getHexPosition(x, y); const [x2, y2] = getHexPosition(x, y);
ctx.lineWidth = 0.05; ctx.lineWidth = 0.05;
@ -310,6 +434,10 @@ function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number, va
ctx.stroke(); ctx.stroke();
} }
function drawCellTarget(ctx: CanvasRenderingContext2D, x: number, y: number, tileImages: TileImages) {
drawCellImage(ctx, x, y, tileImages.attack);
}
type Matrix2 = readonly [readonly [number, number], readonly [number, number]]; type Matrix2 = readonly [readonly [number, number], readonly [number, number]];
function inverse2DMatrix(matrix: Matrix2): Matrix2 { function inverse2DMatrix(matrix: Matrix2): Matrix2 {
const determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0]; const determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];

@ -1,4 +1,5 @@
.toolbar { .toolbar {
margin-top: 2em;
padding-inline: 2em; padding-inline: 2em;
} }

@ -1,7 +1,7 @@
import { PLAYER_COLORS } from "../consts.js"; import { PLAYER_COLORS } from "../consts.js";
import classes from "./Toolbar.module.css"; import classes from "./Toolbar.module.css";
import PlayerTile from "../tile-player-any.svg?raw"; import PlayerTile from "../tile-player-any.svg?raw";
import { Buildings } from "../game.js"; import { Building, Buildings, PlaceableBuildings } from "../game.js";
import { For, Match, Switch } from "solid-js"; import { For, Match, Switch } from "solid-js";
const WIDTH = 173.2; const WIDTH = 173.2;
@ -10,6 +10,12 @@ const HEIGHT = 200;
export type Tool = { export type Tool = {
type: "placeBuilding", type: "placeBuilding",
building: keyof typeof Buildings, building: keyof typeof Buildings,
} | {
type: "attack",
selected: null | {
x: number,
y: number,
}
}; };
export type ToolbarProps = { export type ToolbarProps = {
@ -39,23 +45,20 @@ export default function Toolbar(props: ToolbarProps) {
return <nav class={classes.toolbar}> return <nav class={classes.toolbar}>
<ul> <ul>
<ToolbarItem <ToolbarItem
playerID={props.playerID} type="attack"
type="road" selected={() => props.selectedTool().type === "attack"}
selected={() => isPlacingBuilding("road")} onSelect={() => props.setSelectedTool({ type: "attack", selected: null })}
onSelect={() => selectBuilding("road")}
/>
<ToolbarItem
playerID={props.playerID}
type="pawn"
selected={() => isPlacingBuilding("pawn")}
onSelect={() => selectBuilding("pawn")}
/>
<ToolbarItem
playerID={props.playerID}
type="factory"
selected={() => isPlacingBuilding("factory")}
onSelect={() => selectBuilding("factory")}
/> />
<For each={Object.values(Buildings).flatMap((definition): PlaceableBuildings[] => "cost" in definition ? [definition.name] : [])}>
{(name) => {
return <ToolbarItem
playerID={props.playerID}
type={name}
selected={() => isPlacingBuilding(name)}
onSelect={() => selectBuilding(name)}
/>
}}
</For>
<li> <li>
Resources: {props.resources()} (+{props.resourceGain()}) Resources: {props.resources()} (+{props.resourceGain()})
<br /> <br />
@ -70,12 +73,12 @@ export default function Toolbar(props: ToolbarProps) {
function ToolbarItem(props: { function ToolbarItem(props: {
playerID: string, playerID?: string,
type: keyof typeof Buildings, type: string,
selected: () => boolean, selected: () => boolean,
onSelect: () => void, onSelect: () => void,
}) { }) {
const color = PLAYER_COLORS[+props.playerID % PLAYER_COLORS.length]; const color = props.playerID ? PLAYER_COLORS[+props.playerID % PLAYER_COLORS.length] : "#707070";
return <li return <li
class={[classes.tool, props.selected() ? classes.selected : ""].filter(Boolean).join(" ")} class={[classes.tool, props.selected() ? classes.selected : ""].filter(Boolean).join(" ")}
@ -100,40 +103,30 @@ function ToolbarItem(props: {
</li>; </li>;
} }
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<keyof typeof Buildings, string>;
const TILE_NAMES = {
road: "Road",
base: "HQ",
factory: "Factory",
pawn: "Defender"
} satisfies Record<keyof typeof Buildings, string>;
type KeyOf<T> = T extends object ? keyof T : never; type KeyOf<T> = T extends object ? keyof T : never;
type TryIndex<T, K extends string> = T extends Record<K, unknown> ? T[K] : never; type TryIndex<T, K extends string> = T extends Record<K, unknown> ? T[K] : never;
type Stats = { type Stats = {
[K in KeyOf<typeof Buildings[keyof typeof Buildings]>]: [K in KeyOf<Building>]:
(value: TryIndex<typeof Buildings[keyof typeof Buildings], K>) => string (value: TryIndex<Building, K>) => string
} }
const STATS = { const STATS = {
cost: (cost: number) => `Cost: ${cost}`, cost: (cost: number) => `Cost: ${cost}`,
gain: (gain: number) => `Resource gain: ${gain}/turn`, gain: (gain: number) => `Resource gain: ${gain}/turn`,
hp: (hp: number) => `Health: ${hp}`,
placedOn: (tiles: readonly (keyof typeof Buildings)[]) => { placedOn: (tiles: readonly (keyof typeof Buildings)[]) => {
const tileNames = tiles.map(tile => TILE_NAMES[tile]); const tileNames = tiles.map(tile => Buildings[tile].humanName);
if (tileNames.length <= 2) { if (tileNames.length <= 2) {
return `Must be placed on ${tileNames.join(" or ")}`; return `Must be placed on ${tileNames.join(" or ")}`;
} else { } else {
return `Must be placed on ${tileNames.slice(0, -1).join(", ")} or ${tileNames[tiles.length - 1]}`; return `Must be placed on ${tileNames.slice(0, -1).join(", ")} or ${tileNames[tiles.length - 1]}`;
} }
}, },
} satisfies Stats; attack: (attack) => {
return `Attacks for ${attack.power}, costing ${attack.cost} resources`;
},
} satisfies Partial<Stats>;
function Description(props: { function Description(props: {
selectedTool: () => Tool selectedTool: () => Tool
@ -158,8 +151,8 @@ function Description(props: {
{(_) => { {(_) => {
const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building; const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building;
return (<> return (<>
<h3>{TILE_NAMES[building()]}</h3> <h3>{Buildings[building()].humanName}</h3>
<p>{TILE_DESCRIPTIONS[building()]}</p> <p>{Buildings[building()].description}</p>
<ul class={classes.stats}> <ul class={classes.stats}>
<For each={stats(building())}> <For each={stats(building())}>
{(item, index) => { {(item, index) => {
@ -170,6 +163,14 @@ function Description(props: {
</>); </>);
}} }}
</Match> </Match>
<Match when={props.selectedTool().type === "attack"}>
{(_) => {
return (<>
<h3>Attack tool</h3>
<p>Select one of your buildings to fire an attack from, and then select where to fire the attack</p>
</>);
}}
</Match>
</Switch> </Switch>
</div>; </div>;
} }

@ -1,12 +1,12 @@
import { BASE_URL } from "../consts.js"; import { BASE_URL } from "../consts.js";
import { Buildings } from "../game.js"; import { Buildings } from "../game.js";
export type NonRoads = Exclude<keyof typeof Buildings, "road">; export type NonRoads = Exclude<keyof typeof Buildings, "road"> | "attack";
export type TileImages = Record<NonRoads, HTMLImageElement>; export type TileImages = Record<NonRoads, HTMLImageElement>;
export async function loadTileImages(): Promise<TileImages> { export async function loadTileImages(): Promise<TileImages> {
const tiles = await Promise.all( const tiles = await Promise.all(
(Object.keys(Buildings) as (keyof typeof Buildings)[]) ([...Object.keys(Buildings) as (keyof typeof Buildings)[], "attack"] as const)
.flatMap((building): Promise<[NonRoads, HTMLImageElement]>[] => { .flatMap((building): Promise<[NonRoads, HTMLImageElement]>[] => {
if (building === "road") return []; if (building === "road") return [];
return [new Promise((resolve, reject) => { return [new Promise((resolve, reject) => {

@ -1,3 +1,4 @@
export const PLAYER_COLORS = ["#f06040", "#4050f0"]; export const PLAYER_COLORS = ["#f06040", "#4050f0"];
export const GHOST_COLORS = ["#c05a47", "#4750c0"];
export const BASE_URL = "/"; export const BASE_URL = "/";

@ -3,18 +3,33 @@ import { INVALID_MOVE } from "boardgame.io/core";
// TODO: partial information // TODO: partial information
export type PlaceableBuildings = {
[K in keyof typeof Buildings]: typeof Buildings[K] extends { cost: number } ? K : never
}[keyof typeof Buildings];
export type Cell = { export type Cell = {
owner: null owner: null,
attacked?: number,
} | { } | {
owner: string, owner: string,
building: keyof typeof Buildings, building: keyof typeof Buildings,
hp: number,
ghost?: boolean,
attacked?: number,
}; };
export type Move = { export type Move = {
type: "placeBuilding", type: "placeBuilding",
x: number, x: number,
y: number, y: number,
building: PlaceableBuildings,
} | {
type: "attack",
building: keyof typeof Buildings, building: keyof typeof Buildings,
x: number,
y: number,
targetX: number,
targetY: number,
}; };
export type State = { export type State = {
@ -25,27 +40,86 @@ export type State = {
export const Buildings = { export const Buildings = {
base: { base: {
cost: Infinity, name: "base",
description: "Your HQ, if you lose it, you lose the game!",
humanName: "",
gain: 3, gain: 3,
hp: 5,
}, },
road: { road: {
name: "road",
description: "The most basic tile, can be placed on any empty tile adjacent to one of your tiles. Allows buildings to be constructed on it.",
humanName: "Road",
cost: 1, cost: 1,
hp: 1,
}, },
pawn: { defender: {
name: "defender",
description: "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.",
humanName: "Defender",
cost: 2, cost: 2,
placedOn: ["road"] hp: 2,
placedOn: ["road"],
attack: {
power: 1,
cost: 0,
maxMoves: 1,
targets(cells, x, y) {
const currentPlayer = getCell(cells, x, y)?.owner;
if (!currentPlayer) return [];
return [...adjacentCells(x, y)].filter(([x, y]) => {
const cell = getCell(cells, x, y);
if (cell?.owner) {
return cell.owner !== currentPlayer;
} else {
return true;
}
});
},
damageZone(_cells, _x, _y, targetX, targetY) {
return [[targetX, targetY]];
}
}
}, },
factory: { factory: {
name: "factory",
description: "Gives a steady income of resources, but will explode the adjacent tiles if destroyed.",
humanName: "Factory",
cost: 3, cost: 3,
gain: 2, gain: 2,
hp: 2,
placedOn: ["road"] placedOn: ["road"]
} }
} as const satisfies Readonly<Record<string, Readonly<{ } as const satisfies Readonly<Record<string, Readonly<{
cost: number, name: string,
description: string,
humanName: string,
cost?: number,
hp: number,
gain?: number, gain?: number,
placedOn?: readonly string[] placedOn?: readonly string[],
attack?: {
power: number,
cost: number,
maxMoves: number,
targets: (cells: Record<string, Cell>, x: number, y: number) => [x: number, y: number][],
damageZone: (cells: Record<string, Cell>, x: number, y: number, targetX: number, targetY: number) => [x: number, y: number][],
}
}>>>; }>>>;
export type Building = typeof Buildings[keyof typeof Buildings];
type MapMove<T> = T extends (state: never, ...args: infer Args) => infer Return ? (...args: Args) => Exclude<Return, typeof INVALID_MOVE> : never;
type MapMoves<T extends Record<string, (...args: never[]) => unknown>> = {
[K in keyof T]: MapMove<T[K]>
}
export type Moves = Partial<MapMoves<{
attack: typeof attack,
placeBuilding: typeof placeBuilding,
setReady: (state: {}) => void,
}>>;
export const AcrossTheHex: Game<State, Record<string, unknown>, { export const AcrossTheHex: Game<State, Record<string, unknown>, {
size?: number, size?: number,
initialResources?: number initialResources?: number
@ -56,12 +130,14 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
setCell(cells, 0, 0, { setCell(cells, 0, 0, {
owner: "0", owner: "0",
building: "base" building: "base",
hp: Buildings.base.hp
}); });
setCell(cells, size * 2 - 2, size * 2 - 2, { setCell(cells, size * 2 - 2, size * 2 - 2, {
owner: "1", owner: "1",
building: "base" building: "base",
hp: Buildings.base.hp
}); });
return { return {
@ -97,6 +173,7 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
ctx.events.endStage(); ctx.events.endStage();
}, },
placeBuilding, placeBuilding,
attack,
}, },
}, },
ready: { ready: {
@ -124,6 +201,8 @@ export function canPlaceBuilding(
if (existingCell.owner && existingCell.owner !== playerID) return false; if (existingCell.owner && existingCell.owner !== playerID) return false;
const buildingRules = Buildings[building]; const buildingRules = Buildings[building];
if (!("cost" in buildingRules)) return false;
if (!("placedOn" in buildingRules)) { if (!("placedOn" in buildingRules)) {
// Cannot place a building on an existing building without a placedOn property // Cannot place a building on an existing building without a placedOn property
if (existingCell.owner) return false; if (existingCell.owner) return false;
@ -158,7 +237,8 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
type: "placeBuilding", type: "placeBuilding",
x, x,
y, y,
building, // SAFETY: guaranteed by canPlaceBuilding
building: building as PlaceableBuildings,
}); });
// G.cells[`${y}:${x}`] = { // G.cells[`${y}:${x}`] = {
@ -169,12 +249,99 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
return; return;
} }
export function canAttackFrom(
cells: Record<string, Cell>,
resources: number,
playerID: string,
x: number,
y: number,
): boolean {
// Can only attack from owned, attack-ready cells
const building = getBuilding(cells, x, y, playerID);
if (!building || !("attack" in building)) return false;
// Cannot attack without the required resources
if (resources < building.attack.cost) return false;
return true;
}
export function canAttack(
cells: Record<string, Cell>,
resources: number,
playerID: string,
x: number,
y: number,
targetX: number,
targetY: number,
): boolean {
// Can only attack from owned, attack-ready cells
const building = getBuilding(cells, x, y, playerID);
if (!building || !("attack" in building)) return false;
// Cannot attack without the required resources
if (resources < building.attack.cost) return false;
// Can only attack to one of the listed target cells
const targets = building.attack.targets(cells, x, y);
if (!targets.find(([tx, ty]) => tx === targetX && ty === targetY)) return false;
return true;
}
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),
playerID,
x,
y,
targetX,
targetY,
)) return INVALID_MOVE;
const building = getBuilding(G.cells, x, y);
// The type checker isn't happy with us otherwise
if (!building || !("attack" in building)) return INVALID_MOVE;
// Cannot attack more than the listed amount of attacks per turn
const attackingMoves = (G.moves[playerID] ?? []).filter((move) => {
return move.type === "attack" && move.x === x && move.y === y
});
if (attackingMoves.length >= building.attack.maxMoves) return INVALID_MOVE;
G.moves[playerID]?.push({
type: "attack",
x,
y,
targetX,
targetY,
building: building.name
});
return;
}
export function getBuilding(cells: Record<string, Cell>, x: number, y: number, expectedPlayer?: string): Building | null {
const cell = getCell(cells, x, y);
if (!cell?.owner) return null;
if (expectedPlayer && cell.owner !== expectedPlayer) return null;
return Buildings[cell.building];
}
export function remainingResources(resources: Record<string, number>, moves: Record<string, Move[]>, playerID: string): number { export function remainingResources(resources: Record<string, number>, moves: Record<string, Move[]>, playerID: string): number {
let result = resources[playerID] ?? 0; let result = resources[playerID] ?? 0;
for (const move of moves[playerID] ?? []) { for (const move of moves[playerID] ?? []) {
if (move.type === "placeBuilding") { if (move.type === "placeBuilding") {
result -= Buildings[move.building].cost; result -= Buildings[move.building].cost;
} else if (move.type === "attack") {
const building = Buildings[move.building];
if ("attack" in building) {
result -= building.attack.cost;
}
} }
} }
@ -224,13 +391,13 @@ export function cellIn(grid: Record<string, Cell>, x: number, y: number): boolea
return `${y}:${x}` in grid; return `${y}:${x}` in grid;
} }
function getCell(grid: Record<string, Cell>, x: number, y: number): Cell | null { export function getCell(grid: Record<string, Cell>, x: number, y: number): Cell | null {
if (!cellIn(grid, x, y)) return null; if (!cellIn(grid, x, y)) return null;
return grid[`${y}:${x}`]!; return grid[`${y}:${x}`]!;
} }
function setCell(grid: Record<string, Cell>, x: number, y: number, cell: Cell) { export function setCell(grid: Record<string, Cell>, x: number, y: number, cell: Cell) {
if (!cellIn(grid, x, y)) return; if (!cellIn(grid, x, y)) return;
grid[`${y}:${x}`] = cell; grid[`${y}:${x}`] = cell;
@ -289,7 +456,10 @@ export function* iterateCells(grid: Record<string, Cell>) {
function applyMoves(state: State) { function applyMoves(state: State) {
const players = Object.keys(state.moves); const players = Object.keys(state.moves);
const previousCells = {...state.cells}; const previousCells = {...state.cells};
const moves = state.moves; const moves = Object.entries(state.moves)
.flatMap(([player, moves]): [string, Move][] =>
moves.map((move) => [player, move])
);
state.moves = emptyMoves(players); state.moves = emptyMoves(players);
const extractedResources = initResources(players, 0); const extractedResources = initResources(players, 0);
@ -305,9 +475,20 @@ function applyMoves(state: State) {
} }
// Building placement step // Building placement step
const placeBuildingMoves = Object.entries(moves).flatMap(([player, moves]) => moves.map((move) => [player, move] as const)); const placeBuildingMoves: [player: string, move: Move & { type: "placeBuilding" }][] =
moves.flatMap(([player, move]): [string, Move & { type: "placeBuilding" }][] => move.type === "placeBuilding" ? [[player, move]] : []);
for (const [x, y, _cell] of iterateCells(state.cells)) { for (const [x, y, _cell] of iterateCells(state.cells)) {
const orders = placeBuildingMoves.filter(([, move]) => move.x === x && move.y === y); const orders = placeBuildingMoves
.filter(([, move]) => move.x === x && move.y === y)
.filter(([player, move]) => canPlaceBuilding(
previousCells,
state.resources[player] ?? 0,
player,
move.x,
move.y,
move.building
));
if (orders.length === 0) continue; if (orders.length === 0) continue;
@ -319,23 +500,53 @@ function applyMoves(state: State) {
const order = orders[0]!; const order = orders[0]!;
const cost = Buildings[order[1].building].cost; const cost = Buildings[order[1].building].cost;
if (!canPlaceBuilding(
previousCells,
state.resources[order[0]]!,
order[0],
x,
y,
order[1].building
)) continue;
setCell(state.cells, x, y, { setCell(state.cells, x, y, {
owner: order[0], owner: order[0],
building: order[1].building building: order[1].building,
hp: Buildings[order[1].building].hp
}); });
state.resources[order[0]] -= cost; state.resources[order[0]] -= cost;
} }
// Attacking step
const attackingMoves = moves
.flatMap(([player, move]): [string, Move & { type: "attack" }][] => move.type === "attack" ? [[player, move]] : []);
for (const [player, move] of attackingMoves) {
if (!canAttack(
previousCells,
state.resources[player] ?? 0,
player,
move.x,
move.y,
move.targetX,
move.targetY
)) {
continue;
}
const building = Buildings[move.building];
if (!("attack" in building)) continue;
const attackedSquares = building.attack.damageZone(previousCells, move.x, move.y, move.targetX, move.targetY);
for (const [x, y] of attackedSquares) {
const attackedBuilding = getCell(state.cells, x, y);
if (!attackedBuilding?.owner) continue;
const hp = attackedBuilding.hp - building.attack.power;
if (hp <= 0) {
setCell(state.cells, x, y, {
owner: null
});
} else {
setCell(state.cells, x, y, {
...attackedBuilding,
hp
});
}
}
}
// Resource gathering step // Resource gathering step
for (const player of players) { for (const player of players) {
state.resources[player] += extractedResources[player]!; state.resources[player] += extractedResources[player]!;
@ -349,12 +560,34 @@ export function getLocalState(state: State, playerID: string): State["cells"] {
for (const move of state.moves[playerID] ?? []) { for (const move of state.moves[playerID] ?? []) {
if (move.type === "placeBuilding") { if (move.type === "placeBuilding") {
res[`${move.y}:${move.x}`] = { setCell(res, move.x, move.y, {
owner: playerID, owner: playerID,
building: move.building building: move.building,
}; hp: Buildings[move.building].hp,
ghost: true,
});
} }
} }
// Register attack values
for (const move of state.moves[playerID] ?? []) {
if (move.type !== "attack") continue;
const building = getBuilding(state.cells, move.x, move.y, playerID);
if (!building || !("attack" in building)) continue;
const attacks = building.attack.damageZone(state.cells, move.x, move.y, move.targetX, move.targetY);
for (const [x, y] of attacks) {
const cell = getCell(res, x, y);
if (!cell) continue;
setCell(res, x, y, {
...cell,
attacked: (cell.attacked ?? 0) + building.attack.power
});
}
}
console.log(res);
return res; return res;
} }

@ -6,6 +6,6 @@ export const prerender = true;
--- ---
<Document> <Document>
<Game playerID="0" client:only /> <Game playerID="0" solo client:only />
<Game playerID="1" client:only /> <Game playerID="1" solo client:only />
</Document> </Document>

Loading…
Cancel
Save