From a981c5dcc1f0f448ede289bac27330cc4f93df5e Mon Sep 17 00:00:00 2001 From: Matt Jennings Date: Fri, 16 Feb 2024 22:56:22 -0600 Subject: [PATCH] feat: ex.FontSource resource type (#2934) ## Changes: - Added `ex.FontSource` resource type ```typescript const fontSource = new ex.FontSource('/my-font.ttf', 'My Font') loader.addResource(fontSource) game.start(loader).then(() => { const font = fontSource.toFont() // returns ex.Font }) ``` Font options can be defined either at the source or at the `toFont()` call. If defined in both, `toFont(options)` will override the options in the `FontSource`. ```typescript const fontSource = new ex.FontSource('/my-font.ttf', 'My Font', { filtering: ex.ImageFiltering.Pixel, size: 16, // set a default size }) const font = fontSource.toFont({ // override just the size size: 20, }) ``` - adds `"dom.iterable"` to `lib` in tsconfig.json as it was needed to get the correct types for `document.fonts` --- CHANGELOG.md | 23 +++++++++ karma.conf.js | 1 + src/engine/Resources/Font.ts | 76 +++++++++++++++++++++++++++ src/engine/Resources/Index.ts | 1 + src/engine/tsconfig.json | 1 + src/spec/FontSourceSpec.ts | 82 ++++++++++++++++++++++++++++++ src/spec/fonts/Gorgeous Pixel.ttf | Bin 0 -> 18244 bytes src/spec/tsconfig.json | 1 + 8 files changed, 185 insertions(+) create mode 100644 src/engine/Resources/Font.ts create mode 100644 src/spec/FontSourceSpec.ts create mode 100644 src/spec/fonts/Gorgeous Pixel.ttf diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e22a8b33..b22e446ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,29 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added `ex.FontSource` resource type + ```typescript + const fontSource = new ex.FontSource('/my-font.ttf', 'My Font') + loader.addResource(fontSource) + + game.start(loader).then(() => { + const font = fontSource.toFont() // returns ex.Font + }) + ``` + + Font options can be defined either at the source or at the `toFont()` call. If defined in both, `toFont(options)` will + override the options in the `FontSource`. + + ```typescript + const fontSource = new ex.FontSource('/my-font.ttf', 'My Font', { + filtering: ex.ImageFiltering.Pixel, + size: 16, // set a default size + }) + const font = fontSource.toFont({ + // override just the size + size: 20, + }) + ``` - Added fullscreen after load feature! You can optionally provide a `fullscreenContainer` with a string id or an instance of the `HTMLElement` ```typescript new ex.Loader({ diff --git a/karma.conf.js b/karma.conf.js index 28e4a589e..3342a5448 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -86,6 +86,7 @@ module.exports = (config) => { { pattern: 'src/spec/images/**/*.txt', included: false, served: true }, { pattern: 'src/spec/images/**/*.css', included: false, served: true }, { pattern: 'src/spec/images/**/*.woff2', included: false, served: true }, + { pattern: 'src/spec/fonts/**/*.ttf', included: false, served: true }, ], mime: { 'text/x-typescript': ['ts', 'tsx'] }, preprocessors: { diff --git a/src/engine/Resources/Font.ts b/src/engine/Resources/Font.ts new file mode 100644 index 000000000..546149dd2 --- /dev/null +++ b/src/engine/Resources/Font.ts @@ -0,0 +1,76 @@ +import { Font } from '../Graphics/Font'; +import { FontOptions } from '../Graphics/FontCommon'; +import { GraphicOptions, RasterOptions } from '../Graphics'; +import { Loadable } from '../Interfaces/Loadable'; +import { Resource } from './Resource'; + + +export interface FontSourceOptions + extends Omit, + GraphicOptions, + RasterOptions { + /** + * Whether or not to cache-bust requests + */ + bustCache?: boolean +} + +export class FontSource implements Loadable { + private _resource: Resource; + private _isLoaded = false; + private _options: FontSourceOptions; + + data!: FontFace; + + + constructor( + /** + * Path to the font resource relative from the HTML document hosting the game, or absolute + */ + public readonly path: string, + /** + * The font family name + */ + public readonly family: string, + { bustCache, ...options }: FontSourceOptions = {} + ) { + this._resource = new Resource(path, 'blob', bustCache); + this._options = options; + } + + async load(): Promise { + if (this.isLoaded()) { + return this.data; + } + + try { + const blob = await this._resource.load(); + const url = URL.createObjectURL(blob); + + if (!this.data) { + this.data = new FontFace(this.family, `url(${url})`); + document.fonts.add(this.data); + } + + await this.data.load(); + this._isLoaded = true; + } catch (error) { + throw `Error loading FontSource from path '${this.path}' with error [${ + (error as Error).message + }]`; + } + return this.data; + } + + isLoaded(): boolean { + return this._isLoaded; + } + + /** + * Build a font from this FontSource. + * @param options {FontOptions} Override the font options + */ + toFont(options?: FontOptions): Font { + return new Font({ family: this.family, ...this._options, ...options }); + } +} diff --git a/src/engine/Resources/Index.ts b/src/engine/Resources/Index.ts index ead9e734b..3f91af5bf 100644 --- a/src/engine/Resources/Index.ts +++ b/src/engine/Resources/Index.ts @@ -1,3 +1,4 @@ export * from './Resource'; export * from './Sound/Index'; export * from './Gif'; +export * from './Font'; \ No newline at end of file diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json index 82ae668e7..727bfddfd 100644 --- a/src/engine/tsconfig.json +++ b/src/engine/tsconfig.json @@ -20,6 +20,7 @@ "downlevelIteration": true, "lib": [ "dom", + "dom.iterable", "es5", "es2018" ], diff --git a/src/spec/FontSourceSpec.ts b/src/spec/FontSourceSpec.ts new file mode 100644 index 000000000..f7ff4cebf --- /dev/null +++ b/src/spec/FontSourceSpec.ts @@ -0,0 +1,82 @@ +import * as ex from '@excalibur'; + +describe('A FontSource', () => { + it('exists', () => { + expect(ex.FontSource).toBeDefined(); + }); + + it('can be constructed', () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel'); + expect(fontSource).toBeDefined(); + }); + + it('can load fonts', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel'); + + await fontSource.load(); + + expect(fontSource.data).not.toBeUndefined(); + }); + + it('adds a FontFace to document.fonts', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel'); + + await fontSource.load(); + + expect(document.fonts.has(fontSource.data)).toBeTrue(); + }); + + it('can convert to a Font', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel'); + + await fontSource.load(); + const font = fontSource.toFont(); + + expect(font).toBeInstanceOf(ex.Font); + }); + + it('will use options from FontSource', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel', { + size: 50 + }); + + await fontSource.load(); + const font = fontSource.toFont(); + + expect(font.size).toBe(50); + }); + + it('will override options when converting to a Font', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel', { + size: 50, + opacity: 0.5 + }); + + await fontSource.load(); + const font = fontSource.toFont({ + size: 100 + }); + + expect(font.size).toBe(100); + expect(font.opacity).toBe(0.5); + }); + + it('will resolve the font if already loaded', async () => { + const fontSource = new ex.FontSource('src/spec/fonts/Gorgeous Pixel.ttf', 'Gorgeous Pixel'); + + const font = await fontSource.load(); + + expect(fontSource.isLoaded()).toBe(true); + const alreadyLoadedFont = await fontSource.load(); + + expect(font).toBe(alreadyLoadedFont); + }); + + it('will return error if font doesn\'t exist', async () => { + const fontSource = new ex.FontSource('42.ttf', '42'); + + await expectAsync(fontSource.load()).toBeRejectedWith( + 'Error loading FontSource from path \'42.ttf\' with error [Not Found]' + ); + }); +}); diff --git a/src/spec/fonts/Gorgeous Pixel.ttf b/src/spec/fonts/Gorgeous Pixel.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2917190e21da29d4d3b7a049fdfd53f3f477d987 GIT binary patch literal 18244 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