;
function Description(props: {
selectedTool: () => Tool
@@ -158,8 +151,8 @@ function Description(props: {
{(_) => {
const building = () => (props.selectedTool() as (Tool & { type: "placeBuilding" })).building;
return (<>
- {TILE_NAMES[building()]}
- {TILE_DESCRIPTIONS[building()]}
+ {Buildings[building()].humanName}
+ {Buildings[building()].description}
{(item, index) => {
@@ -170,6 +163,14 @@ function Description(props: {
>);
}}
+
+ {(_) => {
+ return (<>
+ Attack tool
+ Select one of your buildings to fire an attack from, and then select where to fire the attack
+ >);
+ }}
+
;
}
diff --git a/src/components/tiles.ts b/src/components/tiles.ts
index a581236..3d149ab 100644
--- a/src/components/tiles.ts
+++ b/src/components/tiles.ts
@@ -1,12 +1,12 @@
import { BASE_URL } from "../consts.js";
import { Buildings } from "../game.js";
-export type NonRoads = Exclude;
+export type NonRoads = Exclude | "attack";
export type TileImages = Record;
export async function loadTileImages(): Promise {
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) => {
diff --git a/src/consts.ts b/src/consts.ts
index b7946ae..4d52c56 100644
--- a/src/consts.ts
+++ b/src/consts.ts
@@ -1,3 +1,4 @@
export const PLAYER_COLORS = ["#f06040", "#4050f0"];
+export const GHOST_COLORS = ["#c05a47", "#4750c0"];
export const BASE_URL = "/";
diff --git a/src/game.ts b/src/game.ts
index 04a268f..2044766 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -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, x: number, y: number) => [x: number, y: number][],
+ damageZone: (cells: Record, x: number, y: number, targetX: number, targetY: number) => [x: number, y: number][],
+ }
}>>>;
+export type Building = typeof Buildings[keyof typeof Buildings];
+
+type MapMove = T extends (state: never, ...args: infer Args) => infer Return ? (...args: Args) => Exclude : never;
+type MapMoves unknown>> = {
+ [K in keyof T]: MapMove
+}
+
+export type Moves = Partial void,
+}>>;
+
export const AcrossTheHex: Game, {
size?: number,
initialResources?: number
@@ -56,12 +130,14 @@ export const AcrossTheHex: Game, {
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, {
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,
+ 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,
+ 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, 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, 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;
+ } 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, x: number, y: number): boolea
return `${y}:${x}` in grid;
}
-function getCell(grid: Record, x: number, y: number): Cell | null {
+export function getCell(grid: Record, x: number, y: number): Cell | null {
if (!cellIn(grid, x, y)) return null;
return grid[`${y}:${x}`]!;
}
-function setCell(grid: Record, x: number, y: number, cell: Cell) {
+export function setCell(grid: Record, 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) {
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;
}
diff --git a/src/pages/index.astro b/src/pages/index.astro
index a646df2..be88ccd 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -6,6 +6,6 @@ export const prerender = true;
---
-
-
+
+