parent
bc609b6214
commit
7c227366a4
@ -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<string, game.Cell>, 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<string, game.Cell>) {
|
||||
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],
|
||||
];
|
||||
}
|
Loading…
Reference in new issue