🐛 Fix text rendering on firefox

main
Shad Amethyst 12 months ago
parent bc609b6214
commit 7c227366a4

@ -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)",
}}
/>
</div>
@ -298,198 +282,3 @@ export default function Game(props: GameProps) {
</Show>
</div>;
}
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<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;
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],
];
}

@ -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…
Cancel
Save