diff --git a/public/tile-attack.svg b/public/tile-attack.svg new file mode 100644 index 0000000..1d64191 --- /dev/null +++ b/public/tile-attack.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + diff --git a/public/tile-pawn.svg b/public/tile-defender.svg similarity index 100% rename from public/tile-pawn.svg rename to public/tile-defender.svg diff --git a/src/components/Game.tsx b/src/components/Game.tsx index bc0e4c7..4e6000f 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -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(""); const [stage, setStage] = createSignal(); 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
- { - setWidth(width); - setHeight(height); - draw(); - }} - style={{ - width: "400px", - height: "400px", - }} - /> -
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"} - /> + + { + setWidth(width); + setHeight(height); + draw(); + }} + style={{ + width: "400px", + height: "400px", + }} + /> + "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"} + /> +
; } @@ -253,6 +317,9 @@ function getBounds(grid: Record) { 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]; diff --git a/src/components/Toolbar.module.css b/src/components/Toolbar.module.css index 4d83ec1..c8ef936 100644 --- a/src/components/Toolbar.module.css +++ b/src/components/Toolbar.module.css @@ -1,4 +1,5 @@ .toolbar { + margin-top: 2em; padding-inline: 2em; } diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index c1a85d0..3d7b8e7 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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