dominionator/src/dominiontext.ts

467 lines
11 KiB
TypeScript
Raw Normal View History

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";
2025-01-07 20:02:50 -08:00
isBig?: boolean;
prefix?: string;
2025-01-07 08:10:47 -08:00
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 20:02:50 -08:00
const prefixMetrics = context.measureText(piece.prefix ?? "");
2025-01-07 08:10:47 -08:00
const coinImage = getImage(piece.symbol);
2025-01-06 21:06:13 -08:00
context.restore();
2025-01-07 20:02:50 -08:00
const { isBig } = piece;
const scale = isBig ? 2.5 : 1;
2025-01-06 21:06:13 -08:00
return {
type: "content",
2025-01-07 20:02:50 -08:00
width:
scale *
(prefixMetrics.width +
coinImage.width * (height / coinImage.height)),
ascent: scale * metrics.fontBoundingBoxAscent,
descent: scale * metrics.fontBoundingBoxDescent,
prefixWidth: scale * prefixMetrics.width,
scale,
2025-01-06 21:06:13 -08:00
};
},
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-07 20:02:50 -08:00
x + measure.prefixWidth,
2025-01-06 21:06:13 -08:00
y - measure.ascent,
2025-01-07 20:02:50 -08:00
measure.width - measure.prefixWidth,
2025-01-06 21:06:13 -08:00
height
);
2025-01-07 20:02:50 -08:00
const prefixFontInfo = parseFont(context.font);
prefixFontInfo.weight = "bold";
prefixFontInfo.size =
parseInt(prefixFontInfo.size.toString()) * measure.scale;
const prefixFont = stringifyFont(prefixFontInfo);
context.font = prefixFont;
context.fillText(piece.prefix ?? "", x, y);
2025-01-06 21:06:13 -08:00
const fontInfo = parseFont(context.font);
fontInfo.family = ["DominionSpecial"];
fontInfo.weight = "bold";
2025-01-07 20:02:50 -08:00
fontInfo.size =
parseInt(fontInfo.size.toString()) * 1.2 * measure.scale;
2025-01-06 21:06:13 -08:00
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";
2025-01-07 20:02:50 -08:00
context.fillText(
piece.text,
x + measure.prefixWidth + (measure.width - measure.prefixWidth) / 2,
y
);
2025-01-06 21:06:13 -08:00
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;
}
};
2025-01-07 20:02:50 -08:00
export const parse = (
text: string,
options?: { isDescription: boolean }
): Piece[] => {
const { isDescription = false } = options ?? {};
2025-01-06 21:12:28 -05:00
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++) {
2025-01-07 20:02:50 -08:00
const char = text[i]!;
2025-01-06 21:12:28 -05:00
if (char === " ") {
pieces.push({ type: "space" });
} else if (char === "\n") {
pieces.push({ type: "break" });
2025-01-07 20:02:50 -08:00
} else if (char in symbolMap) {
2025-01-07 08:10:47 -08:00
const c = char as keyof typeof symbolMap;
const end = text.slice(i).match(new RegExp(`\\${c}\\w*`))![0]
.length;
2025-01-07 20:02:50 -08:00
const isBig =
isDescription &&
["\n", undefined].includes(text[i - 1]) &&
["\n", undefined].includes(text[i + end]);
2025-01-07 08:10:47 -08:00
pieces.push({
type: "symbol",
...symbolMap[c],
text: text.slice(i + 1, i + end),
2025-01-07 20:02:50 -08:00
isBig,
2025-01-07 08:10:47 -08:00
});
2025-01-06 21:06:13 -08:00
i += end - 1;
2025-01-06 23:51:51 -05:00
} else if (char === "+") {
2025-01-07 20:02:50 -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-07 20:02:50 -08:00
} else if (/\d/.test(char)) {
const match = text.slice(i).match(
new RegExp(
`\\d+(${Object.keys(symbolMap)
.map((s) => `\\${s}`)
.join("|")})`
)
);
if (match) {
const end = match[0].length;
const symbolChar = match[1] as keyof typeof symbolMap;
const isBig =
isDescription &&
["\n", undefined].includes(text[i - 1]) &&
["\n", undefined].includes(text[i + end]);
pieces.push({
type: "symbol",
...symbolMap[symbolChar],
prefix: text.slice(i, i + end - 1),
text: "",
isBig,
});
i += end - 1;
} else {
const end = text.slice(i).match(/\d+/)![0].length;
pieces.push({ type: "text", text: text.slice(i, i + end) });
i += end - 1;
}
2025-01-06 21:12:28 -05:00
} else {
2025-01-07 20:02:50 -08:00
const end = text.slice(i).match(
new RegExp(
`[^${Object.keys(symbolMap)
.map((s) => `\\${s}`)
.join("")} \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
};