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 { 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 { 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 { GHOST_COLORS, PLAYER_COLORS } from "../consts.js";
import { TileImages, loadTileImages } from "./tiles.js";
export type GameProps = {
playerID: string
playerID: string,
solo?: boolean,
};
export default function Game(props: GameProps) {
@ -24,6 +25,7 @@ export default function Game(props: GameProps) {
cells: {},
moves: {}
});
const [soloTurn, setSoloTurn] = createSignal<string>("");
const [stage, setStage] = createSignal<string>();
const cells = createMemo(() => "cells" in state ? getLocalState(state, props.playerID) : {});
const [hoveredCell, setHoveredCell] = createSignal<[number, number] | null>(null);
@ -38,6 +40,21 @@ export default function Game(props: GameProps) {
const [width, setWidth] = 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(() => {
if (!("cells" in state)) return {
scale: 1,
@ -96,16 +113,56 @@ export default function Game(props: GameProps) {
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
);
}
drawCellOutline(ctx, x, y, valid);
}
for (const [x, y] of targetTiles()) {
drawCellTarget(ctx, x, y, images);
}
}
function clickTile(x: number, y: number) {
const tool = selectedTool();
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) {
setState(reconcile(state.G));
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>
<PixelPerfectCanvas
onAttach={setCanvas}
onResize={(_, width, height) => {
setWidth(width);
setHeight(height);
draw();
}}
style={{
width: "400px",
height: "400px",
}}
/>
<div>Resources</div>
<Toolbar
playerID={props.playerID}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
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"}
/>
<Show when={props.solo === true ? soloTurn() === props.playerID : true}>
<PixelPerfectCanvas
onAttach={setCanvas}
onResize={(_, width, height) => {
setWidth(width);
setHeight(height);
draw();
}}
style={{
width: "400px",
height: "400px",
}}
/>
<Toolbar
playerID={props.playerID}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
resources={() => "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"}
/>
</Show>
</div>;
}
@ -253,6 +317,9 @@ function getBounds(grid: Record<string, Cell>) {
return res;
}
const LINE_WIDTH = 0.2;
const SPACING = 0.2;
function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_offset: number) {
ctx.beginPath();
// Start at top corner, and rotate clockwise
@ -266,17 +333,46 @@ function drawHexagon(ctx: CanvasRenderingContext2D, x: number, y: number, line_o
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) {
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;
} 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);
@ -284,24 +380,52 @@ function drawCell(ctx: CanvasRenderingContext2D, cell: Cell, x: number, y: numbe
ctx.fillStyle = "#505050";
ctx.fill();
} else {
ctx.strokeStyle = PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!;
ctx.fillStyle = "#707070";
ctx.strokeStyle = cell.ghost
? GHOST_COLORS[+cell.owner % GHOST_COLORS.length]!
: PLAYER_COLORS[+cell.owner % PLAYER_COLORS.length]!;
ctx.fillStyle = cell.ghost ? "#606060" : "#707070";
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,
drawCellImage(ctx, x, y, tileImages[cell.building]);
}
if (cell.owner && !cell.ghost && (cell.hp !== Buildings[cell.building].hp || true)) {
drawCellInfoBubble(
ctx,
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) {
const [x2, y2] = getHexPosition(x, y);
ctx.lineWidth = 0.05;
@ -310,6 +434,10 @@ function drawCellOutline(ctx: CanvasRenderingContext2D, x: number, y: number, va
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]];
function inverse2DMatrix(matrix: Matrix2): Matrix2 {
const determinant = matrix[0][0] * matrix[1][1] - matrix[0][1] * matrix[1][0];

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

@ -1,7 +1,7 @@
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 { Building, Buildings, PlaceableBuildings } from "../game.js";
import { For, Match, Switch } from "solid-js";
const WIDTH = 173.2;
@ -10,6 +10,12 @@ const HEIGHT = 200;
export type Tool = {
type: "placeBuilding",
building: keyof typeof Buildings,
} | {
type: "attack",
selected: null | {
x: number,
y: number,
}
};
export type ToolbarProps = {
@ -39,23 +45,20 @@ export default function Toolbar(props: ToolbarProps) {
return <nav class={classes.toolbar}>
<ul>
<ToolbarItem
playerID={props.playerID}
type="road"
selected={() => isPlacingBuilding("road")}
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")}
type="attack"
selected={() => props.selectedTool().type === "attack"}
onSelect={() => props.setSelectedTool({ type: "attack", selected: null })}
/>
<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>
Resources: {props.resources()} (+{props.resourceGain()})
<br />
@ -70,12 +73,12 @@ export default function Toolbar(props: ToolbarProps) {
function ToolbarItem(props: {
playerID: string,
type: keyof typeof Buildings,
playerID?: string,
type: string,
selected: () => boolean,
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
class={[classes.tool, props.selected() ? classes.selected : ""].filter(Boolean).join(" ")}
@ -100,40 +103,30 @@ function ToolbarItem(props: {
</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 TryIndex<T, K extends string> = T extends Record<K, unknown> ? T[K] : never;
type Stats = {
[K in KeyOf<typeof Buildings[keyof typeof Buildings]>]:
(value: TryIndex<typeof Buildings[keyof typeof Buildings], K>) => string
[K in KeyOf<Building>]:
(value: TryIndex<Building, K>) => string
}
const STATS = {
cost: (cost: number) => `Cost: ${cost}`,
gain: (gain: number) => `Resource gain: ${gain}/turn`,
hp: (hp: number) => `Health: ${hp}`,
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) {
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;
attack: (attack) => {
return `Attacks for ${attack.power}, costing ${attack.cost} resources`;
},
} satisfies Partial<Stats>;
function Description(props: {
selectedTool: () => Tool
@ -158,8 +151,8 @@ function Description(props: {
{(_) => {
const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building;
return (<>
<h3>{TILE_NAMES[building()]}</h3>
<p>{TILE_DESCRIPTIONS[building()]}</p>
<h3>{Buildings[building()].humanName}</h3>
<p>{Buildings[building()].description}</p>
<ul class={classes.stats}>
<For each={stats(building())}>
{(item, index) => {
@ -170,6 +163,14 @@ function Description(props: {
</>);
}}
</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>
</div>;
}

@ -1,12 +1,12 @@
import { BASE_URL } from "../consts.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 async function loadTileImages(): Promise<TileImages> {
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]>[] => {
if (building === "road") return [];
return [new Promise((resolve, reject) => {

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

@ -3,18 +3,33 @@ import { INVALID_MOVE } from "boardgame.io/core";
// 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 = {
owner: null
owner: null,
attacked?: number,
} | {
owner: string,
building: keyof typeof Buildings,
hp: number,
ghost?: boolean,
attacked?: number,
};
export type Move = {
type: "placeBuilding",
x: number,
y: number,
building: PlaceableBuildings,
} | {
type: "attack",
building: keyof typeof Buildings,
x: number,
y: number,
targetX: number,
targetY: number,
};
export type State = {
@ -25,27 +40,86 @@ export type State = {
export const Buildings = {
base: {
cost: Infinity,
name: "base",
description: "Your HQ, if you lose it, you lose the game!",
humanName: "",
gain: 3,
hp: 5,
},
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,
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,
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: {
name: "factory",
description: "Gives a steady income of resources, but will explode the adjacent tiles if destroyed.",
humanName: "Factory",
cost: 3,
gain: 2,
hp: 2,
placedOn: ["road"]
}
} as const satisfies Readonly<Record<string, Readonly<{
cost: number,
name: string,
description: string,
humanName: string,
cost?: number,
hp: 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>, {
size?: number,
initialResources?: number
@ -56,12 +130,14 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
setCell(cells, 0, 0, {
owner: "0",
building: "base"
building: "base",
hp: Buildings.base.hp
});
setCell(cells, size * 2 - 2, size * 2 - 2, {
owner: "1",
building: "base"
building: "base",
hp: Buildings.base.hp
});
return {
@ -97,6 +173,7 @@ export const AcrossTheHex: Game<State, Record<string, unknown>, {
ctx.events.endStage();
},
placeBuilding,
attack,
},
},
ready: {
@ -124,6 +201,8 @@ export function canPlaceBuilding(
if (existingCell.owner && existingCell.owner !== playerID) return false;
const buildingRules = Buildings[building];
if (!("cost" in buildingRules)) return false;
if (!("placedOn" in buildingRules)) {
// Cannot place a building on an existing building without a placedOn property
if (existingCell.owner) return false;
@ -158,7 +237,8 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
type: "placeBuilding",
x,
y,
building,
// SAFETY: guaranteed by canPlaceBuilding
building: building as PlaceableBuildings,
});
// G.cells[`${y}:${x}`] = {
@ -169,12 +249,99 @@ function placeBuilding({ G, playerID }: { G: State, playerID: string }, x: numbe
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 {
let result = resources[playerID] ?? 0;
for (const move of moves[playerID] ?? []) {
if (move.type === "placeBuilding") {
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;
}
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;
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;
grid[`${y}:${x}`] = cell;
@ -289,7 +456,10 @@ export function* iterateCells(grid: Record<string, Cell>) {
function applyMoves(state: State) {
const players = Object.keys(state.moves);
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);
const extractedResources = initResources(players, 0);
@ -305,9 +475,20 @@ function applyMoves(state: State) {
}
// 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)) {
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;
@ -319,23 +500,53 @@ function applyMoves(state: State) {
const order = orders[0]!;
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, {
owner: order[0],
building: order[1].building
building: order[1].building,
hp: Buildings[order[1].building].hp
});
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
for (const player of players) {
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] ?? []) {
if (move.type === "placeBuilding") {
res[`${move.y}:${move.x}`] = {
setCell(res, move.x, move.y, {
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;
}

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

Loading…
Cancel
Save