use dominiontext framework
This commit is contained in:
		| @@ -1,4 +1,6 @@ | ||||
| type Piece = | ||||
| import { getImage } from "./draw.ts"; | ||||
|  | ||||
| export type Piece = | ||||
| 	| { type: "text"; text: string; isBold?: boolean; isItalic?: boolean } | ||||
| 	| { type: "space" } | ||||
| 	| { type: "break" } | ||||
| @@ -13,18 +15,43 @@ type PieceMeasure = { | ||||
| 	descent: number; | ||||
| }; | ||||
|  | ||||
| type Line = { | ||||
| 	pieces: { | ||||
| 		piece: Piece; | ||||
| 		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>; | ||||
| }; | ||||
|  | ||||
| type PieceDef<T extends Piece["type"], M extends PieceMeasure> = { | ||||
| 	type: T; | ||||
| 	measure( | ||||
| 		context: CanvasRenderingContext2D, | ||||
| 		piece: Piece & { type: T } | ||||
| 		piece: Piece & { type: T }, | ||||
| 		tools: PieceTools | ||||
| 	): PromiseOr<M>; | ||||
| 	render( | ||||
| 		context: CanvasRenderingContext2D, | ||||
| 		piece: Piece & { type: T }, | ||||
| 		x: number, | ||||
| 		y: number, | ||||
| 		measure: NoInfer<M> | ||||
| 		measure: NoInfer<M>, | ||||
| 		tools: PieceTools | ||||
| 	): PromiseOr<void>; | ||||
| }; | ||||
|  | ||||
| @@ -41,8 +68,8 @@ const textPiece = pieceDef({ | ||||
| 		return { | ||||
| 			type: "content", | ||||
| 			width: metrics.width, | ||||
| 			ascent: metrics.emHeightAscent, | ||||
| 			descent: metrics.emHeightDescent, | ||||
| 			ascent: metrics.fontBoundingBoxAscent, | ||||
| 			descent: metrics.fontBoundingBoxDescent, | ||||
| 		}; | ||||
| 	}, | ||||
| 	render(context, piece, x, y) { | ||||
| @@ -57,8 +84,8 @@ const spacePiece = pieceDef({ | ||||
| 		return { | ||||
| 			type: "space", | ||||
| 			width: metrics.width, | ||||
| 			ascent: metrics.emHeightAscent, | ||||
| 			descent: metrics.emHeightDescent, | ||||
| 			ascent: metrics.fontBoundingBoxAscent, | ||||
| 			descent: metrics.fontBoundingBoxDescent, | ||||
| 		}; | ||||
| 	}, | ||||
| 	render() {}, | ||||
| @@ -71,8 +98,8 @@ const breakPiece = pieceDef({ | ||||
| 		return { | ||||
| 			type: "break", | ||||
| 			width: 0, | ||||
| 			ascent: metrics.emHeightAscent, | ||||
| 			descent: metrics.emHeightDescent, | ||||
| 			ascent: metrics.fontBoundingBoxAscent, | ||||
| 			descent: metrics.fontBoundingBoxDescent, | ||||
| 		}; | ||||
| 	}, | ||||
| 	render() {}, | ||||
| @@ -82,33 +109,42 @@ const coinPiece = pieceDef({ | ||||
| 	type: "coin", | ||||
| 	measure(context, _piece) { | ||||
| 		const metrics = context.measureText(" "); | ||||
| 		const height = | ||||
| 			metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; | ||||
| 		const coinImage = getImage("coin"); | ||||
| 		return { | ||||
| 			type: "content", | ||||
| 			width: metrics.emHeightAscent + metrics.emHeightDescent, | ||||
| 			ascent: metrics.emHeightAscent, | ||||
| 			descent: metrics.emHeightDescent, | ||||
| 			width: coinImage.width * (height / coinImage.height), | ||||
| 			ascent: metrics.fontBoundingBoxAscent, | ||||
| 			descent: metrics.fontBoundingBoxDescent, | ||||
| 		}; | ||||
| 	}, | ||||
| 	render(context, piece, x, y, measure) { | ||||
| 		context.save(); | ||||
| 		context.fillStyle = "yellow"; | ||||
| 		context.fillRect( | ||||
| 		// context.fillStyle = "yellow"; | ||||
| 		const height = measure.ascent + measure.descent; | ||||
| 		// context.fillRect(x, y - measure.ascent, measure.width, height); | ||||
| 		context.drawImage( | ||||
| 			getImage("coin"), | ||||
| 			x, | ||||
| 			y - measure.ascent, | ||||
| 			measure.width, | ||||
| 			measure.ascent + measure.descent | ||||
| 			height | ||||
| 		); | ||||
| 		context.fillStyle = "black"; | ||||
| 		context.fillText(piece.text, x, y); | ||||
| 		context.textAlign = "center"; | ||||
| 		context.fillText(piece.text, x + measure.width / 2, y); | ||||
| 		context.restore(); | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const pieceDefs = [textPiece, spacePiece, breakPiece, coinPiece]; | ||||
|  | ||||
| let tools: PieceTools = {} as any; | ||||
|  | ||||
| const measurePiece = (context: CanvasRenderingContext2D, piece: Piece) => { | ||||
| 	const def = pieceDefs.find((def) => def.type === piece.type)!; | ||||
| 	return def.measure(context, piece as any); | ||||
| 	return def.measure(context, piece as any, tools); | ||||
| }; | ||||
|  | ||||
| const renderPiece = ( | ||||
| @@ -118,18 +154,12 @@ const renderPiece = ( | ||||
| 	y: number | ||||
| ) => { | ||||
| 	const def = pieceDefs.find((def) => def.type === piece.type)!; | ||||
| 	const measure = def.measure(context, piece as any); | ||||
| 	return def.render(context, piece as any, x, y, measure as any); | ||||
| 	const measure = def.measure(context, piece as any, tools); | ||||
| 	return def.render(context, piece as any, x, y, measure as any, tools); | ||||
| }; | ||||
|  | ||||
| // export const drawDominionText = ( | ||||
| // 	context: CanvasRenderingContext2D, | ||||
| // 	text: Piece[], | ||||
| // 	x: number, | ||||
| // 	y: number, | ||||
| // 	w: number, | ||||
| // 	h: number | ||||
| // ) => {}; | ||||
| tools.measurePiece = measurePiece; | ||||
| tools.renderPiece = renderPiece; | ||||
|  | ||||
| type DominionFont = { | ||||
| 	font: "text" | "title"; | ||||
| @@ -138,14 +168,106 @@ type DominionFont = { | ||||
| 	isItalic: boolean; | ||||
| }; | ||||
|  | ||||
| export const measureDominionText = ( | ||||
| type PieceWithInfo = { | ||||
| 	piece: Piece; | ||||
| 	measure: PieceMeasure; | ||||
| }; | ||||
|  | ||||
| export const measureDominionText = async ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	pieces: Piece[], | ||||
| 	font: DominionFont, | ||||
| 	maxWidth: number | ||||
| ) => { | ||||
| 	const data = pieces.map((piece) => ({ | ||||
| 		piece, | ||||
| 		measure: measurePiece(context, piece), | ||||
| 	})); | ||||
| 	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") { | ||||
| 			line.pieces.push({ piece: pieceInfo.piece, 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); | ||||
| 			lines.push({ pieces: [], width: 0, ascent: 0, descent: 0 }); | ||||
| 		} else { | ||||
| 			if (line.width + pieceInfo.measure.width > maxWidth) { | ||||
| 				lines.push({ | ||||
| 					pieces: [{ piece: pieceInfo.piece, xOffset: 0 }], | ||||
| 					width: pieceInfo.measure.width, | ||||
| 					ascent: pieceInfo.measure.ascent, | ||||
| 					descent: pieceInfo.measure.descent, | ||||
| 				}); | ||||
| 			} else { | ||||
| 				line.pieces.push({ | ||||
| 					piece: pieceInfo.piece, | ||||
| 					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 { | ||||
| 		lines, | ||||
| 		width: Math.max(...lines.map((line) => line.width)), | ||||
| 		height: lines | ||||
| 			.map((line) => line.ascent + line.descent) | ||||
| 			.reduce((a, b) => a + b), | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export const renderDominionText = async ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	pieces: Piece[], | ||||
| 	x: number, | ||||
| 	y: number, | ||||
| 	maxWidth: number | ||||
| ) => { | ||||
| 	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 | ||||
| 			); | ||||
| 		} | ||||
| 		yOffset += line.descent; | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export const parse = (text: string): Piece[] => { | ||||
| 	const pieces: Piece[] = []; | ||||
| 	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" }); | ||||
| 		} else if (char === "$") { | ||||
| 			const end = text.slice(i).match(/\$\d*/)![0].length; | ||||
| 			pieces.push({ type: "coin", text: text.slice(i + 1, i + end) }); | ||||
| 			i += end - 1; | ||||
| 		} else { | ||||
| 			const end = text.slice(i).match(/\S+/)![0].length; | ||||
| 			pieces.push({ type: "text", text: text.slice(i, i + end) }); | ||||
| 			i += end - 1; | ||||
| 		} | ||||
| 	} | ||||
| 	return pieces; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										158
									
								
								src/draw.ts
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								src/draw.ts
									
									
									
									
									
								
							| @@ -1,3 +1,4 @@ | ||||
| import { parse, renderDominionText } from "./dominiontext.ts"; | ||||
| import { TYPE_ACTION } from "./types.ts"; | ||||
| import { DominionCard } from "./types.ts"; | ||||
|  | ||||
| @@ -39,6 +40,10 @@ const imageList = [ | ||||
| 		key: "card-description-focus", | ||||
| 		src: "/static/assets/DescriptionFocus.png", | ||||
| 	}, | ||||
| 	{ | ||||
| 		key: "coin", | ||||
| 		src: "/static/assets/Coin.png", | ||||
| 	}, | ||||
| ]; | ||||
|  | ||||
| export const loadImages = async () => { | ||||
| @@ -86,132 +91,6 @@ export const drawCard = ( | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| const wrapText = ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	text: string, | ||||
| 	w: number | ||||
| ) => { | ||||
| 	return text.split("\n").flatMap((paragraph) => { | ||||
| 		const lines: string[] = []; | ||||
| 		let words = 0; | ||||
| 		let remainingText = paragraph.trim().replace(/ +/g, " "); | ||||
| 		let oldLine = ""; | ||||
| 		let countdown = 100; | ||||
| 		while (remainingText.length > 0) { | ||||
| 			countdown--; | ||||
| 			if (countdown <= 0) { | ||||
| 				console.log("CUT SHORT"); | ||||
| 				return []; | ||||
| 			} | ||||
| 			words++; | ||||
| 			const newLine = remainingText.split(" ").slice(0, words).join(" "); | ||||
| 			const metrics = context.measureText(newLine); | ||||
| 			if (metrics.width > w) { | ||||
| 				words = 0; | ||||
| 				lines.push(oldLine); | ||||
| 				remainingText = remainingText.slice(oldLine.length).trim(); | ||||
| 			} else if (newLine.length >= remainingText.length) { | ||||
| 				words = 0; | ||||
| 				lines.push(newLine); | ||||
| 				remainingText = ""; | ||||
| 			} | ||||
| 			oldLine = newLine; | ||||
| 		} | ||||
| 		if (!lines.length) { | ||||
| 			return [""]; | ||||
| 		} | ||||
| 		return lines; | ||||
| 	}); | ||||
| }; | ||||
|  | ||||
| const measureText = ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	text: string, | ||||
| 	maxWidth: number | undefined, | ||||
| 	allowWrap: boolean | undefined | ||||
| ) => { | ||||
| 	const measure = context.measureText(text); | ||||
| 	const lineHeight = 1.2 * (measure.emHeightAscent + measure.emHeightDescent); | ||||
| 	if (!allowWrap || !maxWidth) { | ||||
| 		return { | ||||
| 			width: measure.width, | ||||
| 			height: lineHeight, | ||||
| 		}; | ||||
| 	} | ||||
| 	const lines = wrapText(context, text, maxWidth); | ||||
| 	const width = Math.max( | ||||
| 		...lines.map((line) => context.measureText(line).width) | ||||
| 	); | ||||
| 	const height = lines.length * lineHeight; | ||||
| 	return { width, height }; | ||||
| }; | ||||
|  | ||||
| const drawTextCentered = ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	text: string, | ||||
| 	x: number, | ||||
| 	y: number, | ||||
| 	options?: { | ||||
| 		defaultSize?: number; | ||||
| 		maxWidth?: number; | ||||
| 		maxHeight?: number; | ||||
| 		allowWrap?: boolean; | ||||
| 		font?: string; | ||||
| 		fontWeight?: "normal" | "bold"; | ||||
| 		color?: string; | ||||
| 	} | ||||
| ) => { | ||||
| 	const { | ||||
| 		defaultSize = 75, | ||||
| 		maxWidth, | ||||
| 		maxHeight, | ||||
| 		font = "DominionText", | ||||
| 		fontWeight = "normal", | ||||
| 		color, | ||||
| 		allowWrap = false, | ||||
| 	} = options ?? {}; | ||||
| 	context.save(); | ||||
| 	if (color) { | ||||
| 		context.fillStyle = color; | ||||
| 	} | ||||
| 	let size = defaultSize; | ||||
| 	context.font = `${fontWeight} ${size}pt ${font}`; | ||||
| 	if (maxWidth) { | ||||
| 		while ( | ||||
| 			measureText(context, text, maxWidth, allowWrap).width > maxWidth | ||||
| 		) { | ||||
| 			size -= 2; | ||||
| 			context.font = `${fontWeight} ${size}pt ${font}`; | ||||
| 		} | ||||
| 	} | ||||
| 	if (maxHeight) { | ||||
| 		while ( | ||||
| 			measureText(context, text, maxWidth, allowWrap).height > maxHeight | ||||
| 		) { | ||||
| 			size -= 1; | ||||
| 			context.font = `${fontWeight} ${size}pt ${font}`; | ||||
| 		} | ||||
| 	} | ||||
| 	const measure = context.measureText(text); | ||||
| 	const lineHeight = 1.2 * (measure.emHeightAscent + measure.emHeightDescent); | ||||
| 	context.textAlign = "center"; | ||||
| 	context.textBaseline = "middle"; | ||||
| 	if (allowWrap && maxWidth) { | ||||
| 		const lines = wrapText(context, text, maxWidth); | ||||
| 		lines.forEach((line, i) => { | ||||
| 			context.fillText( | ||||
| 				line, | ||||
| 				x, | ||||
| 				y - (lineHeight * lines.length) / 2 + lineHeight * i, | ||||
| 				maxWidth | ||||
| 			); | ||||
| 		}); | ||||
| 	} else { | ||||
| 		context.fillText(text, x, y, maxWidth); | ||||
| 	} | ||||
| 	context.restore(); | ||||
| }; | ||||
|  | ||||
| const drawStandardCard = async ( | ||||
| 	context: CanvasRenderingContext2D, | ||||
| 	card: DominionCard | ||||
| @@ -227,23 +106,22 @@ const drawStandardCard = async ( | ||||
| 	context.drawImage(colorImage(getImage("card-brown"), "#ff9911"), 0, 0); | ||||
| 	context.drawImage(getImage("card-description-focus"), 44, 1094); | ||||
| 	// Draw the name | ||||
| 	drawTextCentered(context, "Moonlit Scheme", w / 2, 220, { | ||||
| 		maxWidth: 1100, | ||||
| 		font: "DominionTitle", | ||||
| 		fontWeight: "bold", | ||||
| 	}); | ||||
| 	// Draw the description | ||||
| 	drawTextCentered( | ||||
| 	context.font = "75pt DominionTitle"; | ||||
| 	await renderDominionText( | ||||
| 		context, | ||||
| 		"You may play an Action card from your hand costing up to \u202f◯\u202f.", | ||||
| 		parse("Moonlit Scheme"), | ||||
| 		w / 2, | ||||
| 		220, | ||||
| 		1100 | ||||
| 	); | ||||
| 	// Draw the description | ||||
| 	context.font = "60pt DominionText"; | ||||
| 	await renderDominionText( | ||||
| 		context, | ||||
| 		parse("You may play an Action card from your hand costing up to $4."), | ||||
| 		w / 2, | ||||
| 		1520, | ||||
| 		{ | ||||
| 			maxWidth: 1100, | ||||
| 			font: "DominionText", | ||||
| 			allowWrap: true, | ||||
| 			defaultSize: 60, | ||||
| 		} | ||||
| 		1100 | ||||
| 	); | ||||
| 	// Draw the types | ||||
| 	// Draw the cost | ||||
|   | ||||
		Reference in New Issue
	
	Block a user