Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/excaliburjs/Excalibur. 🎮 Your friendly TypeScript 2D game engine for the web 🗡️ excaliburjs.com
excalibur excaliburjs game-development game-engine game-framework gamedev games html5-canvas typescript
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393import { BoundingBox } from '../collision/bounding-box';import type { Color } from '../color';import { ExcaliburGraphicsContextWebGL } from './context/excalibur-graphics-context-webgl';import type { ExcaliburGraphicsContext } from './context/excalibur-graphics-context';import type { Font } from './font';import { vec, Vector } from '../math';import { combineHashes, hashString } from '../util/string';
export class FontTextInstance { public canvas: HTMLCanvasElement | null = null; public ctx: CanvasRenderingContext2D | null = null; private _textFragments: { x: number; y: number; canvas: HTMLCanvasElement }[] = []; public dimensions: BoundingBox | null = null; public disposed: boolean = false; private _lastHashCode: number; /** * Maximum upward reach from baseline, in text space */ private _maxAscent: number = 0;
/** * Total height including all lines, in text space */ private _totalHeight: number = 0;
constructor( public readonly font: Font, public readonly text: string, public readonly color: Color, public readonly maxWidth?: number ) { this.canvas = document.createElement('canvas'); const ctx = this.canvas.getContext('2d');
if (!ctx) { throw new Error('Unable to create FontTextInstance, internal canvas failed to create'); }
this.ctx = ctx;
this.canvas.dataset.originalSrc = `text(${text}) font(${font.fontString}`;
this.dimensions = this.measureText(text);
this._setDimension(this.dimensions, this.ctx); this._lastHashCode = this.getHashCode(); }
measureText(text: string, maxWidth?: number): BoundingBox { if (this.disposed) { throw Error('Accessing disposed text instance! ' + this.text); } let lines = null; if (maxWidth != null) { lines = this._getLinesFromText(text, maxWidth); } else { lines = text.split('\n'); }
this._applyFont(this.ctx!); // font must be applied to the context to measure it let maxWidthLine = 0; let maxAscent = 0; let maxDescent = 0; const adjustedPadding = this.font.padding / this.font.quality; for (let i = 0; i < lines.length; i++) { const metrics = this.ctx!.measureText(lines[i]); const width = metrics.width + adjustedPadding * 2; maxWidthLine = Math.max(maxWidthLine, width); maxAscent = Math.max(maxAscent, metrics.actualBoundingBoxAscent); maxDescent = Math.max(maxDescent, metrics.actualBoundingBoxDescent); }
const textHeight = Math.abs(maxAscent) + Math.abs(maxDescent); const totalHeight = textHeight * lines.length + adjustedPadding * 2;
this._maxAscent = maxAscent; this._totalHeight = totalHeight;
// dimensions are in text space return BoundingBox.fromDimension( maxWidthLine, this._totalHeight, vec(this._xAnchorFromAlignment(), this._yAnchorFromBaseline()), Vector.Zero ); }
private _setDimension(textBounds: BoundingBox, bitmap: CanvasRenderingContext2D) { let lineHeightRatio = 1; if (this.font.lineHeight) { lineHeightRatio = this.font.lineHeight / this.font.size; } bitmap.canvas.width = (textBounds.width + this.font.padding * 2) * this.font.quality; bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * this.font.quality * lineHeightRatio; }
public static getHashCode(font: Font, text: string, color?: Color): number { return combineHashes(hashString(text), font.hashCode, color?.hashCode ?? 0); }
public getHashCode(includeColor: boolean = true): number { return FontTextInstance.getHashCode(this.font, this.text, includeColor ? this.color : undefined); }
/** * used in measure text, should not reference final measurements like width/height */ private _xAnchorFromAlignment() { // Calculate x position based on alignment let x; const ltr = this.font.direction === 'ltr'; switch (this.font.textAlign) { case 'left': case 'start': x = ltr ? 0 : 1; break; case 'center': x = 0.5; break; case 'right': case 'end': x = ltr ? 1 : 0; break; default: x = 0; } return x; }
/** * This is for internal positioning on the internal canvas */ private _xFromAlignment() { // Calculate x position based on alignment let x; const ltr = this.font.direction === 'ltr'; switch (this.font.textAlign) { case 'left': case 'start': x = ltr ? 0 : this.canvas!.width; break; case 'center': x = this.canvas!.width / 2; break; case 'right': case 'end': x = ltr ? this.canvas!.width : 0; break; default: x = 0; } return x / this.font.quality; }
/** * used in measure text, should not reference final measurements like width/height * https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline */ private _yAnchorFromBaseline() { let startY; switch (this.font.baseAlign) { case 'top': case 'hanging': startY = 0; break; case 'middle': startY = 0.5; break; case 'bottom': case 'ideographic': startY = 1; break; case 'alphabetic': default: // For alphabetic, position first line properly startY = this._maxAscent / this._totalHeight; break; } return startY; }
protected _applyRasterProperties(ctx: CanvasRenderingContext2D) { ctx.imageSmoothingEnabled = this.font.smoothing; ctx.lineWidth = this.font.lineWidth; ctx.setLineDash(this.font.lineDash ?? ctx.getLineDash()); ctx.strokeStyle = this.font.strokeColor?.toString() ?? ''; ctx.fillStyle = this.color.toString(); }
private _applyFont(ctx: CanvasRenderingContext2D) { ctx.resetTransform(); ctx.scale(this.font.quality, this.font.quality); ctx.translate(this.font.padding, this.font.padding); ctx.textAlign = this.font.textAlign; ctx.textBaseline = this.font.baseAlign; ctx.font = this.font.fontString; ctx.direction = this.font.direction;
if (this.font.shadow) { if (this.font.shadow.color) { ctx.shadowColor = this.font.shadow.color.toString(); } if (this.font.shadow.blur) { ctx.shadowBlur = this.font.shadow.blur; } if (this.font.shadow.offset) { ctx.shadowOffsetX = this.font.shadow.offset.x; ctx.shadowOffsetY = this.font.shadow.offset.y; } } }
private _drawText(ctx: CanvasRenderingContext2D, lines: string[], lineHeight: number): void { this._applyRasterProperties(ctx); this._applyFont(ctx); const x = this._xFromAlignment(); const y = this._maxAscent;
for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (this.color) { ctx.fillText(line, x, y + i * lineHeight); }
if (this.font.strokeColor) { ctx.strokeText(line, x, y + i * lineHeight); } } // //DEBUG // document.body.appendChild(this.canvas); }
private _splitTextBitmap(bitmap: CanvasRenderingContext2D) { const textImages: { x: number; y: number; canvas: HTMLCanvasElement }[] = []; let currentX = 0; let currentY = 0; // 4k is the max for mobile devices const width = Math.min(4096, bitmap.canvas.width); const height = Math.min(4096, bitmap.canvas.height);
// Splits the original bitmap into 4k max chunks while (currentX < bitmap.canvas.width) { while (currentY < bitmap.canvas.height) { // create new bitmap const canvas = document.createElement('canvas'); canvas.dataset.originalSrc = `fragment(${currentX},${currentY}): text(${this.text}) font(${this.font.fontString}`; canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to split internal FontTextInstance bitmap, failed to create internal canvas'); }
// draw current slice to new bitmap in < 4k chunks ctx.drawImage(bitmap.canvas, currentX, currentY, width, height, 0, 0, width, height);
textImages.push({ x: currentX, y: currentY, canvas }); currentY += height; } currentX += width; currentY = 0; } return textImages; }
public flagDirty() { this._dirty = true; } private _dirty = true; private _ex?: ExcaliburGraphicsContext; public render(ex: ExcaliburGraphicsContext, x: number, y: number, maxWidth?: number) { if (this.disposed) { throw Error('Accessing disposed text instance! ' + this.text); } this._ex = ex; const hashCode = this.getHashCode(); if (this._lastHashCode !== hashCode) { this._dirty = true; }
// Calculate image chunks if (this._dirty) { this.dimensions = this.measureText(this.text, maxWidth); this._setDimension(this.dimensions, this.ctx!); const lines = this._getLinesFromText(this.text, maxWidth);
const lineHeight = !this.font.lineHeight ? (this.dimensions.height - this._maxAscent) / lines.length : this.font.lineHeight;
// draws the text to the main bitmap this._drawText(this.ctx!, lines, lineHeight);
// clear any out old fragments if (ex instanceof ExcaliburGraphicsContextWebGL) { for (const frag of this._textFragments) { ex.textureLoader.delete(frag.canvas); } }
// splits to < 4k fragments for large text this._textFragments = this._splitTextBitmap(this.ctx!);
if (ex instanceof ExcaliburGraphicsContextWebGL) { for (const frag of this._textFragments) { ex.textureLoader.load(frag.canvas, { filtering: this.font.filtering }, true); } } this._lastHashCode = hashCode; this._dirty = false; }
const adjustedPadding = this.font.padding / this.font.quality; // text space const destWidth = this.canvas!.width / this.font.quality - adjustedPadding; // text space const destHeight = this._totalHeight; // text space
const alignmentFromAnchor = this._xAnchorFromAlignment() * destWidth + adjustedPadding; const baselineFromAnchor = this._yAnchorFromBaseline() * destHeight + adjustedPadding;
// draws the bitmap fragments to excalibur graphics context for (const frag of this._textFragments) { ex.drawImage( // source coords are in canvas space frag.canvas, 0, 0, frag.canvas.width, frag.canvas.height,
// dest coords are in text space (quality removed) frag.x / this.font.quality + x - alignmentFromAnchor, frag.y / this.font.quality + y - baselineFromAnchor, frag.canvas.width / this.font.quality, frag.canvas.height / this.font.quality ); } }
dispose() { this.disposed = true; this.dimensions = null; this.canvas = null; this.ctx = null; if (this._ex instanceof ExcaliburGraphicsContextWebGL) { for (const frag of this._textFragments) { this._ex.textureLoader.delete(frag.canvas); } } this._textFragments.length = 0; }
/** * Return array of lines split based on the \n character, and the maxWidth? constraint * @param text * @param maxWidth */ private _cachedText?: string; private _cachedLines?: string[]; private _cachedRenderWidth?: number; private _getLinesFromText(text: string, maxWidth?: number): string[] { if (this._cachedText === text && this._cachedRenderWidth === maxWidth && this._cachedLines?.length) { return this._cachedLines; }
const lines = text.split('\n');
if (maxWidth == null) { return lines; }
// If the current line goes past the maxWidth, append a new line without modifying the underlying text. for (let i = 0; i < lines.length; i++) { let line = lines[i]; let newLine = ''; if (this.measureText(line).width > maxWidth) { // FIXME is this width different now since we are using the glyph advance which is more accurate? while (this.measureText(line).width > maxWidth) { newLine = line[line.length - 1] + newLine; line = line.slice(0, -1); // Remove last character from line }
// Update the array with our new values lines[i] = line; lines[i + 1] = newLine; } }
this._cachedText = text; this._cachedLines = lines; this._cachedRenderWidth = maxWidth;
return lines; }}