@dcgw/excalibur-extended-label
Version:
Excalibur's Label class, but with extra features
356 lines (297 loc) • 11.4 kB
text/typescript
import {Actor, BaseAlign, Color, FontStyle, TextAlign, Vector} from "excalibur";
import * as excalibur from "excalibur";
import chain from "@softwareventures/chain";
import {mapFn, foldFn, concat, toArray} from "@softwareventures/iterable";
import {hasProperty} from "unknown";
export interface LabelOptions {
/** The text to draw. */
readonly text?: string;
/** The CSS font family string (e.g. `sans-serif`, `Droid Sans Pro`). Web
* fonts are supported, same as in CSS. */
readonly fontFamily?: string;
/** The font style for the label.
*
* @default FontStyle.Normal */
readonly fontStyle?: FontStyle;
/** True if the text is bold.
*
* @default false */
readonly bold?: boolean;
/** The font size in pixels.
*
* @default 10 */
readonly fontSize?: number;
/** Horizontal text alignment.
*
* @default TextAlign.Left */
readonly textAlign?: TextAlign;
/** Baseline alignment.
*
* @default BaseAlign.Alphabetic */
readonly baseAlign?: BaseAlign;
/** The height of each line of text in pixels, for multiline text.
*
* Set to `undefined` to use the font size as the line height.
*
* @default undefined */
readonly lineHeight?: number;
/** The maximum width of a line of text, in pixels, after which the text
* will wrap to the next line.
*
* Set to `Infinity` to disable text wrapping.
*
* @default Infinity */
readonly wrapWidth?: number;
/** The position of the text in pixels. */
readonly pos?: Vector;
/** The velocity of the text in pixels per second. */
readonly vel?: Vector;
/** The acceleration of the text in pixels per second per second. */
readonly acc?: Vector;
/** The rotation of the text in radians. */
readonly rotation?: number;
/** The rotational velocity of the text in radians per second. */
readonly rx?: number;
/** True if the text is visible, false if it is invisible.
*
* @default true */
readonly visible?: boolean;
/** The color of the text. */
readonly color?: Color;
/** The color of the text outline. Set to Color.Transparent to hide the outline.
*
* @default Color.Transparent */
readonly outlineColor?: Color;
/** The width of the text outline, in pixels. Set to 0 to hide the outline.
*
* @default 0 */
readonly outlineWidth?: number;
/** Overall opacity of the label, from 0 to 1.
*
* @default 1 */
readonly alpha?: number;
/** Do not use.
*
* @deprecated This field has an unwanted and confusing interaction with
* the underlying Actor class.
*
* @see https://github.com/excaliburjs/Excalibur/issues/874#issuecomment-814557137 */
readonly opacity?: number;
/** The color of the shadow. Set to Color.Transparent to hide the shadow.
*
* @default Color.Transparent */
readonly shadowColor?: Color;
/** The offset of the shadow from the text, in pixels.
*
* @default Vector.Zero */
readonly shadowOffset?: Vector;
/** Radius of the shadow blur in pixels.
*
* @default 0 */
readonly shadowBlurRadius?: number;
}
const offscreenCanvas = (() => {
let cache: HTMLCanvasElement | null = null;
return (onscreenCanvas: HTMLCanvasElement): HTMLCanvasElement | null => {
if (cache == null) {
cache = onscreenCanvas.ownerDocument?.createElement("canvas") ?? null;
}
if (cache != null) {
cache.width = onscreenCanvas.width;
cache.height = onscreenCanvas.height;
}
return cache;
};
})();
export default class Label extends Actor {
/** The text to draw. */
public text: string;
/** True if the text is bold.
*
* @default false */
public bold: boolean;
/** The CSS font family string (e.g. `sans-serif`, `Droid Sans Pro`). Web fonts
* are supported, same as in CSS. */
public fontFamily: string;
/** Font size in the selected units (`fontUnit`).
*
* @default 10 */
public fontSize: number;
/** The font style for this label
*
* @default FontStyle.Normal */
public fontStyle: FontStyle;
/** Horizontal text alignment.
*
* @default TextAlign.Left */
public textAlign: TextAlign;
/** Vertical baseline text alignment.
*
* @default BaseAlign.Bottom */
public baseAlign: BaseAlign;
/** The height of each line of text in pixels, for multiline text.
*
* Set to `undefined` to use the font size as the line height. */
public lineHeight: number | undefined;
/** The maximum width of a line of text, in pixels, after which the text
* will wrap to the next line.
*
* Set to `Infinity` to disable text wrapping. */
public wrapWidth: number;
/** The color of the text outline. Set to Color.Transparent to hide the outline. */
public outlineColor: Color;
/** The width of the text outline, in pixels. Set to 0 to hide the outline. */
public outlineWidth: number;
/** The color of the shadow. Set to Color.Transparent to hide the shadow. */
public shadowColor: Color;
/** The offset of the shadow from the text, in pixels. */
public shadowOffset: Vector;
/** Radius of the shadow blur in pixels. */
public shadowBlurRadius: number;
/** Overall opacity of the label, from 0 to 1. */
public alpha: number;
/** Do not use.
*
* @deprecated This field has an unwanted and confusing interaction with
* the underlying Actor class.
*
* @see https://github.com/excaliburjs/Excalibur/issues/874#issuecomment-814557137 */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Unused override to mark deprecated
public opacity: number;
public constructor(options?: LabelOptions) {
super(options);
this.text = options?.text ?? "";
this.bold = options?.bold ?? false;
this.fontFamily = options?.fontFamily ?? "sans-serif";
this.fontSize = options?.fontSize ?? 10;
this.fontStyle = options?.fontStyle ?? FontStyle.Normal;
this.textAlign = options?.textAlign ?? TextAlign.Left;
this.baseAlign = options?.baseAlign ?? BaseAlign.Bottom;
this.lineHeight = options?.lineHeight;
this.wrapWidth = options?.wrapWidth ?? Infinity;
this.outlineColor = options?.outlineColor ?? Color.Transparent;
this.outlineWidth = options?.outlineWidth ?? 0;
this.shadowColor = options?.shadowColor ?? Color.Transparent;
this.shadowOffset = options?.shadowOffset ?? Vector.Zero;
this.shadowBlurRadius = options?.shadowBlurRadius ?? 0;
this.alpha = options?.alpha ?? options?.opacity ?? 1;
if (
hasProperty(excalibur, "Flags") &&
hasProperty(excalibur, "Legacy") &&
hasProperty(excalibur.Legacy, "LegacyDrawing") &&
!excalibur.Flags.isEnabled(excalibur.Legacy.LegacyDrawing)
) {
throw new Error(
"excalibur-extended-label requires you to call Flags.useLegacyDrawing() before constructing Excalibur Engine"
);
}
}
public override draw(context: CanvasRenderingContext2D, delta: number): void {
const shadowVisible =
this.shadowColor.a !== 0 &&
(this.shadowBlurRadius !== 0 || this.shadowOffset.x !== 0 || this.shadowOffset.y !== 0);
const canvas2 = shadowVisible ? offscreenCanvas(context.canvas) : null;
const context2 = canvas2?.getContext("2d") ?? context;
context2.save();
if (context2 !== context) {
context2.setTransform(context.getTransform());
}
context2.translate(this.pos.x, this.pos.y);
context2.scale(this.scale.x, this.scale.y);
context2.rotate(this.rotation);
context2.textAlign = lookupTextAlign(this.textAlign);
context2.textBaseline = lookupBaseAlign(this.baseAlign);
context2.font = `${lookupFontStyle(this.fontStyle)} ${lookupFontWeight(this.bold)} ${
this.fontSize
}px ${this.fontFamily}`;
context2.lineWidth = this.outlineWidth * 2;
context2.strokeStyle =
this.outlineWidth === 0 ? "transparent" : this.outlineColor.toString();
context2.fillStyle = this.color.toString();
const lines = this.wrapLines(context2);
const lineHeight = this.lineHeight ?? this.fontSize;
lines.forEach((line, i) => void context2.strokeText(line, 0, i * lineHeight));
lines.forEach((line, i) => void context2.fillText(line, 0, i * lineHeight));
context2.restore();
if (canvas2 != null) {
context.save();
context.resetTransform();
context.shadowBlur = this.shadowBlurRadius;
context.shadowColor = this.shadowColor.toString();
context.shadowOffsetX = this.shadowOffset.x;
context.shadowOffsetY = this.shadowOffset.y;
context.globalAlpha = this.alpha;
context.drawImage(canvas2, 0, 0);
context.restore();
}
}
private wrapLines(context: CanvasRenderingContext2D): string[] {
const lines = this.text.split("\n");
if (isFinite(this.wrapWidth)) {
return chain(lines)
.map(mapFn(line => line.split(/\s+/u)))
.map(
mapFn(
foldFn(
([line, ...lines], word) =>
line == null
? [word]
: context.measureText(`${line} ${word}`).width < this.wrapWidth
? [`${line} ${word}`, ...lines]
: [word, line, ...lines],
[] as string[]
)
)
)
.map(concat)
.map(toArray)
.value.reverse();
} else {
return lines;
}
}
}
function lookupTextAlign(textAlign: TextAlign): CanvasTextAlign {
switch (textAlign) {
case TextAlign.Left:
return "left";
case TextAlign.Right:
return "right";
case TextAlign.Center:
return "center";
case TextAlign.End:
return "end";
case TextAlign.Start:
return "start";
}
}
function lookupBaseAlign(baseAlign: BaseAlign): CanvasTextBaseline {
switch (baseAlign) {
case BaseAlign.Alphabetic:
return "alphabetic";
case BaseAlign.Bottom:
return "bottom";
case BaseAlign.Hanging:
return "hanging";
case BaseAlign.Ideographic:
return "ideographic";
case BaseAlign.Middle:
return "middle";
case BaseAlign.Top:
return "top";
}
}
function lookupFontStyle(fontStyle: FontStyle): string {
switch (fontStyle) {
case FontStyle.Italic:
return "italic";
case FontStyle.Normal:
return "normal";
case FontStyle.Oblique:
return "oblique";
}
}
function lookupFontWeight(bold: boolean): string {
return bold ? "bold" : "normal";
}