2025-01-06 21:12:28 -05:00
|
|
|
import { getImage } from "./draw.ts";
|
2025-01-06 22:13:53 -05:00
|
|
|
import { parseFont, stringifyFont } from "./fonthelper.ts";
|
2025-01-06 21:12:28 -05:00
|
|
|
|
|
|
|
export type Piece =
|
2025-01-06 12:26:18 -05:00
|
|
|
| { type: "text"; text: string; isBold?: boolean; isItalic?: boolean }
|
|
|
|
| { type: "space" }
|
|
|
|
| { type: "break" }
|
2025-01-07 08:19:47 -08:00
|
|
|
| { type: "hr" }
|
2025-01-07 08:10:47 -08:00
|
|
|
| {
|
|
|
|
type: "symbol";
|
|
|
|
symbol: "coin" | "debt" | "potion" | "vp" | "vp-token";
|
|
|
|
text: string;
|
|
|
|
textColor: string;
|
|
|
|
};
|
2025-01-06 12:26:18 -05:00
|
|
|
|
|
|
|
type PromiseOr<T> = T | Promise<T>;
|
|
|
|
|
|
|
|
type PieceMeasure = {
|
|
|
|
type: "content" | "space" | "break";
|
|
|
|
width: number;
|
|
|
|
ascent: number;
|
|
|
|
descent: number;
|
|
|
|
};
|
|
|
|
|
2025-01-06 21:12:28 -05:00
|
|
|
type Line = {
|
|
|
|
pieces: {
|
|
|
|
piece: Piece;
|
2025-01-06 22:13:53 -05:00
|
|
|
measure: PieceMeasure;
|
2025-01-06 21:12:28 -05:00
|
|
|
xOffset: number;
|
|
|
|
}[];
|
|
|
|
width: number;
|
|
|
|
ascent: number;
|
|
|
|
descent: number;
|
|
|
|
};
|
|
|
|
|
|
|
|
type PieceTools = {
|
|
|
|
measurePiece: (
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
piece: Piece
|
|
|
|
) => PromiseOr<PieceMeasure>;
|
|
|
|
renderPiece: (
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
piece: Piece,
|
|
|
|
x: number,
|
|
|
|
y: number
|
|
|
|
) => PromiseOr<void>;
|
|
|
|
};
|
|
|
|
|
2025-01-06 12:26:18 -05:00
|
|
|
type PieceDef<T extends Piece["type"], M extends PieceMeasure> = {
|
|
|
|
type: T;
|
|
|
|
measure(
|
|
|
|
context: CanvasRenderingContext2D,
|
2025-01-06 21:12:28 -05:00
|
|
|
piece: Piece & { type: T },
|
|
|
|
tools: PieceTools
|
2025-01-06 12:26:18 -05:00
|
|
|
): PromiseOr<M>;
|
|
|
|
render(
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
piece: Piece & { type: T },
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-01-06 21:12:28 -05:00
|
|
|
measure: NoInfer<M>,
|
|
|
|
tools: PieceTools
|
2025-01-06 12:26:18 -05:00
|
|
|
): PromiseOr<void>;
|
|
|
|
};
|
|
|
|
|
|
|
|
const pieceDef = <T extends Piece["type"], M extends PieceMeasure>(
|
|
|
|
def: PieceDef<T, M>
|
|
|
|
) => {
|
|
|
|
return def;
|
|
|
|
};
|
|
|
|
|
|
|
|
const textPiece = pieceDef({
|
|
|
|
type: "text",
|
|
|
|
measure(context, piece) {
|
2025-01-06 23:51:51 -05:00
|
|
|
context.save();
|
|
|
|
const fontInfo = parseFont(context.font);
|
|
|
|
if (piece.isBold) {
|
|
|
|
fontInfo.weight = "bold";
|
|
|
|
}
|
|
|
|
if (piece.isItalic) {
|
|
|
|
fontInfo.style = "italic";
|
|
|
|
}
|
|
|
|
const font = stringifyFont(fontInfo);
|
|
|
|
context.font = font;
|
2025-01-06 12:26:18 -05:00
|
|
|
const metrics = context.measureText(piece.text);
|
2025-01-06 23:51:51 -05:00
|
|
|
context.restore();
|
2025-01-06 12:26:18 -05:00
|
|
|
return {
|
|
|
|
type: "content",
|
|
|
|
width: metrics.width,
|
2025-01-06 21:12:28 -05:00
|
|
|
ascent: metrics.fontBoundingBoxAscent,
|
|
|
|
descent: metrics.fontBoundingBoxDescent,
|
2025-01-06 23:51:51 -05:00
|
|
|
font,
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|
|
|
|
},
|
2025-01-06 23:51:51 -05:00
|
|
|
render(context, piece, x, y, measure) {
|
|
|
|
context.save();
|
|
|
|
context.font = measure.font;
|
2025-01-06 12:26:18 -05:00
|
|
|
context.fillText(piece.text, x, y);
|
2025-01-06 23:51:51 -05:00
|
|
|
context.restore();
|
2025-01-06 12:26:18 -05:00
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
const spacePiece = pieceDef({
|
|
|
|
type: "space",
|
|
|
|
measure(context, _piece) {
|
|
|
|
const metrics = context.measureText(" ");
|
|
|
|
return {
|
|
|
|
type: "space",
|
|
|
|
width: metrics.width,
|
2025-01-06 21:12:28 -05:00
|
|
|
ascent: metrics.fontBoundingBoxAscent,
|
|
|
|
descent: metrics.fontBoundingBoxDescent,
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|
|
|
|
},
|
|
|
|
render() {},
|
|
|
|
});
|
|
|
|
|
|
|
|
const breakPiece = pieceDef({
|
|
|
|
type: "break",
|
|
|
|
measure(context, _piece) {
|
|
|
|
const metrics = context.measureText(" ");
|
|
|
|
return {
|
|
|
|
type: "break",
|
|
|
|
width: 0,
|
2025-01-06 23:51:51 -05:00
|
|
|
ascent: metrics.fontBoundingBoxAscent / 3,
|
|
|
|
descent: metrics.fontBoundingBoxDescent / 3,
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|
|
|
|
},
|
|
|
|
render() {},
|
|
|
|
});
|
|
|
|
|
2025-01-07 08:19:47 -08:00
|
|
|
const hrPiece = pieceDef({
|
|
|
|
type: "hr",
|
|
|
|
measure(context, _piece) {
|
|
|
|
const metrics = context.measureText(" ");
|
|
|
|
return {
|
|
|
|
type: "content",
|
|
|
|
width: 750,
|
|
|
|
ascent: metrics.fontBoundingBoxAscent / 3,
|
|
|
|
descent: metrics.fontBoundingBoxDescent / 3,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
render(context, _piece, x, y, measure) {
|
|
|
|
context.save();
|
|
|
|
context.beginPath();
|
|
|
|
context.moveTo(x, y);
|
|
|
|
context.lineTo(x + measure.width, y);
|
|
|
|
context.lineWidth = 8;
|
|
|
|
context.stroke();
|
|
|
|
context.restore();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-01-07 08:10:47 -08:00
|
|
|
const symbolPiece = pieceDef({
|
|
|
|
type: "symbol",
|
|
|
|
measure(context, piece) {
|
2025-01-06 21:06:13 -08:00
|
|
|
context.save();
|
|
|
|
const metrics = context.measureText(" ");
|
|
|
|
const height =
|
|
|
|
metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
|
2025-01-07 08:10:47 -08:00
|
|
|
const coinImage = getImage(piece.symbol);
|
2025-01-06 21:06:13 -08:00
|
|
|
context.restore();
|
|
|
|
return {
|
|
|
|
type: "content",
|
|
|
|
width: coinImage.width * (height / coinImage.height),
|
|
|
|
ascent: metrics.fontBoundingBoxAscent,
|
|
|
|
descent: metrics.fontBoundingBoxDescent,
|
|
|
|
};
|
|
|
|
},
|
|
|
|
render(context, piece, x, y, measure) {
|
|
|
|
context.save();
|
|
|
|
// context.fillStyle = "yellow";
|
|
|
|
const height = measure.ascent + measure.descent;
|
|
|
|
// context.fillRect(x, y - measure.ascent, measure.width, height);
|
|
|
|
context.drawImage(
|
2025-01-07 08:10:47 -08:00
|
|
|
getImage(piece.symbol),
|
2025-01-06 21:06:13 -08:00
|
|
|
x,
|
|
|
|
y - measure.ascent,
|
|
|
|
measure.width,
|
|
|
|
height
|
|
|
|
);
|
|
|
|
const fontInfo = parseFont(context.font);
|
|
|
|
fontInfo.family = ["DominionSpecial"];
|
|
|
|
fontInfo.weight = "bold";
|
|
|
|
fontInfo.size = parseInt(fontInfo.size.toString()) * 1.2;
|
|
|
|
const font = stringifyFont(fontInfo);
|
|
|
|
context.font = font;
|
2025-01-07 08:10:47 -08:00
|
|
|
context.fillStyle = piece.textColor;
|
2025-01-06 21:06:13 -08:00
|
|
|
context.textAlign = "center";
|
|
|
|
context.fillText(piece.text, x + measure.width / 2, y);
|
|
|
|
context.restore();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-01-07 08:19:47 -08:00
|
|
|
const pieceDefs = [textPiece, spacePiece, breakPiece, symbolPiece, hrPiece];
|
2025-01-06 12:26:18 -05:00
|
|
|
|
2025-01-06 22:13:53 -05:00
|
|
|
const tools: PieceTools = {} as any;
|
2025-01-06 21:12:28 -05:00
|
|
|
|
2025-01-06 12:26:18 -05:00
|
|
|
const measurePiece = (context: CanvasRenderingContext2D, piece: Piece) => {
|
|
|
|
const def = pieceDefs.find((def) => def.type === piece.type)!;
|
2025-01-06 21:12:28 -05:00
|
|
|
return def.measure(context, piece as any, tools);
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
const renderPiece = (
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
piece: Piece,
|
|
|
|
x: number,
|
|
|
|
y: number
|
|
|
|
) => {
|
|
|
|
const def = pieceDefs.find((def) => def.type === piece.type)!;
|
2025-01-06 21:12:28 -05:00
|
|
|
const measure = def.measure(context, piece as any, tools);
|
|
|
|
return def.render(context, piece as any, x, y, measure as any, tools);
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|
|
|
|
|
2025-01-06 21:12:28 -05:00
|
|
|
tools.measurePiece = measurePiece;
|
|
|
|
tools.renderPiece = renderPiece;
|
2025-01-06 12:26:18 -05:00
|
|
|
|
|
|
|
type DominionFont = {
|
|
|
|
font: "text" | "title";
|
|
|
|
size: number;
|
|
|
|
isBold: boolean;
|
|
|
|
isItalic: boolean;
|
|
|
|
};
|
|
|
|
|
2025-01-06 21:12:28 -05:00
|
|
|
type PieceWithInfo = {
|
|
|
|
piece: Piece;
|
|
|
|
measure: PieceMeasure;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const measureDominionText = async (
|
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
pieces: Piece[],
|
2025-01-06 23:01:01 -05:00
|
|
|
maxWidth = Infinity
|
2025-01-06 21:12:28 -05:00
|
|
|
) => {
|
|
|
|
const data: PieceWithInfo[] = await Promise.all(
|
|
|
|
pieces.map(async (piece) => ({
|
|
|
|
piece,
|
|
|
|
measure: await measurePiece(context, piece),
|
|
|
|
}))
|
|
|
|
);
|
|
|
|
const lines: Line[] = [{ pieces: [], width: 0, ascent: 0, descent: 0 }];
|
|
|
|
for (const pieceInfo of data) {
|
|
|
|
const line = lines[lines.length - 1]!;
|
|
|
|
if (pieceInfo.measure.type === "break") {
|
2025-01-06 22:13:53 -05:00
|
|
|
line.pieces.push({ ...pieceInfo, xOffset: line.width });
|
2025-01-06 21:12:28 -05:00
|
|
|
line.width += pieceInfo.measure.width;
|
|
|
|
line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent);
|
|
|
|
line.descent = Math.max(line.descent, pieceInfo.measure.descent);
|
|
|
|
lines.push({ pieces: [], width: 0, ascent: 0, descent: 0 });
|
|
|
|
} else {
|
|
|
|
if (line.width + pieceInfo.measure.width > maxWidth) {
|
|
|
|
lines.push({
|
2025-01-06 22:13:53 -05:00
|
|
|
pieces: [{ ...pieceInfo, xOffset: 0 }],
|
2025-01-06 21:12:28 -05:00
|
|
|
width: pieceInfo.measure.width,
|
|
|
|
ascent: pieceInfo.measure.ascent,
|
|
|
|
descent: pieceInfo.measure.descent,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
line.pieces.push({
|
2025-01-06 22:13:53 -05:00
|
|
|
...pieceInfo,
|
2025-01-06 21:12:28 -05:00
|
|
|
xOffset: line.width,
|
|
|
|
});
|
|
|
|
line.width += pieceInfo.measure.width;
|
|
|
|
line.ascent = Math.max(line.ascent, pieceInfo.measure.ascent);
|
|
|
|
line.descent = Math.max(
|
|
|
|
line.descent,
|
|
|
|
pieceInfo.measure.descent
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return {
|
2025-01-06 22:13:53 -05:00
|
|
|
lines: lines.map((line) => {
|
|
|
|
while (
|
|
|
|
line.pieces[line.pieces.length - 1] &&
|
|
|
|
line.pieces[line.pieces.length - 1]!.measure.type === "space"
|
|
|
|
) {
|
|
|
|
line.pieces = line.pieces.slice(0, -1);
|
|
|
|
}
|
2025-01-06 22:28:57 -05:00
|
|
|
line.width = line.pieces
|
|
|
|
.map((piece) => piece.measure.width)
|
2025-01-06 23:34:41 -05:00
|
|
|
.reduce((a, b) => a + b, 0);
|
2025-01-06 22:13:53 -05:00
|
|
|
return line;
|
|
|
|
}),
|
2025-01-06 21:12:28 -05:00
|
|
|
width: Math.max(...lines.map((line) => line.width)),
|
|
|
|
height: lines
|
|
|
|
.map((line) => line.ascent + line.descent)
|
2025-01-06 23:34:41 -05:00
|
|
|
.reduce((a, b) => a + b, 0),
|
2025-01-06 21:12:28 -05:00
|
|
|
};
|
|
|
|
};
|
|
|
|
|
2025-01-06 22:13:53 -05:00
|
|
|
const debug = false;
|
|
|
|
|
2025-01-06 21:12:28 -05:00
|
|
|
export const renderDominionText = async (
|
2025-01-06 12:26:18 -05:00
|
|
|
context: CanvasRenderingContext2D,
|
|
|
|
pieces: Piece[],
|
2025-01-06 21:12:28 -05:00
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-01-06 21:06:13 -08:00
|
|
|
maxWidth = Infinity
|
2025-01-06 12:26:18 -05:00
|
|
|
) => {
|
2025-01-06 21:12:28 -05:00
|
|
|
const { lines, height } = await measureDominionText(
|
|
|
|
context,
|
|
|
|
pieces,
|
|
|
|
maxWidth
|
|
|
|
);
|
|
|
|
let yOffset = 0;
|
|
|
|
for (const line of lines) {
|
|
|
|
yOffset += line.ascent;
|
|
|
|
for (const { piece, xOffset } of line.pieces) {
|
|
|
|
await renderPiece(
|
|
|
|
context,
|
|
|
|
piece,
|
|
|
|
x - line.width / 2 + xOffset,
|
|
|
|
y - height / 2 + yOffset
|
|
|
|
);
|
2025-01-06 22:13:53 -05:00
|
|
|
if (debug) {
|
|
|
|
context.save();
|
|
|
|
context.strokeStyle = "blue";
|
|
|
|
context.lineWidth = 5;
|
|
|
|
const pieceMeasure = await measurePiece(context, piece);
|
|
|
|
context.strokeRect(
|
|
|
|
x - line.width / 2 + xOffset,
|
|
|
|
y - height / 2 - line.ascent + yOffset,
|
|
|
|
pieceMeasure.width,
|
|
|
|
pieceMeasure.ascent + pieceMeasure.descent
|
|
|
|
);
|
|
|
|
context.strokeStyle = "red";
|
|
|
|
context.beginPath();
|
|
|
|
context.moveTo(
|
|
|
|
x - line.width / 2 + xOffset - 5,
|
|
|
|
y - height / 2 + yOffset
|
|
|
|
);
|
|
|
|
context.lineTo(
|
|
|
|
x - line.width / 2 + xOffset + 5,
|
|
|
|
y - height / 2 + yOffset
|
|
|
|
);
|
|
|
|
context.stroke();
|
|
|
|
context.restore();
|
|
|
|
}
|
2025-01-06 21:12:28 -05:00
|
|
|
}
|
|
|
|
yOffset += line.descent;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
export const parse = (text: string): Piece[] => {
|
|
|
|
const pieces: Piece[] = [];
|
2025-01-07 08:10:47 -08:00
|
|
|
const symbolMap = {
|
|
|
|
"$": { symbol: "coin", textColor: "black" },
|
|
|
|
"@": { symbol: "debt", textColor: "white" },
|
|
|
|
"^": { symbol: "potion", textColor: "white" },
|
|
|
|
"%": { symbol: "vp", textColor: "white" },
|
|
|
|
"#": { symbol: "vp-token", textColor: "black" },
|
|
|
|
} as const;
|
2025-01-06 21:12:28 -05:00
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
const char = text[i];
|
|
|
|
if (char === " ") {
|
|
|
|
pieces.push({ type: "space" });
|
|
|
|
} else if (char === "\n") {
|
|
|
|
pieces.push({ type: "break" });
|
2025-01-07 08:10:47 -08:00
|
|
|
} else if (char && char in symbolMap) {
|
|
|
|
const c = char as keyof typeof symbolMap;
|
|
|
|
const end = text.slice(i).match(new RegExp(`\\${c}\\w*`))![0]
|
|
|
|
.length;
|
|
|
|
pieces.push({
|
|
|
|
type: "symbol",
|
|
|
|
...symbolMap[c],
|
|
|
|
text: text.slice(i + 1, i + end),
|
|
|
|
});
|
2025-01-06 21:06:13 -08:00
|
|
|
i += end - 1;
|
2025-01-06 23:51:51 -05:00
|
|
|
} else if (char === "+") {
|
2025-01-06 21:11:31 -08:00
|
|
|
const match = text.slice(i).match(/\+\d* \w+/);
|
2025-01-06 23:51:51 -05:00
|
|
|
if (match) {
|
|
|
|
const end = match[0].length;
|
|
|
|
pieces.push({
|
|
|
|
type: "text",
|
|
|
|
isBold: true,
|
|
|
|
text: text.slice(i, i + end),
|
|
|
|
});
|
|
|
|
i += end - 1;
|
|
|
|
} else {
|
|
|
|
pieces.push({
|
|
|
|
type: "text",
|
|
|
|
isBold: true,
|
|
|
|
text: "+",
|
|
|
|
});
|
|
|
|
}
|
2025-01-07 08:19:47 -08:00
|
|
|
} else if (
|
|
|
|
char === "-" &&
|
|
|
|
text[i - 1] === "\n" &&
|
|
|
|
text[i + 1] === "\n"
|
|
|
|
) {
|
|
|
|
pieces.push({ type: "hr" });
|
2025-01-06 21:12:28 -05:00
|
|
|
} else {
|
2025-01-06 22:28:57 -05:00
|
|
|
const end = text.slice(i).match(/[^$ \n]+/)![0].length;
|
2025-01-06 21:12:28 -05:00
|
|
|
pieces.push({ type: "text", text: text.slice(i, i + end) });
|
|
|
|
i += end - 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return pieces;
|
2025-01-06 12:26:18 -05:00
|
|
|
};
|