diff --git a/src/components/Game.tsx b/src/components/Game.tsx index ce4425b..c0e87da 100644 --- a/src/components/Game.tsx +++ b/src/components/Game.tsx @@ -2,11 +2,12 @@ import { Show, createEffect, createMemo, createResource, createSignal, onCleanup import { Client } from "boardgame.io/client"; import { Local, SocketIO } from "boardgame.io/multiplayer"; import * as game from "../game.js"; +import * as draw from "./Game/draw.ts"; import { createStore, reconcile } from "solid-js/store"; import { PixelPerfectCanvas } from "@shadryx/pptk/solid"; import Toolbar, { Tool } from "./Toolbar.jsx"; -import { GHOST_COLORS, PLAYER_COLORS, SOCKETIO_SERVER } from "../consts.js"; -import { TileImages, loadTileImages } from "./tiles.js"; +import { SOCKETIO_SERVER } from "../consts.js"; +import { loadTileImages } from "./tiles.js"; import clone from "../clone.js"; import classes from "./Game.module.css"; import Status from "./Status.tsx"; @@ -83,51 +84,30 @@ export default function Game(props: GameProps) { const moves = () => client.moves as game.Moves; - const getTransform = createMemo(() => { - if (!("cells" in state)) return { + const getTransform = createMemo( + () => draw.getTransform(state.cells, width(), height()), + { scale: 1, sx: 0, sy: 0, - }; - - const bounds = getBounds(state.cells); - - const scale = Math.min(width() / (bounds.maxX - bounds.minX), height() / (bounds.maxY - bounds.minY)); - const sx = width() / 2 - (bounds.maxX - bounds.minX) * scale / 2 - bounds.minX * scale; - const sy = height() / 2 - (bounds.maxY - bounds.minY) * scale / 2 - bounds.minY * scale; - - return { - scale, - sx, - sy - }; - }, { - scale: 1, - sx: 0, - sy: 0, - }, - { - equals(prev, next) { - return prev.scale === next.scale && prev.sx === next.sx && prev.sy === next.sy + }, + { + equals(prev, next) { + return prev.scale === next.scale && prev.sx === next.sx && prev.sy === next.sy + } } - }); + ); - function draw() { + function drawBoard() { const ctx = canvas()?.getContext("2d"); const images = tileImages(); if (!ctx || !("cells" in state) || !images) return; ctx.imageSmoothingQuality = "high"; - ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const transform = getTransform(); - - ctx.setTransform(transform.scale, 0, 0, transform.scale, transform.sx, transform.sy); - - for (const [x, y, cell] of game.iterateCells(cells())) { - drawCell(ctx, cell, x, y, images); - } + const transformedCtx = new draw.TransformedCanvas2DCtx(ctx, transform); if (hoveredCell()) { const [x, y] = hoveredCell()!; @@ -156,11 +136,15 @@ export default function Game(props: GameProps) { ); } } - drawCellOutline(ctx, x, y, valid); + draw.drawCellOutline(transformedCtx, x, y, valid); + } + + for (const [x, y, cell] of game.iterateCells(cells())) { + draw.drawCell(transformedCtx, cell, x, y, images); } for (const [x, y] of targetTiles()) { - drawCellTarget(ctx, x, y, images); + draw.drawCellTarget(transformedCtx, x, y, images); } } @@ -219,7 +203,7 @@ export default function Game(props: GameProps) { }); createEffect(() => { - draw(); + drawBoard(); }); createEffect(() => { @@ -234,7 +218,7 @@ export default function Game(props: GameProps) { const x = ((event.clientX - bounds.left) / window.devicePixelRatio - transform.sx) / transform.scale; const y = ((event.clientY - bounds.top) / window.devicePixelRatio - transform.sy) / transform.scale; - const [hexX, hexY] = inverseHexPosition(x, y); + const [hexX, hexY] = draw.inverseHexPosition(x, y); if ("cells" in state && game.cellIn(state.cells, hexX, hexY)) { return [hexX, hexY]; } else { @@ -271,11 +255,11 @@ export default function Game(props: GameProps) { onResize={(_, width, height) => { setWidth(width); setHeight(height); - draw(); + drawBoard(); }} style={{ - width: "600px", - height: "600px", + width: "min(900px, 50vw, 80vh - 13em)", + height: "min(900px, 50vw, 80vh - 13em)", }} /> @@ -298,198 +282,3 @@ export default function Game(props: GameProps) { ; } - -const HEX_BASIS = [ - [2 * Math.cos(Math.PI / 6), Math.cos(Math.PI * 5 / 6)], - [0, 1 + Math.sin(Math.PI * 5 / 6)] -] as const; -const INVERSE_HEX_BASIS = inverse2DMatrix(HEX_BASIS); -const HEX_BOUNDS = { - left: 1 + Math.cos(Math.PI * 5 / 6), - top: 0, - bottom: 2, - right: 1 + Math.cos(Math.PI / 6), -}; - -function getHexPosition(x: number, y: number) { - return [ - HEX_BASIS[0][0] * x + HEX_BASIS[0][1] * y, - HEX_BASIS[1][0] * x + HEX_BASIS[1][1] * y, - ] as const; -} - -function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number, hexY: number] { - // I can't be bothered to properly do this, so this just brute-forces the neighbors to find which is closest, - // using the property that the voronoi cell texture of a 2D close packing is a hex grid - - const guessHexX = Math.floor(spatialX * INVERSE_HEX_BASIS[0][0] + spatialY * INVERSE_HEX_BASIS[0][1]); - const guessHexY = Math.floor(spatialX * INVERSE_HEX_BASIS[1][0] + spatialY * INVERSE_HEX_BASIS[1][1]); - const hexCenterX = (HEX_BOUNDS.right + HEX_BOUNDS.left) / 2; - const hexCenterY = (HEX_BOUNDS.top + HEX_BOUNDS.bottom) / 2; - - function distance(hexX: number, hexY: number) { - const [x2, y2] = getHexPosition(hexX, hexY); - - return (x2 + hexCenterX - spatialX) ** 2 + (y2 + hexCenterY - spatialY) ** 2; - } - - let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const; - - for (const [neighborX, neighborY] of game.adjacentCells(guessHexX, guessHexY)) { - const dist = distance(neighborX, neighborY); - if (dist < closest[2]) { - closest = [neighborX, neighborY, dist]; - } - } - - return [closest[0], closest[1]]; -} - -function getBounds(grid: Record) { - let res = { - minX: Infinity, - maxX: -Infinity, - minY: Infinity, - maxY: -Infinity - }; - - for (const [x, y] of game.iterateCells(grid)) { - const [x2, y2] = getHexPosition(x, y); - res.minX = Math.min(res.minX, x2 + HEX_BOUNDS.left); - res.maxX = Math.max(res.maxX, x2 + HEX_BOUNDS.right); - res.minY = Math.min(res.minY, y2 + HEX_BOUNDS.top); - res.maxY = Math.max(res.maxY, y2 + HEX_BOUNDS.bottom); - } - - 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 - ctx.moveTo(x + 1, y + line_offset); - for (let n = 1; n < 6; n++) { - ctx.lineTo( - x + 1 + Math.cos(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset), - y + 1 + Math.sin(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset), - ); - } - 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 Arial`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(value, dotX, dotY); -} - -function drawCell(ctx: CanvasRenderingContext2D, cell: game.Cell, x: number, y: number, tileImages: TileImages) { - const [x2, y2] = getHexPosition(x, y); - - 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; - } - } - - drawHexagon(ctx, x2, y2, line_offset); - - if (cell.owner === null) { - ctx.fillStyle = "#505050"; - ctx.fill(); - } else { - 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") { - drawCellImage(ctx, x, y, tileImages[cell.building]); - } - - if (cell.owner && !cell.ghost && (cell.hp !== game.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; - ctx.strokeStyle = valid ? "white" : "#d08080"; - drawHexagon(ctx, x2, y2, (0.2 - 0.05) / 2); - 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]; - return [ - [matrix[1][1] / determinant, -matrix[0][1] / determinant], - [-matrix[1][0] / determinant, matrix[0][0] / determinant], - ]; -} diff --git a/src/components/Game/draw.ts b/src/components/Game/draw.ts new file mode 100644 index 0000000..1c266d8 --- /dev/null +++ b/src/components/Game/draw.ts @@ -0,0 +1,377 @@ +import * as game from "../../game.js"; +import { GHOST_COLORS, PLAYER_COLORS } from "../../consts.js"; +import type { TileImages } from "../tiles.js"; + +const HEX_BASIS = [ + [2 * Math.cos(Math.PI / 6), Math.cos(Math.PI * 5 / 6)], + [0, 1 + Math.sin(Math.PI * 5 / 6)] +] as const; +const INVERSE_HEX_BASIS = inverse2DMatrix(HEX_BASIS); +const HEX_BOUNDS = { + left: 1 + Math.cos(Math.PI * 5 / 6), + top: 0, + bottom: 2, + right: 1 + Math.cos(Math.PI / 6), +}; + +export type Transform = { + scale: number, + sx: number, + sy: number, +} + +export function getTransform(cells: Record, width: number, height: number): Transform { + const bounds = getBounds(cells); + + const scale = Math.min(width / (bounds.maxX - bounds.minX), height / (bounds.maxY - bounds.minY)); + const sx = width / 2 - (bounds.maxX - bounds.minX) * scale / 2 - bounds.minX * scale; + const sy = height / 2 - (bounds.maxY - bounds.minY) * scale / 2 - bounds.minY * scale; + + return { + scale, + sx, + sy + }; +} + +// Firefox's CanvasRenderingContext2D.fillText is broken when using setTransform, so I'm using this as a drop-in replacement +export class TransformedCanvas2DCtx { + constructor(public ctx: CanvasRenderingContext2D, public transform: Transform) {} + + beginPath() { + this.ctx.beginPath(); + } + + closePath() { + this.ctx.closePath(); + } + + moveTo(x: number, y: number) { + this.ctx.moveTo(x * this.transform.scale + this.transform.sx, y * this.transform.scale + this.transform.sy); + } + + lineTo(x: number, y: number) { + this.ctx.lineTo(x * this.transform.scale + this.transform.sx, y * this.transform.scale + this.transform.sy); + } + + set lineWidth(value: number) { + this.ctx.lineWidth = value * this.transform.scale; + } + + set fillStyle(value: string) { + this.ctx.fillStyle = value; + } + + set strokeStyle(value: string) { + this.ctx.strokeStyle = value; + } + + fill() { + this.ctx.fill(); + } + + stroke() { + this.ctx.stroke(); + } + + ellipse( + x: number, + y: number, + rx: number, + ry: number, + rotation: number, + startAngle: number, + endAngle: number, + counterClockwise?: boolean | undefined + ) { + this.ctx.ellipse( + x * this.transform.scale + this.transform.sx, + y * this.transform.scale + this.transform.sy, + rx * this.transform.scale, + ry * this.transform.scale, + rotation, + startAngle, + endAngle, + counterClockwise + ); + } + + set font(font: string) { + console.log(font); + const split = /^([a-z-]*\s+)?([\d.]+)px(\s+.+)$/.exec(font); + if (split) { + this.ctx.font = `${split[1] ?? ""} ${+split[2]! * this.transform.scale}px ${split[3]}`; + } else { + this.ctx.font = font; + } + } + + set textAlign(value: CanvasRenderingContext2D["textAlign"]) { + this.ctx.textAlign = value; + } + + set textBaseline(value: CanvasRenderingContext2D["textBaseline"]) { + this.ctx.textBaseline = value; + } + + fillText(text: string, x: number, y: number, maxWidth?: number | undefined) { + this.ctx.fillText( + text, + x * this.transform.scale + this.transform.sx, + y * this.transform.scale + this.transform.sy, + maxWidth ? maxWidth * this.transform.scale : undefined + ); + } + + drawImage(image: CanvasImageSource, dx: number, dy: number): void; + drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void; + drawImage(image: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void; + drawImage(image: CanvasImageSource, sx: number, sy: number, sw?: number, sh?: number, dx?: number, dy?: number, dw?: number, dh?: number) { + if (sw === undefined || sh === undefined) { + this.ctx.drawImage( + image, + sx * this.transform.scale + this.transform.sx, + sy * this.transform.scale + this.transform.sy, + ); + } else if (dx === undefined || dy === undefined || dw === undefined || dh === undefined) { + this.ctx.drawImage( + image, + sx * this.transform.scale + this.transform.sx, + sy * this.transform.scale + this.transform.sy, + sw * this.transform.scale, + sh * this.transform.scale + ); + } else { + this.ctx.drawImage( + image, + sx, + sy, + sw, + sh, + dx * this.transform.scale + this.transform.sx, + dy * this.transform.scale + this.transform.sy, + dw * this.transform.scale, + dh * this.transform.scale, + ); + } + } + + getTransform(): { + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + } { + return { + a: this.transform.scale, + b: 0, + c: 0, + d: this.transform.scale, + e: this.transform.sx, + f: this.transform.sy, + } + } + + set imageSmoothingEnabled(value: boolean) { + this.ctx.imageSmoothingEnabled = value; + } +} + +type Canvas2DCtx = CanvasRenderingContext2D & { ctx?: undefined } | TransformedCanvas2DCtx; + +export function getHexPosition(x: number, y: number, transform?: Transform): [number, number] { + const res: [number, number] = [ + HEX_BASIS[0][0] * x + HEX_BASIS[0][1] * y, + HEX_BASIS[1][0] * x + HEX_BASIS[1][1] * y, + ]; + + if (transform) { + return [ + res[0] * transform.scale + transform.sx, + res[1] * transform.scale + transform.sy, + ]; + } else { + return res; + } +} + +export function inverseHexPosition(spatialX: number, spatialY: number): [hexX: number, hexY: number] { + // I can't be bothered to properly do this, so this just brute-forces the neighbors to find which is closest, + // using the property that the voronoi cell texture of a 2D close packing is a hex grid + + const guessHexX = Math.floor(spatialX * INVERSE_HEX_BASIS[0][0] + spatialY * INVERSE_HEX_BASIS[0][1]); + const guessHexY = Math.floor(spatialX * INVERSE_HEX_BASIS[1][0] + spatialY * INVERSE_HEX_BASIS[1][1]); + const hexCenterX = (HEX_BOUNDS.right + HEX_BOUNDS.left) / 2; + const hexCenterY = (HEX_BOUNDS.top + HEX_BOUNDS.bottom) / 2; + + function distance(hexX: number, hexY: number) { + const [x2, y2] = getHexPosition(hexX, hexY); + + return (x2 + hexCenterX - spatialX) ** 2 + (y2 + hexCenterY - spatialY) ** 2; + } + + let closest = [guessHexX, guessHexY, distance(guessHexX, guessHexY)] as const; + + for (const [neighborX, neighborY] of game.adjacentCells(guessHexX, guessHexY)) { + const dist = distance(neighborX, neighborY); + if (dist < closest[2]) { + closest = [neighborX, neighborY, dist]; + } + } + + return [closest[0], closest[1]]; +} + +export function getBounds(grid: Record) { + let res = { + minX: Infinity, + maxX: -Infinity, + minY: Infinity, + maxY: -Infinity + }; + + for (const [x, y] of game.iterateCells(grid)) { + const [x2, y2] = getHexPosition(x, y); + res.minX = Math.min(res.minX, x2 + HEX_BOUNDS.left); + res.maxX = Math.max(res.maxX, x2 + HEX_BOUNDS.right); + res.minY = Math.min(res.minY, y2 + HEX_BOUNDS.top); + res.maxY = Math.max(res.maxY, y2 + HEX_BOUNDS.bottom); + } + + return res; +} + +const LINE_WIDTH = 0.2; +const SPACING = 0.2; + +export function drawHexagon(ctx: Canvas2DCtx, x: number, y: number, line_offset: number) { + ctx.beginPath(); + // Start at top corner, and rotate clockwise + ctx.moveTo(x + 1, y + line_offset); + for (let n = 1; n < 6; n++) { + ctx.lineTo( + x + 1 + Math.cos(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset), + y + 1 + Math.sin(Math.PI * n / 3 - Math.PI / 2) * (1 - line_offset), + ); + } + ctx.closePath(); +} + +export function drawCellInfoBubble(ctx: Canvas2DCtx, 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 * 1.5}px Arial`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(value, dotX, dotY); +} + +export function drawCell(ctx: TransformedCanvas2DCtx, cell: game.Cell, x: number, y: number, tileImages: TileImages) { + const [x2, y2] = getHexPosition(x, y); + + 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; + } + } + + drawHexagon(ctx, x2, y2, line_offset); + + if (cell.owner === null) { + ctx.fillStyle = "#505050"; + ctx.fill(); + } else { + 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") { + drawCellImage(ctx, x, y, tileImages[cell.building]); + } + + if (cell.owner && !cell.ghost && (cell.hp !== game.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() + ); + } +} + +export function drawCellImage(ctx: Canvas2DCtx, x: number, y: number, image: HTMLImageElement | SVGImageElement) { + const [x2, y2] = getHexPosition(x, y); + + ctx.imageSmoothingEnabled = false; + + ctx.drawImage( + image, + x2 + HEX_BOUNDS.left, + y2 + HEX_BOUNDS.top, + HEX_BOUNDS.right - HEX_BOUNDS.left, + HEX_BOUNDS.bottom - HEX_BOUNDS.top, + ); +} + +export function drawCellOutline(ctx: Canvas2DCtx, x: number, y: number, valid: boolean) { + const [x2, y2] = getHexPosition(x, y); + ctx.lineWidth = LINE_WIDTH / 2; + ctx.strokeStyle = valid ? "white" : "#d08080"; + drawHexagon(ctx, x2, y2, (SPACING - LINE_WIDTH / 2 + 0.025) / 2); + ctx.stroke(); +} + +export function drawCellTarget(ctx: Canvas2DCtx, 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]; + return [ + [matrix[1][1] / determinant, -matrix[0][1] / determinant], + [-matrix[1][0] / determinant, matrix[0][0] / determinant], + ]; +}