From 012b02cdd4a9c66fb9ea82eb7340268648decd11 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Mon, 15 Feb 2021 16:27:15 -0600 Subject: [PATCH] feat: New Graphics API + Flagged WebGL Support (#1730) Related to v0.25.0 Roadmap #1161 Closes #136 #580 #589 #652 #1529 #420 #172 #276 #384 #387 #481 ## TODO before ~~merge~~ next release (team spoke offline) * [ ] Docs PR * [ ] New Benchmarks ## Changes: - Flagged Support for WebGL implementation behind `ex.Flags.enable('use-webgl')` - Batched draw calls by default - New Graphics API - `ExcaliburGraphicsContext` as drawing abstraction - ECS Style `GraphicsComponent` & `GraphicsSystem` - `ImageSource` replaces `Texture` - New `Text` Graphics type for easy text drawing - New `SpriteFont` and `Font` for manipulating fonts - New `Rectangle`, `Circle`, and `Polygon` Graphics - New `GraphicsGroup` for grouping Graphics together into one - Improved `Animation` Graphic with events on `frame`, `loop`, and `end` - New `Canvas` Graphic to act as a `CanvasRenderingContext2D` shim * Update to debug prefix for debug drawing apis * Switch pooling implementations * Add docs * Ensure compatibility between old canvas and new graphics, remove z-index from ex ctx, add diagnostics * Remove un-used import * Fix lint * SpriteText WIP * Refactor Text and Fonts to be more coherent * Fix sprite flicker * Simplify interface * Rename to GraphicsDiagnostics * Add timescale to animation * First pass deprecate legacy Drawing * Basic spritefont shadow * Clean up + deprecate * Pool and Context tests, Partial Animation tests * Finish animation tests * WIP Lots of tests * Fix text rasterizer perf issues * Fix screen pixel ratio change * Change flag name * Fix particle tests * Rename RawImage -> ImageSource * Update refs RawImage -> ImageSource * Fix actor scale test * Test webfont in Travis * Test platform detection * Fix lint * Push tests * Switch to beforeAll * Add linux text snapshots * Fix windows images * Fix linux images * Teak particles fix tests * Fix sandbox build * Add Font watch * Fix iteration * Fix lint * Switch from iterable, remove xtra text * Skip appveyor for Text graphics * Really skip appveyor * Up appveyor timeout * Avoid deadlocking tests * Update timeout * Fix client syntax * Another strategy * More tests! * More tests * Fix tests * Bump for appveyor * Rect -> Rectangle * Bump appveyor build * Discover timing out test in appveyor * Remove new tests round 1 to discover appveyor issue * Bisect 1 * Bisect 2 * Bisect 3 * Self host fonts * Simplify font definitions * Add tests back * Revert appveyor exclusion * Update image diff matcher * Switch graphics to new matcher * Refactor texture loader * Move graphics component draw to system * Fix lint * Change whenLoaded to checkLoaded * Change to ready * Fix tests * Fill out obsolete and deprecated * Fix some doc links * Fix all doc links * Switch flag around * Tests default to legacy draw * fix ImageSource after merge * Fix after merge, small refactor * Fix tests * Add default graphic * Back port sprite sheet spacing * Fix lint * Update per PR feedback * More PR feedback * Back port some tiled updates to new graphics * Adjust text and label to match previous behavior and quality * Fix lint * Fix text quality tests --- .vscode/settings.json | 19 +- appveyor.yml | 2 +- karma.conf.js | 17 +- sandbox/images/kenny-cards.png | Bin 0 -> 3512 bytes sandbox/src/game.ts | 438 +++++++++----- sandbox/tests/group/group.ts | 12 +- sandbox/tests/within/within.ts | 5 + src/engine/Actor.ts | 58 +- src/engine/Camera.ts | 39 +- src/engine/Collision/Body.ts | 10 +- src/engine/Collision/BoundingBox.ts | 10 + src/engine/Debug.ts | 25 + src/engine/Drawing/Animation.ts | 14 + src/engine/Drawing/CanvasDrawingSystem.ts | 17 +- src/engine/Drawing/Color.ts | 11 + src/engine/Drawing/Polygon.ts | 6 + src/engine/Drawing/Sprite.ts | 40 +- src/engine/Drawing/SpriteEffects.ts | 38 ++ src/engine/Drawing/SpriteSheet.ts | 26 +- src/engine/Engine.ts | 53 +- .../Components/TransformComponent.ts | 4 +- .../EntityComponentSystem/SystemManager.ts | 17 +- src/engine/Flags.ts | 15 + src/engine/Graphics/Animation.ts | 243 ++++++++ src/engine/Graphics/Canvas.ts | 45 ++ src/engine/Graphics/Circle.ts | 49 ++ .../Context/ExcaliburGraphicsContext.ts | 158 +++++ .../ExcaliburGraphicsContext2DCanvas.ts | 219 +++++++ .../Context/ExcaliburGraphicsContextWebGL.ts | 268 +++++++++ src/engine/Graphics/Context/batch.ts | 27 + .../Graphics/Context/draw-image-command.ts | 141 +++++ src/engine/Graphics/Context/image-renderer.ts | 276 +++++++++ src/engine/Graphics/Context/line-renderer.ts | 76 +++ src/engine/Graphics/Context/point-renderer.ts | 74 +++ src/engine/Graphics/Context/renderer.ts | 139 +++++ src/engine/Graphics/Context/shader.ts | 220 +++++++ .../Context/shaders/image-fragment.glsl | 30 + .../Context/shaders/image-vertex.glsl | 28 + .../Context/shaders/line-fragment.glsl | 9 + .../Graphics/Context/shaders/line-vertex.glsl | 15 + .../Context/shaders/point-fragment.glsl | 22 + .../Context/shaders/point-vertex.glsl | 11 + src/engine/Graphics/Context/state-stack.ts | 31 + src/engine/Graphics/Context/texture-loader.ts | 102 ++++ .../Graphics/Context/transform-stack.ts | 35 ++ src/engine/Graphics/Context/webgl-util.ts | 27 + src/engine/Graphics/Font.ts | 248 ++++++++ src/engine/Graphics/FontCommon.ts | 129 +++++ src/engine/Graphics/Graphic.ts | 218 +++++++ src/engine/Graphics/GraphicsComponent.ts | 402 +++++++++++++ src/engine/Graphics/GraphicsDiagnostics.ts | 8 + src/engine/Graphics/GraphicsGroup.ts | 91 +++ src/engine/Graphics/GraphicsSystem.ts | 190 +++++++ src/engine/Graphics/ImageSource.ts | 117 ++++ src/engine/Graphics/Polygon.ts | 64 +++ src/engine/Graphics/Raster.ts | 226 ++++++++ src/engine/Graphics/Rectangle.ts | 36 ++ src/engine/Graphics/Sprite.ts | 121 ++++ src/engine/Graphics/SpriteFont.ts | 154 +++++ src/engine/Graphics/SpriteSheet.ts | 120 ++++ src/engine/Graphics/Text.ts | 104 ++++ src/engine/Graphics/index.ts | 26 + src/engine/Interfaces/AudioImplementation.ts | 2 +- src/engine/Interfaces/Drawable.ts | 4 + src/engine/Label.ts | 481 +++++++--------- src/engine/Loader.ts | 11 +- src/engine/Math/matrix.ts | 333 +++++++++++ src/engine/Particles.ts | 80 ++- src/engine/Promises.ts | 9 + src/engine/Resources/Sound/Sound.ts | 2 +- src/engine/Scene.ts | 21 +- src/engine/Screen.ts | 14 +- src/engine/TileMap.ts | 92 ++- src/engine/Util/Actors.ts | 4 +- src/engine/Util/Pool.ts | 90 +++ src/engine/Util/SortedList.ts | 6 +- src/engine/Util/Util.ts | 16 +- src/engine/Util/Watch.ts | 34 ++ src/engine/files.d.ts | 5 + src/engine/index.ts | 8 +- src/engine/tsconfig.json | 1 + src/spec/EngineSpec.ts | 1 - src/spec/ExcaliburGraphicsContextSpec.ts | 334 +++++++++++ src/spec/GraphicSpec.ts | 136 +++++ src/spec/GraphicsAnimationSpec.ts | 379 ++++++++++++ src/spec/GraphicsCanvasSpec.ts | 109 ++++ src/spec/GraphicsCircleSpec.ts | 54 ++ src/spec/GraphicsComponentSpec.ts | 252 ++++++++ src/spec/GraphicsGroupSpec.ts | 93 +++ src/spec/GraphicsImageSourceSpec.ts | 82 +++ src/spec/GraphicsPolygonSpec.ts | 54 ++ src/spec/GraphicsRasterSpec.ts | 107 ++++ src/spec/GraphicsRectangleSpec.ts | 63 ++ src/spec/GraphicsSpriteSheetSpec.ts | 103 ++++ src/spec/GraphicsSpriteSpec.ts | 152 +++++ src/spec/GraphicsSystemSpec.ts | 90 +++ src/spec/GraphicsTextSpec.ts | 538 ++++++++++++++++++ src/spec/LabelSpec.ts | 3 +- src/spec/ParticleSpec.ts | 45 +- src/spec/PoolSpec.ts | 93 +++ src/spec/ScreenSpec.ts | 12 +- src/spec/TileMapSpec.ts | 15 +- .../2d-drawgraphic.png | Bin 0 -> 583 bytes .../2d-drawline.png | Bin 0 -> 667 bytes .../2d-drawpoint.png | Bin 0 -> 1244 bytes .../2d-snap-to-pixel.png | Bin 0 -> 592 bytes .../2d-transform.png | Bin 0 -> 655 bytes .../webgl-drawline.png | Bin 0 -> 594 bytes .../webgl-drawpoint.png | Bin 0 -> 1155 bytes .../webgl-rect.png | Bin 0 -> 603 bytes .../webgl-transform.png | Bin 0 -> 619 bytes src/spec/images/GraphicSpec/base.png | Bin 0 -> 545 bytes src/spec/images/GraphicSpec/debug.png | Bin 0 -> 699 bytes src/spec/images/GraphicSpec/flipped.png | Bin 0 -> 555 bytes src/spec/images/GraphicSpec/opacity.png | Bin 0 -> 556 bytes src/spec/images/GraphicSpec/rotated.png | Bin 0 -> 1487 bytes src/spec/images/GraphicSpec/scaled.png | Bin 0 -> 580 bytes .../images/GraphicsAnimationSpec/frame-1.png | Bin 0 -> 420 bytes .../images/GraphicsAnimationSpec/frame-2.png | Bin 0 -> 578 bytes .../images/GraphicsAnimationSpec/frame-3.png | Bin 0 -> 576 bytes src/spec/images/GraphicsCanvasSpec/draw.png | Bin 0 -> 557 bytes src/spec/images/GraphicsCircleSpec/circle.png | Bin 0 -> 1048 bytes .../GraphicsGroupSpec/graphics-group.png | Bin 0 -> 561 bytes src/spec/images/GraphicsPolygonSpec/poly.png | Bin 0 -> 918 bytes src/spec/images/GraphicsRasterSpec/raster.png | Bin 0 -> 570 bytes .../images/GraphicsRectSpec/clone-rect.png | Bin 0 -> 595 bytes src/spec/images/GraphicsRectSpec/rect.png | Bin 0 -> 556 bytes .../images/GraphicsSpriteSpec/dest-size.png | Bin 0 -> 538 bytes .../images/GraphicsSpriteSpec/dest-view.png | Bin 0 -> 1135 bytes .../images/GraphicsSpriteSpec/source-view.png | Bin 0 -> 572 bytes .../GraphicsSystemSpec/graphics-system.png | Bin 0 -> 1149 bytes .../images/GraphicsTextSpec/bold-linux.png | Bin 0 -> 2094 bytes src/spec/images/GraphicsTextSpec/bold.png | Bin 0 -> 1604 bytes .../images/GraphicsTextSpec/flipped-linux.png | Bin 0 -> 1874 bytes src/spec/images/GraphicsTextSpec/flipped.png | Bin 0 -> 1604 bytes src/spec/images/GraphicsTextSpec/fonts.css | 80 +++ .../GraphicsTextSpec/fonts/italic-400.woff2 | Bin 0 -> 13780 bytes .../GraphicsTextSpec/fonts/italic-600.woff2 | Bin 0 -> 13852 bytes .../GraphicsTextSpec/fonts/italic-700.woff2 | Bin 0 -> 13880 bytes .../GraphicsTextSpec/fonts/italic-800.woff2 | Bin 0 -> 13960 bytes .../GraphicsTextSpec/fonts/normal-400.woff2 | Bin 0 -> 14380 bytes .../GraphicsTextSpec/fonts/normal-600.woff2 | Bin 0 -> 14880 bytes .../GraphicsTextSpec/fonts/normal-700.woff2 | Bin 0 -> 15056 bytes .../GraphicsTextSpec/fonts/normal-800.woff2 | Bin 0 -> 15088 bytes .../images/GraphicsTextSpec/italic-linux.png | Bin 0 -> 2144 bytes src/spec/images/GraphicsTextSpec/italic.png | Bin 0 -> 1823 bytes .../GraphicsTextSpec/rotated-left-linux.png | Bin 0 -> 1536 bytes .../images/GraphicsTextSpec/rotated-left.png | Bin 0 -> 1302 bytes .../images/GraphicsTextSpec/rotated-linux.png | Bin 0 -> 1991 bytes .../GraphicsTextSpec/rotated-right-linux.png | Bin 0 -> 1419 bytes .../images/GraphicsTextSpec/rotated-right.png | Bin 0 -> 1296 bytes src/spec/images/GraphicsTextSpec/rotated.png | Bin 0 -> 1780 bytes .../images/GraphicsTextSpec/shadow-linux.png | Bin 0 -> 3853 bytes src/spec/images/GraphicsTextSpec/shadow.png | Bin 0 -> 3858 bytes .../GraphicsTextSpec/spritefont-shadow.png | Bin 0 -> 1313 bytes .../GraphicsTextSpec/spritefont-text.png | Bin 0 -> 1132 bytes .../images/GraphicsTextSpec/spritefont.png | Bin 0 -> 1357 bytes .../images/GraphicsTextSpec/text-linux.png | Bin 0 -> 1955 bytes src/spec/images/GraphicsTextSpec/text.png | Bin 0 -> 1589 bytes src/spec/images/ParticleSpec/Particles.png | Bin 10956 -> 10530 bytes .../SpriteSheetSpec/NewSpriteSpacing.png | Bin 0 -> 1937 bytes .../SpriteSheetSpec/kenny-cards-license.txt | 22 + .../images/SpriteSheetSpec/kenny-cards.png | Bin 0 -> 3512 bytes src/spec/tsconfig.json | 1 + src/spec/util/TestUtils.ts | 3 +- webpack.config.js | 4 + 166 files changed, 8821 insertions(+), 592 deletions(-) create mode 100644 sandbox/images/kenny-cards.png create mode 100644 src/engine/Graphics/Animation.ts create mode 100644 src/engine/Graphics/Canvas.ts create mode 100644 src/engine/Graphics/Circle.ts create mode 100644 src/engine/Graphics/Context/ExcaliburGraphicsContext.ts create mode 100644 src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts create mode 100644 src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts create mode 100644 src/engine/Graphics/Context/batch.ts create mode 100644 src/engine/Graphics/Context/draw-image-command.ts create mode 100644 src/engine/Graphics/Context/image-renderer.ts create mode 100644 src/engine/Graphics/Context/line-renderer.ts create mode 100644 src/engine/Graphics/Context/point-renderer.ts create mode 100644 src/engine/Graphics/Context/renderer.ts create mode 100644 src/engine/Graphics/Context/shader.ts create mode 100644 src/engine/Graphics/Context/shaders/image-fragment.glsl create mode 100644 src/engine/Graphics/Context/shaders/image-vertex.glsl create mode 100644 src/engine/Graphics/Context/shaders/line-fragment.glsl create mode 100644 src/engine/Graphics/Context/shaders/line-vertex.glsl create mode 100644 src/engine/Graphics/Context/shaders/point-fragment.glsl create mode 100644 src/engine/Graphics/Context/shaders/point-vertex.glsl create mode 100644 src/engine/Graphics/Context/state-stack.ts create mode 100644 src/engine/Graphics/Context/texture-loader.ts create mode 100644 src/engine/Graphics/Context/transform-stack.ts create mode 100644 src/engine/Graphics/Context/webgl-util.ts create mode 100644 src/engine/Graphics/Font.ts create mode 100644 src/engine/Graphics/FontCommon.ts create mode 100644 src/engine/Graphics/Graphic.ts create mode 100644 src/engine/Graphics/GraphicsComponent.ts create mode 100644 src/engine/Graphics/GraphicsDiagnostics.ts create mode 100644 src/engine/Graphics/GraphicsGroup.ts create mode 100644 src/engine/Graphics/GraphicsSystem.ts create mode 100644 src/engine/Graphics/ImageSource.ts create mode 100644 src/engine/Graphics/Polygon.ts create mode 100644 src/engine/Graphics/Raster.ts create mode 100644 src/engine/Graphics/Rectangle.ts create mode 100644 src/engine/Graphics/Sprite.ts create mode 100644 src/engine/Graphics/SpriteFont.ts create mode 100644 src/engine/Graphics/SpriteSheet.ts create mode 100644 src/engine/Graphics/Text.ts create mode 100644 src/engine/Graphics/index.ts create mode 100644 src/engine/Math/matrix.ts create mode 100644 src/engine/Util/Pool.ts create mode 100644 src/engine/Util/Watch.ts create mode 100644 src/spec/ExcaliburGraphicsContextSpec.ts create mode 100644 src/spec/GraphicSpec.ts create mode 100644 src/spec/GraphicsAnimationSpec.ts create mode 100644 src/spec/GraphicsCanvasSpec.ts create mode 100644 src/spec/GraphicsCircleSpec.ts create mode 100644 src/spec/GraphicsComponentSpec.ts create mode 100644 src/spec/GraphicsGroupSpec.ts create mode 100644 src/spec/GraphicsImageSourceSpec.ts create mode 100644 src/spec/GraphicsPolygonSpec.ts create mode 100644 src/spec/GraphicsRasterSpec.ts create mode 100644 src/spec/GraphicsRectangleSpec.ts create mode 100644 src/spec/GraphicsSpriteSheetSpec.ts create mode 100644 src/spec/GraphicsSpriteSpec.ts create mode 100644 src/spec/GraphicsSystemSpec.ts create mode 100644 src/spec/GraphicsTextSpec.ts create mode 100644 src/spec/PoolSpec.ts create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/2d-drawgraphic.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/2d-drawline.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/2d-drawpoint.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/2d-snap-to-pixel.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/2d-transform.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawline.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawpoint.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/webgl-rect.png create mode 100644 src/spec/images/ExcaliburGraphicsContextSpec/webgl-transform.png create mode 100644 src/spec/images/GraphicSpec/base.png create mode 100644 src/spec/images/GraphicSpec/debug.png create mode 100644 src/spec/images/GraphicSpec/flipped.png create mode 100644 src/spec/images/GraphicSpec/opacity.png create mode 100644 src/spec/images/GraphicSpec/rotated.png create mode 100644 src/spec/images/GraphicSpec/scaled.png create mode 100644 src/spec/images/GraphicsAnimationSpec/frame-1.png create mode 100644 src/spec/images/GraphicsAnimationSpec/frame-2.png create mode 100644 src/spec/images/GraphicsAnimationSpec/frame-3.png create mode 100644 src/spec/images/GraphicsCanvasSpec/draw.png create mode 100644 src/spec/images/GraphicsCircleSpec/circle.png create mode 100644 src/spec/images/GraphicsGroupSpec/graphics-group.png create mode 100644 src/spec/images/GraphicsPolygonSpec/poly.png create mode 100644 src/spec/images/GraphicsRasterSpec/raster.png create mode 100644 src/spec/images/GraphicsRectSpec/clone-rect.png create mode 100644 src/spec/images/GraphicsRectSpec/rect.png create mode 100644 src/spec/images/GraphicsSpriteSpec/dest-size.png create mode 100644 src/spec/images/GraphicsSpriteSpec/dest-view.png create mode 100644 src/spec/images/GraphicsSpriteSpec/source-view.png create mode 100644 src/spec/images/GraphicsSystemSpec/graphics-system.png create mode 100644 src/spec/images/GraphicsTextSpec/bold-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/bold.png create mode 100644 src/spec/images/GraphicsTextSpec/flipped-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/flipped.png create mode 100644 src/spec/images/GraphicsTextSpec/fonts.css create mode 100644 src/spec/images/GraphicsTextSpec/fonts/italic-400.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/italic-600.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/italic-700.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/italic-800.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/normal-400.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/normal-600.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/normal-700.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/fonts/normal-800.woff2 create mode 100644 src/spec/images/GraphicsTextSpec/italic-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/italic.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated-left-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated-left.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated-right-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated-right.png create mode 100644 src/spec/images/GraphicsTextSpec/rotated.png create mode 100644 src/spec/images/GraphicsTextSpec/shadow-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/shadow.png create mode 100644 src/spec/images/GraphicsTextSpec/spritefont-shadow.png create mode 100644 src/spec/images/GraphicsTextSpec/spritefont-text.png create mode 100644 src/spec/images/GraphicsTextSpec/spritefont.png create mode 100644 src/spec/images/GraphicsTextSpec/text-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/text.png create mode 100644 src/spec/images/SpriteSheetSpec/NewSpriteSpacing.png create mode 100644 src/spec/images/SpriteSheetSpec/kenny-cards-license.txt create mode 100644 src/spec/images/SpriteSheetSpec/kenny-cards.png diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b2059b7..b3c5e3d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,22 @@ "typescript.tsdk": "./node_modules/typescript/lib", "editor.codeActionsOnSave": { "source.fixAll.eslint": true - } + }, + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#1d87d7", + "activityBar.activeBorder": "#f4abd4", + "activityBar.background": "#1d87d7", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#f4abd4", + "activityBarBadge.foreground": "#15202b", + "statusBar.background": "#176baa", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#1d87d7", + "titleBar.activeBackground": "#176baa", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#176baa99", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#176BAA" } diff --git a/appveyor.yml b/appveyor.yml index e4b6c76e..23ab3f04 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,5 +1,5 @@ version: '{build}' -image: 'Visual Studio 2017' +image: Visual Studio 2019 # Fix line endings on Windows init: - git config --global core.autocrlf true diff --git a/karma.conf.js b/karma.conf.js index 0d7ba012..ea20430d 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -3,16 +3,25 @@ const process = require('process'); const path = require('path'); process.env.CHROME_BIN = require('puppeteer').executablePath(); +const isAppveyor = process.env.APPVEYOR_BUILD_NUMBER ? true : false; + module.exports = (config) => { config.set({ singleRun: true, frameworks: ['jasmine'], + client: { + jasmine: { + timeoutInterval: 30000 + } + }, files: [ 'src/spec/_boot.ts', { pattern: 'src/spec/images/**/*.mp3', included: false, served: true }, { pattern: 'src/spec/images/**/*.png', included: false, served: true }, { pattern: 'src/spec/images/**/*.gif', included: false, served: true }, - { pattern: 'src/spec/images/**/*.txt', included: false, served: true } + { 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 }, ], mime: { 'text/x-typescript': ['ts', 'tsx'] }, preprocessors: { @@ -52,6 +61,10 @@ module.exports = (config) => { } ] }, + { + test: /\.glsl$/, + use: ['raw-loader'] + }, { test: /\.ts$/, enforce: 'post', @@ -70,7 +83,6 @@ module.exports = (config) => { stats: 'normal' }, reporters: ['progress', 'coverage-istanbul'], - coverageReporter: { reporters: [ { type: 'html', dir: 'coverage/' }, @@ -86,6 +98,7 @@ module.exports = (config) => { }, browsers: ['ChromeHeadless_with_audio'], + browserNoActivityTimeout: 60000, // appveyor is slow :( customLaunchers: { ChromeHeadless_with_audio: { base: 'ChromeHeadless', diff --git a/sandbox/images/kenny-cards.png b/sandbox/images/kenny-cards.png new file mode 100644 index 0000000000000000000000000000000000000000..69773596829c04d7db5cde0213b3cb20d592bf6e GIT binary patch literal 3512 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 literal 0 HcmV?d00001 diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 98ff99ab..25f12d6b 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -39,14 +39,16 @@ logger.defaultLevel = ex.LogLevel.Debug; var fullscreenButton = document.getElementById('fullscreen') as HTMLButtonElement; // Create an the game container +ex.Flags.enable(ex.Experiments.WebGL); var game = new ex.Engine({ - width: 800, - height: 600, - displayMode: ex.DisplayMode.Fill, - antialiasing: false, + width: 800 / 2, + height: 600 / 2, + viewport: { width: 800, height: 600 }, canvasElementId: 'game', suppressHiDPIScaling: false, - suppressPlayButton: true + suppressPlayButton: true, + antialiasing: false, + snapToPixel: true }); fullscreenButton.addEventListener('click', () => { @@ -58,21 +60,28 @@ fullscreenButton.addEventListener('click', () => { }); game.showDebug(true); -var heartTex = new ex.Texture('../images/heart.png'); -var imageRun = new ex.Texture('../images/PlayerRun.png'); -var imageJump = new ex.Texture('../images/PlayerJump.png'); -var imageBlocks = new ex.Texture('../images/BlockA0.png'); -var spriteFontImage = new ex.Texture('../images/SpriteFont.png'); +var heartTex = new ex.Graphics.ImageSource('../images/heart.png'); +var heartImageSource = new ex.Graphics.ImageSource('../images/heart.png'); +var imageRun = new ex.Graphics.ImageSource('../images/PlayerRun.png'); +var imageJump = new ex.Graphics.ImageSource('../images/PlayerJump.png'); +var imageRun2 = new ex.Graphics.ImageSource('../images/PlayerRun.png'); +var imageBlocks = new ex.Graphics.ImageSource('../images/BlockA0.png'); +var imageBlocksLegacy = new ex.Texture('../images/BlockA0.png'); +var spriteFontImage = new ex.Graphics.ImageSource('../images/SpriteFont.png'); var jump = new ex.Sound('../sounds/jump.wav', '../sounds/jump.mp3'); +var cards = new ex.Graphics.ImageSource('../images/kenny-cards.png'); jump.volume = 0.3; var loader = new ex.Loader(); +loader.addResource(heartImageSource); loader.addResource(heartTex); loader.addResource(imageRun); loader.addResource(imageJump); loader.addResource(imageBlocks); +loader.addResource(imageBlocksLegacy); loader.addResource(spriteFontImage); +loader.addResource(cards); loader.addResource(jump); // Set background color @@ -85,11 +94,172 @@ ex.Physics.acc = new ex.Vector(0, 800); // global accel // Add some UI //var heart = new ex.ScreenElement(0, 0, 20, 20); var heart = new ex.ScreenElement({ x: 0, y: 0, width: 20 * 2, height: 20 * 2 }); -var heartSprite = heartTex.asSprite(); +heart.graphics.anchor = ex.vec(0, 0); +var heartSprite = ex.Graphics.Sprite.from(heartTex); heartSprite.scale.setTo(2, 2); -heart.addDrawing(heartSprite); +// heart.addDrawing(heartSprite); +var newSprite = new ex.Graphics.Sprite({ image: heartImageSource }); +newSprite.scale = ex.vec(2, 2); + +var circle = new ex.Graphics.Circle({ + radius: 10, + color: ex.Color.Red +}); + +var rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Green +}); + +var triangle = new ex.Graphics.Polygon({ + points: [ex.vec(10 * 5, 0), ex.vec(0, 20 * 5), ex.vec(20 * 5, 20 * 5)], + color: ex.Color.Yellow +}); + +var anim = new ex.Graphics.Animation({ + frames: [ + { + graphic: newSprite, + duration: 1000 + }, + { + graphic: circle, + duration: 1000 + }, + { + graphic: rect, + duration: 1000 + }, + { + graphic: triangle, + duration: 1000 + } + ] +}); + +// alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', +// caseInsensitive: true, +// columns: 16, +// rows: 3, +// spWidth: 16, +// spHeight: 16 + +var cardSpriteSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: cards, + grid: { + rows: 4, + columns: 14, + spriteWidth: 42, + spriteHeight: 60 + }, + spacing: { + originOffset: { x: 11, y: 2 }, + margin: { x: 23, y: 5} + } +}); + +cardSpriteSheet.sprites.forEach(s => s.scale = ex.vec(2, 2)); + +var cardAnimation = ex.Graphics.Animation.fromSpriteSheet(cardSpriteSheet, ex.Util.range(0, 14 * 4), 200); + +var spriteFontSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: spriteFontImage, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } +}); + +var spriteFont = new ex.Graphics.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spriteSheet: spriteFontSheet +}); + +var spriteText = new ex.Graphics.Text({ + text: 'Sprite Text ❤️', + font: spriteFont +}); + +// anim.on('loop', (a) => { +// console.log('loop'); +// }); +// anim.on('frame', (f) => { +// console.log('frame'); +// }); +// anim.on('ended', (a) => { +// console.log('ended'); +// }); + +var text = new ex.Graphics.Text({ + text: 'This is raster text ❤️', + font: new ex.Graphics.Font({ size: 30 }) +}); +// text.showDebug = true; +var ran = new ex.Random(1337); + +var canvasGraphic = new ex.Graphics.Canvas({ + width: 200, + height: 200, + cache: true, + draw: (ctx: CanvasRenderingContext2D) => { + const color = new ex.Color(ran.integer(0, 255), ran.integer(0, 255), ran.integer(0, 255)); + ctx.fillStyle = color.toRGBA(); + ctx.fillRect(0, 0, 100, 100); + } +}); + +var group = new ex.Graphics.GraphicsGroup({ + members: [ + { + graphic: newSprite, + pos: ex.vec(0, 0) + }, + { + graphic: newSprite, + pos: ex.vec(50, 0) + }, + { + graphic: newSprite, + pos: ex.vec(0, 50) + }, + { + graphic: text, + pos: ex.vec(100, 20) + }, + { + graphic: circle, + pos: ex.vec(50, 50) + }, + { + graphic: anim, + pos: ex.vec(200, 200) + }, + { + graphic: cardAnimation, + pos: ex.vec(0, 200) + }, + { + graphic: spriteText, + pos: ex.vec(300, 200) + } + ] +}); + +heart.graphics.add(group); +heart.pos = ex.vec(10, 10); +heart.onPostDraw = (ctx) => { + ctx.fillStyle = ex.Color.Violet.toRGBA(); + ctx.fillRect(0, 0, 100, 100); +} game.add(heart); +var label = new ex.Label('Test Label', 200, 200); +game.add(label); + var pointer = new ex.Actor({ width: 25, @@ -120,38 +290,43 @@ game.input.pointers.primary.on('wheel', (ev) => { }) // Turn on debug diagnostics game.showDebug(false); -//var blockSprite = new ex.Sprite(imageBlocks, 0, 0, 65, 49); -var blockSprite = new ex.Sprite({ +var blockSpriteLegacy = new ex.Sprite(imageBlocksLegacy, 0, 0, 65, 49); +var blockSprite = new ex.Graphics.Sprite({ image: imageBlocks, - x: 0, - y: 0, - width: 65, - height: 49 + destSize: { + width: 65, + height: 49 + } }); // Create spritesheet //var spriteSheetRun = new ex.SpriteSheet(imageRun, 21, 1, 96, 96); -var spriteSheetRun = new ex.SpriteSheet({ +var spriteSheetRun = ex.Graphics.SpriteSheet.fromGrid({ image: imageRun, - columns: 21, - rows: 1, - spWidth: 96, - spHeight: 96 + grid: { + rows: 1, + columns: 21, + spriteHeight: 96, + spriteWidth: 96 + } }); //var spriteSheetJump = new ex.SpriteSheet(imageJump, 21, 1, 96, 96); -var spriteSheetJump = new ex.SpriteSheet({ +var spriteSheetJump = ex.Graphics.SpriteSheet.fromGrid({ image: imageJump, - columns: 21, - rows: 1, - spWidth: 96, - spHeight: 96 + grid: { + columns: 21, + rows: 1, + spriteWidth: 96, + spriteHeight: 96 + } }); var tileBlockWidth = 64, tileBlockHeight = 48, - spriteTiles = new ex.SpriteSheet(imageBlocks, 1, 1, tileBlockWidth, tileBlockHeight); + spriteTiles = new ex.Graphics.SpriteSheet({sprites: [ex.Graphics.Sprite.from(imageBlocks)] }); // create a collision map -//var tileMap = new ex.TileMap(100, 300, tileBlockWidth, tileBlockHeight, 4, 500); +// var tileMap = new ex.TileMap(100, 300, tileBlockWidth, tileBlockHeight, 4, 500); var tileMap = new ex.TileMap({ x: 100, y: 300, cellWidth: tileBlockWidth, cellHeight: tileBlockHeight, rows: 4, cols: 500 }); +var blocks = ex.Graphics.Sprite.from(imageBlocks); tileMap.data.forEach(function(cell: ex.Cell) { cell.solid = true; cell.addSprite(spriteTiles.sprites[0]); @@ -160,35 +335,35 @@ game.add(tileMap); // Create spriteFont //var spriteFont = new ex.SpriteFont(spriteFontImage, '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', true, 16, 3, 16, 16); -var spriteFont = new ex.SpriteFont({ - image: spriteFontImage, - alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', - caseInsensitive: true, - columns: 16, - rows: 3, - spWidth: 16, - spHeight: 16 -}); -//var label = new ex.Label('Hello World', 100, 100, null, spriteFont); -var label = new ex.Label({ - text: 'Hello World', - x: 100, - y: 100, - spriteFont: spriteFont -}); -game.add(label); +// var spriteFont = new ex.SpriteFont({ +// image: spriteFontImage, +// alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', +// caseInsensitive: true, +// columns: 16, +// rows: 3, +// spWidth: 16, +// spHeight: 16 +// }); +// //var label = new ex.Label('Hello World', 100, 100, null, spriteFont); +// var label = new ex.Label({ +// text: 'Hello World', +// x: 100, +// y: 100, +// spriteFont: spriteFont +// }); +// game.add(label); // Retrieve animations for blocks from sprite sheet -var blockAnimation = spriteTiles.getSprite(0).clone(); -blockAnimation.addEffect(new ex.Effects.Grayscale()); +var blockAnimation = spriteTiles.sprites[0].clone(); +// blockAnimation.addEffect(new ex.Effects.Grayscale()); // Animation 'enum' to prevent 'stringly' typed misspelling errors enum Animations { - Block, - Idle, - Left, - Right, - JumpRight, - JumpLeft + Block = 'Block', + Idle = 'Idle', + Left = 'Left', + Right = 'Right', + JumpRight = 'JumpRight', + JumpLeft = 'JumpLeft' } var currentX = 0; @@ -207,47 +382,37 @@ for (var i = 0; i < 36; i++) { //var block = new ex.Actor(currentX, 350 + Math.random() * 100, tileBlockWidth, tileBlockHeight, color); //block.collisionType = ex.CollisionType.Fixed; block.body.collider.group = blockGroup; - block.addDrawing(Animations.Block, blockAnimation); + block.graphics.add(blockAnimation); game.add(block); } var platform = new ex.Actor(400, 300, 200, 50, new ex.Color(0, 200, 0)); +platform.graphics.add(new ex.Graphics.Rectangle({ color: new ex.Color(0, 200, 0), width: 200, height: 50 })); platform.body.collider.type = ex.CollisionType.Fixed; -platform.actions - .moveTo(200, 300, 100) - .moveTo(600, 300, 100) - .moveTo(400, 300, 100) - .repeatForever(); +platform.actions.moveTo(200, 300, 100).moveTo(600, 300, 100).moveTo(400, 300, 100).repeatForever(); game.add(platform); var platform2 = new ex.Actor(800, 300, 200, 20, new ex.Color(0, 0, 140)); +platform2.graphics.add(new ex.Graphics.Rectangle({ color: new ex.Color(0, 0, 140), width: 200, height: 20 })); platform2.body.collider.type = ex.CollisionType.Fixed; -platform2.actions - .moveTo(2000, 300, 100) - .moveTo(2000, 100, 100) - .moveTo(800, 100, 100) - .moveTo(800, 300, 100) - .repeatForever(); +platform2.actions.moveTo(2000, 300, 100).moveTo(2000, 100, 100).moveTo(800, 100, 100).moveTo(800, 300, 100).repeatForever(); game.add(platform2); var platform3 = new ex.Actor(-200, 400, 200, 20, new ex.Color(50, 0, 100)); +platform3.graphics.add(new ex.Graphics.Rectangle({ color: new ex.Color(50, 0, 100), width: 200, height: 20 })); platform3.body.collider.type = ex.CollisionType.Fixed; -platform3.actions - .moveTo(-200, 800, 300) - .moveTo(-200, 400, 50) - .delay(3000) - .moveTo(-200, 300, 800) - .moveTo(-200, 400, 800) - .repeatForever(); +platform3.actions.moveTo(-200, 800, 300).moveTo(-200, 400, 50).delay(3000).moveTo(-200, 300, 800).moveTo(-200, 400, 800).repeatForever(); game.add(platform3); var platform4 = new ex.Actor(75, 300, 100, 50, ex.Color.Azure); +platform4.graphics.add(new ex.Graphics.Rectangle({ color: ex.Color.Azure, width: 100, height: 50 })); platform4.body.collider.type = ex.CollisionType.Fixed; game.add(platform4); // Test follow api var follower = new ex.Actor(50, 100, 20, 20, ex.Color.Black); +follower.graphics.add(new ex.Graphics.Rectangle({ color: ex.Color.Black, width: 20, height: 20 })); follower.body.collider.type = ex.CollisionType.PreventCollision; game.add(follower); @@ -262,6 +427,7 @@ var player = new ex.Actor({ enableCapturePointer: true, collisionType: ex.CollisionType.Active }); +player.graphics.copyGraphics = false; follower.actions .meet(player, 60) .asPromise() @@ -274,49 +440,69 @@ follower.actions player.rotation = 0; // Health bar example -var healthbar = new ex.Actor({x: 0, y: -70, width: 140, height: 5, color: new ex.Color(0, 255, 0)}); +var healthbar = new ex.Actor(0, -70, 140, 5, new ex.Color(0, 255, 0)); player.add(healthbar); +// player.onPostDraw = (ctx: CanvasRenderingContext2D) => { +// ctx.fillStyle = 'red'; +// ctx.fillRect(0, 0, 100, 100); +// }; +player.graphics.onPostDraw = (ctx: ex.Graphics.ExcaliburGraphicsContext) => { + ctx.debug.drawLine(ex.vec(0, 0), ex.vec(200, 0)); + ctx.debug.drawPoint(ex.vec(0, 0), { size: 20, color: ex.Color.Black }); +}; + +var healthbar2 = new ex.Graphics.Rectangle({ + width: 140, + height: 5, + color: new ex.Color(0, 255, 0) +}); -// Add Title above player -var playerLabel = new ex.Label({ - text: 'My Player', - pos: new ex.Vector(-70, -69), - fontFamily: 'Times New Roman' - // spriteFont: spriteFont +var backroundLayer = player.graphics.layers.create({ + name: 'background', + order: -1 }); -player.add(playerLabel); +backroundLayer.show(healthbar2, { offset: ex.vec(0, -70) }); +var playerText = new ex.Graphics.Text({ + text: 'A long piece of text is long', + font: new ex.Graphics.Font({ + size: 20, + family: 'Times New Roman' + }) +}); +// playerText.showDebug = true; +backroundLayer.show(playerText, { offset: ex.vec(0, -70) }); // Retrieve animations for player from sprite sheet -var left_sprites = spriteSheetRun.getAnimationBetween(game, 1, 11, 50).sprites; -var left = new ex.Animation({ - engine: game, - speed: 50, - sprites: left_sprites -}); +var left = ex.Graphics.Animation.fromSpriteSheet(spriteSheetRun, ex.Util.range(1, 10), 50); // var left = new ex.Animation(game, left_sprites, 50); -var right = spriteSheetRun.getAnimationBetween(game, 1, 11, 50); +var right = left.clone(); // spriteSheetRun.getAnimationBetween(game, 1, 11, 50); right.flipHorizontal = true; -var idle = spriteSheetRun.getAnimationByIndices(game, [0], 200); +var idle = ex.Graphics.Animation.fromSpriteSheet(spriteSheetRun, [0], 200); // spriteSheetRun.getAnimationByIndices(game, [0], 200); //idle.anchor.setTo(.5, .5); -var jumpLeft = spriteSheetJump.getAnimationBetween(game, 0, 11, 100); -var jumpRight = spriteSheetJump.getAnimationBetween(game, 11, 22, 100); -left.loop = true; -right.loop = true; -idle.loop = true; - -jumpRight.freezeFrame = 0; -jumpLeft.freezeFrame = 11; +var jumpLeft = ex.Graphics.Animation.fromSpriteSheet( + spriteSheetJump, + ex.Util.range(0, 10).reverse(), + 100, + ex.Graphics.AnimationStrategy.Freeze +); // spriteSheetJump.getAnimationBetween(game, 0, 11, 100); +var jumpRight = ex.Graphics.Animation.fromSpriteSheet(spriteSheetJump, ex.Util.range(11, 21), 100, ex.Graphics.AnimationStrategy.Freeze); // spriteSheetJump.getAnimationBetween(game, 11, 22, 100); +// left.loop = true; +// right.loop = true; +// idle.loop = true; + +// jumpRight.freezeFrame = 0; +// jumpLeft.freezeFrame = 11; // Add animations to player -player.addDrawing(Animations.Left, left); -player.addDrawing(Animations.Right, right); -player.addDrawing(Animations.Idle, idle); -player.addDrawing(Animations.JumpRight, jumpRight); -player.addDrawing(Animations.JumpLeft, jumpLeft); +player.graphics.add(Animations.Left, left); +player.graphics.add(Animations.Right, right); +player.graphics.add(Animations.Idle, idle); +player.graphics.add(Animations.JumpRight, jumpRight); +player.graphics.add(Animations.JumpLeft, jumpLeft); // Set default animation -player.setDrawing(Animations.Idle); +player.graphics.use(Animations.Idle); var inAir = true; var groundSpeed = 150; @@ -327,7 +513,7 @@ player.on('postupdate', () => { if (game.input.keyboard.isHeld(ex.Input.Keys.Left)) { direction = -1; if (!inAir) { - player.setDrawing(Animations.Left); + player.graphics.use(Animations.Left); } if (inAir) { player.vel.x = -airSpeed; @@ -337,7 +523,7 @@ player.on('postupdate', () => { } else if (game.input.keyboard.isHeld(ex.Input.Keys.Right)) { direction = 1; if (!inAir) { - player.setDrawing(Animations.Right); + player.graphics.use(Animations.Right); } if (inAir) { player.vel.x = airSpeed; @@ -351,9 +537,9 @@ player.on('postupdate', () => { player.vel.y = -jumpSpeed; inAir = true; if (direction === 1) { - player.setDrawing(Animations.JumpRight); + player.graphics.use(Animations.JumpRight).reset(); } else { - player.setDrawing(Animations.JumpLeft); + player.graphics.use(Animations.JumpLeft).reset(); } jump.play(); } @@ -364,7 +550,7 @@ game.input.keyboard.on('up', (e?: ex.Input.KeyEvent) => { if (inAir) return; if (e.key === ex.Input.Keys.Left || e.key === ex.Input.Keys.Right) { - player.setDrawing(Animations.Idle); + player.graphics.use(Animations.Idle); } }); @@ -420,7 +606,7 @@ player.on('precollision', (data?: ex.PreCollisionEvent) => { isColliding = true; if (inAir) { - player.setDrawing(Animations.Idle); + player.graphics.use(Animations.Idle); } inAir = false; if ( @@ -472,7 +658,7 @@ game.input.keyboard.on('down', (keyDown?: ex.Input.KeyEvent) => { if (keyDown.key === ex.Input.Keys.B) { var block = new ex.Actor(currentX, 350, 44, 50, color); currentX += 46; - block.addDrawing(Animations.Block, blockAnimation); + block.graphics.add(blockAnimation); game.add(block); } if (keyDown.key === ex.Input.Keys.D) { @@ -497,8 +683,7 @@ var camera = game.currentScene.camera; game.add(player); // Add particle emitter -var sprite = blockSprite.clone(); -sprite.anchor = new ex.Vector(0.5, 0.5); +// sprite.anchor = new ex.Vector(0.5, 0.5); var emitter = new ex.ParticleEmitter({ pos: new ex.Vector(100, 300), width: 2, @@ -512,36 +697,15 @@ var emitter = new ex.ParticleEmitter({ opacity: 0.84, fadeFlag: true, particleLife: 2465, - maxSize: 1.5, - minSize: 0.1, + maxSize: 20.5, + minSize: 10, acceleration: new ex.Vector(0, 460), beginColor: ex.Color.Red, endColor: ex.Color.Yellow, - particleSprite: sprite, + // particleSprite: blockSpriteLegacy, particleRotationalVelocity: Math.PI / 10, randomRotation: true }); -// var emitter = new ex.ParticleEmitter(100, 300, 2, 2); -// emitter.minVel = 417; -// emitter.maxVel = 589; -// emitter.minAngle = Math.PI; -// emitter.maxAngle = Math.PI * 2; -// emitter.isEmitting = false; -// emitter.emitRate = 494; -// emitter.opacity = 0.84; -// emitter.fadeFlag = true; -// emitter.particleLife = 2465; -// emitter.maxSize = 1.5; -// emitter.minSize = .1; -// emitter.acceleration = new ex.Vector(0, 460); -// emitter.beginColor = ex.Color.Red; -// emitter.endColor = ex.Color.Yellow; -// emitter.particleSprite = blockSprite.clone(); -// emitter.particleSprite.anchor = new ex.Vector(.5, .5); -// emitter.particleRotationalVelocity = Math.PI / 10; -// emitter.randomRotation = true; -emitter.particleSprite.addEffect(new ex.Effects.Grayscale()); - game.add(emitter); var exploding = false; diff --git a/sandbox/tests/group/group.ts b/sandbox/tests/group/group.ts index bc00810e..578f8ea7 100644 --- a/sandbox/tests/group/group.ts +++ b/sandbox/tests/group/group.ts @@ -1,5 +1,9 @@ /// +(function() { + + + var width = 600; var height = 400; var minVel = -200; @@ -10,8 +14,8 @@ var engine = new ex.Engine({ width: width, height: height, canvasElementId: 'gam //engine.showDebug(true); ex.Physics.useRigidBodyPhysics(); var blockGroup = ex.CollisionGroupManager.create('blocks'); -var blockSprite = blockTexture.asSprite(); -blockSprite.scale.setTo(0.2, 0.2); +var blockSprite2 = blockTexture.asSprite(); +blockSprite2.scale.setTo(0.2, 0.2); var player = new ex.Actor(width / 2, height / 2, 30, 30, ex.Color.Cyan); player.body.collider.type = ex.CollisionType.Fixed; @@ -21,7 +25,7 @@ engine.currentScene.add(player); for (var i = 0; i < numActors; i++) { var actor = new ex.Actor(Math.random() * width, Math.random() * height, 0.2 * 64, 0.2 * 48); - actor.addDrawing('default', blockSprite.clone()); + actor.addDrawing('default', blockSprite2.clone()); actor.body.collider.type = ex.CollisionType.Active; actor.body.collider.group = blockGroup; @@ -57,3 +61,5 @@ for (var i = 0; i < numActors; i++) { engine.start(new ex.Loader([blockTexture])).then(() => { // do stuff }); + +})(); \ No newline at end of file diff --git a/sandbox/tests/within/within.ts b/sandbox/tests/within/within.ts index 4170087b..1b17afe0 100644 --- a/sandbox/tests/within/within.ts +++ b/sandbox/tests/within/within.ts @@ -1,5 +1,8 @@ /// +(function() { + + ex.Physics.acc = new ex.Vector(0, 200); ex.Physics.collisionResolutionStrategy = ex.CollisionResolutionStrategy.RigidBody; ex.Physics.allowRigidBodyRotation = false; @@ -191,3 +194,5 @@ game.add(edge); game.add(block4); game.add(floor); game.start(); + +})(); \ No newline at end of file diff --git a/src/engine/Actor.ts b/src/engine/Actor.ts index 2324089e..46f7327c 100644 --- a/src/engine/Actor.ts +++ b/src/engine/Actor.ts @@ -49,6 +49,8 @@ import { Shape } from './Collision/Shape'; import { Entity } from './EntityComponentSystem/Entity'; import { CanvasDrawComponent } from './Drawing/CanvasDrawComponent'; import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; +import { GraphicsComponent } from './Graphics/GraphicsComponent'; +import { Rectangle } from './Graphics/Rectangle'; /** * Type guard for checking if something is an Actor @@ -84,7 +86,7 @@ export interface ActorDefaults { */ export class ActorImpl - extends Entity + extends Entity implements Actionable, Eventable, PointerEvents, CanInitialize, CanUpdate, CanDraw, CanBeKilled { // #region Properties @@ -311,8 +313,17 @@ export class ActorImpl public isOffScreen: boolean = false; /** * The visibility of an actor + * @deprecated Use [[GraphicsComponent.visible|Actor.graphics.visible]] */ - public visible: boolean = true; + @obsolete({ message: 'Actor.visible will be removed in v0.26.0', alternateMethod: 'Use Actor.graphics.visible' }) + public get visible(): boolean { + return this.graphics.visible; + } + + public set visible(isVisible: boolean) { + this.graphics.visible = isVisible; + } + /** * The opacity of an actor. Passing in a color in the [[constructor]] will use the * color's opacity. @@ -445,6 +456,9 @@ export class ActorImpl // #endregion + public transform: TransformComponent; + public graphics: GraphicsComponent; + /** * @param xOrConfig The starting x coordinate of the actor, or an option bag of [[ActorArgs]] * @param y The starting y coordinate of the actor @@ -459,7 +473,8 @@ export class ActorImpl // initialize default options this._initDefaults(); - this.addComponent(new TransformComponent()); + this.addComponent((this.transform = new TransformComponent())); + this.addComponent((this.graphics = new GraphicsComponent())); this.addComponent(new CanvasDrawComponent((ctx, delta) => this.draw(ctx, delta))); let shouldInitializeBody = true; @@ -475,6 +490,7 @@ export class ActorImpl } width = config.width; height = config.height; + color = config.color; if (config.body) { shouldInitializeBody = false; @@ -514,8 +530,19 @@ export class ActorImpl this.opacity = color.a; } + if (color) { + this.graphics.add( + new Rectangle({ + color: color, + width: this._width, + height: this._height + }) + ); + } + // Build default pipeline this.traits.push(new Traits.TileMapCollisionDetection()); + // TODO remove this trait this.traits.push(new Traits.OffscreenCulling()); this.traits.push(new Traits.CapturePointer()); @@ -899,14 +926,20 @@ export class ActorImpl * Sets the current drawing of the actor to the drawing corresponding to * the key. * @param key The key of the drawing + * @deprecated Use [[GraphicsComponent.show|Actor.graphics.show]] or [[GraphicsComponent.swap|Actor.graphics.swap]] */ public setDrawing(key: string): void; /** * Sets the current drawing of the actor to the drawing corresponding to * an `enum` key (e.g. `Animations.Left`) * @param key The `enum` key of the drawing + * @deprecated Use [[GraphicsComponent.show|Actor.graphics.show]] or [[GraphicsComponent.swap|Actor.graphics.swap]] */ public setDrawing(key: number): void; + @obsolete({ + message: 'Actor.setDrawing will be removed in v0.26.0', + alternateMethod: 'Use Actor.graphics.show() or Actor.graphics.swap()' + }) public setDrawing(key: any): void { key = key.toString(); if (this.currentDrawing !== this.frames[key]) { @@ -924,18 +957,25 @@ export class ActorImpl /** * Adds a whole texture as the "default" drawing. Set a drawing using [[setDrawing]]. + * @deprecated Use [[GraphicsComponent.add|Actor.graphics.add]] */ public addDrawing(texture: Texture): void; /** * Adds a whole sprite as the "default" drawing. Set a drawing using [[setDrawing]]. + * @deprecated Use [[GraphicsComponent.add|Actor.graphics.add]] */ public addDrawing(sprite: Sprite): void; /** * Adds a drawing to the list of available drawings for an actor. Set a drawing using [[setDrawing]]. * @param key The key to associate with a drawing for this actor * @param drawing This can be an [[Animation]], [[Sprite]], or [[Polygon]]. + * @deprecated Use [[GraphicsComponent.add|Actor.graphics.add]] */ public addDrawing(key: any, drawing: Drawable): void; + @obsolete({ + message: 'Actor.addDrawing will be removed in v0.26.0', + alternateMethod: 'Use Actor.graphics.add()' + }) public addDrawing(): void { if (arguments.length === 2) { this.frames[arguments[0]] = arguments[1]; @@ -963,8 +1003,12 @@ export class ActorImpl /** * Gets the z-index of an actor. The z-index determines the relative order an actor is drawn in. * Actors with a higher z-index are drawn on top of actors with a lower z-index - * @deprecated Use actor.z + * @deprecated Use [[Actor.z]] */ + @obsolete({ + message: 'Actor.getZIndex will be removed in v0.26.0', + alternateMethod: 'Use Actor.transform.z or Actor.z' + }) public getZIndex(): number { return this.components.transform.z; } @@ -974,8 +1018,12 @@ export class ActorImpl * The z-index determines the relative order an actor is drawn in. * Actors with a higher z-index are drawn on top of actors with a lower z-index * @param newIndex new z-index to assign - * @deprecated Use actor.z + * @deprecated Use [[Actor.z]] */ + @obsolete({ + message: 'Actor.setZIndex will be removed in v0.26.0', + alternateMethod: 'Use Actor.transform.z or Actor.z' + }) public setZIndex(newIndex: number) { this.components.transform.z = newIndex; } diff --git a/src/engine/Camera.ts b/src/engine/Camera.ts index eb2aa333..f3616424 100644 --- a/src/engine/Camera.ts +++ b/src/engine/Camera.ts @@ -8,6 +8,7 @@ import { PreUpdateEvent, PostUpdateEvent, GameEvent, InitializeEvent } from './E import { Class } from './Class'; import { BoundingBox } from './Collision/BoundingBox'; import { Logger } from './Util/Log'; +import { ExcaliburGraphicsContext } from './Graphics/Context/ExcaliburGraphicsContext'; /** * Interface that describes a custom camera strategy for tracking targets @@ -316,6 +317,9 @@ export class Camera extends Class implements CanUpdate, CanInitialize { private _zoomEasing: EasingFunction = EasingFunctions.EaseInOutCubic; private _easing: EasingFunction = EasingFunctions.EaseInOutCubic; + private _halfWidth: number = 0; + private _halfHeight: number = 0; + /** * Get the camera's x position */ @@ -483,16 +487,15 @@ export class Camera extends Class implements CanUpdate, CanInitialize { return this.z; } + private _viewport: BoundingBox = null; /** * Gets the bounding box of the viewport of this camera in world coordinates */ public get viewport(): BoundingBox { - if (this._engine) { - const halfWidth = this._engine.halfDrawWidth; - const halfHeight = this._engine.halfDrawHeight; - - return new BoundingBox(this.x - halfWidth, this.y - halfHeight, this.x + halfWidth, this.y + halfHeight); + if (this._viewport) { + return this._viewport; } + return new BoundingBox(0, 0, 0, 0); } @@ -571,6 +574,8 @@ export class Camera extends Class implements CanUpdate, CanInitialize { super.emit('initialize', new InitializeEvent(_engine, this)); this._isInitialized = true; this._engine = _engine; + this._halfWidth = this._engine.halfDrawWidth; + this._halfHeight = this._engine.halfDrawHeight; } } @@ -674,6 +679,13 @@ export class Camera extends Class implements CanUpdate, CanInitialize { this.pos = s.action.call(s, s.target, this, _engine, delta); } + this._viewport = new BoundingBox( + this.x - this._halfWidth, + this.y - this._halfHeight, + this.x + this._halfWidth, + this.y + this._halfHeight + ); + this._postupdate(_engine, delta); } @@ -681,11 +693,20 @@ export class Camera extends Class implements CanUpdate, CanInitialize { * Applies the relevant transformations to the game canvas to "move" or apply effects to the Camera * @param ctx Canvas context to apply transformations */ - public draw(ctx: CanvasRenderingContext2D) { + public draw(ctx: CanvasRenderingContext2D): void; + public draw(ctx: ExcaliburGraphicsContext): void; + public draw(ctx: CanvasRenderingContext2D | ExcaliburGraphicsContext): void { + let canvasWidth = 0; + let canvasHeight = 0; + if (ctx instanceof CanvasRenderingContext2D) { + canvasWidth = ctx.canvas.width; + canvasHeight = ctx.canvas.height; + } else { + canvasWidth = ctx.width; + canvasHeight = ctx.height; + } const focus = this.getFocus(); - const canvasWidth = ctx.canvas.width; - const canvasHeight = ctx.canvas.height; - const pixelRatio = this._engine ? this._engine.pixelRatio : window.devicePixelRatio; + const pixelRatio = this._engine ? this._engine.pixelRatio : 1; const zoom = this.getZoom(); const newCanvasWidth = canvasWidth / zoom / pixelRatio; diff --git a/src/engine/Collision/Body.ts b/src/engine/Collision/Body.ts index f922beee..872be7da 100644 --- a/src/engine/Collision/Body.ts +++ b/src/engine/Collision/Body.ts @@ -147,9 +147,15 @@ export class Body implements Clonable { /** * The scale vector of the actor - * @obsolete ex.Body.scale will be removed in v0.25.0 + * @obsolete ex.Body.scale will be removed in v0.25.0, Use ex.Transform.scale */ - public scale: Vector = Vector.One; + public get scale(): Vector { + return this.transform.scale; + } + + public set scale(val: Vector) { + this.transform.scale = val; + } /** * The scale of the actor last frame diff --git a/src/engine/Collision/BoundingBox.ts b/src/engine/Collision/BoundingBox.ts index 473668ef..73ea5132 100644 --- a/src/engine/Collision/BoundingBox.ts +++ b/src/engine/Collision/BoundingBox.ts @@ -4,6 +4,7 @@ import { Actor } from '../Actor'; import { Vector, Ray } from '../Algebra'; import { Color } from '../Drawing/Color'; import { Side } from './Side'; +import { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext'; export interface BoundingBoxOptions { left: number; @@ -404,4 +405,13 @@ export class BoundingBox { ctx.strokeStyle = color.toString(); ctx.strokeRect(this.left, this.top, this.width, this.height); } + + /** + * Draw a debug bounding box + * @param ex + * @param color + */ + public draw(ex: ExcaliburGraphicsContext, color: Color = Color.Yellow) { + ex.debug.drawRect(this.left, this.top, this.width, this.height, { color }); + } } diff --git a/src/engine/Debug.ts b/src/engine/Debug.ts index eb2a0bff..0c06ca44 100644 --- a/src/engine/Debug.ts +++ b/src/engine/Debug.ts @@ -50,6 +50,11 @@ export interface FrameStatistics { * Physics statistics */ physics: PhysicsStatistics; + + /** + * Graphics statistics + */ + graphics: GraphicsStatistics; } /** @@ -142,6 +147,11 @@ export interface PhysicsStatistics { narrowphase: number; } +export interface GraphicsStatistics { + drawCalls: number; + drawnImages: number; +} + /** * Debug statistics and flags for Excalibur. If polling these values, it would be * best to do so on the `postupdate` event for [[Engine]], after all values have been @@ -209,6 +219,11 @@ export class FrameStats implements FrameStatistics { private _physicsStats: PhysicsStats = new PhysicsStats(); + private _graphicsStats: GraphicsStatistics = { + drawCalls: 0, + drawnImages: 0 + } + /** * Zero out values or clone other IFrameStat stats. Allows instance reuse. * @@ -225,11 +240,14 @@ export class FrameStats implements FrameStatistics { this.duration.update = otherStats.duration.update; this.duration.draw = otherStats.duration.draw; this._physicsStats.reset(otherStats.physics); + this.graphics.drawCalls = otherStats.graphics.drawCalls; + this.graphics.drawnImages = otherStats.graphics.drawnImages; } else { this.id = this.delta = this.fps = 0; this.actors.alive = this.actors.killed = this.actors.ui = 0; this.duration.update = this.duration.draw = 0; this._physicsStats.reset(); + this.graphics.drawnImages = this.graphics.drawCalls = 0; } } @@ -308,6 +326,13 @@ export class FrameStats implements FrameStatistics { public get physics() { return this._physicsStats; } + + /** + * Gets the frame's graphics statistics + */ + public get graphics() { + return this._graphicsStats; + } } export class PhysicsStats implements PhysicsStatistics { diff --git a/src/engine/Drawing/Animation.ts b/src/engine/Drawing/Animation.ts index a955fbc9..8c8b3cdf 100644 --- a/src/engine/Drawing/Animation.ts +++ b/src/engine/Drawing/Animation.ts @@ -7,7 +7,11 @@ import { Vector } from '../Algebra'; import { Engine } from '../Engine'; import * as Util from '../Util/Util'; import { Configurable } from '../Configurable'; +import { obsolete } from '../Util/Decorators'; +/** + * @deprecated Use [["Graphics/Animation".HasTick]] + */ export interface HasTick { /** * @@ -18,6 +22,7 @@ export interface HasTick { /** * @hidden + * @deprecated Use [["Graphics/Animation".Animation]] */ export class AnimationImpl implements Drawable, HasTick { /** @@ -350,6 +355,10 @@ export class AnimationImpl implements Drawable, HasTick { } } + +/** + * @deprecated Use [["Graphics/Animation".Animation]] + */ export interface AnimationArgs extends Partial { engine: Engine; sprites: Sprite[]; @@ -367,7 +376,12 @@ export interface AnimationArgs extends Partial { /** * Animations allow you to display a series of images one after another, * creating the illusion of change. Generally these images will come from a [[SpriteSheet]] source. + * @deprecated Use [["Graphics/Animation".Animation]] */ +@obsolete({ + message: 'Animation will be removed in v0.26.0', + alternateMethod: 'Use Graphics.Animation' +}) export class Animation extends Configurable(AnimationImpl) { constructor(config: AnimationArgs); constructor(engine: Engine, images: Sprite[], speed: number, loop?: boolean); diff --git a/src/engine/Drawing/CanvasDrawingSystem.ts b/src/engine/Drawing/CanvasDrawingSystem.ts index 6b575801..90caa245 100644 --- a/src/engine/Drawing/CanvasDrawingSystem.ts +++ b/src/engine/Drawing/CanvasDrawingSystem.ts @@ -5,6 +5,7 @@ import { CanvasDrawComponent } from './CanvasDrawComponent'; import { Scene } from '../Scene'; import { Camera } from '../Camera'; import { CoordPlane, TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; +import { GraphicsDiagnostics } from '../Graphics/GraphicsDiagnostics'; /** * Draws anything with a transform and a "draw" method @@ -66,20 +67,24 @@ export class CanvasDrawingSystem extends System naturalWidth) { this.logger.warn(`The sprite width ${this.width} exceeds the width - ${naturalWidth} of the backing texture ${this._texture.path}`); + ${naturalWidth} of the backing texture ${this.texture.path}`); } if (this.width <= 0 || naturalWidth <= 0) { @@ -123,7 +125,7 @@ export class SpriteImpl implements Drawable { if (this.height > naturalHeight) { this.logger.warn(`The sprite height ${this.height} exceeds the height - ${naturalHeight} of the backing texture ${this._texture.path}`); + ${naturalHeight} of the backing texture ${this.texture.path}`); } if (this.height <= 0 || naturalHeight <= 0) { @@ -137,12 +139,12 @@ export class SpriteImpl implements Drawable { } private _flushTexture() { - const naturalWidth = this._texture.image.naturalWidth || 0; - const naturalHeight = this._texture.image.naturalHeight || 0; + const naturalWidth = this.texture.image.naturalWidth || 0; + const naturalHeight = this.texture.image.naturalHeight || 0; this._spriteCtx.clearRect(0, 0, this.width, this.height); this._spriteCtx.drawImage( - this._texture.image, + this.texture.image, clamp(this.x, 0, naturalWidth), clamp(this.y, 0, naturalHeight), clamp(this.width, 0, naturalWidth), @@ -227,7 +229,7 @@ export class SpriteImpl implements Drawable { this.effects.push(effect); // We must check if the texture and the backing sprite pixels are loaded as well before // an effect can be applied - if (!this._texture.isLoaded() || !this._pixelsLoaded) { + if (!this.texture.isLoaded() || !this._pixelsLoaded) { this._dirtyEffect = true; } else { this._applyEffects(); @@ -262,7 +264,7 @@ export class SpriteImpl implements Drawable { // We must check if the texture and the backing sprite pixels are loaded as well before // an effect can be applied - if (!this._texture.isLoaded() || !this._pixelsLoaded) { + if (!this.texture.isLoaded() || !this._pixelsLoaded) { this._dirtyEffect = true; } else { this._applyEffects(); @@ -410,7 +412,7 @@ export class SpriteImpl implements Drawable { * Produces a copy of the current sprite */ public clone(): SpriteImpl { - const result = new Sprite(this._texture, this.x, this.y, this.width, this.height); + const result = new Sprite(this.texture, this.x, this.y, this.width, this.height); result.anchor = this.anchor.clone(); result.scale = this.scale.clone(); result.rotation = this.rotation; @@ -425,6 +427,9 @@ export class SpriteImpl implements Drawable { } } +/** + * @deprecated Use [["Graphics/Sprite".Sprite]] + */ export interface SpriteArgs extends Partial { image?: Texture; x?: number; @@ -440,7 +445,12 @@ export interface SpriteArgs extends Partial { /** * A [[Sprite]] is one of the main drawing primitives. It is responsible for drawing * images or parts of images from a [[Texture]] resource to the screen. + * @deprecated Use [["Graphics/Sprite".Sprite]] */ +@obsolete({ + message: 'Label.clearTextShadow will be removed in v0.26.0', + alternateMethod: 'Use Label.font.shadow' +}) export class Sprite extends Configurable(SpriteImpl) { constructor(config: SpriteArgs); constructor(image: Texture, x: number, y: number, width: number, height: number); diff --git a/src/engine/Drawing/SpriteEffects.ts b/src/engine/Drawing/SpriteEffects.ts index 44556250..900ba678 100644 --- a/src/engine/Drawing/SpriteEffects.ts +++ b/src/engine/Drawing/SpriteEffects.ts @@ -7,10 +7,12 @@ * @typedoc */ +import { obsolete } from '../Util/Decorators'; import { Color } from './Color'; /** * The interface that all sprite effects must implement + * @deprecated SpriteEffect will be removed in v0.26.0 */ export interface SpriteEffect { /** @@ -24,7 +26,11 @@ export interface SpriteEffect { /** * Applies the "Grayscale" effect to a sprite, removing color information. + * @deprecated [[Grayscale]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Grayscale will be removed in v0.26.0' +}) export class Grayscale implements SpriteEffect { updatePixel(x: number, y: number, imageData: ImageData): void { const firstPixel = (x + y * imageData.width) * 4; @@ -38,7 +44,11 @@ export class Grayscale implements SpriteEffect { /** * Applies the "Invert" effect to a sprite, inverting the pixel colors. + * @deprecated [[Invert]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Invert will be removed in v0.26.0' +}) export class Invert implements SpriteEffect { updatePixel(x: number, y: number, imageData: ImageData): void { const firstPixel = (x + y * imageData.width) * 4; @@ -51,7 +61,11 @@ export class Invert implements SpriteEffect { /** * Applies the "Opacity" effect to a sprite, setting the alpha of all pixels to a given value. + * @deprecated [[Opacity]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Opacity will be removed in v0.26.0' +}) export class Opacity implements SpriteEffect { /** * @param opacity The new opacity of the sprite from 0-1.0 @@ -69,7 +83,11 @@ export class Opacity implements SpriteEffect { /** * Applies the "Colorize" effect to a sprite, changing the color channels of all the pixels to an * average of the original color and the provided color + * @deprecated [[Colorize]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Colorize will be removed in v0.26.0' +}) export class Colorize implements SpriteEffect { /** * @param color The color to apply to the sprite @@ -88,7 +106,11 @@ export class Colorize implements SpriteEffect { /** * Applies the "Lighten" effect to a sprite, changes the lightness of the color according to HSL + * @deprecated [[Lighten]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Lighten will be removed in v0.26.0' +}) export class Lighten implements SpriteEffect { /** * @param factor The factor of the effect between 0-1 @@ -109,7 +131,11 @@ export class Lighten implements SpriteEffect { /** * Applies the "Darken" effect to a sprite, changes the darkness of the color according to HSL + * @deprecated [[Darken]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Darken will be removed v0.26.0' +}) export class Darken implements SpriteEffect { /** * @param factor The factor of the effect between 0-1 @@ -130,7 +156,11 @@ export class Darken implements SpriteEffect { /** * Applies the "Saturate" effect to a sprite, saturates the color according to HSL + * @deprecated [[Saturate]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Saturate will be removed in v0.26.0' +}) export class Saturate implements SpriteEffect { /** * @param factor The factor of the effect between 0-1 @@ -151,7 +181,11 @@ export class Saturate implements SpriteEffect { /** * Applies the "Desaturate" effect to a sprite, desaturates the color according to HSL + * @deprecated [[Desaturate]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Desaturate will be removed in v0.26.0' +}) export class Desaturate implements SpriteEffect { /** * @param factor The factor of the effect between 0-1 @@ -173,7 +207,11 @@ export class Desaturate implements SpriteEffect { /** * Applies the "Fill" effect to a sprite, changing the color channels of all non-transparent pixels to match * a given color + * @deprecated [[Fill]] will be removed in v0.26.0 */ +@obsolete({ + message: 'Fill will be removed in v0.26.0' +}) export class Fill implements SpriteEffect { /** * @param color The color to apply to the sprite diff --git a/src/engine/Drawing/SpriteSheet.ts b/src/engine/Drawing/SpriteSheet.ts index 2ba87206..84c5e9f9 100644 --- a/src/engine/Drawing/SpriteSheet.ts +++ b/src/engine/Drawing/SpriteSheet.ts @@ -6,7 +6,8 @@ import * as Effects from './SpriteEffects'; import { Texture } from '../Resources/Texture'; import { Engine } from '../Engine'; import { Logger } from '../Util/Log'; -import { TextAlign, BaseAlign } from '../Label'; +import { BaseAlign, TextAlign } from '../Graphics/FontCommon'; +import { obsolete } from '../Util/Decorators'; export interface SpriteSheetArgs { image: Texture; @@ -28,6 +29,7 @@ export interface SpriteSheetSpacingDimensions { * Sprite sheets are a useful mechanism for slicing up image resources into * separate sprites or for generating in game animations. [[Sprite|Sprites]] are organized * in row major order in the [[SpriteSheet]]. + * @deprecated Will be removed in v0.26.0 */ export class SpriteSheet { private _sprites: Sprite[] = []; @@ -326,8 +328,12 @@ export interface SpriteFontArgs extends SpriteSheetArgs { * Sprite fonts are a used in conjunction with a [[Label]] to specify * a particular bitmap as a font. Note that some font features are not * supported by Sprite fonts. + * @deprecated Will be removed into v0.26.0 */ - +@obsolete({ + message: 'SpriteSheet will be removed in v0.26.0', + alternateMethod: 'Use Graphics.SpriteSheet' +}) export class SpriteFont extends SpriteSheet { private _currentColor: Color = Color.Black; private _currentOpacity: Number = 1.0; @@ -340,8 +346,8 @@ export class SpriteFont extends SpriteSheet { private _textShadowSprites: { [key: string]: Sprite } = {}; private _shadowOffsetX: number = 5; private _shadowOffsetY: number = 5; - private _alphabet: string; - private _caseInsensitive: boolean; + public readonly alphabet: string; + public readonly caseInsensitive: boolean; constructor(config: SpriteFontArgs); @@ -393,8 +399,8 @@ export class SpriteFont extends SpriteSheet { caseInsensitive = imageOrConfig.caseInsensitive; } - this._alphabet = alphabet; - this._caseInsensitive = caseInsensitive; + this.alphabet = alphabet; + this.caseInsensitive = caseInsensitive; this._spriteRecord = this.getTextSprites(); } @@ -403,9 +409,9 @@ export class SpriteFont extends SpriteSheet { */ public getTextSprites(): { [key: string]: Sprite } { const lookup: { [key: string]: Sprite } = {}; - for (let i = 0; i < this._alphabet.length; i++) { - let char = this._alphabet[i]; - if (this._caseInsensitive) { + for (let i = 0; i < this.alphabet.length; i++) { + let char = this.alphabet[i]; + if (this.caseInsensitive) { char = char.toLowerCase(); } lookup[char] = this.sprites[i].clone(); @@ -499,7 +505,7 @@ export class SpriteFont extends SpriteSheet { for (let i = 0; i < text.length; i++) { let character = text[i]; - if (this._caseInsensitive) { + if (this.caseInsensitive) { character = character.toLowerCase(); } try { diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 1b7b7dbc..3250812c 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,5 +1,5 @@ import { EX_VERSION } from './'; -import { Flags } from './Flags'; +import { Experiments, Flags } from './Flags'; import { polyfill } from './Polyfill'; polyfill(); import { CanUpdate, CanDraw, CanInitialize } from './Interfaces/LifecycleEvents'; @@ -39,6 +39,7 @@ import * as Input from './Input/Index'; import * as Events from './Events'; import { BrowserEvents } from './Util/Browser'; import { obsolete } from './Util/Decorators'; +import { ExcaliburGraphicsContext, ExcaliburGraphicsContext2DCanvas, ExcaliburGraphicsContextWebGL } from './Graphics'; /** * Enum representing the different mousewheel event bubble prevention @@ -104,6 +105,11 @@ export interface EngineOptions { */ canvasElement?: HTMLCanvasElement; + /** + * Optionally snap drawings to nearest pixel + */ + snapToPixel?: boolean + /** * The [[DisplayMode]] of the game. Depending on this value, [[width]] and [[height]] may be ignored. */ @@ -188,6 +194,8 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { */ public ctx: CanvasRenderingContext2D; + public graphicsContext: ExcaliburGraphicsContext; + /** * Direct access to the canvas element ID, if an ID exists */ @@ -433,6 +441,7 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { enableCanvasTransparency: true, canvasElementId: '', canvasElement: undefined, + snapToPixel: false, pointerScope: Input.PointerScope.Document, suppressConsoleBootMessage: null, suppressMinimumBrowserFeatureDetection: null, @@ -556,12 +565,31 @@ O|===|* >________________>\n\ displayMode = DisplayMode.Fit; } - // eslint-disable-next-line - this.ctx = this.canvas.getContext('2d', { alpha: this.enableCanvasTransparency }); + if (Flags.isEnabled(Experiments.WebGL)) { + const exWebglCtx = new ExcaliburGraphicsContextWebGL({ + canvasElement: this.canvas, + enableTransparency: this.enableCanvasTransparency, + smoothing: options.antialiasing, + backgroundColor: options.backgroundColor, + snapToPixel: options.snapToPixel + }); + this.graphicsContext = exWebglCtx; + this.ctx = exWebglCtx.__ctx; + } else { + const ex2dCtx = new ExcaliburGraphicsContext2DCanvas({ + canvasElement: this.canvas, + enableTransparency: this.enableCanvasTransparency, + smoothing: options.antialiasing, + backgroundColor: options.backgroundColor, + snapToPixel: options.snapToPixel + }); + this.graphicsContext = ex2dCtx; + this.ctx = ex2dCtx.__ctx; + } this.screen = new Screen({ canvas: this.canvas, - context: this.ctx, + context: this.graphicsContext, antialiasing: options.antialiasing ?? true, browser: this.browser, viewport: options.viewport ?? { width: options.width, height: options.height }, @@ -630,7 +658,7 @@ O|===|* >________________>\n\ * @param y y game coordinate to play the animation * @deprecated */ - @obsolete({message: 'Will be removed in excalibur v0.26.0'}) + @obsolete({ message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use Actor.graphics' }) public playAnimation(animation: Animation, x: number, y: number) { this._animations.push(new AnimationNode(animation, x, y)); } @@ -999,14 +1027,14 @@ O|===|* >________________>\n\ this._predraw(ctx, delta); if (this._isLoading) { - this._loader.draw(ctx); + this._loader.canvas.draw(this.graphicsContext, 0, 0); + this.graphicsContext.flush(); // Drawing nothing else while loading return; } - ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); - ctx.fillStyle = this.backgroundColor.toString(); - ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight); + // TODO move to graphics systems? + this.graphicsContext.backgroundColor = this.backgroundColor; this.currentScene.draw(this.ctx, delta); @@ -1018,6 +1046,7 @@ O|===|* >________________>\n\ } // Draw debug information + // TODO don't access ctx directly if (this.isDebug) { this.ctx.font = 'Consolas'; this.ctx.fillStyle = this.debugColor.toString(); @@ -1087,11 +1116,10 @@ O|===|* >________________>\n\ if (!this._compatible) { return Promise.reject('Excalibur is incompatible with your browser'); } - // Changing resolution invalidates context state, so we need to capture it before applying this.screen.pushResolutionAndViewport(); this.screen.resolution = this.screen.viewport; this.screen.applyResolutionAndViewport(); - + this.graphicsContext.updateViewport(); let loadingComplete: Promise; if (loader) { this._loader = loader; @@ -1105,6 +1133,7 @@ O|===|* >________________>\n\ loadingComplete.then(() => { this.screen.popResolutionAndViewport(); this.screen.applyResolutionAndViewport(); + this.graphicsContext.updateViewport(); this.emit('start', new GameStartEvent(this)); }); @@ -1234,7 +1263,7 @@ O|===|* >________________>\n\ * @internal * @deprecated */ -@obsolete({message: 'Will be removed in excalibur v0.26.0'}) +@obsolete({ message: 'Will be removed in excalibur v0.26.0' }) class AnimationNode { constructor(public animation: Animation, public x: number, public y: number) {} } diff --git a/src/engine/EntityComponentSystem/Components/TransformComponent.ts b/src/engine/EntityComponentSystem/Components/TransformComponent.ts index 24135214..83c1e86c 100644 --- a/src/engine/EntityComponentSystem/Components/TransformComponent.ts +++ b/src/engine/EntityComponentSystem/Components/TransformComponent.ts @@ -21,12 +21,12 @@ export class TransformComponent extends Component<'transform'> { public readonly type = 'transform'; /** - * The [[coordinate plane|CoordPlane]] for this transform for the entity. + * The [[CoordPlane|coordinate plane|]] for this transform for the entity. */ public coordPlane = CoordPlane.World; /** - * The current position of the entity in world space or in screen space depending on the the [[coordinate plan|CoordPlane]] + * The current position of the entity in world space or in screen space depending on the the [[CoordPlane|coordinate plane]] */ public pos: Vector = Vector.Zero; diff --git a/src/engine/EntityComponentSystem/SystemManager.ts b/src/engine/EntityComponentSystem/SystemManager.ts index ee7c36f7..a255c8d4 100644 --- a/src/engine/EntityComponentSystem/SystemManager.ts +++ b/src/engine/EntityComponentSystem/SystemManager.ts @@ -1,5 +1,5 @@ import { System, SystemType } from './System'; -import { Util } from '..'; +import { Scene, Util } from '..'; import { World } from './World'; /** @@ -49,25 +49,32 @@ export class SystemManager { /** * Updates all systems * @param type whether this is an update or draw system - * @param engine engine reference + * @param context context reference * @param delta time in milliseconds */ - public updateSystems(type: SystemType, engine: ContextType, delta: number) { + public updateSystems(type: SystemType, context: ContextType, delta: number) { const systems = this.systems.filter((s) => s.systemType === type); for (const s of systems) { if (s.preupdate) { - s.preupdate(engine, delta); + s.preupdate(context, delta); } } for (const s of systems) { + // Get entities that match the system types, pre-sort const entities = this._world.queryManager.getQuery(s.types).getEntities(s.sort); + // Initialize entities if needed + if (context instanceof Scene) { + for (const entity of entities) { + entity._initialize(context?.engine); + } + } s.update(entities, delta); } for (const s of systems) { if (s.postupdate) { - s.postupdate(engine, delta); + s.postupdate(context, delta); } } } diff --git a/src/engine/Flags.ts b/src/engine/Flags.ts index 69f8455a..b9c4028c 100644 --- a/src/engine/Flags.ts +++ b/src/engine/Flags.ts @@ -1,3 +1,18 @@ + +/** + * Features in preview + */ +export enum Experiments { + WebGL = 'use-webgl' +} + +/** + * Legacy features that will go away + */ +export enum Legacy { + LegacyDrawing= 'use-legacy-drawing' +} + /** * Flags is a feature flag implementation for Excalibur. They can only be operated **before [[Engine]] construction** * after which they are frozen and are read-only. diff --git a/src/engine/Graphics/Animation.ts b/src/engine/Graphics/Animation.ts new file mode 100644 index 00000000..41ea06e0 --- /dev/null +++ b/src/engine/Graphics/Animation.ts @@ -0,0 +1,243 @@ +import { Graphic, GraphicOptions } from './Graphic'; +import { clamp } from '../Util/Util'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { EventDispatcher } from '../EventDispatcher'; +import { SpriteSheet } from './SpriteSheet'; + +export interface HasTick { + /** + * + * @param elapsedMilliseconds The amount of real world time in milliseconds that has elapsed that must be updated in the animation + * @param idempotencyToken Optional idempotencyToken prevents a ticking animation from updating twice per frame + */ + tick(elapsedMilliseconds: number, idempotencyToken?: number): void; +} + +export enum AnimationStrategy { + /** + * Animation ends without displaying anything + */ + End = 'end', + /** + * Animation loops to the first frame after the last frame + */ + Loop = 'loop', + /** + * Animation plays to the last frame, then backwards to the first frame, then repeats + */ + PingPong = 'pingpong', + + /** + * Animation ends stopping on the last frame + */ + Freeze = 'freeze' +} + +export interface Frame { + graphic?: Graphic; + duration?: number; // number of ms the frame should be visible, overrides the animation duration +} + +export interface AnimationOptions { + frames: Frame[]; + frameDuration?: number; + strategy?: AnimationStrategy; +} + +// TODO wire up to new Emitter +export type AnimationEvents = { + frame: Frame; + loop: Animation; + ended: Animation; +}; + +export class Animation extends Graphic implements HasTick { + public events = new EventDispatcher(this); // TODO replace with new Emitter + public frames: Frame[] = []; + public strategy: AnimationStrategy = AnimationStrategy.Loop; + public frameDuration: number = 100; + public timeScale: number = 1; + + private _idempotencyToken = -1; + + private _firstTick = true; + private _currentFrame = 0; + private _timeLeftInFrame = 0; + private _direction = 1; + private _done = false; + private _playing = true; + + constructor(options: GraphicOptions & AnimationOptions) { + super(options); + this.frames = options.frames; + this.strategy = options.strategy ?? this.strategy; + this.frameDuration = options.frameDuration ?? this.frameDuration; + + this.goToFrame(0); + } + + public clone(): Animation { + return new Animation({ + frames: this.frames.map((f) => ({ ...f })), + frameDuration: this.frameDuration, + strategy: this.strategy, + ...this.cloneGraphicOptions() + }); + } + + public static fromSpriteSheet( + spriteSheet: SpriteSheet, + frameIndices: number[], + duration: number, + strategy: AnimationStrategy = AnimationStrategy.Loop + ): Animation { + return new Animation({ + frames: spriteSheet.sprites + .filter((_, index) => frameIndices.indexOf(index) > -1) + .map((f) => ({ + graphic: f, + duration: duration + })), + strategy: strategy + }); + } + + public get currentFrame(): Frame | null { + if (this._currentFrame >= 0 && this._currentFrame < this.frames.length) { + return this.frames[this._currentFrame]; + } + return null; + } + + public get isPlaying(): boolean { + return this._playing; + } + + public play(): void { + this._playing = true; + } + + public pause(): void { + this._playing = false; + this._firstTick = true; + } + + public reset(): void { + this._done = false; + this._firstTick = true; + this._currentFrame = 0; + } + + public get canFinish(): boolean { + switch (this.strategy) { + case AnimationStrategy.End: + case AnimationStrategy.Freeze: { + return true; + } + default: { + return false; + } + } + } + + public get done(): boolean { + return this._done; + } + + public goToFrame(frameNumber: number) { + this._currentFrame = frameNumber; + this._timeLeftInFrame = this.frameDuration; + const maybeFrame = this.frames[this._currentFrame]; + if (maybeFrame && !this._done) { + this._timeLeftInFrame = maybeFrame?.duration || this.frameDuration; + this.width = maybeFrame.graphic?.width; + this.height = maybeFrame.graphic?.height; + this.events.emit('frame', maybeFrame as any); + } + } + + private _nextFrame(): number { + const currentFrame = this._currentFrame; + if (this._done) { + return currentFrame; + } + let next = -1; + + switch (this.strategy) { + case AnimationStrategy.Loop: { + next = (currentFrame + 1) % this.frames.length; + if (next === 0) { + this.events.emit('loop', this as any); + } + break; + } + case AnimationStrategy.End: { + next = currentFrame + 1; + if (next >= this.frames.length) { + this._done = true; + this._currentFrame = this.frames.length; + this.events.emit('end', this as any); + } + break; + } + case AnimationStrategy.Freeze: { + next = clamp(currentFrame + 1, 0, this.frames.length - 1); + if (next >= this.frames.length - 1) { + this._done = true; + this.events.emit('end', this as any); + } + break; + } + case AnimationStrategy.PingPong: { + if (currentFrame + this._direction >= this.frames.length) { + this._direction = -1; + this.events.emit('loop', this as any); + } + + if (currentFrame + this._direction < 0) { + this._direction = 1; + this.events.emit('loop', this as any); + } + + next = currentFrame + (this._direction % this.frames.length); + break; + } + } + return next; + } + + public tick(elapsedMilliseconds: number, idempotencyToken: number = 0) { + if (this._idempotencyToken === idempotencyToken) { + return; + } + this._idempotencyToken = idempotencyToken; + if (!this._playing) { + return; + } + + // if it's the first frame emit frame event + if (this._firstTick) { + this._firstTick = false; + this.events.emit('frame', this.currentFrame as any); + } + + this._timeLeftInFrame -= elapsedMilliseconds * this.timeScale; + if (this._timeLeftInFrame <= 0) { + this.goToFrame(this._nextFrame()); + } + this._updateDimensions(); + } + + private _updateDimensions() { + if (this.currentFrame) { + this.width = this.currentFrame.graphic?.width; + this.height = this.currentFrame.graphic?.height; + } + } + + protected _drawImage(ctx: ExcaliburGraphicsContext, x: number, y: number) { + if (this.currentFrame) { + this.currentFrame.graphic.draw(ctx, x, y); + } + } +} diff --git a/src/engine/Graphics/Canvas.ts b/src/engine/Graphics/Canvas.ts new file mode 100644 index 00000000..f3526c04 --- /dev/null +++ b/src/engine/Graphics/Canvas.ts @@ -0,0 +1,45 @@ +import { GraphicOptions } from './Graphic'; +import { Raster, RasterOptions } from './Raster'; + +export interface CanvasOptions { + draw?: (ctx: CanvasRenderingContext2D) => void; + cache?: boolean; +} + +/** + * A canvas [[Graphic]] to provide an adapter between the 2D Canvas API and the [[ExcaliburGraphicsContext]]. + * + * The [[Canvas]] works by re-rastering a draw handler to a HTMLCanvasElement for every draw which is then passed + * to the [[ExcaliburGraphicsContext]] implementation as a rendered image. + * + * **Low performance API** + */ +export class Canvas extends Raster { + /** + * Return the 2D graphics context of this canvas + */ + public get ctx() { + return this._ctx; + } + + constructor(private _options: GraphicOptions & RasterOptions & CanvasOptions) { + super(_options); + } + + public clone(): Canvas { + return new Canvas({ + ...this._options, + ...this.cloneGraphicOptions(), + ...this.cloneRasterOptions() + }); + } + + execute(ctx: CanvasRenderingContext2D): void { + if (this._options?.draw) { + this._options?.draw(ctx); + } + if (!this._options.cache) { + this.flagDirty(); + } + } +} diff --git a/src/engine/Graphics/Circle.ts b/src/engine/Graphics/Circle.ts new file mode 100644 index 00000000..5aa25990 --- /dev/null +++ b/src/engine/Graphics/Circle.ts @@ -0,0 +1,49 @@ +import { Raster, RasterOptions } from './Raster'; + +export interface CircleOptions { + radius: number; +} + +/** + * A circle [[Graphic]] for drawing circles to the [[ExcaliburGraphicsContext]] + */ +export class Circle extends Raster { + private _radius: number = 0; + public get radius() { + return this._radius; + } + public set radius(value: number) { + this._radius = value; + this.width = this._radius * 2; + this.height = this._radius * 2; + this.flagDirty(); + } + constructor(options: RasterOptions & CircleOptions) { + super(options); + this.radius = options.radius; + this.rasterize(); + } + + public clone(): Circle { + return new Circle({ + radius: this.radius, + ...this.cloneGraphicOptions(), + ...this.cloneRasterOptions() + }); + } + + execute(ctx: CanvasRenderingContext2D): void { + if (this.radius > 0) { + ctx.beginPath(); + ctx.arc(this.radius, this.radius, this.radius, 0, Math.PI * 2); + + if (this.color) { + ctx.fill(); + } + + if (this.strokeColor) { + ctx.stroke(); + } + } + } +} diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts new file mode 100644 index 00000000..e8fcadfe --- /dev/null +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext.ts @@ -0,0 +1,158 @@ +import { Vector } from '../../Algebra'; +import { Color } from '../../Drawing/Color'; + +export type HTMLImageSource = HTMLImageElement | HTMLCanvasElement; + +export interface ExcaliburGraphicsContextOptions { + canvasElement: HTMLCanvasElement; + smoothing?: boolean; + enableTransparency?: boolean; + snapToPixel?: boolean; + backgroundColor?: Color; +} + +export interface ExcaliburGraphicsContextState { + opacity: number; +} +export interface LineGraphicsOptions { + color: Color; +} + +export interface RectGraphicsOptions { + color: Color; +} + +export interface PointGraphicsOptions { + color: Color; + size: number; +} + +export interface DebugDraw { + /** + * Draw a debugging rectangle to the screen + * @param x + * @param y + * @param width + * @param height + * @param rectOptions + */ + drawRect(x: number, y: number, width: number, height: number, rectOptions?: RectGraphicsOptions): void; + /** + * Draw a debugging line to the screen + * @param start ' + * @param end + * @param lineOptions + */ + drawLine(start: Vector, end: Vector, lineOptions?: LineGraphicsOptions): void; + /** + * Draw a debugging point to the screen + * @param point + * @param pointOptions + */ + drawPoint(point: Vector, pointOptions?: PointGraphicsOptions): void; +} + +export interface ExcaliburGraphicsContext { + width: number; + height: number; + + /** + * Snaps all drawings to the nearest pixel trucated down, by default false + */ + snapToPixel: boolean; + + /** + * Enable smoothed drawing (also known as anti-aliasing), by default false + */ + smoothing: boolean; + + /** + * Set the background color of the graphics context, default is [[Color.ExcaliburBlue]] + */ + backgroundColor: Color; + + /** + * Sets the opacity of the current [[Graphic]] being drawn, default is 1 + */ + opacity: number; + + /** + * Resets the current transform to the identity matrix + */ + resetTransform(): void; + + /** + * Update the context with the curren tviewport dimensions (used in resizing) + */ + updateViewport(): void; + + /** + * Access the debug drawing api + */ + debug: DebugDraw; + + /** + * Draw an image to the Excalibur Graphics context at an x and y coordinate using the images width and height + */ + drawImage(image: HTMLImageSource, x: number, y: number): void; + /** + * + * Draw an image to the Excalibur Graphics context at an x and y coordinate with a specific width and height + */ + drawImage(image: HTMLImageSource, x: number, y: number, width: number, height: number): void; + /** + * + * Draw an image to the Excalibur Graphics context specifying the source image coordinates (sx, sy, swidth, sheight) + * and to a specific destination on the context (dx, dy, dwidth, dheight) + */ + drawImage( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ): void; + + /** + * Save the current state of the canvas to the stack (transforms and opacity) + */ + save(): void; + + /** + * Restore the state of the canvas from the stack + */ + restore(): void; + + /** + * Translate the origin of the context by an x and y + * @param x + * @param y + */ + translate(x: number, y: number): void; + + /** + * Rotate the context about the current origin + */ + rotate(angle: number): void; + + /** + * Scale the context by an x and y factor + * @param x + * @param y + */ + scale(x: number, y: number): void; + + /** + * Clears the screen with the current background color + */ + clear(): void; + + /** + * Flushes the batched draw calls to the screen + */ + flush(): void; +} diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts new file mode 100644 index 00000000..f312cec3 --- /dev/null +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContext2DCanvas.ts @@ -0,0 +1,219 @@ +import { + ExcaliburGraphicsContext, + LineGraphicsOptions, + PointGraphicsOptions, + ExcaliburGraphicsContextOptions, + DebugDraw, + HTMLImageSource +} from './ExcaliburGraphicsContext'; +import { Vector } from '../../Algebra'; +import { Color } from '../../Drawing/Color'; +import { StateStack } from './state-stack'; +import { GraphicsDiagnostics } from '../GraphicsDiagnostics'; + +class ExcaliburGraphicsContext2DCanvasDebug implements DebugDraw { + constructor(private _ex: ExcaliburGraphicsContext2DCanvas) {} + /** + * Draw a debug rectangle to the context + * @param x + * @param y + * @param width + * @param height + */ + drawRect(x: number, y: number, width: number, height: number): void { + this._ex.__ctx.save(); + this._ex.__ctx.strokeStyle = 'red'; + this._ex.__ctx.strokeRect( + this._ex.snapToPixel ? ~~x : x, + this._ex.snapToPixel ? ~~y : y, + this._ex.snapToPixel ? ~~width : width, + this._ex.snapToPixel ? ~~height : height + ); + this._ex.__ctx.restore(); + } + + drawLine(start: Vector, end: Vector, lineOptions: LineGraphicsOptions = { color: Color.Black }): void { + this._ex.__ctx.save(); + this._ex.__ctx.beginPath(); + this._ex.__ctx.strokeStyle = lineOptions.color.toString(); + this._ex.__ctx.moveTo(this._ex.snapToPixel ? ~~start.x : start.x, this._ex.snapToPixel ? ~~start.y : start.y); + this._ex.__ctx.lineTo(this._ex.snapToPixel ? ~~end.x : end.x, this._ex.snapToPixel ? ~~end.y : end.y); + this._ex.__ctx.lineWidth = 2; + this._ex.__ctx.stroke(); + this._ex.__ctx.closePath(); + this._ex.__ctx.restore(); + } + + drawPoint(point: Vector, pointOptions: PointGraphicsOptions = { color: Color.Black, size: 5 }): void { + this._ex.__ctx.save(); + this._ex.__ctx.beginPath(); + this._ex.__ctx.fillStyle = pointOptions.color.toString(); + this._ex.__ctx.arc( + this._ex.snapToPixel ? ~~point.x : point.x, + this._ex.snapToPixel ? ~~point.y : point.y, + pointOptions.size, + 0, + Math.PI * 2 + ); + this._ex.__ctx.fill(); + this._ex.__ctx.closePath(); + this._ex.__ctx.restore(); + } +} + +export class ExcaliburGraphicsContext2DCanvas implements ExcaliburGraphicsContext { + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __ctx: CanvasRenderingContext2D; + public get width() { + return this.__ctx.canvas.width; + } + + public get height() { + return this.__ctx.canvas.height; + } + + public backgroundColor: Color = Color.ExcaliburBlue; + + private _state = new StateStack(); + + public get opacity(): number { + return this._state.current.opacity; + } + + public set opacity(value: number) { + this._state.current.opacity = value; + } + + public snapToPixel: boolean = true; + + public get smoothing(): boolean { + return this.__ctx.imageSmoothingEnabled; + } + + public set smoothing(value: boolean) { + this.__ctx.imageSmoothingEnabled = value; + } + + constructor(options: ExcaliburGraphicsContextOptions) { + const { canvasElement, enableTransparency, snapToPixel, smoothing, backgroundColor } = options; + this.__ctx = canvasElement.getContext('2d', { + alpha: enableTransparency ?? true + }); + this.backgroundColor = backgroundColor ?? this.backgroundColor; + this.snapToPixel = snapToPixel ?? this.snapToPixel; + this.smoothing = smoothing ?? this.smoothing; + } + + public resetTransform(): void { + this.__ctx.resetTransform(); + } + + public updateViewport(): void { + // pass + } + + /** + * Draw an image to the Excalibur Graphics context at an x and y coordinate using the images width and height + */ + drawImage(image: HTMLImageSource, x: number, y: number): void; + /** + * + * Draw an image to the Excalibur Graphics context at an x and y coordinate with a specific width and height + */ + drawImage(image: HTMLImageSource, x: number, y: number, width: number, height: number): void; + /** + * + * Draw an image to the Excalibur Graphics context specifying the source image coordinates (sx, sy, swidth, sheight) + * and to a specific destination on the context (dx, dy, dwidth, dheight) + */ + drawImage( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ): void; + + drawImage( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ): void { + this.__ctx.globalAlpha = this.opacity; + const args = [image, sx, sy, swidth, sheight, dx, dy, dwidth, dheight] + .filter((a) => a !== undefined) + .map((a) => (typeof a === 'number' && this.snapToPixel ? ~~a : a)); + this.__ctx.drawImage.apply(this.__ctx, args); + GraphicsDiagnostics.DrawCallCount++; + GraphicsDiagnostics.DrawnImagesCount = 1; + } + + debug = new ExcaliburGraphicsContext2DCanvasDebug(this); + + /** + * Save the current state of the canvas to the stack (transforms and opacity) + */ + save(): void { + this.__ctx.save(); + } + + /** + * Restore the state of the canvas from the stack + */ + restore(): void { + this.__ctx.restore(); + } + + /** + * Translate the origin of the context by an x and y + * @param x + * @param y + */ + translate(x: number, y: number): void { + this.__ctx.translate(this.snapToPixel ? ~~x : x, this.snapToPixel ? ~~y : y); + } + + /** + * Rotate the context about the current origin + */ + rotate(angle: number): void { + this.__ctx.rotate(angle); + } + + /** + * Scale the context by an x and y factor + * @param x + * @param y + */ + scale(x: number, y: number): void { + this.__ctx.scale(x, y); + } + + clear(): void { + // Clear frame + this.__ctx.clearRect(0, 0, this.width, this.height); + this.__ctx.fillStyle = this.backgroundColor.toString(); + this.__ctx.fillRect(0, 0, this.width, this.height); + GraphicsDiagnostics.clear(); + } + + /** + * Flushes the batched draw calls to the screen + */ + flush(): void { + // pass + } +} diff --git a/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts new file mode 100644 index 00000000..10f97ab5 --- /dev/null +++ b/src/engine/Graphics/Context/ExcaliburGraphicsContextWebGL.ts @@ -0,0 +1,268 @@ +import { + ExcaliburGraphicsContext, + LineGraphicsOptions, + RectGraphicsOptions, + PointGraphicsOptions, + ExcaliburGraphicsContextOptions, + DebugDraw, + HTMLImageSource +} from './ExcaliburGraphicsContext'; + +import { Matrix } from '../../Math/matrix'; +import { TransformStack } from './transform-stack'; +import { Vector, vec } from '../../Algebra'; +import { Color } from '../../Drawing/Color'; +import { StateStack } from './state-stack'; +import { Logger } from '../../Util/Log'; +import { LineRenderer } from './line-renderer'; +import { ImageRenderer } from './image-renderer'; +import { PointRenderer } from './point-renderer'; +import { Canvas } from '../Canvas'; +import { GraphicsDiagnostics } from '../GraphicsDiagnostics'; + +class ExcaliburGraphicsContextWebGLDebug implements DebugDraw { + constructor(private _webglCtx: ExcaliburGraphicsContextWebGL) {} + + /** + * Draw a debugging rectangle to the context + * @param x + * @param y + * @param width + * @param height + */ + drawRect(x: number, y: number, width: number, height: number, rectOptions: RectGraphicsOptions = { color: Color.Black }): void { + this.drawLine(vec(x, y), vec(x + width, y), { ...rectOptions }); + this.drawLine(vec(x + width, y), vec(x + width, y + height), { ...rectOptions }); + this.drawLine(vec(x + width, y + height), vec(x, y + height), { ...rectOptions }); + this.drawLine(vec(x, y + height), vec(x, y), { ...rectOptions }); + } + + /** + * Draw a debugging line to the context + * @param start + * @param end + * @param lineOptions + */ + drawLine(start: Vector, end: Vector, lineOptions: LineGraphicsOptions = { color: Color.Black }): void { + this._webglCtx.__lineRenderer.addLine(start, end, lineOptions.color); + } + + /** + * Draw a debugging point to the context + * @param point + * @param pointOptions + */ + drawPoint(point: Vector, pointOptions: PointGraphicsOptions = { color: Color.Black, size: 5 }): void { + this._webglCtx.__pointRenderer.addPoint(point, pointOptions.color, pointOptions.size); + } +} + +export interface WebGLGraphicsContextInfo { + transform: TransformStack; + state: StateStack; + matrix: Matrix; +} + +export class ExcaliburGraphicsContextWebGL implements ExcaliburGraphicsContext { + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __gl: WebGLRenderingContext; + + /** + * Holds the 2d context shim + */ + private _canvas: Canvas; // Configure z for shim? + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __ctx: CanvasRenderingContext2D; + + private _transform = new TransformStack(); + private _state = new StateStack(); + private _ortho!: Matrix; + + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __pointRenderer: PointRenderer; + + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __lineRenderer: LineRenderer; + + /** + * Meant for internal use only. Access the internal context at your own risk and no guarantees this will exist in the future. + * @internal + */ + public __imageRenderer: ImageRenderer; + + public snapToPixel: boolean = true; + + public smoothing: boolean = false; + + public backgroundColor: Color = Color.ExcaliburBlue; + + public get opacity(): number { + return this._state.current.opacity; + } + + public set opacity(value: number) { + this._state.current.opacity = value; + } + + public get width() { + return this.__gl.canvas.width; + } + + public get height() { + return this.__gl.canvas.height; + } + + constructor(options: ExcaliburGraphicsContextOptions) { + const { canvasElement, enableTransparency, smoothing, snapToPixel, backgroundColor } = options; + this.__gl = canvasElement.getContext('webgl', { + antialias: smoothing ?? this.smoothing, + premultipliedAlpha: false, + alpha: enableTransparency ?? true, + depth: true, + powerPreference: 'high-performance' + }); + this.snapToPixel = snapToPixel ?? this.snapToPixel; + this.smoothing = smoothing ?? this.smoothing; + this.backgroundColor = backgroundColor ?? this.backgroundColor; + this._init(); + } + + private _init() { + const gl = this.__gl; + // Setup viewport and view matrix + this._ortho = Matrix.ortho(0, gl.canvas.width, gl.canvas.height, 0, 400, -400); + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + // Clear background + gl.clearColor(this.backgroundColor.r / 255, this.backgroundColor.g / 255, this.backgroundColor.b / 255, this.backgroundColor.a); + gl.clear(gl.COLOR_BUFFER_BIT); + + // Enable alpha blending + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + this.__pointRenderer = new PointRenderer(gl, { matrix: this._ortho, transform: this._transform, state: this._state }); + this.__lineRenderer = new LineRenderer(gl, { matrix: this._ortho, transform: this._transform, state: this._state }); + this.__imageRenderer = new ImageRenderer(gl, { matrix: this._ortho, transform: this._transform, state: this._state }); + + // 2D ctx shim + this._canvas = new Canvas({ + width: gl.canvas.width, + height: gl.canvas.height + }); + this.__ctx = this._canvas.ctx; + } + + public resetTransform(): void { + this._transform.current = Matrix.identity(); + } + + public updateViewport(): void { + const gl = this.__gl; + this._ortho = this._ortho = Matrix.ortho(0, gl.canvas.width, gl.canvas.height, 0, 400, -400); + this.__pointRenderer.shader.addUniformMatrix('u_matrix', this._ortho.data); + this.__lineRenderer.shader.addUniformMatrix('u_matrix', this._ortho.data); + this.__imageRenderer.shader.addUniformMatrix('u_matrix', this._ortho.data); + + // 2D ctx shim + this._canvas.width = gl.canvas.width; + this._canvas.height = gl.canvas.height; + } + + drawImage(image: HTMLImageSource, x: number, y: number): void; + drawImage(image: HTMLImageSource, x: number, y: number, width: number, height: number): void; + drawImage( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ): void; + drawImage( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ): void { + if (!image) { + Logger.getInstance().warn('Cannot draw a null or undefined image'); + // tslint:disable-next-line: no-console + if (console.trace) { + // tslint:disable-next-line: no-console + console.trace(); + } + return; + } + this.__imageRenderer.addImage(image, sx, sy, swidth, sheight, dx, dy, dwidth, dheight); + } + + debug = new ExcaliburGraphicsContextWebGLDebug(this); + + public save(): void { + this._transform.save(); + this._state.save(); + } + + public restore(): void { + this._transform.restore(); + this._state.restore(); + } + + public translate(x: number, y: number): void { + this._transform.translate(this.snapToPixel ? ~~x : x, this.snapToPixel ? ~~y : y); + } + + public rotate(angle: number): void { + this._transform.rotate(angle); + } + + public scale(x: number, y: number): void { + this._transform.scale(x, y); + } + + public transform(matrix: Matrix) { + this._transform.current = matrix; + } + + clear() { + const gl = this.__gl; + gl.clearColor(this.backgroundColor.r / 255, this.backgroundColor.g / 255, this.backgroundColor.b / 255, this.backgroundColor.a); + // Clear the context with the newly set color. This is + // the function call that actually does the drawing. + gl.clear(gl.COLOR_BUFFER_BIT); + GraphicsDiagnostics.clear(); + } + + /** + * Flushes all batched rendering to the screen + */ + flush() { + const gl = this.__gl; + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + this.__imageRenderer.render(); + this.__lineRenderer.render(); + this.__pointRenderer.render(); + } +} diff --git a/src/engine/Graphics/Context/batch.ts b/src/engine/Graphics/Context/batch.ts new file mode 100644 index 00000000..3beaa89e --- /dev/null +++ b/src/engine/Graphics/Context/batch.ts @@ -0,0 +1,27 @@ +import { Pool, Poolable } from '../../Util/Pool'; + +export class BatchCommand implements Poolable { + _pool: Pool = undefined; + public commands: T[] = []; + constructor(public max: number) {} + + isFull() { + if (this.commands.length >= this.max) { + return true; + } + return false; + } + + canAdd() { + return !this.isFull(); + } + + add(cmd: T) { + this.commands.push(cmd); + } + + dispose() { + this.commands.length = 0; + return this; + } +} diff --git a/src/engine/Graphics/Context/draw-image-command.ts b/src/engine/Graphics/Context/draw-image-command.ts new file mode 100644 index 00000000..e454b118 --- /dev/null +++ b/src/engine/Graphics/Context/draw-image-command.ts @@ -0,0 +1,141 @@ +import { Matrix } from '../../Math/matrix'; +import { BoundingBox } from '../../Collision/Index'; +import { Color } from '../../Drawing/Color'; +import { Pool, Poolable } from '../../Util/Pool'; +import { HTMLImageSource } from './ExcaliburGraphicsContext'; + +export class DrawImageCommand implements Poolable { + _pool: Pool = undefined; + + public snapToPixel: boolean = true; + public image: HTMLImageSource; + public opacity: number = 1; + public width: number = 0; + public height: number = 0; + public dest: [number, number] = [0, 0]; // x, y + public view: [number, number, number, number] = [0, 0, 0, 0]; // sx, sy, sw, sh + private _geom: [number, number][] = [ + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [0, 0], + [0, 0] + ]; + constructor(); + constructor(image: HTMLImageSource, x: number, y: number); + constructor(image: HTMLImageSource, x: number, y: number, width?: number, height?: number); + constructor( + image: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ); + constructor( + image?: HTMLImageSource, + sx?: number, + sy?: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ) { + this.init(image, sx, sy, swidth, sheight, dx, dy, dwidth, dheight); + } + + public init( + image?: HTMLImageSource, + sx?: number, + sy?: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ) { + this.image = image; + this.width = image?.width || swidth || 0; + this.height = image?.height || sheight || 0; + this.view = [0, 0, swidth ?? image?.width, sheight ?? image?.height]; + this.dest = [sx, sy]; + // If destination is specified, update view and dest + if (dx !== undefined && dy !== undefined && dwidth !== undefined && dheight !== undefined) { + this.view = [sx, sy, swidth ?? image?.width, sheight ?? image?.height]; + this.dest = [dx, dy]; + this.width = dwidth; + this.height = dheight; + } + + let index = 0; + this._geom[index++] = [this.dest[0], this.dest[1]]; + this._geom[index++] = [this.dest[0], this.dest[1] + this.height]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1]]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1]]; + this._geom[index++] = [this.dest[0], this.dest[1] + this.height]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1] + this.height]; + if (this.snapToPixel) { + for (const point of this._geom) { + point[0] = ~~point[0]; + point[1] = ~~point[1]; + } + } + return this; + } + + public dispose() { + this.image = null; + this.width = 0; + this.height = 0; + this.view = [0, 0, 0, 0]; + this.dest = [0, 0]; + + let index = 0; + this._geom[index++] = [this.dest[0], this.dest[1]]; + this._geom[index++] = [this.dest[0], this.dest[1] + this.height]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1]]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1]]; + this._geom[index++] = [this.dest[0], this.dest[1] + this.height]; + this._geom[index++] = [this.dest[0] + this.width, this.dest[1] + this.height]; + return this; + } + + // todo weird + applyTransform(transform: Matrix, opacity: number): void { + if (transform) { + for (let i = 0; i < this._geom.length; i++) { + this._geom[i] = transform.multv(this._geom[i]); + if (this.snapToPixel) { + this._geom[i] = [~~this._geom[i][0], ~~this._geom[i][1]]; + } + } + } + this.opacity = opacity; + } + + public get geometry() { + return this._geom; + } +} + +export class DrawRectCommand { + public dest: [number, number] = [0, 0]; + public width: number = 0; + public height: number = 0; + constructor(x: number, y: number, width: number, height: number) { + this.dest = [x, y]; + this.width = width; + this.height = height; + } +} + +export class DrawDebugRectCommand { + constructor(public bounds: BoundingBox, public color: Color) {} +} diff --git a/src/engine/Graphics/Context/image-renderer.ts b/src/engine/Graphics/Context/image-renderer.ts new file mode 100644 index 00000000..da8323bf --- /dev/null +++ b/src/engine/Graphics/Context/image-renderer.ts @@ -0,0 +1,276 @@ +import { Shader } from './shader'; +import imageVertexSource from './shaders/image-vertex.glsl'; +import imageFragmentSource from './shaders/image-fragment.glsl'; +import { BatchCommand } from './batch'; +import { DrawImageCommand } from './draw-image-command'; +import { Graphic } from '../Graphic'; +import { ensurePowerOfTwo } from './webgl-util'; +import { BatchRenderer } from './renderer'; +import { WebGLGraphicsContextInfo } from './ExcaliburGraphicsContextWebGL'; +import { TextureLoader } from './texture-loader'; +import { HTMLImageSource } from './ExcaliburGraphicsContext'; + +export class BatchImage extends BatchCommand { + public textures: WebGLTexture[] = []; + public commands: DrawImageCommand[] = []; + private _graphicMap: { [id: string]: Graphic } = {}; + + constructor(public maxDraws: number, public maxTextures: number) { + super(maxDraws); + } + + isFull() { + if (this.commands.length >= this.maxDraws) { + return true; + } + if (this.textures.length >= this.maxTextures) { + return true; + } + return false; + } + + canAdd() { + if (this.commands.length >= this.maxDraws) { + return false; + } + + if (this.textures.length < this.maxTextures) { + return true; + } + + return false; + } + + private _isCommandFull() { + return this.commands.length >= this.maxDraws; + } + + private _isTextureFull() { + return this.textures.length >= this.maxTextures; + } + + private _wouldAddTexture(command: DrawImageCommand) { + return !this._graphicMap[command.image.id]; + } + + maybeAdd(command: DrawImageCommand): boolean { + if ((this._isCommandFull() || this._isTextureFull()) && this._wouldAddTexture(command)) { + return false; + } + + this.add(command); + return true; + } + + add(command: DrawImageCommand) { + const texture = TextureLoader.load(command.image); + if (this.textures.indexOf(texture) === -1) { + this.textures.push(texture); + } + + this.commands.push(command); + } + + bindTextures(gl: WebGLRenderingContext) { + // Bind textures in the correct order + for (let i = 0; i < this.maxTextures; i++) { + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, this.textures[i] || this.textures[0]); + } + } + + getBatchTextureId(command: DrawImageCommand) { + if (command.image) { + return this.textures.indexOf(TextureLoader.get(command.image)); + } + return -1; + } + + dispose() { + this.clear(); + return this; + } + + clear() { + this.commands.length = 0; + this.textures.length = 0; + this._graphicMap = {}; + } +} + +export class ImageRenderer extends BatchRenderer { + constructor(gl: WebGLRenderingContext, private _contextInfo: WebGLGraphicsContextInfo) { + super({ + gl, + command: DrawImageCommand, + // 6 verts per quad + verticesPerCommand: 6, + maxCommandsPerBatch: 2000, + batchFactory: () => new BatchImage(2000, gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)) + }); + TextureLoader.registerContext(gl); + this.init(); + } + + buildShader(gl: WebGLRenderingContext): Shader { + // Initialilze default batch rendering shader + const maxGPUTextures = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS); + const shader = new Shader(gl, imageVertexSource, this._transformFragmentSource(imageFragmentSource, maxGPUTextures)); + shader.addAttribute('a_position', 3, gl.FLOAT); + shader.addAttribute('a_texcoord', 2, gl.FLOAT); + shader.addAttribute('a_textureIndex', 1, gl.FLOAT); + shader.addAttribute('a_opacity', 1, gl.FLOAT); + shader.addUniformMatrix('u_matrix', this._contextInfo.matrix.data); + // Initialize texture slots to [0, 1, 2, 3, 4, .... maxGPUTextures] + shader.addUniformIntegerArray( + 'u_textures', + [...Array(maxGPUTextures)].map((_, i) => i) + ); + return shader; + } + + private _transformFragmentSource(source: string, maxTextures: number): string { + let newSource = source.replace('%%count%%', maxTextures.toString()); + let texturePickerBuilder = ''; + for (let i = 0; i < maxTextures; i++) { + texturePickerBuilder += ` } else if (v_textureIndex <= ${i}.5) {\n + gl_FragColor = texture2D(u_textures[${i}], v_texcoord);\n + gl_FragColor.w = gl_FragColor.w * v_opacity;\n`; + } + newSource = newSource.replace('%%texture_picker%%', texturePickerBuilder); + return newSource; + } + + public addImage( + graphic: HTMLImageSource, + sx: number, + sy: number, + swidth?: number, + sheight?: number, + dx?: number, + dy?: number, + dwidth?: number, + dheight?: number + ) { + const command = this.commands.get().init(graphic, sx, sy, swidth, sheight, dx, dy, dwidth, dheight); + command.applyTransform(this._contextInfo.transform.current, this._contextInfo.state.current.opacity); + this.addCommand(command); + } + + public renderBatch(gl: WebGLRenderingContext, batch: BatchImage, vertexCount: number) { + // Bind textures in the correct order + batch.bindTextures(gl); + // draw the quads + gl.drawArrays(gl.TRIANGLES, 0, vertexCount); + } + + buildBatchVertices(vertexBuffer: Float32Array, batch: BatchImage): number { + let vertIndex = 0; + let sx: number = 0; + let sy: number = 0; + let sw: number = 0; + let sh: number = 0; + let potWidth: number = 0; + let potHeight: number = 0; + let textureId = 0; + for (const command of batch.commands) { + sx = command.view[0]; + sy = command.view[1]; + sw = command.view[2]; + sh = command.view[3]; + + potWidth = ensurePowerOfTwo(command.image.width || command.width); + potHeight = ensurePowerOfTwo(command.image.height || command.height); + + textureId = batch.getBatchTextureId(command); + + // potential optimization when divding by 2 (bitshift) + // Modifying the images to poweroftwo images warp the UV coordinates + const uvx0 = sx / potWidth; + const uvy0 = sy / potHeight; + const uvx1 = (sx + sw) / potWidth; + const uvy1 = (sy + sh) / potHeight; + + // Quad update + // (0, 0, z) z-index doesn't work in batch rendering between batches + vertexBuffer[vertIndex++] = command.geometry[0][0]; // x + 0 * width; + vertexBuffer[vertIndex++] = command.geometry[0][1]; //y + 0 * height; + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx0; // 0; + vertexBuffer[vertIndex++] = uvy0; // 0; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + + // (0, 1) + vertexBuffer[vertIndex++] = command.geometry[1][0]; // x + 0 * width; + vertexBuffer[vertIndex++] = command.geometry[1][1]; // y + 1 * height; + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx0; // 0; + vertexBuffer[vertIndex++] = uvy1; // 1; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + + // (1, 0) + vertexBuffer[vertIndex++] = command.geometry[2][0]; // x + 1 * width; + vertexBuffer[vertIndex++] = command.geometry[2][1]; // y + 0 * height; + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx1; //1; + vertexBuffer[vertIndex++] = uvy0; //0; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + + // (1, 0) + vertexBuffer[vertIndex++] = command.geometry[3][0]; // x + 1 * width; + vertexBuffer[vertIndex++] = command.geometry[3][1]; // y + 0 * height; + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx1; //1; + vertexBuffer[vertIndex++] = uvy0; //0; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + + // (0, 1) + vertexBuffer[vertIndex++] = command.geometry[4][0]; // x + 0 * width; + vertexBuffer[vertIndex++] = command.geometry[4][1]; // y + 1 * height + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx0; // 0; + vertexBuffer[vertIndex++] = uvy1; // 1; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + + // (1, 1) + vertexBuffer[vertIndex++] = command.geometry[5][0]; // x + 1 * width; + vertexBuffer[vertIndex++] = command.geometry[5][1]; // y + 1 * height; + vertexBuffer[vertIndex++] = 0; + + // UV coords + vertexBuffer[vertIndex++] = uvx1; // 1; + vertexBuffer[vertIndex++] = uvy1; // 1; + // texture id + vertexBuffer[vertIndex++] = textureId; + // opacity + vertexBuffer[vertIndex++] = command.opacity; + } + + return vertIndex / this.vertexSize; + } +} diff --git a/src/engine/Graphics/Context/line-renderer.ts b/src/engine/Graphics/Context/line-renderer.ts new file mode 100644 index 00000000..6c75dcac --- /dev/null +++ b/src/engine/Graphics/Context/line-renderer.ts @@ -0,0 +1,76 @@ +import { Vector } from '../../Algebra'; +import { Color } from '../../Drawing/Color'; +import { Shader } from './shader'; +import lineVertexSource from './shaders/line-vertex.glsl'; +import lineFragmentSource from './shaders/line-fragment.glsl'; +import { BatchRenderer } from './renderer'; +import { BatchCommand } from './batch'; +import { WebGLGraphicsContextInfo } from './ExcaliburGraphicsContextWebGL'; +import { Pool, Poolable } from '../../Util/Pool'; + +export class DrawLine implements Poolable { + _pool?: Pool; + public color: Color = Color.Black; + public start: Vector = Vector.Zero; + public end: Vector = Vector.Zero; + public dispose() { + this.color.r = 0; + this.color.g = 0; + this.color.b = 0; + this.color.a = 1; + this.start.setTo(0, 0); + this.end.setTo(0, 0); + return this; + } +} + +export class LineRenderer extends BatchRenderer { + constructor(gl: WebGLRenderingContext, private _contextInfo: WebGLGraphicsContextInfo) { + super({ gl, command: DrawLine, verticesPerCommand: 2 }); + this.init(); + } + + buildShader(gl: WebGLRenderingContext) { + const shader = new Shader(gl, lineVertexSource, lineFragmentSource); + + shader.addAttribute('a_position', 2, gl.FLOAT); + shader.addAttribute('a_color', 4, gl.FLOAT); + shader.addUniformMatrix('u_matrix', this._contextInfo.matrix.data); + return shader; + } + + addLine(start: Vector, end: Vector, color: Color) { + const cmd = this.commands.get(); + cmd.start = this._contextInfo.transform.current.multv(start); + cmd.end = this._contextInfo.transform.current.multv(end); + cmd.color = color; + cmd.color.a = cmd.color.a * this._contextInfo.state.current.opacity; + this.addCommand(cmd); + } + + buildBatchVertices(vertexBuffer: Float32Array, batch: BatchCommand): number { + let index = 0; + for (const command of batch.commands) { + // Start + vertexBuffer[index++] = command.start.x; + vertexBuffer[index++] = command.start.y; + vertexBuffer[index++] = command.color.r / 255; + vertexBuffer[index++] = command.color.g / 255; + vertexBuffer[index++] = command.color.b / 255; + vertexBuffer[index++] = command.color.a; + + // End + vertexBuffer[index++] = command.end.x; + vertexBuffer[index++] = command.end.y; + vertexBuffer[index++] = command.color.r / 255; + vertexBuffer[index++] = command.color.g / 255; + vertexBuffer[index++] = command.color.b / 255; + vertexBuffer[index++] = command.color.a; + } + return index / this.vertexSize; + } + + renderBatch(gl: WebGLRenderingContext, _batch: BatchCommand, vertexCount: number) { + gl.drawArrays(gl.LINES, 0, vertexCount); + } +} diff --git a/src/engine/Graphics/Context/point-renderer.ts b/src/engine/Graphics/Context/point-renderer.ts new file mode 100644 index 00000000..a947e593 --- /dev/null +++ b/src/engine/Graphics/Context/point-renderer.ts @@ -0,0 +1,74 @@ +import { Shader } from './shader'; +import pointVertexSource from './shaders/point-vertex.glsl'; +import pointFragmentSource from './shaders/point-fragment.glsl'; +import { Vector } from '../../Algebra'; +import { Color } from '../../Drawing/Color'; +import { BatchRenderer } from './renderer'; +import { BatchCommand } from './batch'; +import { WebGLGraphicsContextInfo } from './ExcaliburGraphicsContextWebGL'; +import { Pool, Poolable } from '../../Util/Pool'; +// import { Random } from '../../Math/Index'; + +export class DrawPoint implements Poolable { + _pool?: Pool; + public point: Vector = Vector.Zero; + public color: Color = Color.Black; + public size: number = 1; + public dispose() { + this.point.setTo(0, 0); + this.color.r = 0; + this.color.g = 0; + this.color.b = 0; + this.color.a = 1; + this.size = 1; + return this; + } +} + +export class PointRenderer extends BatchRenderer { + constructor(gl: WebGLRenderingContext, private _contextInfo: WebGLGraphicsContextInfo) { + super({ gl, command: DrawPoint, verticesPerCommand: 1 }); + this.init(); + } + + buildShader(gl: WebGLRenderingContext): Shader { + gl.getExtension('OES_standard_derivatives'); + const shader = new Shader(gl, pointVertexSource, pointFragmentSource); + shader.addAttribute('a_position', 2, gl.FLOAT); + shader.addAttribute('a_color', 4, gl.FLOAT); + shader.addAttribute('a_size', 1, gl.FLOAT); + shader.addUniformMatrix('u_matrix', this._contextInfo.matrix.data); + return shader; + } + + addPoint(point: Vector, color: Color, size: number) { + const cmd = this.commands.get(); + cmd.point = this._contextInfo.transform.current.multv(point); + cmd.color.r = color.r; + cmd.color.g = color.g; + cmd.color.b = color.b; + cmd.color.a = color.a * this._contextInfo.state.current.opacity; + cmd.size = size * Math.max(this._contextInfo.transform.current.data[0], this._contextInfo.transform.current.data[5]); + this.addCommand(cmd); + } + + buildBatchVertices(vertexBuffer: Float32Array, batch: BatchCommand): number { + let index = 0; + for (const command of batch.commands) { + vertexBuffer[index++] = command.point.x; + vertexBuffer[index++] = command.point.y; + // normalize to [0, 1] for webgl + vertexBuffer[index++] = command.color.r / 255; + vertexBuffer[index++] = command.color.g / 255; + vertexBuffer[index++] = command.color.b / 255; + vertexBuffer[index++] = command.color.a; + + vertexBuffer[index++] = command.size; + } + return index / this.vertexSize; + } + + renderBatch(gl: WebGLRenderingContext, _batch: BatchCommand, vertexCount: number): void { + gl.drawArrays(gl.POINTS, 0, vertexCount); + } +} diff --git a/src/engine/Graphics/Context/renderer.ts b/src/engine/Graphics/Context/renderer.ts new file mode 100644 index 00000000..be62c5eb --- /dev/null +++ b/src/engine/Graphics/Context/renderer.ts @@ -0,0 +1,139 @@ +import { BatchCommand } from './batch'; +import { Shader } from './shader'; +// import { Pool, Poolable } from './pool'; +import { GraphicsDiagnostics } from '../GraphicsDiagnostics'; +import { Pool, Poolable } from '../../Util/Pool'; + +export interface Renderer { + render(): void; +} + +export interface Ctor { + new (): T; +} + +export interface BatchRendererOptions { + gl: WebGLRenderingContext; + /** + * Draw command constructor + */ + command: Ctor; + /** + * Number of vertices that are generated per draw command + */ + verticesPerCommand?: number; + /** + * Maximum commands to batch before drawing + */ + maxCommandsPerBatch?: number; + /** + * Override the built in command batching mechanism + */ + batchFactory?: () => BatchCommand; +} + +export abstract class BatchRenderer implements Renderer { + priority = 0; + private _gl: WebGLRenderingContext; + private _vertices: Float32Array; + private _verticesPerCommand: number; + private _buffer: WebGLBuffer | null = null; + private _maxCommandsPerBatch: number = 2000; + + public shader: Shader; + + public commands: Pool; + private _batchPool: Pool>; + private _batches: BatchCommand[] = []; + constructor(options: BatchRendererOptions) { + this._gl = options.gl; + const command = options.command; + this._verticesPerCommand = options?.verticesPerCommand ?? 1; + this._maxCommandsPerBatch = options?.maxCommandsPerBatch ?? this._maxCommandsPerBatch; + const batchFactory = options?.batchFactory ?? (() => new BatchCommand(this._maxCommandsPerBatch)); + + this.commands = new Pool( + () => new command(), + (c) => c.dispose(), + this._maxCommandsPerBatch + ); + this._batchPool = new Pool>(batchFactory, (b) => b.dispose(), 100); + } + + /** + * Initialize render, builds shader and initialized webgl buffers + */ + public init() { + const gl = this._gl; + this.shader = this.buildShader(gl); + // Initialize VBO + // https://groups.google.com/forum/#!topic/webgl-dev-list/vMNXSNRAg8M + this._vertices = new Float32Array(this.shader.vertexAttributeSize * this._verticesPerCommand * this._maxCommandsPerBatch); + this._buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer); + gl.bufferData(gl.ARRAY_BUFFER, this._vertices, gl.DYNAMIC_DRAW); + } + + public get vertexSize(): number { + return this.shader.vertexAttributeSize; + } + + public addCommand(cmd: T) { + if (this._batches.length === 0) { + this._batches.push(this._batchPool.get()); + } + + const lastBatch = this._batches[this._batches.length - 1]; + if (lastBatch.canAdd()) { + lastBatch.add(cmd); + } else { + const newBatch = this._batchPool.get(); + newBatch.add(cmd); + this._batches.push(newBatch); + } + } + + /** + * Construct or return the Shader to be used in this batch renderer + * @param gl + */ + abstract buildShader(gl: WebGLRenderingContext): Shader; + + /** + * Implement populating the vertex buffer, return the number of vertices added to the buffer + * @param vertices + * @param batch + */ + abstract buildBatchVertices(vertexBuffer: Float32Array, batch: BatchCommand): number; + + /** + * Implement gl draw call to render batch. The vertextBuffer from buildBatchVertices is already bound and the data has been updated. + */ + abstract renderBatch(gl: WebGLRenderingContext, batch: BatchCommand, vertexCount: number): void; + + /** + * Build batch geometry, submit to the gpu, and issue draw command to underlying webgl + */ + public render(): void { + const gl = this._gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer); + this.shader.use(); + let drawCallCount = 0; + let drawnImagesCount = 0; + for (const batch of this._batches) { + // Build all geometry and ship to GPU + // interleave VBOs https://goharsha.com/lwjgl-tutorial-series/interleaving-buffer-objects/ + const vertexCount = this.buildBatchVertices(this._vertices, batch); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this._vertices); + + this.renderBatch(gl, batch, vertexCount); + drawnImagesCount += batch.commands.length; + drawCallCount++; + } + this.commands.done(); + this._batchPool.done(); + this._batches.length = 0; + GraphicsDiagnostics.DrawCallCount += drawCallCount; + GraphicsDiagnostics.DrawnImagesCount += drawnImagesCount; + } +} diff --git a/src/engine/Graphics/Context/shader.ts b/src/engine/Graphics/Context/shader.ts new file mode 100644 index 00000000..9d57e3b3 --- /dev/null +++ b/src/engine/Graphics/Context/shader.ts @@ -0,0 +1,220 @@ +export interface VertexAttributeDefinition { + name: string; + size: number; + glType: number; + normalized: boolean; + location: number; +} + +export interface UniformDefinition { + name: string; + location: WebGLUniformLocation; + type: string; + data: any; +} + +/** + * Create a shader program for the Excalibur WebGL Graphics Context + */ +export class Shader { + public program: WebGLProgram | null = null; + + public uniforms: { [variableName: string]: UniformDefinition } = {}; + public attributes: { [variableName: string]: VertexAttributeDefinition } = {}; + public layout: VertexAttributeDefinition[] = []; + + /** + * Create a shader program in excalibur + * @param _gl WebGL graphics context + * @param _vertexSource Vertex shader source as a string + * @param _fragmentSource Fragment shader source as a string + */ + constructor(private _gl: WebGLRenderingContext, private _vertexSource: string, private _fragmentSource: string) { + this.compile(_gl); + } + + private _createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram { + const program = gl.createProgram(); + if (program === null) { + throw Error('Could not create graphics shader program'); + } + + // attach the shaders. + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + + // link the program. + gl.linkProgram(program); + + const success = gl.getProgramParameter(program, gl.LINK_STATUS); + if (!success) { + throw Error(`Could not link the program: [${gl.getProgramInfoLog(program)}]`); + } + + return program; + } + + private _compileShader(gl: WebGLRenderingContext, source: string, type: number): WebGLShader { + const shader = gl.createShader(type); + if (shader === null) { + throw Error(`Could not build shader: [${source}]`); + } + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); + if (!success) { + throw Error(`Could not compile shader [${gl.getShaderInfoLog(shader)}]`); + } + return shader; + } + + /** + * Compile the current shader against a webgl context + * @param gl WebGL context + */ + compile(gl: WebGLRenderingContext): WebGLProgram { + const vertexShader = this._compileShader(gl, this._vertexSource, gl.VERTEX_SHADER); + const fragmentShader = this._compileShader(gl, this._fragmentSource, gl.FRAGMENT_SHADER); + const program = this._createProgram(gl, vertexShader, fragmentShader); + return (this.program = program); + } + + /** + * Add a uniform [[Matrix]] to the shader + * @param name Name of the uniform in the shader source + * @param data (4x4) matrix in column major order + */ + public addUniformMatrix(name: string, data: Float32Array) { + if (!data) { + throw Error(`Shader Uniform Matrix '${name}' was set to null or undefined`); + } + const gl = this._gl; + this.uniforms[name] = { + name, + type: 'matrix', + location: gl.getUniformLocation(this.program, name), + data: data + }; + } + + /** + * Add a uniform array of numbers to the shader + * @param name Name of the uniform in the shader source + * @param data List of numbers + */ + public addUniformIntegerArray(name: string, data: number[]) { + if (!data) { + throw Error(`Shader Uniform Integery Array '${name}' was set to null or undefined`); + } + const gl = this._gl; + this.uniforms[name] = { + name, + type: 'numbers', + location: gl.getUniformLocation(this.program, name), + data: data + }; + } + + /** + * Add attributes in the order they appear in the VBO + * @param name Name of the attribute in the shader source + * @param size The size of the attribute in gl.Type units, for example `vec2 a_pos` would be 2 gl.FLOAT + * @param glType The gl.Type of the attribute + */ + public addAttribute(name: string, size: number, glType: number, normalized = false) { + const gl = this._gl; + // TODO needs to be compiled first + const location = gl.getAttribLocation(this.program, name); + this.attributes[name] = { + name, + size, + glType, + normalized, + location + }; + this.layout.push(this.attributes[name]); + } + + /** + * Number of javascript floats a vertex will take up + */ + public get vertexAttributeSize(): number { + let vertexSize = 0; + for (const vert of this.layout) { + vertexSize += vert.size; + } + return vertexSize; + } + + /** + * Total number of bytes that the vertex will take up + */ + public get totalVertexSizeBytes(): number { + let vertexSize = 0; + for (const vert of this.layout) { + let typeSize = 1; + switch (vert.glType) { + case this._gl.FLOAT: { + typeSize = 4; + break; + } + default: { + typeSize = 1; + } + } + vertexSize += typeSize * vert.size; + } + + return vertexSize; + } + + /** + * Get a previously defined attribute size in bytes + * @param name + */ + public getAttributeSize(name: string) { + let typeSize = 1; + switch (this.attributes[name].glType) { + case this._gl.FLOAT: { + typeSize = 4; + break; + } + default: { + typeSize = 1; + } + } + return typeSize * this.attributes[name].size; + } + + /** + * Sets this shader program as the current in the underlying webgl context + * + * **Must** specify all attributes and uniforms before calling this + */ + public use() { + const gl = this._gl; + gl.useProgram(this.program); + let offset = 0; + for (const vert of this.layout) { + gl.vertexAttribPointer(vert.location, vert.size, vert.glType, vert.normalized, this.totalVertexSizeBytes, offset); + gl.enableVertexAttribArray(vert.location); + offset += this.getAttributeSize(vert.name); + } + + for (const key in this.uniforms) { + const uniform = this.uniforms[key]; + switch (uniform.type) { + case 'matrix': { + gl.uniformMatrix4fv(uniform.location, false, uniform.data); + break; + } + case 'numbers': { + gl.uniform1iv(uniform.location, uniform.data); + break; + } + } + } + } +} diff --git a/src/engine/Graphics/Context/shaders/image-fragment.glsl b/src/engine/Graphics/Context/shaders/image-fragment.glsl new file mode 100644 index 00000000..ed54dad1 --- /dev/null +++ b/src/engine/Graphics/Context/shaders/image-fragment.glsl @@ -0,0 +1,30 @@ +precision mediump float; + +// UV coord +varying vec2 v_texcoord; + +// Texture index +varying lowp float v_textureIndex; + +// Opacity +varying float v_opacity; + +uniform sampler2D u_textures[%%count%%]; + +void main() { + // In order to support the most efficient sprite batching, we have multiple + // textures loaded into the gpu (usually 8) this picker logic skips over textures + // that do not apply to a particular sprite. + + // Error color + vec4 color = vec4(1.0, 0.0, 0.0, 1.0); + + // Always at least 1 texture at 0 + if (v_textureIndex <= .5) { + gl_FragColor = texture2D(u_textures[0], v_texcoord); + gl_FragColor.w = gl_FragColor.w * v_opacity; + %%texture_picker%% + } else { + gl_FragColor = color; + } +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shaders/image-vertex.glsl b/src/engine/Graphics/Context/shaders/image-vertex.glsl new file mode 100644 index 00000000..c34b062d --- /dev/null +++ b/src/engine/Graphics/Context/shaders/image-vertex.glsl @@ -0,0 +1,28 @@ +attribute vec4 a_position; + +// Opacity +attribute float a_opacity; +varying float v_opacity; + +// UV coordinate +attribute vec2 a_texcoord; +varying vec2 v_texcoord; + +// Texture number +attribute lowp float a_textureIndex; +varying lowp float v_textureIndex; + +uniform mat4 u_matrix; + + +void main() { + // Set the vertex position using the ortho transform matrix + gl_Position = u_matrix * a_position; + + // Pass through the Opacity to the fragment shader + v_opacity = a_opacity; + // Pass through the UV coord to the fragment shader + v_texcoord = a_texcoord; + // Pass through the texture number to the fragment shader + v_textureIndex = a_textureIndex; +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shaders/line-fragment.glsl b/src/engine/Graphics/Context/shaders/line-fragment.glsl new file mode 100644 index 00000000..0786a979 --- /dev/null +++ b/src/engine/Graphics/Context/shaders/line-fragment.glsl @@ -0,0 +1,9 @@ +precision mediump float; + +// Color +varying lowp vec4 v_color; + + +void main() { + gl_FragColor = v_color; +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shaders/line-vertex.glsl b/src/engine/Graphics/Context/shaders/line-vertex.glsl new file mode 100644 index 00000000..187be823 --- /dev/null +++ b/src/engine/Graphics/Context/shaders/line-vertex.glsl @@ -0,0 +1,15 @@ +attribute vec4 a_position; +attribute vec4 a_color; + +varying lowp vec4 v_color; + +uniform mat4 u_matrix; + + +void main() { + // Set the vertex position using the ortho transform matrix + gl_Position = u_matrix * a_position; + + // Passthrough the color + v_color = a_color; +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shaders/point-fragment.glsl b/src/engine/Graphics/Context/shaders/point-fragment.glsl new file mode 100644 index 00000000..ece3ed91 --- /dev/null +++ b/src/engine/Graphics/Context/shaders/point-fragment.glsl @@ -0,0 +1,22 @@ +#ifdef GL_OES_standard_derivatives +#extension GL_OES_standard_derivatives : enable +#endif + +precision mediump float; +varying lowp vec4 v_color; + +void main() { + float r = 0.0, delta = 0.0, alpha = 1.0; + vec2 cxy = 2.0 * gl_PointCoord - 1.0; + r = dot(cxy, cxy); + +#ifdef GL_OES_standard_derivatives + delta = fwidth(r); + alpha = 1.0 - smoothstep(1.0 - delta, 1.0 + delta, r); +#else + if (r > 1.0) { + discard; + } +#endif + gl_FragColor = vec4(v_color.rgb, v_color.a * alpha); +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/shaders/point-vertex.glsl b/src/engine/Graphics/Context/shaders/point-vertex.glsl new file mode 100644 index 00000000..b697591d --- /dev/null +++ b/src/engine/Graphics/Context/shaders/point-vertex.glsl @@ -0,0 +1,11 @@ +attribute vec4 a_position; +attribute vec4 a_color; +attribute float a_size; +varying lowp vec4 v_color; +uniform mat4 u_matrix; + +void main() { + gl_Position = u_matrix * a_position; + gl_PointSize = a_size * 2.0; + v_color = a_color; +} \ No newline at end of file diff --git a/src/engine/Graphics/Context/state-stack.ts b/src/engine/Graphics/Context/state-stack.ts new file mode 100644 index 00000000..d2c3b725 --- /dev/null +++ b/src/engine/Graphics/Context/state-stack.ts @@ -0,0 +1,31 @@ +import { ExcaliburGraphicsContextState } from './ExcaliburGraphicsContext'; + +export class StateStack { + private _states: ExcaliburGraphicsContextState[] = []; + private _currentState: ExcaliburGraphicsContextState = this._getDefaultState(); + + private _getDefaultState() { + return { + opacity: 1 + }; + } + + private _cloneState() { + return { + opacity: this._currentState.opacity + }; + } + + public save(): void { + this._states.push(this._currentState); + this._currentState = this._cloneState(); + } + + public restore(): void { + this._currentState = this._states.pop(); + } + + public get current(): ExcaliburGraphicsContextState { + return this._currentState; + } +} diff --git a/src/engine/Graphics/Context/texture-loader.ts b/src/engine/Graphics/Context/texture-loader.ts new file mode 100644 index 00000000..4fcdcfbd --- /dev/null +++ b/src/engine/Graphics/Context/texture-loader.ts @@ -0,0 +1,102 @@ +import { HTMLImageSource } from './ExcaliburGraphicsContext'; +import { ensurePowerOfTwo, isPowerOfTwo } from './webgl-util'; + +/** + * Manages loading image sources into webgl textures, a unique id is associated with all sources + */ +export class TextureLoader { + private static _POT_CANVAS = document.createElement('canvas'); + private static _POT_CTX = TextureLoader._POT_CANVAS.getContext('2d'); + + private static _GL: WebGLRenderingContext; + + private static _TEXTURE_MAP = new Map(); + + public static registerContext(context: WebGLRenderingContext): void { + TextureLoader._GL = context; + } + + /** + * Get [[WebGLTexture]] from a source image + * @param image + */ + public static get(image: HTMLImageSource): WebGLTexture { + return TextureLoader._TEXTURE_MAP.get(image); + } + + /** + * Returns whether a source image has been loaded as a texture + * @param image + */ + public static has(image: HTMLImageSource): boolean { + return TextureLoader._TEXTURE_MAP.has(image); + } + + /** + * Loads a graphic into webgl and returns it's texture info, a webgl context must be previously registered + * @param image Source graphic + * @param forceUpdate Optionally force a texture to be reloaded, useful if the source graphic has changed + */ + public static load(image: HTMLImageSource, forceUpdate = false): WebGLTexture { + // Ignore loading if webgl is not registered + const gl = TextureLoader._GL; + if (!gl) { + return null; + } + + let tex: WebGLTexture = null; + // If reuse the texture if it's from the same source + if (TextureLoader.has(image)) { + tex = TextureLoader.get(image); + } + + // Update existing webgl texture and return early + if (tex) { + if (forceUpdate) { + gl.bindTexture(gl.TEXTURE_2D, tex); + const source = TextureLoader.toPowerOfTwoImage(image); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + } + return tex; + } + + // No texture exists create a new one + tex = gl.createTexture(); + const source = TextureLoader.toPowerOfTwoImage(image); + + gl.bindTexture(gl.TEXTURE_2D, tex); + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // NEAREST for pixels + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + + TextureLoader._TEXTURE_MAP.set(image, tex); + return tex; + } + + /** + * Converts source images into power of two images, WebGL only supports POT images + * https://www.khronos.org/webgl/wiki/WebGL_and_OpenGL_Differences#Non-Power_of_Two_Texture_Support + * @param image + */ + public static toPowerOfTwoImage(image: HTMLImageSource): HTMLImageSource { + const potCanvas = TextureLoader._POT_CANVAS; + const potCtx = TextureLoader._POT_CTX; + if (!isPowerOfTwo(image.width) || !isPowerOfTwo(image.height)) { + // Scale up the texture to the next highest power of two dimensions. + + potCanvas.width = ensurePowerOfTwo(image.width); + potCanvas.height = ensurePowerOfTwo(image.height); + potCtx.imageSmoothingEnabled = false; + potCtx.clearRect(0, 0, potCanvas.width, potCanvas.height); + potCtx.drawImage(image, 0, 0, image.width, image.height); + image = potCanvas; + } + return image; + } +} diff --git a/src/engine/Graphics/Context/transform-stack.ts b/src/engine/Graphics/Context/transform-stack.ts new file mode 100644 index 00000000..420c0cd4 --- /dev/null +++ b/src/engine/Graphics/Context/transform-stack.ts @@ -0,0 +1,35 @@ +import { Matrix } from '../../Math/matrix'; + +export class TransformStack { + private _transforms: Matrix[] = []; + private _currentTransform: Matrix = Matrix.identity(); + + public save(): void { + this._transforms.push(this._currentTransform); + this._currentTransform = this._currentTransform.clone(); + } + + public restore(): void { + this._currentTransform = this._transforms.pop(); + } + + public translate(x: number, y: number): Matrix { + return this._currentTransform.translate(x, y); + } + + public rotate(angle: number): Matrix { + return this._currentTransform.rotate(angle); + } + + public scale(x: number, y: number): Matrix { + return this._currentTransform.scale(x, y); + } + + public set current(matrix: Matrix) { + this._currentTransform = matrix; + } + + public get current(): Matrix { + return this._currentTransform; + } +} diff --git a/src/engine/Graphics/Context/webgl-util.ts b/src/engine/Graphics/Context/webgl-util.ts new file mode 100644 index 00000000..f1d94cc9 --- /dev/null +++ b/src/engine/Graphics/Context/webgl-util.ts @@ -0,0 +1,27 @@ +/** + * Checks if the current number is a power of two + */ +export function isPowerOfTwo(x: number): boolean { + return (x & (x - 1)) === 0; +} + +/** + * Returns the next highest power of two + */ +export function nextHighestPowerOfTwo(x: number): number { + --x; + for (let i = 1; i < 32; i <<= 1) { + x = x | (x >> i); + } + return x + 1; +} + +/** + * Returns the input number if a power of two, otherwise the next highest power of two + */ +export function ensurePowerOfTwo(x: number): number { + if (!isPowerOfTwo(x)) { + return nextHighestPowerOfTwo(x); + } + return x; +} diff --git a/src/engine/Graphics/Font.ts b/src/engine/Graphics/Font.ts new file mode 100644 index 00000000..92b9f10a --- /dev/null +++ b/src/engine/Graphics/Font.ts @@ -0,0 +1,248 @@ +import { Vector } from '../Algebra'; +import { BoundingBox } from '../Collision/Index'; +import { Color } from '../Drawing/Color'; +import { line } from '../Util/DrawUtil'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { BaseAlign, Direction, FontOptions, FontStyle, FontUnit, TextAlign, FontRenderer } from './FontCommon'; +import { Raster, RasterOptions } from './Raster'; + +export class Font extends Raster implements FontRenderer { + constructor(options: FontOptions & RasterOptions = {}) { + super(options); + this.family = options?.family ?? this.family; + this.style = options?.style ?? this.style; + this.bold = options?.bold ?? this.bold; + this.size = options?.size ?? this.size; + this.unit = options?.unit ?? this.unit; + this.textAlign = options?.textAlign ?? this.textAlign; + this.baseAlign = options?.baseAlign ?? this.baseAlign; + this.direction = options?.direction ?? this.direction; + this.quality = options?.quality ?? this.quality; + if (options?.shadow) { + this.shadow = {}; + this.shadow.blur = options.shadow.blur ?? this.shadow.blur; + this.shadow.offset = options.shadow.offset ?? this.shadow.offset; + this.shadow.color = options.shadow.color ?? this.shadow.color; + } + this.flagDirty(); + } + + public clone() { + return new Font({ + ...this.cloneGraphicOptions(), + ...this.cloneRasterOptions(), + size: this.size, + unit: this.unit, + family: this.family, + style: this.style, + bold: this.bold, + textAlign: this.textAlign, + baseAlign: this.baseAlign, + direction: this.direction, + shadow: this.shadow + ? { + blur: this.shadow.blur, + offset: this.shadow.offset, + color: this.shadow.color + } + : null + }); + } + + /** + * Font quality determines the size of the underlying rastered text, higher quality means less jagged edges. + * If quality is set to 1, then just enough raster bitmap is generated to render the text. + * + * You can think of quality as how zoomed in to the text you can get before seeing jagged edges. + * + * (Default 4) + */ + public quality = 4; + + public family: string = 'sans-serif'; + public style: FontStyle = FontStyle.Normal; + public bold: boolean = false; + public unit: FontUnit = FontUnit.Px; + public textAlign: TextAlign = TextAlign.Left; + public baseAlign: BaseAlign = BaseAlign.Alphabetic; + public direction: Direction = Direction.LeftToRight; + public size: number = 10; + public shadow: { blur?: number; offset?: Vector; color?: Color } = null; + + public get fontString() { + return `${this.style} ${this.bold ? 'bold' : ''} ${this.size}${this.unit} ${this.family}`; + } + + private _text: string; + private _textBounds: BoundingBox = new BoundingBox(); + private _textWidth: number = 0; + private _textHeight: number = 0; + + public get width() { + return this._textWidth; + } + + public set width(value: number) { + this._textWidth = value; + } + + public get height() { + return this._textHeight; + } + + public set height(value: number) { + this._textHeight = value; + } + + private get _rasterWidth() { + return this._bitmap.width; + } + + private get _rasterHeight() { + return this._bitmap.height; + } + + private get _halfRasterWidth() { + return Math.floor(this._bitmap.width / 2); + } + + private get _halfRasterHeight() { + return Math.floor(this._bitmap.height / 2); + } + + public get localBounds(): BoundingBox { + return this._textBounds; + } + + protected _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number) { + if (this.dirty) { + this.rasterize(); + } + + ex.drawImage( + this._bitmap, + 0, + 0, + this._rasterWidth, + this._rasterHeight, + x - (this._rasterWidth / this.quality) / 2, + y - (this._rasterHeight / this.quality) / 2, + this._rasterWidth / this.quality, + this._rasterHeight / this.quality + ); + } + + protected _rotate(ex: ExcaliburGraphicsContext) { + // TODO this needs to change depending on the bounding box... + const origin = this.origin ?? this._textBounds.center; + ex.translate(origin.x, origin.y); + ex.rotate(this.rotation); + ex.translate(-origin.x, -origin.y); + } + + protected _flip(ex: ExcaliburGraphicsContext) { + if (this.flipHorizontal) { + ex.translate(this._textBounds.width / this.scale.x, 0); + ex.scale(-1, 1); + } + + if (this.flipVertical) { + ex.translate(0, -this._textBounds.height / 2 / this.scale.y); + ex.scale(1, -1); + } + } + + public updateText(text: string) { + if (this._text !== text) { + this._text = text; + this._updateDimensions(); + this.flagDirty(); + } + } + + private _updateDimensions() { + if (this._text) { + this._applyFont(this._ctx); + const metrics = this._ctx.measureText(this._text); + this._textWidth = Math.abs(metrics.actualBoundingBoxLeft) + Math.abs(metrics.actualBoundingBoxRight); + this._textHeight = Math.abs(metrics.actualBoundingBoxAscent) + Math.abs(metrics.actualBoundingBoxDescent); + + // Changing the width and height clears the context properties + // We double the bitmap width to account for alignment + // We scale by "quality" so we render text without jaggies + this._bitmap.width = (this._textWidth + this.padding * 2) * 2 * this.quality; + this._bitmap.height = (this._textHeight + this.padding * 2) * 2 * this.quality; + + // These bounds exist in raster bitmap space where the top left corner is the corder of the bitmap + // TODO need to account for padding + const x = 0; + const y = 0; + this._textBounds = new BoundingBox({ + left: x - Math.abs(metrics.actualBoundingBoxLeft) - this.padding, + top: y - Math.abs(metrics.actualBoundingBoxAscent) - this.padding, + bottom: y + Math.abs(metrics.actualBoundingBoxDescent) + this.padding, + right: x + Math.abs(metrics.actualBoundingBoxRight) + this.padding + }); + } + } + + protected _preDraw(ex: ExcaliburGraphicsContext, x: number, y: number): void { + if (this.dirty) { + this._updateDimensions(); + } + super._preDraw(ex, x, y); + } + + protected _postDraw(ex: ExcaliburGraphicsContext): void { + if (this.showDebug) { + /* istanbul ignore next */ + ex.debug.drawRect(-this._halfRasterWidth, -this._halfRasterHeight, this._rasterWidth, this._rasterHeight); + } + ex.restore(); + } + + private _applyFont(ctx: CanvasRenderingContext2D) { + ctx.translate(this.padding + this._halfRasterWidth, this.padding + this._halfRasterHeight); + ctx.scale(this.quality, this.quality); + ctx.textAlign = this.textAlign; + ctx.textBaseline = this.baseAlign; + ctx.font = this.fontString; + ctx.direction = this.direction; + + if (this.shadow) { + ctx.shadowColor = this.shadow.color.toString(); + ctx.shadowBlur = this.shadow.blur; + ctx.shadowOffsetX = this.shadow.offset.x; + ctx.shadowOffsetY = this.shadow.offset.y; + } + } + + execute(ctx: CanvasRenderingContext2D): void { + if (this._text) { + // The reason we need to re-apply the font is setting raster properties (like width/height) can reset the context props + this._applyRasterProperites(ctx); + this._applyFont(ctx); + if (this.color) { + ctx.fillText(this._text, 0, 0); + } + + if (this.strokeColor) { + ctx.strokeText(this._text, 0, 0); + } + + if (this.showDebug) { + // Horizontal line + /* istanbul ignore next */ + line(ctx, Color.Red, -this._halfRasterWidth, 0, this._halfRasterWidth, 0, 2); + // Vertical line + /* istanbul ignore next */ + line(ctx, Color.Red, 0, -this._halfRasterHeight, 0, this._halfRasterHeight, 2); + } + } + } + + public render(ex: ExcaliburGraphicsContext, text: string, x: number, y: number) { + this.updateText(text); + this.draw(ex, x, y); + } +} diff --git a/src/engine/Graphics/FontCommon.ts b/src/engine/Graphics/FontCommon.ts new file mode 100644 index 00000000..9858adec --- /dev/null +++ b/src/engine/Graphics/FontCommon.ts @@ -0,0 +1,129 @@ +import { Color } from '../Drawing/Color'; +import { Vector } from '../Algebra'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; + +/** + * Enum representing the different font size units + * https://developer.mozilla.org/en-US/docs/Web/CSS/font-size + */ +export enum FontUnit { + /** + * Em is a scalable unit, 1 em is equal to the current font size of the current element, parent elements can effect em values + */ + Em = 'em', + /** + * Rem is similar to the Em, it is a scalable unit. 1 rem is equal to the font size of the root element + */ + Rem = 'rem', + /** + * Pixel is a unit of length in screen pixels + */ + Px = 'px', + /** + * Point is a physical unit length (1/72 of an inch) + */ + Pt = 'pt', + /** + * Percent is a scalable unit similar to Em, the only difference is the Em units scale faster when Text-Size stuff + */ + Percent = '%' +} + +/** + * Enum representing the different horizontal text alignments + */ +export enum TextAlign { + /** + * The text is left-aligned. + */ + Left = 'left', + /** + * The text is right-aligned. + */ + Right = 'right', + /** + * The text is centered. + */ + Center = 'center', + /** + * The text is aligned at the normal start of the line (left-aligned for left-to-right locales, + * right-aligned for right-to-left locales). + */ + Start = 'start', + /** + * The text is aligned at the normal end of the line (right-aligned for left-to-right locales, + * left-aligned for right-to-left locales). + */ + End = 'end' +} + +/** + * Enum representing the different baseline text alignments + */ +export enum BaseAlign { + /** + * The text baseline is the top of the em square. + */ + Top = 'top', + /** + * The text baseline is the hanging baseline. Currently unsupported; this will act like + * alphabetic. + */ + Hanging = 'hanging', + /** + * The text baseline is the middle of the em square. + */ + Middle = 'middle', + /** + * The text baseline is the normal alphabetic baseline. + */ + Alphabetic = 'alphabetic', + /** + * The text baseline is the ideographic baseline; this is the bottom of + * the body of the characters, if the main body of characters protrudes + * beneath the alphabetic baseline. Currently unsupported; this will + * act like alphabetic. + */ + Ideographic = 'ideographic', + /** + * The text baseline is the bottom of the bounding box. This differs + * from the ideographic baseline in that the ideographic baseline + * doesn't consider descenders. + */ + Bottom = 'bottom' +} + +/** + * Enum representing the different possible font styles + */ +export enum FontStyle { + Normal = 'normal', + Italic = 'italic', + Oblique = 'oblique' +} + +export enum Direction { + LeftToRight = 'ltr', + RightToLeft = 'rtl' +} + +export interface FontOptions { + size?: number; + unit?: FontUnit; + family?: string; + style?: FontStyle; + bold?: boolean; + textAlign?: TextAlign; + baseAlign?: BaseAlign; + direction?: Direction; + quality?: number; + shadow?: { + blur?: number; + offset?: Vector; + color?: Color; + }; +} + +export interface FontRenderer { + render(ex: ExcaliburGraphicsContext, text: string, x: number, y: number): void; +} \ No newline at end of file diff --git a/src/engine/Graphics/Graphic.ts b/src/engine/Graphics/Graphic.ts new file mode 100644 index 00000000..f1bd37f8 --- /dev/null +++ b/src/engine/Graphics/Graphic.ts @@ -0,0 +1,218 @@ +import { Vector, vec } from '../Algebra'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { BoundingBox } from '../Collision/BoundingBox'; + +export interface GraphicOptions { + /** + * The width of the graphic + */ + width?: number; + /** + * The height of the graphic + */ + height?: number; + /** + * SHould the graphic be flipped horizontally + */ + flipHorizontal?: boolean; + /** + * Should the graphic be flipped vertically + */ + flipVertical?: boolean; + /** + * The rotation of the graphic + */ + rotation?: number; + /** + * The scale of the graphic + */ + scale?: Vector; + /** + * The opacity of the graphic + */ + opacity?: number; + /** + * The origin of the drawing in pixels to use when applying transforms, by default it will be the center of the image + */ + origin?: Vector; +} + +/** + * A Graphic is the base Excalibur primitive for something that can be drawn to the [[ExcaliburGraphicsContext]]. + * [[Sprite]], [[Animation]], [[GraphicsGroup]], [[Canvas]], [[Rectangle]], [[Circle]], and [[Polygon]] all derive from the + * [[Graphic]] abstract class. + * + * Implementors of a Graphic must override the abstract [[Graphic._drawImage]] method to render an image to the graphics context. Graphic + * handles all the position, rotation, and scale transformations in [[Graphic._preDraw]] and [[Graphic._postDraw]] + */ +export abstract class Graphic { + private static _ID: number = 0; + readonly id = Graphic._ID++; + + /** + * Gets or sets wether to show debug information about the graphic + */ + public showDebug: boolean = false; + + /** + * Gets or sets the flipHorizontal, which will flip the graphic horizontally (across the y axis) + */ + public flipHorizontal: boolean = false; + + /** + * Gets or sets the flipVertical, which will flip the graphic vertically (across the x axis) + */ + public flipVertical: boolean = false; + + /** + * Gets or sets the rotation of the graphic + */ + public rotation: number = 0; + + /** + * Gets or sets the opacity of the graphic, 0 is transparent, 1 is solid (opaque). + */ + public opacity: number = 1; + + /** + * Gets or sets the scale of the graphic, this affects the width and + */ + public scale = Vector.One; + + /** + * Gets or sets the origin of the graphic, if not set the center of the graphic is the origin + */ + public origin: Vector | null = null; + + constructor(options?: GraphicOptions) { + if (options) { + this.origin = options.origin ?? this.origin; + this.flipHorizontal = options.flipHorizontal ?? this.flipHorizontal; + this.flipVertical = options.flipVertical ?? this.flipVertical; + this.rotation = options.rotation ?? this.rotation; + this.opacity = options.opacity ?? this.opacity; + this.scale = options.scale ?? this.scale; + } + } + + public cloneGraphicOptions(): GraphicOptions { + return { + origin: this.origin ? this.origin.clone() : null, + flipHorizontal: this.flipHorizontal, + flipVertical: this.flipVertical, + rotation: this.rotation, + opacity: this.opacity, + scale: this.scale ? this.scale.clone() : null + }; + } + + private _width: number = 0; + + /** + * Gets or sets the width of the graphic (always positive) + */ + public get width() { + return Math.abs(this._width * this.scale.x); + } + + private _height: number = 0; + + /** + * Gets or sets the height of the graphic (always positive) + */ + public get height() { + return Math.abs(this._height * this.scale.y); + } + + public set width(value: number) { + this._width = value; + } + + public set height(value: number) { + this._height = value; + } + + /** + * Gets a copy of the bounds in pixels occupied by the graphic on the the screen. This includes scale. + */ + public get localBounds(): BoundingBox { + return BoundingBox.fromDimension(this.width, this.height, Vector.Zero); + } + + /** + * Draw the whole graphic to the context including transform + * @param ex The excalibur graphics context + * @param x + * @param y + */ + public draw(ex: ExcaliburGraphicsContext, x: number, y: number): void { + this._preDraw(ex, x, y); + this._drawImage(ex, 0, 0); + this._postDraw(ex); + } + + /** + * Meant to be overriden by the graphic implementation to draw the underlying image (HTMLCanvasElement or HTMLImageElement) + * to the graphics context without transform. Transformations like position, rotation, and scale are handled by [[Graphic._preDraw]] + * and [[Graphic._postDraw]] + * @param ex The excalibur graphics context + * @param x + * @param y + */ + protected abstract _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number): void; + + /** + * Apply affine transformations to the graphics context to manipulate the graphic before [[Graphic._drawImage]] + * @param ex + * @param x + * @param y + */ + protected _preDraw(ex: ExcaliburGraphicsContext, x: number, y: number): void { + ex.save(); + ex.translate(x, y); + ex.scale(this.scale.x, this.scale.y); + this._rotate(ex); + this._flip(ex); + // it is important to multiply alphas so graphics respect the current context + ex.opacity = ex.opacity * this.opacity; + } + + protected _rotate(ex: ExcaliburGraphicsContext) { + const scaleDirX = this.scale.x > 0 ? 1 : -1; + const scaleDirY = this.scale.y > 0 ? 1 : -1; + const origin = this.origin ?? vec(this.width / 2, this.height / 2); + ex.translate(origin.x, origin.y); + ex.rotate(this.rotation); + // This is for handling direction changes 1 or -1, that way we don't have mismatched translates() + ex.scale(scaleDirX, scaleDirY); + ex.translate(-origin.x, -origin.y); + } + + protected _flip(ex: ExcaliburGraphicsContext) { + if (this.flipHorizontal) { + ex.translate(this.width / this.scale.x, 0); + ex.scale(-1, 1); + } + + if (this.flipVertical) { + ex.translate(0, this.height / this.scale.y); + ex.scale(1, -1); + } + } + + /** + * Apply any addtional work after [[Graphic._drawImage]] and restore the context state. + * @param ex + */ + protected _postDraw(ex: ExcaliburGraphicsContext): void { + if (this.showDebug) { + ex.debug.drawRect(0, 0, this.width, this.height); + } + ex.restore(); + } + + /** + * Returns a new instance of the graphic that has the same properties + */ + abstract clone(): Graphic; +} diff --git a/src/engine/Graphics/GraphicsComponent.ts b/src/engine/Graphics/GraphicsComponent.ts new file mode 100644 index 00000000..352646a6 --- /dev/null +++ b/src/engine/Graphics/GraphicsComponent.ts @@ -0,0 +1,402 @@ +import { Vector, vec } from '../Algebra'; +import { Graphic } from './Graphic'; +import { Animation } from './Animation'; +import { GraphicsGroup } from './GraphicsGroup'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { Logger } from '../Util/Log'; +import { BoundingBox } from '../Collision/Index'; +import { Component } from '../EntityComponentSystem/Component'; + +export interface GraphicsShowOptions { + offset?: Vector; + anchor?: Vector; +} + +export interface GraphicsComponentOptions { + onPostDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void; + onPreDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void; + + /** + * Name of current graphic to use + */ + current?: string; + + /** + * Optionally copy instances of graphics by calling .clone(), you may set this to false to avoid sharing graphics when added to the + * component for performance reasons. By default graphics are not copied and are shared when added to the component. + */ + copyGraphics?: boolean; + + /** + * Optional visible flag, if the graphics component is not visible it will not be displayed + */ + visible?: boolean; + + /** + * Optional opacity + */ + opacity?: number; + + /** + * List of graphics + */ + graphics?: { [graphicName: string]: Graphic }; + + /** + * Optional offset in absolute pixels to shift all graphics in this component from each graphic's anchor (default is top left corner) + */ + offset?: Vector; + + /** + * Optional anchor + */ + anchor?: Vector; +} + +export interface GraphicsLayerOptions { + /** + * Name of the layer required, for example 'background' + */ + name: string; + /** + * Order of the layer, a layer with order -1 will be below a layer with order of 1 + */ + order: number; + /** + * Offset to shift the entire layer + */ + offset?: Vector; +} +export class GraphicsLayer { + public graphics: { graphic: Graphic; options: GraphicsShowOptions }[] = []; + constructor(private _options: GraphicsLayerOptions, private _graphics: GraphicsComponent) {} + public get name(): string { + return this._options.name; + } + + /** + * Remove any instance(s) of a graphic currently being shown in this layer + */ + public hide(nameOrGraphic: string | Graphic): void; + /** + * Remove all currently shown graphics in this layer + */ + public hide(): void; + public hide(nameOrGraphic?: string | Graphic): void { + if (!nameOrGraphic) { + this.graphics.length = 0; + } else { + let gfx: Graphic = null; + if (nameOrGraphic instanceof Graphic) { + gfx = nameOrGraphic; + } else { + gfx = this._graphics.getGraphic(nameOrGraphic); + } + this.graphics = this.graphics.filter((g) => g.graphic !== gfx); + } + } + + /** + * Show a graphic by name or instance at an offset, graphics are shown in the order in which `show()` is called. + * + * If `show()` is called multiple times for the same graphic it will be shown multiple times. + * @param nameOrGraphic + * @param options + */ + public show(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { + options = { offset: this._graphics.offset.clone(), anchor: this._graphics.anchor.clone(), ...options }; + let gfx: Graphic; + if (nameOrGraphic instanceof Graphic) { + gfx = this._graphics.copyGraphics ? nameOrGraphic.clone() : nameOrGraphic; + } else { + gfx = this._graphics.getGraphic(nameOrGraphic); + if (!gfx) { + Logger.getInstance().error( + `No such graphic added to component named ${nameOrGraphic}. These named graphics are available: `, + this._graphics.getNames() + ); + } + } + if (gfx) { + this.graphics.push({ graphic: gfx, options }); + return gfx as T; + } else { + return null; + } + } + + /** + * Use a specific graphic, swap out any current graphics being shown + * @param nameOrGraphic + * @param options + */ + public use(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { + options = { offset: this._graphics.offset.clone(), anchor: this._graphics.anchor.clone(), ...options }; + this.hide(); + return this.show(nameOrGraphic, options); + } + + /** + * Current order of the layer, higher numbers are on top, lower numbers are on the bottom. + * + * For example a layer with `order = -1` would be under a layer of `order = 1` + */ + public get order(): number { + return this._options.order; + } + + /** + * Set the order of the layer, higher numbers are on top, lower numbers are on the bottom. + * + * For example a layer with `order = -1` would be under a layer of `order = 1` + */ + public set order(order: number) { + this._options.order = order; + } + + /** + * Get or set the pixel offset from the layer anchor for all graphics in the layer + */ + public get offset(): Vector { + return this._options.offset ?? Vector.Zero; + } + + public set offset(value: Vector) { + this._options.offset = value; + } +} + +export class GraphicsLayers { + private _layers: GraphicsLayer[] = []; + private _layerMap: { [layerName: string]: GraphicsLayer } = {}; + public default: GraphicsLayer; + constructor(private _component: GraphicsComponent) { + this.default = new GraphicsLayer({ name: 'default', order: 0 }, _component); + this._maybeAddLayer(this.default); + } + public create(options: GraphicsLayerOptions): GraphicsLayer { + const layer = new GraphicsLayer(options, this._component); + return this._maybeAddLayer(layer); + } + + /** + * Retrieve a single layer by name + * @param name + */ + public get(name: string): GraphicsLayer; + /** + * Retrieve all layers + */ + public get(): readonly GraphicsLayer[]; + public get(name?: string): GraphicsLayer | readonly GraphicsLayer[] { + if (name) { + return this._getLayer(name); + } + return this._layers; + } + + public has(name: string): boolean { + return name in this._layerMap; + } + + private _maybeAddLayer(layer: GraphicsLayer) { + if (this._layerMap[layer.name]) { + // todo log warning + return this._layerMap[layer.name]; + } + this._layerMap[layer.name] = layer; + this._layers.push(layer); + this._layers.sort((a, b) => a.order - b.order); + return layer; + } + + private _getLayer(name: string): GraphicsLayer | undefined { + return this._layerMap[name]; + } +} + +/** + * Component to manage drawings, using with the position component + */ +export class GraphicsComponent extends Component<'graphics'> { + static type: 'graphics'; + readonly type = 'graphics'; + + private _graphics: { [graphicName: string]: Graphic } = {}; + + public layers: GraphicsLayers; + + public getGraphic(name: string): Graphic | undefined { + return this._graphics[name]; + } + + /** + * Get registered graphics names + */ + public getNames(): string[] { + return Object.keys(this._graphics); + } + + public onPreDraw: (ctx: ExcaliburGraphicsContext, elapsedMilliseconds: number) => void; + public onPostDraw: (ctx: ExcaliburGraphicsContext, elapsedMilliseconds: number) => void; + + /** + * Sets or gets wether any drawing should be visible in this component + */ + public visible: boolean = true; + + /** + * Sets or gets wither all drawings should have an opacity applied + */ + public opacity: number = 1; + + /** + * Offset to apply to graphics by default + */ + public offset: Vector = Vector.Zero; + + /** + * Anchor to apply to graphics by default + */ + public anchor: Vector = Vector.Half; + + /** + * If set to true graphics added to the component will be copied. This can affect performance + */ + public copyGraphics: boolean = false; + + constructor(options?: GraphicsComponentOptions) { + super(); + // Defaults + options = { + visible: this.visible, + ...options + }; + + const { current, anchor, opacity, visible, graphics, offset, copyGraphics, onPreDraw, onPostDraw } = options; + + this._graphics = graphics || {}; + this.offset = offset ?? this.offset; + this.opacity = opacity ?? this.opacity; + this.anchor = anchor ?? this.anchor; + this.copyGraphics = copyGraphics ?? this.copyGraphics; + this.onPreDraw = onPreDraw ?? this.onPreDraw; + this.onPostDraw = onPostDraw ?? this.onPostDraw; + this.visible = !!visible; + + this.layers = new GraphicsLayers(this); + if (current && this._graphics[current]) { + this.show(this._graphics[current]); + } + } + + /** + * Returns the currently displayed graphics and their offsets, empty array if hidden + */ + public get current(): { graphic: Graphic; options: GraphicsShowOptions }[] { + return this.layers.default.graphics; + } + + /** + * Returns all graphics associated with this component + */ + public get graphics(): { [graphicName: string]: Graphic } { + return this._graphics; + } + + /** + * Adds a named graphic to this component, if the name is "default" or not specified, it will be shown by default without needing to call + * `show("default")` + * @param graphic + */ + public add(graphic: Graphic): Graphic; + public add(name: string, graphic: Graphic): Graphic; + public add(nameOrGraphic: string | Graphic, graphic?: Graphic): Graphic { + let name = 'default'; + let graphicToSet: Graphic = null; + if (typeof nameOrGraphic === 'string') { + name = nameOrGraphic; + graphicToSet = graphic; + } else { + graphicToSet = nameOrGraphic; + } + + this._graphics[name] = this.copyGraphics ? graphicToSet.clone() : graphicToSet; + if (name === 'default') { + this.show('default'); + } + return graphicToSet; + } + + /** + * Show a graphic by name on the **default** layer, returns the new [[Graphic]] + */ + public show(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { + return this.layers.default.show(nameOrGraphic, options); + } + + /** + * Use a graphic only, swap out any graphics on the **default** layer, returns the new [[Graphic]] + * @param nameOrGraphic + * @param options + */ + public use(nameOrGraphic: string | T, options?: GraphicsShowOptions): T { + return this.layers.default.use(nameOrGraphic, options); + } + + /** + * Remove any instance(s) of a graphic currently being shown in the **default** layer + */ + public hide(nameOrGraphic: string | Graphic): void; + /** + * Remove all currently shown graphics in the **default** layer + */ + public hide(): void; + public hide(nameOrGraphic?: string | Graphic): void { + this.layers.default.hide(nameOrGraphic); + } + + private _isAnimationOrGroup(graphic: Graphic): graphic is Animation | GraphicsGroup { + return graphic instanceof Animation || graphic instanceof GraphicsGroup; + } + + private _bounds: BoundingBox = null; + public set localBounds(bounds: BoundingBox) { + this._bounds = bounds; + } + + public get localBounds(): BoundingBox { + if (this._bounds) { + return this._bounds; + } + let bb = new BoundingBox(); + for (const layer of this.layers.get()) { + for (const { + graphic, + options: { offset, anchor } + } of layer.graphics) { + const bounds = graphic.localBounds; + const offsetX = -bounds.width * graphic.scale.x * anchor.x + offset.x; + const offsetY = -bounds.height * graphic.scale.y * anchor.y + offset.y; + bb = graphic?.localBounds.translate(vec(offsetX + layer.offset.x, offsetY + layer.offset.y)).combine(bb); + } + } + return bb; + } + + /** + * Update underlying graphics if necesary, called internally + * @param elapsed + * @internal + */ + public update(elapsed: number, idempotencyToken: number = 0) { + for (const layer of this.layers.get()) { + for (const { graphic } of layer.graphics) { + if (this._isAnimationOrGroup(graphic)) { + graphic?.tick(elapsed, idempotencyToken); + } + } + } + } + +} diff --git a/src/engine/Graphics/GraphicsDiagnostics.ts b/src/engine/Graphics/GraphicsDiagnostics.ts new file mode 100644 index 00000000..d76ce772 --- /dev/null +++ b/src/engine/Graphics/GraphicsDiagnostics.ts @@ -0,0 +1,8 @@ +export class GraphicsDiagnostics { + public static DrawCallCount: number = 0; + public static DrawnImagesCount: number = 0; + public static clear(): void { + GraphicsDiagnostics.DrawCallCount = 0; + GraphicsDiagnostics.DrawnImagesCount = 0; + } +} diff --git a/src/engine/Graphics/GraphicsGroup.ts b/src/engine/Graphics/GraphicsGroup.ts new file mode 100644 index 00000000..1b92e231 --- /dev/null +++ b/src/engine/Graphics/GraphicsGroup.ts @@ -0,0 +1,91 @@ +import { Vector } from '../Algebra'; +import { Graphic, GraphicOptions } from './Graphic'; +import { Animation, HasTick } from './Animation'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { BoundingBox } from '../Collision/Index'; + +export interface GraphicsGroupingOptions { + members: GraphicsGrouping[]; +} + +export interface GraphicsGrouping { + pos: Vector; + graphic: Graphic; +} + +export class GraphicsGroup extends Graphic implements HasTick { + public members: GraphicsGrouping[] = []; + + constructor(options: GraphicsGroupingOptions & GraphicOptions) { + super(options); + this.members = options.members; + this._updateDimensions(); + } + + public clone(): GraphicsGroup { + return new GraphicsGroup({ + members: [...this.members], + ...this.cloneGraphicOptions() + }); + } + + private _updateDimensions(): BoundingBox { + let bb = new BoundingBox(); + for (const { graphic, pos } of this.members) { + bb = graphic.localBounds.translate(pos).combine(bb); + } + + this.width = bb.width; + this.height = bb.height; + + return bb; + } + + public get localBounds(): BoundingBox { + let bb = new BoundingBox(); + for (const { graphic, pos } of this.members) { + bb = graphic.localBounds.translate(pos).combine(bb); + } + return bb; + } + + private _isAnimationOrGroup(graphic: Graphic): graphic is Animation | GraphicsGroup { + return graphic instanceof Animation || graphic instanceof GraphicsGroup; + } + + public tick(elapsedMilliseconds: number, idempotencyToken?: number) { + for (const member of this.members) { + const maybeAnimation = member.graphic; + if (this._isAnimationOrGroup(maybeAnimation)) { + maybeAnimation.tick(elapsedMilliseconds, idempotencyToken); + } + } + } + + public reset() { + for (const member of this.members) { + const maybeAnimation = member.graphic; + if (this._isAnimationOrGroup(maybeAnimation)) { + maybeAnimation.reset(); + } + } + } + + protected _preDraw(ex: ExcaliburGraphicsContext, x: number, y: number) { + this._updateDimensions(); + super._preDraw(ex, x, y); + } + + protected _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number) { + for (const member of this.members) { + ex.save(); + ex.translate(x, y); + member.graphic.draw(ex, member.pos.x, member.pos.y); + if (this.showDebug) { + /* istanbul ignore next */ + ex.debug.drawRect(0, 0, this.width, this.height); + } + ex.restore(); + } + } +} diff --git a/src/engine/Graphics/GraphicsSystem.ts b/src/engine/Graphics/GraphicsSystem.ts new file mode 100644 index 00000000..262276fd --- /dev/null +++ b/src/engine/Graphics/GraphicsSystem.ts @@ -0,0 +1,190 @@ +import { isActor } from '../Actor'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { Scene } from '../Scene'; +import { GraphicsComponent } from './GraphicsComponent'; +import { vec, Vector } from '../Algebra'; +import { Color } from '../Drawing/Color'; +import { CoordPlane, TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; +import { Entity } from '../EntityComponentSystem/Entity'; +import { Camera } from '../Camera'; +import { System, SystemType } from '../EntityComponentSystem'; +import { Engine } from '../Engine'; +import { GraphicsDiagnostics } from './GraphicsDiagnostics'; + +export class GraphicsSystem extends System { + public readonly types = ['transform', 'graphics'] as const; + public readonly systemType = SystemType.Draw; + public priority = 0; + private _token = 0; + private _graphicsContext: ExcaliburGraphicsContext; + private _camera: Camera; + private _engine: Engine; + + public initialize(scene: Scene): void { + this._graphicsContext = scene.engine.graphicsContext; + this._camera = scene.camera; + this._engine = scene.engine; + } + + public sort(a: Entity, b: Entity) { + return a.components.transform.z - b.components.transform.z; + } + + public update(entities: Entity[], delta: number): void { + this._clearScreen(); + this._token++; + let transform: TransformComponent; + let graphics: GraphicsComponent; + + for (const entity of entities) { + transform = entity.components.transform; + graphics = entity.components.graphics; + + // Skip entities that have graphics offscreen + if (this._isOffscreen(transform, graphics)) { + continue; + } + + // This optionally sets our camera based on the entity coord plan (world vs. screen) + this._pushCameraTransform(transform); + + this._graphicsContext.save(); + + // Optionally run the onPreDraw graphics lifecycle draw + if (graphics.onPreDraw) { + graphics.onPreDraw(this._graphicsContext, delta); + } + + // Tick any graphics state (but only once) for animations and graphics groups + graphics.update(delta, this._token); + + // Position the entity + this._applyTransform(transform); + + this._graphicsPositionDebugDraw(); + + this._graphicsContext.opacity = graphics.opacity * ((entity as any).opacity ?? 1); + + // Draw the graphics component + this._drawGraphicsComponent(graphics); + + // Optionally run the onPostDraw graphics lifecycle draw + if (graphics.onPostDraw) { + graphics.onPostDraw(this._graphicsContext, delta); + } + + this._graphicsContext.restore(); + + // Draw the graphics bounds + this._graphicsBoundsDebugDraw(entity, transform, graphics); + + // Reset the transform back to the original + this._popCameraTransform(transform); + } + + this._graphicsContext.flush(); + this._engine.stats.currFrame.graphics.drawnImages = GraphicsDiagnostics.DrawnImagesCount; + this._engine.stats.currFrame.graphics.drawCalls = GraphicsDiagnostics.DrawCallCount; + } + + private _clearScreen(): void { + this._graphicsContext.clear(); + } + + private _isOffscreen(transform: TransformComponent, graphics: GraphicsComponent) { + if (transform.coordPlane === CoordPlane.World) { + const graphicsOffscreen = !this._camera.viewport.intersect(graphics.localBounds.translate(transform.pos)); + return graphicsOffscreen; + } else { + // TODO sceen coordinates + return false; + } + } + + private _drawGraphicsComponent(graphicsComponent: GraphicsComponent) { + if (graphicsComponent.visible) { + // this should be moved to the graphics system + for (const layer of graphicsComponent.layers.get()) { + for (const { + graphic, + options: { offset, anchor } + } of layer.graphics) { + // See https://github.com/excaliburjs/Excalibur/pull/619 for discussion on this formula + const offsetX = -graphic.width * anchor.x + offset.x; + const offsetY = -graphic.height * anchor.y + offset.y; + + graphic?.draw( + this._graphicsContext, + offsetX + layer.offset.x, + offsetY + layer.offset.y + ); + + if (this._engine?.isDebug) { + /* istanbul ignore next */ + graphic?.localBounds.translate(vec(offsetX + layer.offset.x, offsetY + layer.offset.y)).draw(this._graphicsContext, Color.Red); + } + } + } + if (this._engine?.isDebug) { + /* istanbul ignore next */ + graphicsComponent.localBounds.draw(this._graphicsContext, Color.Red); + } + } + } + + /** + * This applies the current entity transform to the graphics context + * @param transform + */ + private _applyTransform(transform: TransformComponent): void { + this._graphicsContext.translate(transform.pos.x, transform.pos.y); + this._graphicsContext.rotate(transform.rotation); + this._graphicsContext.scale(transform.scale.x, transform.scale.y); + } + + /** + * Applies the current camera transform if in world coordinates + * @param transform + */ + private _pushCameraTransform(transform: TransformComponent) { + // Establish camera offset per entity + if (transform.coordPlane === CoordPlane.World) { + this._graphicsContext.save(); + if (this._camera) { + this._camera.draw(this._graphicsContext); + } + } + } + + /** + * Resets the current camera transform if in world coordinates + * @param transform + */ + private _popCameraTransform(transform: TransformComponent) { + if (transform.coordPlane === CoordPlane.World) { + // Apply camera world offset + this._graphicsContext.restore(); + } + } + + /* istanbul ignore next */ + private _graphicsPositionDebugDraw() { + if (this._engine?.isDebug) { + this._graphicsContext.debug.drawPoint(Vector.Zero, { color: Color.Yellow, size: 5 }); + } + } + + /* istanbul ignore next */ + private _graphicsBoundsDebugDraw( + entity: Entity, + _transform: TransformComponent, + _graphics: GraphicsComponent + ) { + if (this._engine?.isDebug) { + if (isActor(entity)) { + const bb = entity.body.collider.localBounds.translate(entity.getWorldPos()); + bb.draw(this._graphicsContext); + } + } + } +} diff --git a/src/engine/Graphics/ImageSource.ts b/src/engine/Graphics/ImageSource.ts new file mode 100644 index 00000000..261991c2 --- /dev/null +++ b/src/engine/Graphics/ImageSource.ts @@ -0,0 +1,117 @@ +import { Resource } from '../Resources/Resource'; +import { Texture } from '../Resources/Texture'; +import { Sprite } from './Sprite'; +import { Loadable } from '../Interfaces/Index'; + +export class ImageSource implements Loadable { + private _resource: Resource; + + /** + * The original size of the source image in pixels + */ + public get width() { + return this.image.naturalWidth; + } + + /** + * The original height of the source image in pixels + */ + public get height() { + return this.image.naturalHeight; + } + + /** + * Returns true if the Texture is completely loaded and is ready + * to be drawn. + */ + public isLoaded(): boolean { + return !!this.data.src; + } + + /** + * Access to the underlying html image elmeent + */ + public data: HTMLImageElement = new Image(); + public get image(): HTMLImageElement { + return this.data; + } + + /** + * Promise the resolves when the image is loaded and ready for use, does not initiate loading + */ + public ready: Promise; + private _loadedResolve: (value?: HTMLImageElement | PromiseLike) => void; + + /** + * The path to the image, can also be a data url like 'data:image/' + * @param path + */ + constructor(public readonly path: string, bustCache: boolean = false) { + this._resource = new Resource(path, 'blob', bustCache); + this.ready = new Promise((resolve) => { + this._loadedResolve = resolve; + }); + } + + /** + * Begins loading the image and returns a promise that resolves when the image is loaded + */ + async load(): Promise { + if (this.isLoaded()) { + return this.data; + } + try { + // Load base64 or blob if needed + let url: string; + if (!this.path.includes('data:image/')) { + const blob = await this._resource.load(); + url = URL.createObjectURL(blob); + } else { + url = this.path; + } + + // Decode the image + const image = new Image(); + image.src = url; + await image.decode(); + + // Set results + this.data = image; + } catch { + await Promise.reject('Error loading texture'); + } + // todo emit complete + this._loadedResolve(this.data); + return this.data; + } + + /** + * Build a sprite from this ImageSource + */ + public toSprite(): Sprite { + return Sprite.from(this); + } + + /** + * Create a ImageSource from legacy texture + * @param tex + */ + public static fromLegacyTexture(tex: Texture): ImageSource { + const image = new ImageSource(tex.path); + if (tex.isLoaded()) { + image.data = tex.data; + } else { + tex.loaded.then(() => { + image.data = tex.data; + }); + } + return image; + } + + /** + * Unload images from memory + */ + unload(): void { + this.data = new Image(); + } +} diff --git a/src/engine/Graphics/Polygon.ts b/src/engine/Graphics/Polygon.ts new file mode 100644 index 00000000..0bc0bcab --- /dev/null +++ b/src/engine/Graphics/Polygon.ts @@ -0,0 +1,64 @@ +import { Vector, vec } from '../Algebra'; +import { Raster, RasterOptions } from './Raster'; + +export interface PolygonOptions { + points: Vector[]; +} + +/** + * A polygon [[Graphic]] for drawing arbitrary polygons to the [[ExcaliburGraphicsContext]] + */ +export class Polygon extends Raster { + private _points: Vector[]; + public get points(): Vector[] { + return this._points; + } + public set points(points: Vector[]) { + this._points = points; + const min = this.minPoint; + this.width = this._points.reduce((max, p) => Math.max(p.x, max), 0) - min.x; + this.height = this._points.reduce((max, p) => Math.max(p.y, max), 0) - min.y; + this.flagDirty(); + } + + public get minPoint() { + const minX = this._points.reduce((min, p) => Math.min(p.x, min), Infinity); + const minY = this._points.reduce((min, p) => Math.min(p.y, min), Infinity); + return vec(minX, minY); + } + + constructor(options: RasterOptions & PolygonOptions) { + super(options); + this.points = options.points; + this.rasterize(); + } + + public clone(): Polygon { + return new Polygon({ + points: this.points.map((p) => p.clone()), + ...this.cloneGraphicOptions(), + ...this.cloneRasterOptions() + }); + } + + execute(ctx: CanvasRenderingContext2D): void { + if (this.points && this.points.length) { + ctx.beginPath(); + // Iterate through the supplied points and construct a 'polygon' + const min = this.minPoint.negate(); + const firstPoint = this.points[0].add(min); + ctx.moveTo(firstPoint.x, firstPoint.y); + this.points.forEach((point) => { + ctx.lineTo(point.x + min.x, point.y + min.y); + }); + ctx.lineTo(firstPoint.x, firstPoint.y); + ctx.closePath(); + if (this.color) { + ctx.fill(); + } + if (this.strokeColor) { + ctx.stroke(); + } + } + } +} diff --git a/src/engine/Graphics/Raster.ts b/src/engine/Graphics/Raster.ts new file mode 100644 index 00000000..2f7eab02 --- /dev/null +++ b/src/engine/Graphics/Raster.ts @@ -0,0 +1,226 @@ +import { Graphic, GraphicOptions } from './Graphic'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { Color } from '../Drawing/Color'; +import { ensurePowerOfTwo } from './Context/webgl-util'; +import { Vector } from '../Algebra'; +import { BoundingBox } from '../Collision/BoundingBox'; +import { watch } from '../Util/Watch'; +import { TextureLoader } from './Context/texture-loader'; + +export interface RasterOptions { + smoothing?: boolean; + color?: Color; + strokeColor?: Color; + lineWidth?: number; + lineDash?: number[]; + padding?: number; +} + +/** + * A Raster is a Graphic that needs to be first painted to a HTMLCanvasElement before it can be drawn to the + * [[ExcaliburGraphicsContext]]. This is useful for generating custom images using the 2D canvas api. + * + * Implementors must implemenet the [[Raster.execute]] method to rasterize their drawing. + */ +export abstract class Raster extends Graphic { + public _bitmap: HTMLCanvasElement; + protected _ctx: CanvasRenderingContext2D; + private _dirty: boolean = true; + + constructor(options?: GraphicOptions & RasterOptions) { + super(options); + if (options) { + this.color = options.color ?? Color.Black; + this.strokeColor = options?.strokeColor; + this.smoothing = options.smoothing ?? this.smoothing; + this.lineWidth = options.lineWidth ?? this.lineWidth; + this.lineDash = options.lineDash ?? this.lineDash; + this.padding = options.padding ?? this.padding; + } + + this._bitmap = document.createElement('canvas'); + const bitmapWidth = options?.width ?? this._bitmap.width; + const bitmapHeight = options?.height ?? this._bitmap.height; + // Rasters use power of two images as an optimization for webgl + this._bitmap.width = ensurePowerOfTwo(bitmapWidth); + this._bitmap.height = ensurePowerOfTwo(bitmapHeight); + const maybeCtx = this._bitmap.getContext('2d'); + if (!maybeCtx) { + /* istanbul ignore next */ + throw new Error('Browser does not support 2d canvas drawing, cannot create Raster graphic'); + } else { + this._ctx = maybeCtx; + } + } + + public cloneRasterOptions(): RasterOptions { + return { + color: this.color ? this.color.clone() : null, + strokeColor: this.strokeColor ? this.strokeColor.clone() : null, + smoothing: this.smoothing, + lineWidth: this.lineWidth, + lineDash: this.lineDash, + padding: this.padding + }; + } + + /** + * Gets whether the graphic is dirty, this means there are changes that haven't been re-rasterized + */ + public get dirty() { + return this._dirty; + } + + /** + * Flags the graphic as dirty, meaning it must be re-rasterized before draw. + * This should be called any time the graphics state changes such that it affects the outputed drawing + */ + public flagDirty() { + this._dirty = true; + } + + /** + * Gets or sets the current width of the Raster graphic. Setting the width will cause the raster + * to be flagged dirty causing a re-raster on the next draw. + */ + public get width() { + return this._bitmap.width; + } + public set width(value: number) { + this._bitmap.width = value; + this.flagDirty(); + } + + /** + * Gets or sets the current height of the Raster graphic. Setting the height will cause the raster + * to be flagged dirty causing a re-raster on the next draw. + */ + public get height() { + return this._bitmap.height; + } + public set height(value: number) { + this._bitmap.height = value; + this.flagDirty(); + } + + public get localBounds() { + return BoundingBox.fromDimension(this.width * this.scale.x, this.height * this.scale.y, Vector.Zero); + } + + private _smoothing: boolean = false; + /** + * Gets or sets the smoothing (anti-aliasing of the graphic). Setting the height will cause the raster + * to be flagged dirty causing a re-raster on the next draw. + */ + public get smoothing() { + return this._smoothing; + } + public set smoothing(value: boolean) { + this._smoothing = value; + this.flagDirty(); + } + + private _color: Color = watch(Color.Black, () => this.flagDirty()); + /** + * Gets or sets the fillStyle of the Raster graphic. Setting the fillStyle will cause the raster to be + * flagged dirty causing a re-raster on the next draw. + */ + public get color() { + return this._color; + } + public set color(value) { + if (!this._color?.equal(value)) { + this.flagDirty(); + } + this._color = watch(value, () => this.flagDirty()); + } + + private _strokeColor: Color; + /** + * Gets or sets the strokeStyle of the Raster graphic. Setting the strokeStyle will cause the raster to be + * flagged dirty causing a re-raster on the next draw. + */ + public get strokeColor() { + return this._strokeColor; + } + public set strokeColor(value) { + if (!this._strokeColor?.equal(value)) { + this.flagDirty(); + } + this._strokeColor = watch(value, () => this.flagDirty()); + } + + private _lineWidth: number = 1; + /** + * Gets or sets the line width of the Raster graphic. Setting the lineWidth will cause the raster to be + * flagged dirty causing a re-raster on the next draw. + */ + public get lineWidth() { + return this._lineWidth; + } + public set lineWidth(value) { + this._lineWidth = value; + this.flagDirty(); + } + + private _lineDash: number[] = []; + public get lineDash() { + return this._lineDash; + } + + public set lineDash(value) { + this._lineDash = value; + this.flagDirty(); + } + + private _padding: number = 0; + public get padding() { + return this._padding; + } + + public set padding(value: number) { + this._padding = value; + this.flagDirty(); + } + + /** + * Rasterize the graphic to a bitmap making it usuable as in excalibur. Rasterize is called automatically if + * the graphic is [[Raster.dirty]] on the next [[Graphic.draw]] call + */ + public rasterize(): void { + this._dirty = false; + this._ctx.clearRect(0, 0, this.width, this.height); + this._ctx.save(); + this._applyRasterProperites(this._ctx); + this.execute(this._ctx); + this._ctx.restore(); + // The webgl texture needs to be updated if it exists after a raster cycle + TextureLoader.load(this._bitmap, true); + } + + protected _applyRasterProperites(ctx: CanvasRenderingContext2D) { + this._bitmap.width = this._bitmap.width + this.padding * 2; + this._bitmap.height = this._bitmap.height + this.padding * 2; + ctx.translate(this.padding, this.padding); + ctx.imageSmoothingEnabled = this.smoothing; + ctx.lineWidth = this.lineWidth; + ctx.setLineDash(this.lineDash ?? ctx.getLineDash()); + ctx.strokeStyle = this.strokeColor?.toString(); + ctx.fillStyle = this.color?.toString(); + } + + protected _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number) { + if (this._dirty) { + this.rasterize(); + } + + ex.drawImage(this._bitmap, x, y); + } + + /** + * Executes drawing implemenation of the graphic, this is where the specific drawing code for the graphic + * should be implemented. Once `rasterize()` the graphic can be drawn to the [[ExcaliburGraphicsContext]] via `draw(...)` + * @param ctx Canvas to draw the graphic to + */ + abstract execute(ctx: CanvasRenderingContext2D): void; +} diff --git a/src/engine/Graphics/Rectangle.ts b/src/engine/Graphics/Rectangle.ts new file mode 100644 index 00000000..9edc3a1b --- /dev/null +++ b/src/engine/Graphics/Rectangle.ts @@ -0,0 +1,36 @@ +import { Raster, RasterOptions } from './Raster'; + +export interface RectangleOptions { + width: number; + height: number; +} + +/** + * A Rectangle [[Graphic]] for drawing rectangles to the [[ExcaliburGraphicsContext]] + */ +export class Rectangle extends Raster { + constructor(options: RasterOptions & RectangleOptions) { + super(options); + this.width = options.width; + this.height = options.height; + this.rasterize(); + } + + public clone(): Rectangle { + return new Rectangle({ + width: this.width, + height: this.height, + ...this.cloneGraphicOptions(), + ...this.cloneRasterOptions() + }); + } + + execute(ctx: CanvasRenderingContext2D): void { + if (this.color) { + ctx.fillRect(0, 0, this.width, this.height); + } + if (this.strokeColor) { + ctx.strokeRect(0, 0, this.width, this.height); + } + } +} diff --git a/src/engine/Graphics/Sprite.ts b/src/engine/Graphics/Sprite.ts new file mode 100644 index 00000000..9b142008 --- /dev/null +++ b/src/engine/Graphics/Sprite.ts @@ -0,0 +1,121 @@ +import { Graphic, GraphicOptions } from './Graphic'; +import { ImageSource } from './ImageSource'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; + +import { Sprite as LegacySprite } from '../Drawing/Sprite'; +import { Texture } from '../Resources/Texture'; + +export type SourceView = { x: number; y: number; width: number; height: number }; +export type DestinationSize = { width: number; height: number }; + +export interface SpriteOptions { + /** + * Image to create a sprite from + */ + image: ImageSource; + /** + * By default the source is the entire dimension of the [[ImageSource]] + */ + sourceView?: { x: number; y: number; width: number; height: number }; + /** + * By default the size of the final sprite is the size of the [[ImageSource]] + */ + destSize?: { width: number; height: number }; +} + +export class Sprite extends Graphic { + public image: ImageSource; + public sourceView: SourceView; + public destSize: DestinationSize; + + public static from(image: ImageSource): Sprite { + return new Sprite({ + image: image + }); + } + + constructor(options: GraphicOptions & SpriteOptions) { + super(options); + this.image = options.image; + this.sourceView = options.sourceView ?? { x: 0, y: 0, width: 0, height: 0 }; + this.destSize = options.destSize ?? { width: 0, height: 0 }; + this._updateSpriteDimensions(); + this.image.ready.then(() => { + this._updateSpriteDimensions(); + }); + } + + private _updateSpriteDimensions() { + const { width: nativeWidth, height: nativeHeight } = this.image; + // This code uses || to avoid 0's + // If the source is not specified, use the native dimension + this.sourceView.width = this.sourceView?.width || nativeWidth; + this.sourceView.height = this.sourceView?.height || nativeHeight; + + // If the destination is not specified, use the source if specified, then native + this.destSize.width = this.destSize?.width || this.sourceView?.width || nativeWidth; + this.destSize.height = this.destSize?.height || this.sourceView?.height || nativeHeight; + + this.width = Math.ceil(this.destSize.width); + this.height = Math.ceil(this.destSize.height); + } + + protected _preDraw(ex: ExcaliburGraphicsContext, x: number, y: number): void { + if (this.image.isLoaded()) { + this._updateSpriteDimensions(); + } + super._preDraw(ex, x, y); + } + + public _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number): void { + if (this.image.isLoaded()) { + this._updateSpriteDimensions(); + ex.drawImage( + this.image.image, + this.sourceView.x, + this.sourceView.y, + this.sourceView.width, + this.sourceView.height, + x, + y, + this.destSize.width, + this.destSize.height + ); + } + } + + /** + * Create a ImageSource from legacy texture + * @param sprite + */ + public static fromLegacySprite(sprite: LegacySprite): Sprite { + const tex = sprite.texture; + const image = ImageSource.fromLegacyTexture(tex); + return new Sprite({ + image, + sourceView: { + x: sprite.x, + y: sprite.y, + width: sprite.width, + height: sprite.height + } + }); + } + + public static toLegacySprite(sprite: Sprite): LegacySprite { + const image = sprite.image; + const tex = new Texture(image.path); + tex.data = image.image; + + return new LegacySprite(tex, sprite.sourceView.x, sprite.sourceView.y, sprite.sourceView.width, sprite.sourceView.height); + } + + public clone(): Sprite { + return new Sprite({ + image: this.image, + sourceView: { ...this.sourceView }, + destSize: { ...this.destSize }, + ...this.cloneGraphicOptions() + }); + } +} diff --git a/src/engine/Graphics/SpriteFont.ts b/src/engine/Graphics/SpriteFont.ts new file mode 100644 index 00000000..e0345041 --- /dev/null +++ b/src/engine/Graphics/SpriteFont.ts @@ -0,0 +1,154 @@ +import { Vector } from '../Algebra'; +import { SpriteFont as LegacySpriteFont } from '../Drawing/Index'; +import { Logger } from '../Util/Log'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { FontRenderer } from './FontCommon'; +import { Graphic, GraphicOptions } from './Graphic'; +import { Sprite } from './Sprite'; +import { SpriteSheet } from './SpriteSheet'; + + +export interface SpriteFontOptions { + /** + * Alphabet string in spritsheet order (default is row column order) + * example: 'abcdefghijklmnopqrstuvwxyz' + */ + alphabet: string; + /** + * [[SpriteSheet]] to source character sprites from + */ + spriteSheet: SpriteSheet; + /** + * Optionally ignore case in the supplied text; + */ + caseInsensitive?: boolean; + /** + * Optionally adjust the spacing between character sprites + */ + spacing?: number; + /** + * Optionally specify a "shadow" + */ + shadow?: { offset: Vector }; +} + +export class SpriteFont extends Graphic implements FontRenderer { + private _text = ''; + private _dirty = true; + public alphabet: string = ''; + public spriteSheet: SpriteSheet; + + public shadow: { offset: Vector } = null; + public caseInsensitive = false; + public spacing: number = 0; + + private _logger = Logger.getInstance(); + + static fromLegacySpriteFont(spriteFont: LegacySpriteFont): SpriteFont { + const sprites = spriteFont.sprites.map(Sprite.fromLegacySprite); + return new SpriteFont({ + alphabet: spriteFont.alphabet, + spacing: 0, + caseInsensitive: spriteFont.caseInsensitive, + spriteSheet: new SpriteSheet({ + sprites + }) + }); + } + + constructor(options: SpriteFontOptions & GraphicOptions) { + super(options); + const { alphabet, spriteSheet, caseInsensitive, spacing, shadow } = options; + this.alphabet = alphabet; + this.spriteSheet = spriteSheet; + this.caseInsensitive = caseInsensitive ?? this.caseInsensitive; + this.spacing = spacing ?? this.spacing; + this.shadow = shadow ?? this.shadow; + + this.spriteSheet.sprites[0].image.ready.then(() => { + this._updateDimensions(); + }); + } + + private _sprites: Sprite[] = []; + private _getCharacterSprites(text: string): Sprite[] { + if (!this._dirty) { + return this._sprites; + } + + const results: Sprite[] = []; + // handle case insenstive + const textToRender = this.caseInsensitive ? text.toLocaleLowerCase() : text; + const alphabet = this.caseInsensitive ? this.alphabet.toLocaleLowerCase() : this.alphabet; + + // for each letter in text + for (let letterIndex = 0; letterIndex < textToRender.length; letterIndex++) { + // find the sprite index in alphabet , if there is an error pick the first + const letter = textToRender[letterIndex]; + let spriteIndex = alphabet.indexOf(letter); + if (spriteIndex === -1) { + spriteIndex = 0; + this._logger.warn(`SpriteFont - Cannot find letter '${letter}' in configured alphabet '${alphabet}'`); + } + + const letterSprite = this.spriteSheet.sprites[spriteIndex]; + if (letterSprite) { + results.push(letterSprite); + } else { + this._logger.warn(`SpriteFont - Cannot find sprite for '${letter}' at index '${spriteIndex}' in configured SpriteSheet`); + } + } + this._dirty = false; + return (this._sprites = results); + } + + private _updateDimensions() { + const sprites = this._getCharacterSprites(this._text); + let width = 0; + let height = 0; + for (const sprite of sprites) { + width += sprite.width + this.spacing; + height = Math.max(height, sprite.height); + } + this.width = width; + this.height = height; + } + + protected _preDraw(ex: ExcaliburGraphicsContext, x: number, y: number): void { + this._updateDimensions(); + super._preDraw(ex, x, y); + } + + protected _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number): void { + let cursor = 0; + for (const sprite of this._getCharacterSprites(this._text)) { + // draw it in the right spot and incresase the cursor by sprite width + sprite.draw(ex, x + cursor, y); + cursor += sprite.width + this.spacing; + } + } + + render(ex: ExcaliburGraphicsContext, text: string, x: number, y: number) { + if (this._text !== text) { + this._dirty = true; + this._text = text; + } + + if (this.shadow) { + ex.save(); + ex.translate(this.shadow.offset.x, this.shadow.offset.y); + this.draw(ex, x, y); + ex.restore(); + } + + this.draw(ex, x, y); + } + + clone(): SpriteFont { + return new SpriteFont({ + alphabet: this.alphabet, + spriteSheet: this.spriteSheet, + spacing: this.spacing + }); + } +} diff --git a/src/engine/Graphics/SpriteSheet.ts b/src/engine/Graphics/SpriteSheet.ts new file mode 100644 index 00000000..7e5d4bd7 --- /dev/null +++ b/src/engine/Graphics/SpriteSheet.ts @@ -0,0 +1,120 @@ +import { ImageSource } from './ImageSource'; +import { Sprite } from './Sprite'; +import { SpriteSheet as LegacySpriteSheet } from '../Drawing/SpriteSheet'; + + +/** + * Specify sprite sheet spacing options, useful if your sprites are not tightly packed + * and have space between them. + */ +export interface SpriteSheetSpacingDimensions { + /** + * The starting point to offset and start slicing the sprite sheet from the top left of the image. + * Default is (0, 0) + */ + originOffset?: { x?: number, y?: number }; + + /** + * The margin between sprites. + * Default is (0, 0) + */ + margin?: {x?: number, y?: number}; +} + +/** + * Sprite sheet options for slicing up images + */ +export interface SpriteSheetGridOptions { + /** + * Source image to use for each sprite + */ + image: ImageSource; + /** + * Grid definition for the sprite sheet + */ + grid: { + /** + * Number of rows in the sprite sheet + */ + rows: number; + /** + * Number of columns in the sprite sheet + */ + columns: number; + /** + * Width of each individual sprite + */ + spriteWidth: number; + /** + * Height of each individual sprite + */ + spriteHeight: number; + }; + /** + * Optionally specifiy any spacing information between sprites + */ + spacing?: SpriteSheetSpacingDimensions; +} + +export interface SpriteSheetOptions { + sprites: Sprite[]; +} + +/** + * Represents a collection of sprites from a source image + */ +export class SpriteSheet { + public readonly sprites: Sprite[] = []; + + /** + * Build a new sprite sheet from a list of sprites + * @param options + */ + constructor(options: SpriteSheetOptions) { + const { sprites } = options; + this.sprites = sprites; + } + + /** + * To a graphics sprite sheet from a legacy sprite sheet + */ + public static fromLegacySpriteSheet(legacySpriteSheet: LegacySpriteSheet): SpriteSheet { + const sprites = legacySpriteSheet.sprites.map(oldSprite => Sprite.fromLegacySprite(oldSprite)); + return new SpriteSheet({ + sprites + }); + } + + /** + * Parse a sprite sheet from grid options + * @param options + */ + public static fromGrid(options: SpriteSheetGridOptions): SpriteSheet { + const sprites: Sprite[] = []; + options.spacing = options.spacing ?? {}; + const { + image, + grid: { rows, columns: cols, spriteWidth, spriteHeight }, + spacing: { originOffset, margin } + } = options; + const offsetDefaults = { x: 0, y: 0, ...originOffset}; + const marginDefaults = { x: 0, y: 0, ...margin}; + for (let x = 0; x < cols; x++) { + for (let y = 0; y < rows; y++) { + sprites[x + y * cols] = new Sprite({ + image: image, + sourceView: { + x: x * spriteWidth + marginDefaults.x * x + offsetDefaults.x, + y: y * spriteHeight + marginDefaults.y * y + offsetDefaults.y, + width: spriteWidth, + height: spriteHeight + }, + destSize: { height: spriteHeight, width: spriteWidth } + }); + } + } + return new SpriteSheet({ + sprites: sprites + }); + } +} diff --git a/src/engine/Graphics/Text.ts b/src/engine/Graphics/Text.ts new file mode 100644 index 00000000..5d615781 --- /dev/null +++ b/src/engine/Graphics/Text.ts @@ -0,0 +1,104 @@ +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; +import { BoundingBox } from '../Collision/BoundingBox'; +import { SpriteFont } from './SpriteFont'; +import { Graphic, GraphicOptions } from './Graphic'; +import { Color } from '../Drawing/Color'; +import { Font } from './Font'; +import { watch } from '../Util/Watch'; + +export interface TextOptions { + text: string; + color?: Color; + font?: Font | SpriteFont; +} + +export class Text extends Graphic { + constructor(options: TextOptions & GraphicOptions) { + super(options); + // This order is important font, color, then text + this.font = options.font ?? new Font(); + this.color = options.color ?? this.color; + this.text = options.text; + } + + public clone(): Text { + return new Text({ + text: this.text.slice(), + color: this.color.clone(), + font: this.font.clone() + }); + } + + private _text: string = ''; + public get text() { + return this._text; + } + + public set text(value: string) { + this._text = value; + if (this.font instanceof Font) { + this.font.updateText(value); + } + } + + // TODO SpriteFont doesn't support a color yet :( + public get color() { + if (this.font instanceof Font) { + return this.font.color; + } + return Color.Black; + } + + public set color(color: Color) { + if (this.font instanceof Font) { + this.font.color = color; + } + } + + private _font: Font | SpriteFont; + public get font(): Font | SpriteFont { + return this._font; + } + public set font(font: Font | SpriteFont) { + if (font instanceof Font) { + this._font = watch(font, (font) => font.flagDirty()); + } else { + this._font = font; + } + } + + public get width() { + return this.font.width; + } + + public get height() { + return this.font.height; + } + + public get localBounds(): BoundingBox { + return this.font.localBounds; + } + + protected _rotate(_ex: ExcaliburGraphicsContext) { + // None this is delegated to font + // This override erases the default behavior + } + + protected _flip(_ex: ExcaliburGraphicsContext) { + // None this is delegated to font + // This override erases the default behavior + } + + protected _drawImage(ex: ExcaliburGraphicsContext, x: number, y: number) { + if (this.font instanceof Font) { + this.font.color = this.color; + } + this.font.flipHorizontal = this.flipHorizontal; + this.font.flipVertical = this.flipVertical; + this.font.scale = this.scale; + this.font.rotation = this.rotation; + this.font.origin = this.origin; + this.font.opacity = this.opacity; + this.font.render(ex, this._text, x, y); + } +} diff --git a/src/engine/Graphics/index.ts b/src/engine/Graphics/index.ts new file mode 100644 index 00000000..21f25d63 --- /dev/null +++ b/src/engine/Graphics/index.ts @@ -0,0 +1,26 @@ +// Graphics +export * from './Graphic'; +export * from './Sprite'; +export * from './SpriteSheet'; +export * from './GraphicsGroup'; +export * from './ImageSource'; +export * from './Animation'; + +// Graphics ECS +export * from './GraphicsComponent'; +export * from './GraphicsSystem'; + +// Raster graphics +export * from './Raster'; +export * from './Circle'; +export * from './Rectangle'; +export * from './Polygon'; +export * from './Text'; +export * from './FontCommon'; +export * from './Font'; +export * from './SpriteFont'; +export * from './Canvas'; + +export * from './Context/ExcaliburGraphicsContext'; +export * from './Context/ExcaliburGraphicsContext2DCanvas'; +export * from './Context/ExcaliburGraphicsContextWebGL'; diff --git a/src/engine/Interfaces/AudioImplementation.ts b/src/engine/Interfaces/AudioImplementation.ts index 1ee00bf4..955a037a 100644 --- a/src/engine/Interfaces/AudioImplementation.ts +++ b/src/engine/Interfaces/AudioImplementation.ts @@ -18,7 +18,7 @@ export class ExResponse { } /** - * Represents an audio implementation like [[AudioTagInstance]] or [[WebAudioInstance]] + * Represents an audio implementation like [[WebAudioInstance]] */ export interface AudioImplementation { /** diff --git a/src/engine/Interfaces/Drawable.ts b/src/engine/Interfaces/Drawable.ts index 0562a087..5a655906 100644 --- a/src/engine/Interfaces/Drawable.ts +++ b/src/engine/Interfaces/Drawable.ts @@ -1,6 +1,9 @@ import { Vector } from '../Algebra'; import { SpriteEffect } from '../Drawing/SpriteEffects'; +/** + * @deprecated Will be removed in v0.26.0 + */ export interface DrawOptions { ctx: CanvasRenderingContext2D; x: number; @@ -17,6 +20,7 @@ export interface DrawOptions { /** * Interface for implementing anything in Excalibur that can be drawn to the screen. + * @deprecated Will be removed in v0.26.0 */ export interface Drawable { /** diff --git a/src/engine/Label.ts b/src/engine/Label.ts index 77245e4d..bdebf88d 100644 --- a/src/engine/Label.ts +++ b/src/engine/Label.ts @@ -1,193 +1,211 @@ import { Engine } from './Engine'; import { Color } from './Drawing/Color'; -import { SpriteFont } from './Drawing/SpriteSheet'; -import { Actor } from './Actor'; import { Configurable } from './Configurable'; -import { Vector } from './Algebra'; -import { CollisionType } from './Collision/CollisionType'; -/** - * Enum representing the different font size units - * https://developer.mozilla.org/en-US/docs/Web/CSS/font-size - */ -export enum FontUnit { - /** - * Em is a scalable unit, 1 em is equal to the current font size of the current element, parent elements can effect em values - */ - Em, - /** - * Rem is similar to the Em, it is a scalable unit. 1 rem is equal to the font size of the root element - */ - Rem, - /** - * Pixel is a unit of length in screen pixels - */ - Px, - /** - * Point is a physical unit length (1/72 of an inch) - */ - Pt, - /** - * Percent is a scalable unit similar to Em, the only difference is the Em units scale faster when Text-Size stuff - */ - Percent -} - -/** - * Enum representing the different horizontal text alignments - */ -export enum TextAlign { - /** - * The text is left-aligned. - */ - Left, - /** - * The text is right-aligned. - */ - Right, - /** - * The text is centered. - */ - Center, - /** - * The text is aligned at the normal start of the line (left-aligned for left-to-right locales, - * right-aligned for right-to-left locales). - */ - Start, - /** - * The text is aligned at the normal end of the line (right-aligned for left-to-right locales, - * left-aligned for right-to-left locales). - */ - End -} - -/** - * Enum representing the different baseline text alignments - */ -export enum BaseAlign { - /** - * The text baseline is the top of the em square. - */ - Top, - /** - * The text baseline is the hanging baseline. Currently unsupported; this will act like - * alphabetic. - */ - Hanging, - /** - * The text baseline is the middle of the em square. - */ - Middle, - /** - * The text baseline is the normal alphabetic baseline. - */ - Alphabetic, - /** - * The text baseline is the ideographic baseline; this is the bottom of - * the body of the characters, if the main body of characters protrudes - * beneath the alphabetic baseline. Currently unsupported; this will - * act like alphabetic. - */ - Ideographic, - /** - * The text baseline is the bottom of the bounding box. This differs - * from the ideographic baseline in that the ideographic baseline - * doesn't consider descenders. - */ - Bottom -} - -/** - * Enum representing the different possible font styles - */ -export enum FontStyle { - Normal, - Italic, - Oblique -} +import { vec, Vector } from './Algebra'; +import { Text } from './Graphics/Text'; +import { BaseAlign, FontStyle, FontUnit, TextAlign } from './Graphics/FontCommon'; +import { obsolete } from './Util/Decorators'; +import { SpriteFont as LegacySpriteFont } from './Drawing/SpriteSheet'; +import { ExcaliburGraphicsContext, GraphicsComponent, SpriteFont } from './Graphics'; +import { Font } from './Graphics/Font'; +import { CanvasDrawComponent } from './Drawing/Index'; +import { TransformComponent } from './EntityComponentSystem'; +import { Actor } from './Actor'; -export interface LabelArgs extends Partial { +export interface LabelOptions { + text?: string; x?: number; y?: number; - text?: string; bold?: boolean; pos?: Vector; - spriteFont?: SpriteFont; + spriteFont?: LegacySpriteFont; fontFamily?: string; fontSize?: number; fontStyle?: FontStyle; fontUnit?: FontUnit; textAlign?: TextAlign; - maxWidth?: number; } /** * @hidden */ export class LabelImpl extends Actor { + public font: Font = new Font(); + private _text: Text = new Text({ text: '', font: this.font}); + /** * The text to draw. */ - public text: string; + public get text(): string { + return this._text.text; + } + + public set text(text: string) { + this._text.text = text; + } + + public get color(): Color { + return this._text.color; + } + + public set color(color: Color) { + this._text.color = color; + } /** * Sets or gets the bold property of the label's text, by default it's false - */ - public bold: boolean = false; + * @deprecated Use [[Font.bold|Label.font.bold]] + */ + @obsolete({ + message: 'Label.bold will be removed in v0.26.0', + alternateMethod: 'Use Label.font.bold' + }) + public get bold(): boolean { + return this.font.bold; + } + + public set bold(isBold: boolean) { + this.font.bold = isBold; + } - /** - * The [[SpriteFont]] to use, if any. Overrides [[fontFamily]] if present. - */ - public spriteFont: SpriteFont; /** * The CSS font family string (e.g. `sans-serif`, `Droid Sans Pro`). Web fonts * are supported, same as in CSS. - */ - public fontFamily: string; + * @deprecated Use [[Font.family|Label.font.family]] + */ + @obsolete({ + message: 'Label.fontFamily will be removed in v0.26.0', + alternateMethod: 'Use Label.font.family' + }) + public get fontFamily(): string { + return this.font.family; + } + + public set fontFamily(family: string) { + this.font.family = family; + } /** * The font size in the selected units, default is 10 (default units is pixel) - */ - public fontSize: number = 10; + * @deprecated Use [[Font.size|Label.font.size]] + */ + @obsolete({ + message: 'Label.fontSize will be removed in v0.26.0', + alternateMethod: 'Use Label.font.size' + }) + public get fontSize(): number { + return this.font.size; + } + + public set fontSize(sizeInUnit: number) { + this.font.size = sizeInUnit; + } /** * The font style for this label, the default is [[FontStyle.Normal]] - */ - public fontStyle: FontStyle = FontStyle.Normal; + * @deprecated Use [[Font.style|Label.font.style]] + */ + @obsolete({ + message: 'Label.fontStyle will be removed in v0.26.0', + alternateMethod: 'Use Lable.font.style' + }) + public get fontStyle(): FontStyle { + return this.font.style; + } + + public set fontStyle(style: FontStyle) { + this.font.style = style; + } + /** * The css units for a font size such as px, pt, em (SpriteFont only support px), by default is 'px'; - */ - public fontUnit: FontUnit = FontUnit.Px; + * @deprecated Use [[Font.unit|Label.font.unit]] + */ + @obsolete({ + message: 'Label.fontUnit will be removed in v0.26.0', + alternateMethod: 'Use Label.font.unit' + }) + public get fontUnit(): FontUnit { + return this.font.unit; + } + + public set fontUnit(unit: FontUnit) { + this.font.unit = unit; + } /** * Gets or sets the horizontal text alignment property for the label. - */ - public textAlign: TextAlign = TextAlign.Left; + * @deprecated Use [[Font.textAlign|Label.font.textAlign]] + */ + @obsolete({ + message: 'Label.textAlign will be removed in v0.26.0', + alternateMethod: 'Use Label.font.textAlign' + }) + public get textAlign(): TextAlign { + return this.font.textAlign; + } + + public set textAlign(align: TextAlign) { + this.font.textAlign = align; + } /** * Gets or sets the baseline alignment property for the label. - */ - public baseAlign: BaseAlign = BaseAlign.Bottom; + * @deprecated Use [[Font.baseAlign|Label.font.baseAlign]] + */ + @obsolete({ + message: 'Label.baseAlign will be removed in v0.26.0', + alternateMethod: 'Use Label.font.baseAlign' + }) + public get baseAlign(): BaseAlign { + return this.font.baseAlign; + } + + public set baseAlign(align: BaseAlign) { + this.font.baseAlign = align; + } /** * Gets or sets the maximum width (in pixels) that the label should occupy + * @deprecated The maxWidth constraint is gone */ public maxWidth: number; + private _legacySpriteFont: LegacySpriteFont; + private _spriteFont: SpriteFont; + /** + * The [[SpriteFont]] to use, if any. Overrides [[fontFamily]] if present. + * @deprecated Use [["Graphics/SpriteFont".SpriteFont]] + */ + @obsolete() + public get spriteFont(): LegacySpriteFont { + return this._legacySpriteFont; + } + + public set spriteFont(sf: LegacySpriteFont) { + this._legacySpriteFont = sf; + if (sf) { + this._spriteFont = SpriteFont.fromLegacySpriteFont(sf); + this._text.font = this._spriteFont; + } + } + + /** * Gets or sets the letter spacing on a Label. Only supported with Sprite Fonts. + * @deprecated Use [[SpriteFont.spacing]] */ public letterSpacing: number = 0; //px /** * Whether or not the [[SpriteFont]] will be case-sensitive when matching characters. + * @deprecated Use [["Graphics/SpriteFont".SpriteFont.caseInsensitive]] */ public caseInsensitive: boolean = true; - private _textShadowOn: boolean; - private _shadowOffsetX: number; - private _shadowOffsetY: number; + private _graphicsContext: ExcaliburGraphicsContext; /** * @param textOrConfig The text of the label, or label option bag @@ -197,113 +215,48 @@ export class LabelImpl extends Actor { * @param spriteFont Use an Excalibur sprite font for the label's font, if a SpriteFont is provided it will take precedence * over a css font. */ - constructor(textOrConfig?: string | Partial, x?: number, y?: number, fontFamily?: string, spriteFont?: SpriteFont) { - super(textOrConfig && typeof textOrConfig === 'object' ? textOrConfig : { pos: new Vector(x, y) }); - + constructor(textOrConfig?: string | LabelOptions, x?: number, y?: number, fontFamily?: string, spriteFont?: LegacySpriteFont) { + super(); let text = ''; + let pos = Vector.Zero; if (textOrConfig && typeof textOrConfig === 'object') { fontFamily = textOrConfig.fontFamily; spriteFont = textOrConfig.spriteFont; text = textOrConfig.text; + pos = textOrConfig.pos ?? vec(textOrConfig.x ?? 0, textOrConfig.y ?? 0); } else { text = textOrConfig; + pos = vec(x ?? 0, y ?? 0); } + this.addComponent(new TransformComponent); + this.components.transform.pos = pos; + + this.addComponent(new CanvasDrawComponent((ctx, delta) => this.draw(ctx, delta))); + this.addComponent(new GraphicsComponent); + this.components.graphics.anchor = Vector.Zero; + this.components.graphics.use(this._text); + this.text = text || ''; this.color = Color.Black; - this.spriteFont = spriteFont; - this.body.collider.type = CollisionType.PreventCollision; - this.fontFamily = fontFamily || 'sans-serif'; // coalesce to default canvas font - - this._textShadowOn = false; - this._shadowOffsetX = 0; - this._shadowOffsetY = 0; + if (fontFamily) { + this.font.family = fontFamily; + } if (spriteFont) { - //this._textSprites = spriteFont.getTextSprites(); + this.spriteFont = spriteFont; } } + public _initialize(engine: Engine) { + super._initialize(engine); + this._graphicsContext = engine.graphicsContext; + } + /** * Returns the width of the text in the label (in pixels); - * @param ctx Rendering context to measure the string with */ - public getTextWidth(ctx: CanvasRenderingContext2D): number { - const oldFont = ctx.font; - ctx.font = this._fontString; - const width = ctx.measureText(this.text).width; - ctx.font = oldFont; - return width; - } - - /* istanbul ignore next */ - private _lookupFontUnit(fontUnit: FontUnit): string { - switch (fontUnit) { - case FontUnit.Em: - return 'em'; - case FontUnit.Rem: - return 'rem'; - case FontUnit.Pt: - return 'pt'; - case FontUnit.Px: - return 'px'; - case FontUnit.Percent: - return '%'; - default: - return 'px'; - } - } - - /* istanbul ignore next */ - private _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'; - default: - return 'start'; - } - } - - /* istanbul ignore next */ - private _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'; - default: - return 'alphabetic'; - } - } - - /* istanbul ignore next */ - private _lookupFontStyle(fontStyle: FontStyle): string { - const boldstring = this.bold ? ' bold' : ''; - switch (fontStyle) { - case FontStyle.Italic: - return 'italic' + boldstring; - case FontStyle.Normal: - return 'normal' + boldstring; - case FontStyle.Oblique: - return 'oblique' + boldstring; - default: - return 'normal' + boldstring; - } + public getTextWidth(): number { + return this._text.width; } /** @@ -311,85 +264,45 @@ export class LabelImpl extends Actor { * @param offsetX The x offset in pixels to place the shadow * @param offsetY The y offset in pixels to place the shadow * @param shadowColor The color of the text shadow + * @deprecated Use [[Font.shadow|Label.font.shadow]] */ + @obsolete({ + message: 'Label.setTextShadow will be removed in v0.26.0', + alternateMethod: 'Use Label.font.shadow' + }) public setTextShadow(offsetX: number, offsetY: number, shadowColor: Color) { - this.spriteFont.setTextShadow(offsetX, offsetY, shadowColor); + this.font.shadow = { offset: vec(offsetX, offsetY), blur: 2, color: shadowColor }; } /** * Toggles text shadows on or off, only applies when using sprite fonts + * @deprecated Use [[Font.shadow|Label.font.shadow]] */ + @obsolete({ + message: 'Label.useTextShadow will be removed in v0.26.0', + alternateMethod: 'Use Label.font.shadow' + }) public useTextShadow(on: boolean) { - this.spriteFont.useTextShadow(on); + if (this.spriteFont) { + this.spriteFont.useTextShadow(on); + } } /** * Clears the current text shadow + * @deprecated Use [[Font.shadow|Label.font.shadow]] */ + @obsolete({ + message: 'Label.clearTextShadow will be removed in v0.26.0', + alternateMethod: 'Use Label.font.shadow' + }) public clearTextShadow() { - this._textShadowOn = false; - this._shadowOffsetX = 0; - this._shadowOffsetY = 0; - } - - public update(engine: Engine, delta: number) { - super.update(engine, delta); - } - - public draw(ctx: CanvasRenderingContext2D, delta: number) { - ctx.save(); - super._predraw(ctx, delta); - if (this._textShadowOn) { - ctx.save(); - ctx.translate(this._shadowOffsetX, this._shadowOffsetY); - this._fontDraw(ctx); - ctx.restore(); - } - this._fontDraw(ctx); - - ctx.restore(); - super._postdraw(ctx, delta); - } - - private _fontDraw(ctx: CanvasRenderingContext2D) { - if (this.spriteFont) { - this.spriteFont.draw(ctx, this.text, 0, 0, { - color: this.color.clone(), - baseAlign: this.baseAlign, - textAlign: this.textAlign, - fontSize: this.fontSize, - letterSpacing: this.letterSpacing, - opacity: this.opacity - }); - } else { - const oldAlign = ctx.textAlign; - const oldTextBaseline = ctx.textBaseline; - - ctx.textAlign = this._lookupTextAlign(this.textAlign); - ctx.textBaseline = this._lookupBaseAlign(this.baseAlign); - if (this.color) { - this.color.a = this.opacity; - } - ctx.fillStyle = this.color.toString(); - ctx.font = this._fontString; - - if (this.maxWidth) { - ctx.fillText(this.text, 0, 0, this.maxWidth); - } else { - ctx.fillText(this.text, 0, 0); - } - - ctx.textAlign = oldAlign; - ctx.textBaseline = oldTextBaseline; - } - } - - protected get _fontString() { - return `${this._lookupFontStyle(this.fontStyle)} ${this.fontSize}${this._lookupFontUnit(this.fontUnit)} ${this.fontFamily}`; + this.font.shadow = null; } - public debugDraw(ctx: CanvasRenderingContext2D) { - super.debugDraw(ctx); + public draw(_ctx: CanvasRenderingContext2D, _delta: number) { + const exctx = this._graphicsContext; + this._text.draw(exctx, 0, 0); } } @@ -399,9 +312,9 @@ export class LabelImpl extends Actor { */ export class Label extends Configurable(LabelImpl) { constructor(); - constructor(config?: LabelArgs); - constructor(text?: string, x?: number, y?: number, fontFamily?: string, spriteFont?: SpriteFont); - constructor(textOrConfig?: string | LabelArgs, x?: number, y?: number, fontFamily?: string, spriteFont?: SpriteFont) { + constructor(config?: LabelOptions); + constructor(text?: string, x?: number, y?: number, fontFamily?: string, spriteFont?: LegacySpriteFont); + constructor(textOrConfig?: string | LabelOptions, x?: number, y?: number, fontFamily?: string, spriteFont?: LegacySpriteFont) { super(textOrConfig, x, y, fontFamily, spriteFont); } } diff --git a/src/engine/Loader.ts b/src/engine/Loader.ts index e4aaea60..3cd8e085 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Loader.ts @@ -7,6 +7,7 @@ import * as DrawUtil from './Util/DrawUtil'; import logoImg from './Loader.logo.png'; import loaderCss from './Loader.css'; +import { Canvas } from './Graphics/Canvas'; import { Vector } from './Algebra'; import { clamp, delay } from './Util/Util'; @@ -76,6 +77,10 @@ import { clamp, delay } from './Util/Util'; * ``` */ export class Loader extends Class implements Loadable[]> { + public canvas: Canvas = new Canvas({ + smoothing: true, + draw: this.draw.bind(this) + }); private _resourceList: Loadable[] = []; private _index = 0; @@ -196,6 +201,8 @@ export class Loader extends Class implements Loadable[]> { public wireEngine(engine: Engine) { this._engine = engine; + this.canvas.width = this._engine.canvas.width; + this.canvas.height = this._engine.canvas.height; } /** @@ -323,8 +330,8 @@ export class Loader extends Class implements Loadable[]> { const canvasWidth = this._engine.canvasWidth / this._engine.pixelRatio; if (this._playButtonRootElement) { - const left = ctx.canvas.offsetLeft; - const top = ctx.canvas.offsetTop; + const left = this._engine.canvas.offsetLeft; + const top = this._engine.canvas.offsetTop; const buttonWidth = this._playButton.clientWidth; const buttonHeight = this._playButton.clientHeight; if (this.playButtonPosition) { diff --git a/src/engine/Math/matrix.ts b/src/engine/Math/matrix.ts new file mode 100644 index 00000000..f3444015 --- /dev/null +++ b/src/engine/Math/matrix.ts @@ -0,0 +1,333 @@ +import { Vector } from '../Algebra'; + + +/** + * Excalibur Matrix helper for 4x4 matrices + */ +export class Matrix { + + /** + * 4x4 matrix in column major order + * | data[0], data[4], data[8], data[12] | + * | data[1], data[5], data[9], data[13] | + * | data[2], data[6], data[10], data[14] | + * | data[3], data[7], data[11], data[15] | + */ + public data: Float32Array = new Float32Array(16); + + /** + * Creates an orthographic (flat non-perspective) projection + * https://en.wikipedia.org/wiki/Orthographic_projection + * @param left + * @param right + * @param bottom + * @param top + * @param near + * @param far + */ + public static ortho(left: number, right: number, bottom: number, top: number, near: number, far: number): Matrix { + const mat = new Matrix(); + mat.data[0] = 2 / (right - left); + mat.data[1] = 0; + mat.data[2] = 0; + mat.data[3] = 0; + + mat.data[4] = 0; + mat.data[5] = 2 / (top - bottom); + mat.data[6] = 0; + mat.data[7] = 0; + + mat.data[8] = 0; + mat.data[9] = 0; + mat.data[10] = -2 / (far - near); + mat.data[11] = 0; + + mat.data[12] = -(right + left) / (right - left); + mat.data[13] = -(top + bottom) / (top - bottom); + mat.data[14] = -(far + near) / (far - near); + mat.data[15] = 1; + return mat; + } + + /** + * Creates a new Matrix with the same data as the current 4x4 + */ + public clone(): Matrix { + const mat = new Matrix(); + mat.data[0] = this.data[0]; + mat.data[1] = this.data[1]; + mat.data[2] = this.data[2]; + mat.data[3] = this.data[3]; + + mat.data[4] = this.data[4]; + mat.data[5] = this.data[5]; + mat.data[6] = this.data[6]; + mat.data[7] = this.data[7]; + + mat.data[8] = this.data[8]; + mat.data[9] = this.data[9]; + mat.data[10] = this.data[10]; + mat.data[11] = this.data[11]; + + mat.data[12] = this.data[12]; + mat.data[13] = this.data[13]; + mat.data[14] = this.data[14]; + mat.data[15] = this.data[15]; + return mat; + } + + /** + * Creates a new identity matrix (a matrix that when applied does nothing) + */ + public static identity(): Matrix { + const mat = new Matrix(); + mat.data[0] = 1; + mat.data[1] = 0; + mat.data[2] = 0; + mat.data[3] = 0; + + mat.data[4] = 0; + mat.data[5] = 1; + mat.data[6] = 0; + mat.data[7] = 0; + + mat.data[8] = 0; + mat.data[9] = 0; + mat.data[10] = 1; + mat.data[11] = 0; + + mat.data[12] = 0; + mat.data[13] = 0; + mat.data[14] = 0; + mat.data[15] = 1; + return mat; + } + + /** + * Creates a brand new translation matrix at the specified 3d point + * @param x + * @param y + * @param z + */ + public static translation(x: number, y: number, z: number = 0): Matrix { + const mat = Matrix.identity(); + mat.data[12] = x; + mat.data[13] = y; + mat.data[14] = z; + return mat; + } + + /** + * Creates a brand new scaling matrix with the specified scaling factor + * @param sx + * @param sy + */ + public static scale(sx: number, sy: number): Matrix { + const mat = Matrix.identity(); + mat.data[0] = sx; + mat.data[5] = sy; + mat.data[10] = 1; + mat.data[15] = 1; + return mat; + } + + /** + * Creates a brand new rotation matrix with the specified angle + * @param angleRadians + */ + public static rotation(angleRadians: number): Matrix { + const mat = Matrix.identity(); + mat.data[0] = Math.cos(angleRadians); + mat.data[4] = -Math.sin(angleRadians); + mat.data[1] = Math.sin(angleRadians); + mat.data[5] = Math.cos(angleRadians); + return mat; + } + + /** + * Multiplies the current matrix by a vector and returns the resulting vector + * @param other + */ + multv(other: [number, number]): [number, number]; + multv(other: Vector): Vector; + multv(other: [number, number] | Vector): [number, number] | Vector { + const z = 0; + if (other instanceof Vector) { + return new Vector( + other.x * this.data[0] + other.y * this.data[4] + z * this.data[6] + 1 * this.data[12], + other.x * this.data[1] + other.y * this.data[5] + z * this.data[9] + 1 * this.data[13] + ); + } else { + const dest: [number, number] = [ + other[0] * this.data[0] + other[1] * this.data[4] + z * this.data[6] + 1 * this.data[12], + + other[0] * this.data[1] + other[1] * this.data[5] + z * this.data[9] + 1 * this.data[13] + ]; + return dest; + } + } + + /** + * Multiplies the current matrix by another and returns the resulting matrix + * @param other + */ + multm(other: Matrix): Matrix { + const dest = new Matrix(); + const a11 = this.data[0]; + const a21 = this.data[1]; + const a31 = this.data[2]; + const a41 = this.data[3]; + + const a12 = this.data[4]; + const a22 = this.data[5]; + const a32 = this.data[6]; + const a42 = this.data[7]; + + const a13 = this.data[8]; + const a23 = this.data[9]; + const a33 = this.data[10]; + const a43 = this.data[11]; + + const a14 = this.data[12]; + const a24 = this.data[13]; + const a34 = this.data[14]; + const a44 = this.data[15]; + + const b11 = other.data[0]; + const b21 = other.data[1]; + const b31 = other.data[2]; + const b41 = other.data[3]; + + const b12 = other.data[4]; + const b22 = other.data[5]; + const b32 = other.data[6]; + const b42 = other.data[7]; + + const b13 = other.data[8]; + const b23 = other.data[9]; + const b33 = other.data[10]; + const b43 = other.data[11]; + + const b14 = other.data[12]; + const b24 = other.data[13]; + const b34 = other.data[14]; + const b44 = other.data[15]; + + dest.data[0] = a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41; + dest.data[1] = a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41; + dest.data[2] = a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41; + dest.data[3] = a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41; + + dest.data[4] = a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42; + dest.data[5] = a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42; + dest.data[6] = a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42; + dest.data[7] = a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42; + + dest.data[8] = a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43; + dest.data[9] = a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43; + dest.data[10] = a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43; + dest.data[11] = a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43; + + dest.data[12] = a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44; + dest.data[13] = a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44; + dest.data[14] = a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44; + dest.data[15] = a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44; + + return dest; + } + + /** + * Applies translation to the current matrix mutating it + * @param x + * @param y + */ + translate(x: number, y: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + const a31 = this.data[2]; + const a41 = this.data[3]; + + const a12 = this.data[4]; + const a22 = this.data[5]; + const a32 = this.data[6]; + const a42 = this.data[7]; + + const a13 = this.data[8]; + const a23 = this.data[9]; + const a33 = this.data[10]; + const a43 = this.data[11]; + + const a14 = this.data[12]; + const a24 = this.data[13]; + const a34 = this.data[14]; + const a44 = this.data[15]; + + // Doesn't change z + this.data[12] = a11 * x + a12 * y + a13 + a14; + this.data[13] = a21 * x + a22 * y + a23 + a24; + this.data[14] = a31 * x + a32 * y + a33 + a34; + this.data[15] = a41 * x + a42 * y + a43 + a44; + + return this; + } + + /** + * Applies rotation to the current matrix mutating it + * @param angle + */ + rotate(angle: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + const a31 = this.data[2]; + const a41 = this.data[3]; + + const a12 = this.data[4]; + const a22 = this.data[5]; + const a32 = this.data[6]; + const a42 = this.data[7]; + + const sine = Math.sin(angle); + const cosine = Math.cos(angle); + + this.data[0] = cosine * a11 + sine * a12; + this.data[1] = cosine * a21 + sine * a22; + this.data[2] = cosine * a31 + sine * a32; + this.data[3] = cosine * a41 + sine * a42; + + this.data[4] = cosine * a12 - sine * a11; + this.data[5] = cosine * a22 - sine * a21; + this.data[6] = cosine * a32 - sine * a31; + this.data[7] = cosine * a42 - sine * a41; + + return this; + } + + /** + * Applies scaling to the current matrix mutating it + * @param x + * @param y + */ + scale(x: number, y: number) { + const a11 = this.data[0]; + const a21 = this.data[1]; + const a31 = this.data[2]; + const a41 = this.data[3]; + + const a12 = this.data[4]; + const a22 = this.data[5]; + const a32 = this.data[6]; + const a42 = this.data[7]; + + this.data[0] = a11 * x; + this.data[1] = a21 * x; + this.data[2] = a31 * x; + this.data[3] = a41 * x; + + this.data[4] = a12 * y; + this.data[5] = a22 * y; + this.data[6] = a32 * y; + this.data[7] = a42 * y; + + return this; + } +} diff --git a/src/engine/Particles.ts b/src/engine/Particles.ts index 074afe16..40a4c15a 100644 --- a/src/engine/Particles.ts +++ b/src/engine/Particles.ts @@ -2,13 +2,18 @@ import { Engine } from './Engine'; import { Actor } from './Actor'; import { Sprite } from './Drawing/Sprite'; import { Color } from './Drawing/Color'; -import { Vector } from './Algebra'; +import { vec, Vector } from './Algebra'; import * as Util from './Util/Util'; import * as DrawUtil from './Util/DrawUtil'; import * as Traits from './Traits/Index'; import { Configurable } from './Configurable'; import { Random } from './Math/Random'; import { CollisionType } from './Collision/CollisionType'; +import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; +import { GraphicsComponent } from './Graphics/GraphicsComponent'; +import { Entity } from './EntityComponentSystem/Entity'; +import { Graphics } from '.'; +import { CanvasDrawComponent } from './Drawing/Index'; /** * An enum that represents the types of emitter nozzles @@ -27,7 +32,7 @@ export enum EmitterType { /** * @hidden */ -export class ParticleImpl { +export class ParticleImpl extends Entity { public position: Vector = new Vector(0, 0); public velocity: Vector = new Vector(0, 0); public acceleration: Vector = new Vector(0, 0); @@ -60,6 +65,12 @@ export class ParticleImpl { public sizeRate: number = 0; public elapsedMultiplier: number = 0; + public visible = true; + public isOffscreen = false; + + public transform: TransformComponent; + public graphics: GraphicsComponent; + constructor( emitterOrConfig: ParticleEmitter | ParticleArgs, life?: number, @@ -72,6 +83,7 @@ export class ParticleImpl { startSize?: number, endSize?: number ) { + super(); let emitter = emitterOrConfig; if (emitter && !(emitterOrConfig instanceof ParticleEmitter)) { const config = emitterOrConfig; @@ -92,7 +104,7 @@ export class ParticleImpl { this.endColor = endColor || this.endColor.clone(); this.beginColor = beginColor || this.beginColor.clone(); this._currentColor = this.beginColor.clone(); - this.position = position || this.position; + this.position = (position || this.position).add(this.emitter.pos); this.velocity = velocity || this.velocity; this.acceleration = acceleration || this.acceleration; this._rRate = (this.endColor.r - this.beginColor.r) / this.life; @@ -107,6 +119,27 @@ export class ParticleImpl { this.sizeRate = (this.endSize - this.startSize) / this.life; this.particleSize = this.startSize; } + + this.addComponent((this.transform = new TransformComponent())); + this.addComponent(new CanvasDrawComponent((ctx) => this.draw(ctx))); + this.addComponent((this.graphics = new GraphicsComponent())); + + this.transform.pos = this.position; + this.transform.rotation = this.currentRotation; + this.transform.scale = vec(1, 1); // TODO wut + if (this.particleSprite) { + this.graphics.opacity = this.opacity; + this.graphics.use(Graphics.Sprite.fromLegacySprite(this.particleSprite)); + } else { + this.graphics.onPostDraw = (ctx) => { + ctx.save(); + this.graphics.opacity = this.opacity; + const tmpColor = this._currentColor.clone(); + tmpColor.a = 1; + ctx.debug.drawPoint(vec(0, 0), { color: tmpColor, size: this.particleSize }); + ctx.restore(); + }; + } } public kill() { @@ -153,22 +186,28 @@ export class ParticleImpl { if (this.particleRotationalVelocity) { this.currentRotation = (this.currentRotation + (this.particleRotationalVelocity * delta) / 1000) % (2 * Math.PI); } + + this.transform.pos = this.position; + this.transform.rotation = this.currentRotation; + this.transform.scale = vec(1, 1); // todo wut + this.graphics.opacity = this.opacity; } public draw(ctx: CanvasRenderingContext2D) { if (this.particleSprite) { - this.particleSprite.rotation = this.currentRotation; - this.particleSprite.scale.setTo(this.particleSize, this.particleSize); - this.particleSprite.draw(ctx, this.position.x, this.position.y); + this.particleSprite.opacity(this.opacity); + this.particleSprite.draw(ctx, 0, 0); return; } + ctx.save(); this._currentColor.a = Util.clamp(this.opacity, 0.0001, 1); ctx.fillStyle = this._currentColor.toString(); ctx.beginPath(); - ctx.arc(this.position.x, this.position.y, this.particleSize, 0, Math.PI * 2); + ctx.arc(0, 0, this.particleSize, 0, Math.PI * 2); ctx.fill(); ctx.closePath(); + ctx.restore(); } } @@ -318,11 +357,21 @@ export class ParticleEmitterImpl extends Actor { */ public endColor: Color = Color.White; + private _og: Sprite = null; + private _sprite: Graphics.Sprite = null; /** * Gets or sets the sprite that a particle should use - * @warning Performance intensive */ - public particleSprite: Sprite = null; + public get particleSprite(): Sprite { + return this._og; + } + + public set particleSprite(val: Sprite) { + this._og = val; + if (val) { + this._sprite = Graphics.Sprite.fromLegacySprite(val); + } + } /** * Gets or sets the emitter type for the particle emitter @@ -357,6 +406,7 @@ export class ParticleEmitterImpl extends Actor { this.particles = new Util.Collection(); this.deadParticles = new Util.Collection(); this.random = new Random(); + this.removeComponent(this.components.canvas); // Remove offscreen culling from particle emitters for (let i = 0; i < this.traits.length; i++) { @@ -368,6 +418,9 @@ export class ParticleEmitterImpl extends Actor { public removeParticle(particle: Particle) { this.deadParticles.push(particle); + if (this?.scene?.world) { + this.scene.world.remove(particle); + } } /** @@ -376,7 +429,11 @@ export class ParticleEmitterImpl extends Actor { */ public emitParticles(particleCount: number) { for (let i = 0; i < particleCount; i++) { - this.particles.push(this._createParticle()); + const p = this._createParticle(); + this.particles.push(p); + if (this?.scene?.world) { + this.scene.world.add(p); + } } } @@ -421,6 +478,8 @@ export class ParticleEmitterImpl extends Actor { p.particleSize = size; if (this.particleSprite) { p.particleSprite = this.particleSprite; + p.graphics.opacity = this.opacity; + p.graphics.use(this._sprite); } p.particleRotationalVelocity = this.particleRotationalVelocity; if (this.randomRotation) { @@ -438,7 +497,6 @@ export class ParticleEmitterImpl extends Actor { if (this.isEmitting) { this._particlesToEmit += this.emitRate * (delta / 1000); - //var numParticles = Math.ceil(this.emitRate * delta / 1000); if (this._particlesToEmit > 1.0) { this.emitParticles(Math.floor(this._particlesToEmit)); this._particlesToEmit = this._particlesToEmit - Math.floor(this._particlesToEmit); diff --git a/src/engine/Promises.ts b/src/engine/Promises.ts index 1829ace6..2212d1da 100644 --- a/src/engine/Promises.ts +++ b/src/engine/Promises.ts @@ -50,6 +50,7 @@ export class Promise implements PromiseLike { /** * Create and resolve a Promise with an optional value * @param value An optional value to wrap in a resolved promise + * @deprecated Use browser native promises */ public static resolve(value?: T): Promise { const promise = new Promise().resolve(value); @@ -60,6 +61,7 @@ export class Promise implements PromiseLike { /** * Create and reject a Promise with an optional value * @param value An optional value to wrap in a rejected promise + * @deprecated Use browser native promises */ public static reject(value?: T): Promise { const promise = new Promise().reject(value); @@ -70,12 +72,14 @@ export class Promise implements PromiseLike { /** * Returns a new promise that resolves when all the promises passed to it resolve, or rejects * when at least 1 promise rejects. + * @deprecated Use browser native promises */ public static join(promises: Promise[]): Promise; /** * Returns a new promise that resolves when all the promises passed to it resolve, or rejects * when at least 1 promise rejects. + * @deprecated Use browser native promises */ public static join(...promises: Promise[]): Promise; @@ -131,6 +135,7 @@ export class Promise implements PromiseLike { * Chain success and reject callbacks after the promise is resolved * @param successCallback Call on resolution of promise * @param rejectCallback Call on rejection of promise + * @deprecated Use browser native promises */ public then(successCallback?: (value?: T) => any, rejectCallback?: (value?: any) => any) { if (successCallback) { @@ -164,6 +169,7 @@ export class Promise implements PromiseLike { /** * Add an error callback to the promise * @param errorCallback Call if there was an error in a callback + * @deprecated Use browser native promises */ public error(errorCallback?: (value?: any) => any) { if (errorCallback) { @@ -175,6 +181,7 @@ export class Promise implements PromiseLike { /** * Resolve the promise and pass an option value to the success callbacks * @param value Value to pass to the success callbacks + * @deprecated Use browser native promises */ public resolve(value?: T): Promise { if (this._state === PromiseState.Pending) { @@ -196,6 +203,7 @@ export class Promise implements PromiseLike { /** * Reject the promise and pass an option value to the reject callbacks * @param value Value to pass to the reject callbacks + * @deprecated Use browser native promises */ public reject(value?: any) { if (this._state === PromiseState.Pending) { @@ -214,6 +222,7 @@ export class Promise implements PromiseLike { /** * Inspect the current state of a promise + * @deprecated Use browser native promises */ public state(): PromiseState { return this._state; diff --git a/src/engine/Resources/Sound/Sound.ts b/src/engine/Resources/Sound/Sound.ts index 9b48188a..3ac89f37 100644 --- a/src/engine/Resources/Sound/Sound.ts +++ b/src/engine/Resources/Sound/Sound.ts @@ -239,7 +239,7 @@ export class Sound extends Class implements Audio, Loadable { /** * Get Id of provided AudioInstance in current trackList - * @param track [[AudioInstance]] which Id is to be given + * @param track [[Audio]] which Id is to be given */ public getTrackId(track: Audio): number { return this._tracks.indexOf(track); diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts index 6863e778..803f6343 100644 --- a/src/engine/Scene.ts +++ b/src/engine/Scene.ts @@ -28,9 +28,12 @@ import * as ActorUtils from './Util/Actors'; import { Trigger } from './Trigger'; import { Body } from './Collision/Body'; import { SystemType } from './EntityComponentSystem/System'; -import { CanvasDrawingSystem } from './Drawing/CanvasDrawingSystem'; +// import { CanvasDrawingSystem } from './Drawing/CanvasDrawingSystem'; import { obsolete } from './Util/Decorators'; import { World } from './EntityComponentSystem/World'; +import { GraphicsSystem } from './Graphics/GraphicsSystem'; +import { CanvasDrawingSystem } from './Drawing/CanvasDrawingSystem'; +import { Flags, Legacy } from './Flags'; /** * [[Actor|Actors]] are composed together into groupings called Scenes in * Excalibur. The metaphor models the same idea behind real world @@ -76,7 +79,7 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact /** * The [[ScreenElement]]s in a scene, if any; these are drawn last - * @deprecated + * @deprecated Use [[Scene.actors]] */ @obsolete({ message: 'Will be removed in excalibur v0.26.0', @@ -240,7 +243,11 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact } // Initialize systems - this.world.add(new CanvasDrawingSystem()); + if (Flags.isEnabled(Legacy.LegacyDrawing)) { + this.world.add(new CanvasDrawingSystem()); + } else { + this.world.add(new GraphicsSystem()); + } // This order is important! we want to be sure any custom init that add actors // fire before the actor init @@ -560,7 +567,7 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact * Adds (any) actor to act as a piece of UI, meaning it is always positioned * in screen coordinates. UI actors do not participate in collisions. * @todo Should this be `ScreenElement` only? - * @deprecated + * @deprecated Use [[Scene.add]] */ @obsolete({message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use Scene.add'}) public addScreenElement(actor: Actor) { @@ -569,7 +576,7 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact /** * Removes an actor as a piece of UI - * @deprecated + * @deprecated Use [[Scene.remove]] */ @obsolete({message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use Scene.remove'}) public removeScreenElement(actor: Actor) { @@ -578,7 +585,7 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact /** * Adds a [[TileMap]] to the scene, once this is done the TileMap will be drawn and updated. - * @deprecated + * @deprecated Use [[Scene.add]] */ @obsolete({message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use Scene.add'}) public addTileMap(tileMap: TileMap) { @@ -588,7 +595,7 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact /** * Removes a [[TileMap]] from the scene, it will no longer be drawn or updated. - * @deprecated + * @deprecated Use [[Scene.remove]] */ @obsolete({message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use Scene.remove'}) public removeTileMap(tileMap: TileMap) { diff --git a/src/engine/Screen.ts b/src/engine/Screen.ts index 201ed4fd..1edb71fa 100644 --- a/src/engine/Screen.ts +++ b/src/engine/Screen.ts @@ -3,6 +3,7 @@ import { Logger } from './Util/Log'; import { Camera } from './Camera'; import { BrowserEvents } from './Util/Browser'; import { BoundingBox } from './Collision/Index'; +import { ExcaliburGraphicsContext } from './Graphics/Context/ExcaliburGraphicsContext'; import { getPosition } from './Util/Util'; /** @@ -125,14 +126,6 @@ export interface ScreenDimension { height: number; } -export interface ExcaliburGraphicsContext { - save(): void; - resetTransform(): void; - scale(x: number, y: number): void; - imageSmoothingEnabled: boolean; - restore(): void; -} - export interface ScreenOptions { /** * Canvas element to build a screen on @@ -345,9 +338,10 @@ export class Screen { this._canvas.style.height = this.viewport.height + 'px'; // After messing with the canvas width/height the graphics context is invalidated and needs to have some properties reset + this._ctx.updateViewport(); this._ctx.resetTransform(); this._ctx.scale(this.pixelRatio, this.pixelRatio); - this._ctx.imageSmoothingEnabled = this._antialiasing; + this._ctx.smoothing = this._antialiasing; } public get antialiasing() { @@ -356,7 +350,7 @@ export class Screen { public set antialiasing(isSmooth: boolean) { this._antialiasing = isSmooth; - this._ctx.imageSmoothingEnabled = this._antialiasing; + this._ctx.smoothing = this._antialiasing; } /** diff --git a/src/engine/TileMap.ts b/src/engine/TileMap.ts index 6d8a05c4..e4f8215a 100644 --- a/src/engine/TileMap.ts +++ b/src/engine/TileMap.ts @@ -8,23 +8,27 @@ import { SpriteSheet } from './Drawing/SpriteSheet'; import * as Events from './Events'; import { Configurable } from './Configurable'; import { Entity } from './EntityComponentSystem/Entity'; -import { CanvasDrawComponent } from './Drawing/CanvasDrawComponent'; import { TransformComponent } from './EntityComponentSystem/Components/TransformComponent'; -import { Sprite } from './Drawing/Index'; +import { ExcaliburGraphicsContext, GraphicsComponent } from './Graphics'; +import * as Graphics from './Graphics'; +import { CanvasDrawComponent, Sprite } from './Drawing/Index'; +import { Sprite as LegacySprite } from './Drawing/Index'; import { removeItemFromArray } from './Util/Util'; import { obsolete } from './Util/Decorators'; /** * @hidden */ -export class TileMapImpl extends Entity { +export class TileMapImpl extends Entity { private _collidingX: number = -1; private _collidingY: number = -1; private _onScreenXStart: number = 0; private _onScreenXEnd: number = 9999; private _onScreenYStart: number = 0; private _onScreenYEnd: number = 9999; - private _spriteSheets: { [key: string]: SpriteSheet } = {}; + private _spriteSheets: { [key: string]: Graphics.SpriteSheet } = {}; + + private _legacySpriteMap = new Map(); public logger: Logger = Logger.getInstance(); public data: Cell[] = []; public visible = true; @@ -137,9 +141,23 @@ export class TileMapImpl extends Entity this.draw(ctx, delta) + }) + ); this.addComponent(new CanvasDrawComponent((ctx, delta) => this.draw(ctx, delta))); + this.components.graphics.localBounds = new BoundingBox({ + left: 0, + top: 0, + right: this.cols * this.cellWidth, + bottom: this.rows * this.cellHeight + }); } + public _initialize(engine: Engine) { + super._initialize(engine); + } /** * @@ -147,10 +165,17 @@ export class TileMapImpl extends Entity { private _bounds: BoundingBox; public x: number; public y: number; @@ -358,7 +392,10 @@ export class CellImpl { public height: number; public index: number; public solid: boolean = false; - public sprites: Sprite[] = []; + public sprites: Graphics.Sprite[] = []; + + // public transform: TransformComponent; + // public graphics: GraphicsComponent; /** * @param xOrConfig Gets or sets x coordinate of the cell in world coordinates or cell option bag @@ -376,8 +413,9 @@ export class CellImpl { height: number, index: number, solid: boolean = false, - sprites: Sprite[] = [] + sprites: Graphics.Sprite[] = [] ) { + super(); if (xOrConfig && typeof xOrConfig === 'object') { const config = xOrConfig; xOrConfig = config.x; @@ -410,23 +448,27 @@ export class CellImpl { * Add another [[Sprite]] to this cell * @deprecated Use addSprite, will be removed in v0.26.0 */ - @obsolete({message: 'Will be removed in v0.26.0', alternateMethod: 'addSprite'}) - public pushSprite(sprite: Sprite) { - this.sprites.push(sprite); + @obsolete({ message: 'Will be removed in v0.26.0', alternateMethod: 'addSprite' }) + public pushSprite(sprite: Graphics.Sprite | LegacySprite) { + this.addSprite(sprite); } /** * Add another [[Sprite]] to this TileMap cell * @param sprite */ - public addSprite(sprite: Sprite) { - this.sprites.push(sprite); + public addSprite(sprite: Graphics.Sprite | LegacySprite) { + if (sprite instanceof LegacySprite) { + this.sprites.push(Graphics.Sprite.fromLegacySprite(sprite)); + } else { + this.sprites.push(sprite); + } } /** * Remove an instance of [[Sprite]] from this cell */ - public removeSprite(sprite: Sprite) { + public removeSprite(sprite: Graphics.Sprite | LegacySprite) { removeItemFromArray(sprite, this.sprites); } /** @@ -444,7 +486,7 @@ export interface CellArgs extends Partial { height: number; index: number; solid?: boolean; - sprites?: Sprite[]; + sprites?: Graphics.Sprite[]; } /** @@ -459,7 +501,7 @@ export interface CellArgs extends Partial { */ export class Cell extends Configurable(CellImpl) { constructor(config: CellArgs); - constructor(x: number, y: number, width: number, height: number, index: number, solid?: boolean, sprites?: Sprite[]); + constructor(x: number, y: number, width: number, height: number, index: number, solid?: boolean, sprites?: Graphics.Sprite[]); constructor( xOrConfig: number | CellArgs, y?: number, @@ -467,7 +509,7 @@ export class Cell extends Configurable(CellImpl) { height?: number, index?: number, solid?: boolean, - sprites?: Sprite[] + sprites?: Graphics.Sprite[] ) { super(xOrConfig, y, width, height, index, solid, sprites); } diff --git a/src/engine/Util/Actors.ts b/src/engine/Util/Actors.ts index cc231292..8dfc5a9f 100644 --- a/src/engine/Util/Actors.ts +++ b/src/engine/Util/Actors.ts @@ -5,7 +5,7 @@ import { Trigger } from '../Trigger'; /** * Type guard to detect if something is an actor - * @deprecated + * @deprecated Will be removed in v0.26.0 * @param actor */ export function isVanillaActor(actor: Actor) { @@ -14,7 +14,7 @@ export function isVanillaActor(actor: Actor) { /** * Type guard to detect a screen element - * TODO: Move to screen element + * @todo move to ScreenElement */ export function isScreenElement(actor: Actor) { return actor instanceof ScreenElement; diff --git a/src/engine/Util/Pool.ts b/src/engine/Util/Pool.ts new file mode 100644 index 00000000..ba0e7942 --- /dev/null +++ b/src/engine/Util/Pool.ts @@ -0,0 +1,90 @@ +export interface Poolable { + /** + * Any type that is a member of a an object pool will have a reference to teh pool + * @internal + */ + _pool?: Pool; + dispose(): this; +} + +export class Pool { + public totalAllocations = 0; + public index = 0; + public objects: Type[] = []; + + constructor( + public builder: (...args: any[]) => Type, + public recycler: (instance: Type, ...args: any[]) => Type, + public maxObjects: number = 100 + ) {} + + /** + * Use many instances out of the in the context and return all to the pool. + * + * By returning values out of the contex they will be un-hooked from the pool and are free to be passed to consumers + * @param context + */ + using(context: (pool: Pool) => Type[] | void) { + const result = context(this); + if (result) { + return this.done(...result); + } + return this.done(); + } + + /** + * Use a single instance out of th pool and immediately return it to the pool + * @param context + */ + borrow(context: (object: Type) => void) { + const object = this.get(); + context(object); + this.index--; + } + + /** + * Retrieve a value from the pool, will allocate a new instance if necessary or recycle from the pool + * @param args + */ + get(...args: any[]): Type { + if (this.index === this.maxObjects) { + // TODO implement hard or soft cap + throw new Error('Max pooled objects reached, possible memory leak?'); + } + + if (this.objects[this.index]) { + // Pool has an available object already constructed + return this.recycler(this.objects[this.index++], ...args); + } else { + // New allocation + this.totalAllocations++; + const object = (this.objects[this.index++] = this.builder(...args)); + object._pool = this; + return object; + } + } + + /** + * Signals we are done with the pool objects for now, Reclaims all objects in the pool. + * + * If a list of pooled objects is passed to done they are un-hooked from the pool and are free + * to be passed to consumers + * @param objects A list of object to separate from the pool + */ + done(...objects: Type[]): Type[]; + done(): void; + done(...objects: Type[]): Type[] | void { + // All objects in pool now considered "free" + this.index = 0; + for (const object of objects) { + const poolIndex = this.objects.indexOf(object); + // Build a new object to take the pool place + this.objects[poolIndex] = (this as any).builder(); // TODO problematic 0-arg only support + this.objects[poolIndex]._pool = this; + this.totalAllocations++; + // Unhook object from the pool + object._pool = undefined; + } + return objects; + } +} diff --git a/src/engine/Util/SortedList.ts b/src/engine/Util/SortedList.ts index 35951982..22f9c247 100644 --- a/src/engine/Util/SortedList.ts +++ b/src/engine/Util/SortedList.ts @@ -2,7 +2,7 @@ import { obsolete } from './Decorators'; /** * A sorted list implementation. NOTE: this implementation is not self-balancing - * @deprecated + * @deprecated WIll be removed in v0.26.0 Use built in JS array.sort */ @obsolete({message: 'Will be removed in excalibur v0.26.0', alternateMethod: 'Use built in JS array.sort'}) export class SortedList { @@ -187,7 +187,7 @@ export class SortedList { /** * A tree node part of [[SortedList]] - * @deprecated + * @deprecated Will be removed in v0.26.0 */ @obsolete({message: 'Will be removed in excalibur v0.26.0'}) export class BinaryTreeNode { @@ -240,7 +240,7 @@ export class BinaryTreeNode { * Mock element for testing * * @internal - * @deprecated + * @deprecated Will be removed in v0.26.0 */ export class MockedElement { private _key: number = 0; diff --git a/src/engine/Util/Util.ts b/src/engine/Util/Util.ts index de469f7a..23692268 100644 --- a/src/engine/Util/Util.ts +++ b/src/engine/Util/Util.ts @@ -98,7 +98,7 @@ export function extend() { /** * Encode a string in base64 - * @deprecated This method is marked for removal + * @deprecated This will be removed in v0.26.0 */ export function base64Encode(inputStr: string) { const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; @@ -225,7 +225,7 @@ export function getPosition(el: HTMLElement): Vector { /** * Add an item to an array list if it doesn't already exist. Returns true if added, false if not and already exists in the array. - * @deprecated + * @deprecated Will be removed in v0.26.0 */ export function addItemToArray(item: T, array: T[]): boolean { if (array.indexOf(item) === -1) { @@ -237,6 +237,7 @@ export function addItemToArray(item: T, array: T[]): boolean { /** * Remove an item from an list + * @deprecated Will be removed in v0.26.0 */ export function removeItemFromArray(item: T, array: T[]): boolean { let index = -1; @@ -263,7 +264,7 @@ export function contains(array: Array, obj: any): boolean { /** * Get the opposit side * TODO: Move to Side type - * @deprecated + * @deprecated Will be removed in v0.26.0 */ export function getOppositeSide(side: Side) { if (side === Side.Top) { @@ -304,7 +305,7 @@ export function getSideFromDirection(direction: Vector) { /** * Excalibur's dynamically resizing collection - * @deprecated Will be removed in future releases + * @deprecated Will be removed v0.26.0 */ export class Collection { /** @@ -463,6 +464,13 @@ export function fail(message: never): never { throw new Error(message); } +/** + * Generate a range of numbers + * @param from inclusive + * @param to inclusive + */ +export const range = (from: number, to: number) => Array.from(new Array(to - from + 1), (_x, i) => i + from); + /** * Create a promise that resolves after a certain number of milliseconds * @param milliseconds diff --git a/src/engine/Util/Watch.ts b/src/engine/Util/Watch.ts new file mode 100644 index 00000000..6fc02369 --- /dev/null +++ b/src/engine/Util/Watch.ts @@ -0,0 +1,34 @@ +/** + * Watch an object with a proxy + */ +export function watch(type: T, change: (type: T) => any): T { + if (!type) { + return type; + } + if ((type as any).__isProxy === undefined) { + // expando hack to mark a proxy + return new Proxy(type, { + set: (obj, prop, value) => { + // The default behavior to store the value + if ((obj as any)[prop] !== value) { + (obj as any)[prop] = value; + // Avoid watching private junk + if (typeof prop === 'string') { + if (prop[0] !== '_') { + change(obj); + } + } + } + // Indicate success + return true; + }, + get: (obj, prop) => { + if (prop !== '__isProxy') { + return (obj as any)[prop]; + } + return true; + } + }); + } + return type; +} diff --git a/src/engine/files.d.ts b/src/engine/files.d.ts index 5f02df92..5b685716 100644 --- a/src/engine/files.d.ts +++ b/src/engine/files.d.ts @@ -10,3 +10,8 @@ declare module '*.css' { const value: { toString: () => string }; export default value; } + +declare module '*.glsl' { + const value: string; + export default value; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 440ae072..a9116736 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -21,7 +21,8 @@ export * from './Debug'; export * from './EventDispatcher'; export * from './Events/MediaEvents'; export * from './Events'; -export { Label, FontStyle, FontUnit, TextAlign, BaseAlign } from './Label'; +export { Label } from './Label'; +export { FontStyle, FontUnit, TextAlign, BaseAlign } from './Graphics/FontCommon'; export * from './Loader'; export { Particle, ParticleEmitter, ParticleArgs, ParticleEmitterArgs, EmitterType } from './Particles'; export * from './Physics'; @@ -41,6 +42,10 @@ export * from './Resources/Index'; export * from './EntityComponentSystem/index'; +// ex.Graphics namespace +import * as graphics from './Graphics/index'; +export { graphics as Graphics }; + // ex.Events namespace import * as events from './Events'; export { events as Events }; @@ -64,6 +69,7 @@ export * from './Util/CullingBox'; export * from './Util/EasingFunctions'; export * from './Util/Log'; export * from './Util/SortedList'; +export * from './Util/Pool'; // ex.Deprecated export * from './Promises'; diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json index d235178a..ac27eab2 100644 --- a/src/engine/tsconfig.json +++ b/src/engine/tsconfig.json @@ -17,6 +17,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "allowUnreachableCode": false, + "downlevelIteration": true, "lib": [ "dom", "es5", diff --git a/src/spec/EngineSpec.ts b/src/spec/EngineSpec.ts index a682fba6..3a081262 100644 --- a/src/spec/EngineSpec.ts +++ b/src/spec/EngineSpec.ts @@ -62,7 +62,6 @@ describe('The engine', () => { engine = TestUtils.engine({ suppressPlayButton: true }); - engine.currentScene = scene; engine.currentScene.add( new ex.Actor({ pos: new ex.Vector(250, 250), diff --git a/src/spec/ExcaliburGraphicsContextSpec.ts b/src/spec/ExcaliburGraphicsContextSpec.ts new file mode 100644 index 00000000..413ab6de --- /dev/null +++ b/src/spec/ExcaliburGraphicsContextSpec.ts @@ -0,0 +1,334 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +/** + * + */ +function flushWebGLCanvasTo2D(source: HTMLCanvasElement): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = source.width; + canvas.height = source.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(source, 0, 0); + return canvas; +} + +describe('The ExcaliburGraphicsContext', () => { + describe('2D', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.ExcaliburGraphicsContext2DCanvas).toBeDefined(); + }); + + it('can be constructed', () => { + const canvas = document.createElement('canvas'); + const context = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement: canvas, + backgroundColor: ex.Color.Red + }); + expect(context).toBeDefined(); + }); + + it('has the same dimensions as the canvas', () => { + const canvas = document.createElement('canvas'); + canvas.width = 123; + canvas.height = 456; + const context = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement: canvas, + backgroundColor: ex.Color.Red + }); + expect(context.width).toBe(canvas.width); + expect(context.height).toBe(canvas.height); + }); + + it('can draw a graphic', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.drawImage(rect._bitmap, 20, 20); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/2d-drawgraphic.png'); + }); + + it('can draw debug point', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + sut.clear(); + sut.debug.drawPoint(ex.vec(50, 50), { + size: 20, + color: ex.Color.Blue + }); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/2d-drawpoint.png'); + }); + + it('can draw debug line', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + sut.clear(); + sut.debug.drawLine(ex.vec(0, 0), ex.vec(100, 100), { + color: ex.Color.Blue + }); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/2d-drawline.png'); + }); + + it('can transform the context', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.save(); + sut.opacity = 0.5; + sut.translate(50, 50); + sut.rotate(Math.PI / 4); + sut.scale(0.5, 0.5); + sut.drawImage(rect._bitmap, -25, -25); + sut.restore(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/2d-transform.png'); + }); + + it('can snap drawings to pixel', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement: canvasElement, + enableTransparency: false, + snapToPixel: true, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.drawImage(rect._bitmap, 1.9, 1.9); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/2d-snap-to-pixel.png'); + }); + }); + + describe('WebGL', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.ExcaliburGraphicsContextWebGL).toBeDefined(); + }); + + it('can be constructed', () => { + const canvas = document.createElement('canvas'); + const context = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvas, + backgroundColor: ex.Color.Red + }); + expect(context).toBeDefined(); + }); + + it('has the same dimensions as the canvas', () => { + const canvas = document.createElement('canvas'); + canvas.width = 123; + canvas.height = 456; + const context = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvas, + backgroundColor: ex.Color.Red + }); + expect(context.width).toBe(canvas.width); + expect(context.height).toBe(canvas.height); + }); + + it('can draw a graphic', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.drawImage(rect._bitmap, 20, 20); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage( + 'src/spec/images/ExcaliburGraphicsContextSpec/2d-drawgraphic.png' + ); + }); + + it('can draw debug point', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + sut.clear(); + sut.debug.drawPoint(ex.vec(50, 50), { + size: 20, + color: ex.Color.Blue + }); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage( + 'src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawpoint.png' + ); + }); + + it('can draw debug line', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + sut.clear(); + sut.debug.drawLine(ex.vec(0, 0), ex.vec(100, 100), { + color: ex.Color.Blue + }); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage( + 'src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawline.png' + ); + }); + + it('can draw debug rectangle', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + sut.clear(); + sut.debug.drawRect(10, 10, 80, 80, { + color: ex.Color.Blue + }); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage('src/spec/images/ExcaliburGraphicsContextSpec/webgl-rect.png'); + }); + + it('can transform the context', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.save(); + sut.opacity = 0.5; + sut.translate(50, 50); + sut.rotate(Math.PI / 4); + sut.scale(0.5, 0.5); + sut.drawImage(rect._bitmap, -25, -25); + sut.restore(); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage( + 'src/spec/images/ExcaliburGraphicsContextSpec/webgl-transform.png' + ); + }); + + it('can snap drawings to pixel', async () => { + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const sut = new ex.Graphics.ExcaliburGraphicsContextWebGL({ + canvasElement: canvasElement, + enableTransparency: false, + snapToPixel: true, + backgroundColor: ex.Color.White + }); + + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Blue + }); + + sut.clear(); + sut.drawImage(rect._bitmap, 1.9, 1.9); + sut.flush(); + + await expectAsync(flushWebGLCanvasTo2D(canvasElement)).toEqualImage( + 'src/spec/images/ExcaliburGraphicsContextSpec/2d-snap-to-pixel.png' + ); + }); + }); +}); diff --git a/src/spec/GraphicSpec.ts b/src/spec/GraphicSpec.ts new file mode 100644 index 00000000..938050eb --- /dev/null +++ b/src/spec/GraphicSpec.ts @@ -0,0 +1,136 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +class TestGraphic extends ex.Graphics.Graphic { + constructor(options?: ex.Graphics.GraphicOptions) { + super(options ?? {}); + this.width = 50; + this.height = 50; + } + private _rect1 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Green + }); + private _rect2 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Red + }); + private _rect3 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Yellow + }); + private _rect4 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Blue + }); + + protected _drawImage(ex: ex.Graphics.ExcaliburGraphicsContext, x: number, y: number): void { + this._rect1.draw(ex, x, y); + this._rect2.draw(ex, x, y + 25); + this._rect3.draw(ex, x + 25, y); + this._rect4.draw(ex, x + 25, y + 25); + } + + getSource(): ex.Graphics.HTMLImageSource { + return null; + } + clone(): ex.Graphics.Graphic { + return null; + } +} + +describe('A Graphic', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + }); + + it('exists', () => { + expect(ex.Graphics.Graphic).toBeDefined(); + }); + + it('generates a new id', () => { + const graphic1 = new TestGraphic(); + const graphic2 = new TestGraphic(); + expect(graphic1.id).not.toBe(graphic2.id); + }); + + it('can clone all graphic options', () => { + const originalOptions: ex.Graphics.GraphicOptions = { + origin: ex.vec(1, 1), + flipHorizontal: true, + flipVertical: true, + rotation: Math.PI / 8, + opacity: 0.25, + scale: ex.vec(0.5, 0.75) + }; + + const sut = new TestGraphic(originalOptions); + + expect(sut.cloneGraphicOptions()).toEqual(originalOptions); + }); + + it('can draw a graphic implementation', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/base.png'); + }); + + it('has local bounds based on the width/height', () => { + const sut = new TestGraphic(); + expect(sut.localBounds).toEqual(new ex.BoundingBox(0, 0, sut.width, sut.height)); + }); + + it('can rotate a graphic implementation', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.rotation = Math.PI / 4; + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/rotated.png'); + }); + + it('can flip a graphic implementation', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.flipHorizontal = true; + sut.flipVertical = true; + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/flipped.png'); + }); + + it('can scale a graphic implementation', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.scale = ex.vec(2, 2); + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/scaled.png'); + }); + + it('can set opacity on a graphic implementation', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.opacity = 0.2; + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/opacity.png'); + }); + + it('can show a debug rect', async () => { + ctx.clear(); + const sut = new TestGraphic(); + sut.showDebug = true; + sut.draw(ctx, 25, 25); + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicSpec/debug.png'); + }); +}); diff --git a/src/spec/GraphicsAnimationSpec.ts b/src/spec/GraphicsAnimationSpec.ts new file mode 100644 index 00000000..df3d28c9 --- /dev/null +++ b/src/spec/GraphicsAnimationSpec.ts @@ -0,0 +1,379 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; +import { ExcaliburGraphicsContext2DCanvas } from '../engine/Graphics'; + +describe('A Graphics Animation', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.Animation).toBeDefined(); + }); + + it('can be constructed', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + frames: [ + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim).toBeDefined(); + }); + + it('can be cloned', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + frames: [ + { + graphic: rect, + duration: 100 + } + ], + frameDuration: 222, + strategy: ex.Graphics.AnimationStrategy.Freeze + }); + + const clone = anim.clone(); + + expect(clone.frameDuration).toBe(222); + expect(clone.strategy).toBe(ex.Graphics.AnimationStrategy.Freeze); + }); + + it('can be defined from a spritesheet', () => { + const sourceImage = new ex.Graphics.ImageSource('some/image.png'); + const ss = ex.Graphics.SpriteSheet.fromGrid({ + image: sourceImage, + grid: { + spriteWidth: 10, + spriteHeight: 10, + rows: 10, + columns: 10 + } + }); + const anim = ex.Graphics.Animation.fromSpriteSheet(ss, [0, 1, 2, 3], 100, ex.Graphics.AnimationStrategy.Freeze); + + expect(anim.strategy).toBe(ex.Graphics.AnimationStrategy.Freeze); + expect(anim.frames[0].duration).toBe(100); + expect(anim.frames.length).toBe(4); + }); + + it('is playing and looping by default', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + frames: [ + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim.isPlaying).toBe(true); + expect(anim.strategy).toBe(ex.Graphics.AnimationStrategy.Loop); + }); + + it('only ticks once per frame', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + frames: [ + { + graphic: rect, + duration: 100 + } + ] + }); + + expect((anim as any)._timeLeftInFrame).toBe(100); + anim.tick(1, 1234); + anim.tick(1, 1234); + anim.tick(1, 1234); + anim.tick(1, 1234); + expect((anim as any)._timeLeftInFrame).toBe(99); + }); + + it('can be played with end strategy', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.End, + frames: [ + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim.canFinish).toBe(true); + expect(anim.done).toBe(false); + anim.play(); + anim.tick(50, 0); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(50, 1); + expect(anim.currentFrame).toBe(null); + expect(anim.done).toBe(true); + }); + + it('can be played with loop strategy', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.Loop, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim.canFinish).toBe(false); + anim.play(); + anim.tick(50, 0); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(50, 1); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 2); + expect(anim.currentFrame).toBe(anim.frames[0]); + }); + + it('can be played with ping pong strategy', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.PingPong, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim.canFinish).toBe(false); + anim.play(); + anim.tick(50, 0); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(50, 1); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 2); + expect(anim.currentFrame).toBe(anim.frames[2]); + anim.tick(100, 3); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 4); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(100, 5); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 6); + expect(anim.currentFrame).toBe(anim.frames[2]); + anim.tick(100, 7); + expect(anim.currentFrame).toBe(anim.frames[1]); + }); + + it('can be played with freeze frame strategy', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.Freeze, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ] + }); + expect(anim.canFinish).toBe(true); + anim.play(); + anim.tick(50, 0); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(50, 1); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 2); + expect(anim.currentFrame).toBe(anim.frames[1]); + anim.tick(100, 3); + expect(anim.currentFrame).toBe(anim.frames[1]); + }); + + it('has animation events', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.End, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ] + }); + + const looper = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.Loop, + frames: [ + { + graphic: rect, + duration: 100 + } + ] + }); + + const onFrame = jasmine.createSpy('onFrame'); + const onEnd = jasmine.createSpy('onEnd'); + const onLoop = jasmine.createSpy('onLoop'); + anim.events.on('frame', onFrame); + anim.events.on('end', onEnd); + looper.events.on('loop', onLoop); + anim.tick(100, 0); + anim.tick(100, 1); + looper.tick(100, 0); + looper.tick(100, 1); + + expect(onFrame).toHaveBeenCalledTimes(2); + expect(onEnd).toHaveBeenCalledTimes(1); + expect(onLoop).toHaveBeenCalledTimes(2); + }); + + it('can be paused or reset', () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.End, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + }, + { + graphic: rect, + duration: 100 + } + ] + }); + + const onFrame = jasmine.createSpy('onFrame'); + anim.events.on('frame', onFrame); + anim.tick(100, 0); + anim.pause(); + anim.tick(100, 1); + expect(anim.currentFrame).toBe(anim.frames[1]); + + anim.reset(); + expect(anim.currentFrame).toBe(anim.frames[0]); + anim.tick(100, 2); + // Reseting should re-fire the first tick frame event + expect(onFrame).toHaveBeenCalledTimes(2); + }); + + it('draws the right frame to the screen', async () => { + const rect = new ex.Graphics.Rectangle({ + width: 100, + height: 100, + color: ex.Color.Blue + }); + const rect2 = new ex.Graphics.Rectangle({ + width: 90, + height: 90, + color: ex.Color.Yellow + }); + const rect3 = new ex.Graphics.Rectangle({ + width: 80, + height: 80, + color: ex.Color.Red + }); + const anim = new ex.Graphics.Animation({ + strategy: ex.Graphics.AnimationStrategy.End, + frames: [ + { + graphic: rect, + duration: 100 + }, + { + graphic: rect2, + duration: 100 + }, + { + graphic: rect3, + duration: 100 + } + ] + }); + + const output = document.createElement('canvas'); + output.width = 100; + output.height = 100; + const ctx = new ExcaliburGraphicsContext2DCanvas({ canvasElement: output }); + + ctx.clear(); + anim.draw(ctx, 0, 0); + await expectAsync(output).toEqualImage('src/spec/images/GraphicsAnimationSpec/frame-1.png'); + + ctx.clear(); + anim.tick(100, 0); + anim.draw(ctx, 0, 0); + await expectAsync(output).toEqualImage('src/spec/images/GraphicsAnimationSpec/frame-2.png'); + + ctx.clear(); + anim.tick(100, 1); + anim.draw(ctx, 0, 0); + await expectAsync(output).toEqualImage('src/spec/images/GraphicsAnimationSpec/frame-3.png'); + }); +}); diff --git a/src/spec/GraphicsCanvasSpec.ts b/src/spec/GraphicsCanvasSpec.ts new file mode 100644 index 00000000..e4828771 --- /dev/null +++ b/src/spec/GraphicsCanvasSpec.ts @@ -0,0 +1,109 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Canvas Graphic', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.Canvas).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.Graphics.Canvas({ + width: 100, + height: 100 + }); + expect(sut).toBeDefined(); + }); + + it('can be cloned', () => { + const sut = new ex.Graphics.Canvas({ + width: 100, + height: 100, + draw: (ctx) => { + ctx.fillStyle = 'green'; + ctx.fillRect(25, 25, 50, 50); + } + }); + const clone = sut.clone(); + + expect(sut.draw).toBe(clone.draw); + expect(sut.width).toBe(clone.width); + expect(sut.height).toBe(clone.height); + }); + + it('can be drawn using the 2d canvas api', async () => { + const sut = new ex.Graphics.Canvas({ + width: 100, + height: 100, + draw: (ctx) => { + ctx.fillStyle = 'green'; + ctx.fillRect(25, 25, 50, 50); + } + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + sut.draw(ctx, 0, 0); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsCanvasSpec/draw.png'); + }); + + it('can cache draws', () => { + const sut = new ex.Graphics.Canvas({ + width: 100, + height: 100, + cache: true, + draw: (ctx) => { + ctx.fillStyle = 'red'; + ctx.fillRect(25, 25, 50, 50); + } + }); + + spyOn(sut, 'execute').and.callThrough(); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + + expect(sut.execute).toHaveBeenCalledTimes(1); + }); + + it('can redraw on each draws', () => { + const sut = new ex.Graphics.Canvas({ + width: 100, + height: 100, + cache: false, + draw: (ctx) => { + ctx.fillStyle = 'red'; + ctx.fillRect(25, 25, 50, 50); + } + }); + + spyOn(sut, 'execute').and.callThrough(); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + sut.draw(ctx, 0, 0); + + expect(sut.execute).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/spec/GraphicsCircleSpec.ts b/src/spec/GraphicsCircleSpec.ts new file mode 100644 index 00000000..d8d23425 --- /dev/null +++ b/src/spec/GraphicsCircleSpec.ts @@ -0,0 +1,54 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Circle Graphic', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.Circle).toBeDefined(); + }); + + it('can have a radius', () => { + const sut = new ex.Graphics.Circle({ + radius: 10 + }); + expect(sut.radius).toBe(10); + expect(sut.width).toBe(20); + expect(sut.height).toBe(20); + }); + + it('can set a color', async () => { + const sut = new ex.Graphics.Circle({ + radius: 10, + color: ex.Color.Green, + strokeColor: ex.Color.Black + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 50, 50); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsCircleSpec/circle.png'); + }); + + it('can be cloned', () => { + const sut = new ex.Graphics.Circle({ + radius: 10, + color: ex.Color.Green, + strokeColor: ex.Color.Black + }); + + const clone = sut.clone(); + + expect(clone.radius).toBe(10); + expect(clone.color).toEqual(ex.Color.Green); + expect(clone.strokeColor).toEqual(ex.Color.Black); + }); +}); diff --git a/src/spec/GraphicsComponentSpec.ts b/src/spec/GraphicsComponentSpec.ts new file mode 100644 index 00000000..960e5ae6 --- /dev/null +++ b/src/spec/GraphicsComponentSpec.ts @@ -0,0 +1,252 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Graphics ECS Component', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.GraphicsComponent); + }); + + it('can be constructed', () => { + const sut = new ex.Graphics.GraphicsComponent(); + expect(sut).toBeDefined(); + expect(sut.anchor).toBeVector(ex.vec(0.5, 0.5)); + expect(sut.offset).toBeVector(ex.vec(0, 0)); + expect(sut.opacity).toBe(1); + expect(sut.visible).toBe(true); + expect(sut.current).toEqual([]); + expect(sut.graphics).toEqual({}); + }); + + it('can be constructed with optional params', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent({ + graphics: { + 'some-gfx': rect + }, + current: 'some-gfx', + visible: false, + opacity: 0.5, + copyGraphics: true, + offset: ex.vec(10, 11), + anchor: ex.vec(0, 0) + }); + + expect(sut.anchor).toBeVector(ex.vec(0, 0)); + expect(sut.offset).toBeVector(ex.vec(10, 11)); + expect(sut.opacity).toBe(0.5); + expect(sut.visible).toBe(false); + expect(sut.current).not.toBeNull(); + expect(sut.graphics).toEqual({ + 'some-gfx': rect + }); + expect(sut.getNames()).toEqual(['some-gfx']); + expect(sut.getGraphic('some-gfx')).toEqual(rect); + }); + + it('can implicitly copy graphics', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent({ + copyGraphics: true + }); + const shownRect = sut.show(rect); + expect(shownRect.id).not.toEqual(rect.id); + }); + + it('can show graphics', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent(); + + sut.show(rect, { offset: ex.vec(1, 2), anchor: ex.vec(1, 1) }); + sut.show(rect, { offset: ex.vec(-1, -2), anchor: ex.vec(0, 0) }); + + expect(sut.current).toEqual([ + { + graphic: rect, + options: { + offset: ex.vec(1, 2), + anchor: ex.vec(1, 1) + } + }, + { + graphic: rect, + options: { + offset: ex.vec(-1, -2), + anchor: ex.vec(0, 0) + } + } + ]); + }); + + it('can show graphics by name if it exists', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent({ + graphics: { + 'some-gfx-2': rect + } + }); + + const logger = ex.Logger.getInstance(); + spyOn(logger, 'error'); + + expect(sut.current).toEqual([]); + sut.show('some-gfx-2'); + expect(sut.current).toEqual([ + { + graphic: rect, + options: { + offset: ex.vec(0, 0), + anchor: ex.vec(0.5, 0.5) + } + } + ]); + + const none = sut.show('made-up-name'); + expect(none).toBeNull(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('can swap all the graphics for a graphic', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const rect2 = new ex.Graphics.Rectangle({ + width: 50, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent(); + + sut.show(rect, { offset: ex.vec(1, 2), anchor: ex.vec(1, 1) }); + + expect(sut.current).toEqual([ + { + graphic: rect, + options: { + offset: ex.vec(1, 2), + anchor: ex.vec(1, 1) + } + } + ]); + + sut.use(rect2); + + expect(sut.current).toEqual([ + { + graphic: rect2, + options: { + offset: ex.vec(0, 0), + anchor: ex.vec(0.5, 0.5) + } + } + ]); + }); + + it('can hide graphics', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent(); + + sut.show(rect, { offset: ex.vec(1, 2), anchor: ex.vec(1, 1) }); + sut.show(rect, { offset: ex.vec(-1, -2), anchor: ex.vec(0, 0) }); + + sut.hide(); + + expect(sut.current).toEqual([]); + }); + + it('can hide graphics by reference or name', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const rect2 = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent({ + graphics: { + 'gfx-1': rect, + 'gfx-2': rect2 + } + }); + + const shown = sut.show('gfx-1'); + expect(shown).not.toBeNull(); + const shown2 = sut.show('gfx-2'); + expect(shown2).not.toBeNull(); + + sut.hide(shown); + expect(sut.current.length).toBe(1); + + sut.hide('gfx-2'); + expect(sut.current.length).toBe(0); + }); + + it('can have graphics added to it', () => { + const rect = new ex.Graphics.Rectangle({ + width: 10, + height: 20 + }); + const sut = new ex.Graphics.GraphicsComponent(); + + sut.add('some-graphic', rect); + expect(sut.graphics['some-graphic']).toBe(rect); + + sut.add(rect); + expect(sut.graphics.default).toBe(rect); + }); + + it('can have multiple layers', () => { + const rect = new ex.Graphics.Rectangle({ + width: 40, + height: 40 + }); + const sut = new ex.Graphics.GraphicsComponent(); + + sut.show(rect); + sut.layers.create({ name: 'background', order: -1 }).show(rect); + + const layers = sut.layers.get(); + + expect(sut.layers.has('background')).toBeTrue(); + expect(sut.layers.has('default')).toBeTrue(); + expect(layers.length).toBe(2); + expect(layers[0].name).toBe('background'); + expect(layers[0].order).toBe(-1); + expect(layers[1].name).toBe('default'); + expect(layers[1].order).toBe(0); + }); + + it('ticks graphics that need ticking', () => { + const animation = new ex.Graphics.Animation({ + frames: [] + }); + spyOn(animation, 'tick'); + + const sut = new ex.Graphics.GraphicsComponent(); + sut.add(animation); + + sut.update(123, 4); + + expect(animation.tick).toHaveBeenCalledWith(123, 4); + }); +}); diff --git a/src/spec/GraphicsGroupSpec.ts b/src/spec/GraphicsGroupSpec.ts new file mode 100644 index 00000000..d702d4fc --- /dev/null +++ b/src/spec/GraphicsGroupSpec.ts @@ -0,0 +1,93 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Graphics Group', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + it('exists', () => { + expect(ex.Graphics.GraphicsGroup).toBeDefined(); + }); + + it('can be created and drawn', async () => { + const rect1 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Blue + }); + + const rect2 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Yellow + }); + + const group = new ex.Graphics.GraphicsGroup({ + members: [ + { pos: ex.vec(0, 0), graphic: rect1 }, + { pos: ex.vec(25, 25), graphic: rect2 } + ] + }); + + expect(group.width).toBe(50); + expect(group.height).toBe(50); + expect(group.localBounds.width).toBe(50); + expect(group.localBounds.height).toBe(50); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + group.draw(ctx, 25, 25); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsGroupSpec/graphics-group.png'); + }); + + it('can be cloned', () => { + const animation = new ex.Graphics.Animation({ + frames: [] + }); + const group = new ex.Graphics.GraphicsGroup({ + members: [{ pos: ex.vec(0, 0), graphic: animation }] + }); + + const clone = group.clone(); + expect(clone.members).toEqual(group.members); + }); + + it('will tick any graphics that need ticking in the group', () => { + const animation = new ex.Graphics.Animation({ + frames: [] + }); + spyOn(animation, 'tick').and.callFake((elapsed, idempot) => { + // fake + }); + + const group = new ex.Graphics.GraphicsGroup({ + members: [{ pos: ex.vec(0, 0), graphic: animation }] + }); + + group.tick(100, 1234); + expect(animation.tick).toHaveBeenCalledWith(100, 1234); + }); + + it('will tick any graphics that need reseting in the group', () => { + const animation = new ex.Graphics.Animation({ + frames: [] + }); + spyOn(animation, 'reset').and.callFake(() => { + // fake + }); + + const group = new ex.Graphics.GraphicsGroup({ + members: [{ pos: ex.vec(0, 0), graphic: animation }] + }); + + group.reset(); + expect(animation.reset).toHaveBeenCalled(); + }); +}); diff --git a/src/spec/GraphicsImageSourceSpec.ts b/src/spec/GraphicsImageSourceSpec.ts new file mode 100644 index 00000000..1c45ebf1 --- /dev/null +++ b/src/spec/GraphicsImageSourceSpec.ts @@ -0,0 +1,82 @@ +import * as ex from '@excalibur'; + +describe('A ImageSource', () => { + it('exists', () => { + expect(ex.Graphics.ImageSource).toBeDefined(); + }); + + it('can be constructed', () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + expect(spriteFontImage).toBeDefined(); + }); + + it('can load images', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const whenLoaded = jasmine.createSpy('whenLoaded'); + const image = await spriteFontImage.load(); + await spriteFontImage.ready.then(whenLoaded); + + expect(image.src).not.toBeNull(); + expect(whenLoaded).toHaveBeenCalledTimes(1); + }); + + it('can convert to a Sprite', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sprite = spriteFontImage.toSprite(); + + // Sprites have no width/height until the underlying image is loaded + expect(sprite.width).toBe(0); + expect(sprite.height).toBe(0); + + const image = await spriteFontImage.load(); + await spriteFontImage.ready; + expect(sprite.width).toBe(image.width); + expect(sprite.height).toBe(image.height); + }); + + it('can unload from memory', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + await spriteFontImage.load(); + expect(spriteFontImage.image.src).not.toBeNull(); + spriteFontImage.unload(); + expect(spriteFontImage.image.src).toBe(''); + }); + + it('can load from a legacy texture', async () => { + const tex = new ex.Texture('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + await tex.load(); + const img = ex.Graphics.ImageSource.fromLegacyTexture(tex); + expect(img.width).not.toBe(0); + expect(img.height).not.toBe(0); + }); + + it('can load from an unloaded legacy texture', async () => { + const tex = new ex.Texture('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const img = ex.Graphics.ImageSource.fromLegacyTexture(tex); + await tex.load(); + expect(img.width).not.toBe(0); + expect(img.height).not.toBe(0); + }); + + it('will resolve the image if alreadly loaded', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const image = await spriteFontImage.load(); + + expect(spriteFontImage.isLoaded()).toBe(true); + const alreadyLoadedImage = await spriteFontImage.load(); + + expect(image).toBe(alreadyLoadedImage); + }); + + it('will load base64 strings', async () => { + const base64Image = new ex.Graphics.ImageSource( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==' + ); + await base64Image.load(); + + expect(base64Image.isLoaded()).toBe(true); + expect(base64Image.image.src).toBe( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==' + ); + }); +}); diff --git a/src/spec/GraphicsPolygonSpec.ts b/src/spec/GraphicsPolygonSpec.ts new file mode 100644 index 00000000..90b627ab --- /dev/null +++ b/src/spec/GraphicsPolygonSpec.ts @@ -0,0 +1,54 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Polygon Graphic', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + }); + + it('exists', () => { + expect(ex.Graphics.Polygon).toBeDefined(); + }); + + it('can be constructed', () => { + const poly = new ex.Graphics.Polygon({ + points: [ex.vec(10 * 5, 0), ex.vec(0, 20 * 5), ex.vec(20 * 5, 20 * 5)], + color: ex.Color.Green + }); + expect(poly).toBeDefined(); + }); + + it('can be cloned', () => { + const poly = new ex.Graphics.Polygon({ + points: [ex.vec(10 * 5, 0), ex.vec(0, 20 * 5), ex.vec(20 * 5, 20 * 5)], + color: ex.Color.Green, + strokeColor: ex.Color.Violet + }); + + const clone = poly.clone(); + + expect(clone.points).toEqual(poly.points); + expect(clone.color).toEqual(poly.color); + expect(clone.strokeColor).toEqual(poly.strokeColor); + }); + + it('can be drawn', async () => { + const poly = new ex.Graphics.Polygon({ + points: [ex.vec(10 * 5, 0), ex.vec(0, 20 * 5), ex.vec(20 * 5, 20 * 5)], + color: ex.Color.Green, + strokeColor: ex.Color.Violet + }); + + poly.draw(ctx, 0, 0); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsPolygonSpec/poly.png'); + }); +}); diff --git a/src/spec/GraphicsRasterSpec.ts b/src/spec/GraphicsRasterSpec.ts new file mode 100644 index 00000000..52a6bbf4 --- /dev/null +++ b/src/spec/GraphicsRasterSpec.ts @@ -0,0 +1,107 @@ +import * as ex from '@excalibur'; +import { Color } from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; +import { RasterOptions } from '../engine/Graphics'; + +class TestRaster extends ex.Graphics.Raster { + constructor(options?: RasterOptions) { + super(options); + this.width = 50; + this.height = 50; + } + execute(ctx: CanvasRenderingContext2D): void { + ctx.fillStyle = 'green'; + ctx.fillRect(0, 0, 50, 50); + ctx.strokeStyle = 'red'; + ctx.strokeRect(0, 0, 50, 50); + } + + clone(): ex.Graphics.Graphic { + return null; + } +} + +describe('A Raster', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + }); + + it('exists', () => { + expect(ex.Graphics.Raster).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new TestRaster(); + expect(sut).toBeDefined(); + }); + + it('creates a backing bitmap', () => { + const sut = new TestRaster(); + expect(sut._bitmap.width).toBe(50); + expect(sut._bitmap.height).toBe(50); + expect(sut.width).toBe(50); + expect(sut.height).toBe(50); + }); + + it('flags dirty when color is changed', () => { + const sut = new TestRaster(); + sut.draw(ctx, 0, 0); + + expect(sut.dirty).toBeFalse(); + sut.color = new ex.Color(125, 0, 0); + sut.color.b = 125; + + expect(sut.color.r).toBe(125); + expect(sut.color.b).toBe(125); + expect(sut.dirty).toBeTrue(); + + sut.draw(ctx, 0, 0); + + expect(sut.dirty).toBeFalse(); + + sut.strokeColor = new ex.Color(1, 1, 1); + sut.strokeColor.r = 12; + + expect(sut.dirty).toBeTrue(); + }); + + it('can clone RasterOptions', () => { + const originalRasterOptions = { + color: new Color(1, 2, 3, 0.5), + strokeColor: new Color(5, 6, 7, 0.8), + smoothing: false, + lineWidth: 2, + lineDash: [2, 2], + padding: 2 + }; + const sut = new TestRaster(originalRasterOptions); + expect(sut.cloneRasterOptions()).toEqual(originalRasterOptions); + }); + + it('can be drawn', async () => { + const sut = new TestRaster(); + spyOn(sut, 'rasterize').and.callThrough(); + spyOn(sut, 'execute').and.callThrough(); + sut.draw(ctx, 0, 0); + expect(sut.execute).toHaveBeenCalledTimes(1); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsRasterSpec/raster.png'); + }); + + it('knows when it must be re-rastered', () => { + const sut = new TestRaster(); + expect(sut.dirty).toBe(true); + sut.draw(ctx, 0, 0); + expect(sut.dirty).toBe(false); + sut.smoothing = false; + expect(sut.dirty).toBe(true); + }); +}); diff --git a/src/spec/GraphicsRectangleSpec.ts b/src/spec/GraphicsRectangleSpec.ts new file mode 100644 index 00000000..c134b763 --- /dev/null +++ b/src/spec/GraphicsRectangleSpec.ts @@ -0,0 +1,63 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Rectangle Graphic', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + }); + + it('exists', () => { + expect(ex.Graphics.Rectangle).toBeDefined(); + }); + + it('can be constructed', () => { + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50 + }); + expect(rect).toBeDefined(); + }); + + it('can be drawn', async () => { + const rect = new ex.Graphics.Rectangle({ + width: 50, + height: 50, + color: ex.Color.Green + }); + + rect.draw(ctx, 25, 25); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsRectSpec/rect.png'); + }); + + it('can be cloned', async () => { + const rect = new ex.Graphics.Rectangle({ + width: 75, + height: 50, + lineWidth: 10, + lineDash: [5, 5], + color: ex.Color.Green, + strokeColor: ex.Color.Violet + }); + + const rect2 = rect.clone(); + expect(rect2.width).toBe(75); + expect(rect2.height).toBe(50); + expect(rect2.lineDash).toEqual([5, 5]); + expect(rect2.lineWidth).toBe(10); + expect(rect2.color).toEqual(ex.Color.Green); + expect(rect2.strokeColor).toEqual(ex.Color.Violet); + + rect2.draw(ctx, 15, 25); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsRectSpec/clone-rect.png'); + }); +}); diff --git a/src/spec/GraphicsSpriteSheetSpec.ts b/src/spec/GraphicsSpriteSheetSpec.ts new file mode 100644 index 00000000..16db568a --- /dev/null +++ b/src/spec/GraphicsSpriteSheetSpec.ts @@ -0,0 +1,103 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A SpriteSheet for Graphics', () => { + + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 120; + canvasElement.height = 120; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement, smoothing: false }); + }); + + it('exists', () => { + expect(ex.Graphics.SpriteSheet).toBeDefined(); + }); + + it('can be created with a constructor', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + await image.load(); + const ss = new ex.Graphics.SpriteSheet({ + sprites: [ex.Graphics.Sprite.from(image)] + }); + + expect(ss.sprites.length).toBe(1); + expect(ss.sprites[0].width).toBe(258); + expect(ss.sprites[0].height).toBe(53); + }); + + it('can be created from a grid', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + await image.load(); + const ss = ex.Graphics.SpriteSheet.fromGrid({ + image, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + expect(ss.sprites.length).toBe(3 * 16); + expect(ss.sprites[0].width).toBe(16); + expect(ss.sprites[0].height).toBe(16); + }); + + it('can be created from a grid with interesting spacing', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/SpriteSheetSpec/kenny-cards.png'); + + await image.load(); + + const ss = ex.Graphics.SpriteSheet.fromGrid({ + image, + grid: { + rows: 4, + columns: 14, + spriteWidth: 42, + spriteHeight: 60 + }, + spacing: { + originOffset: { x: 11, y: 2 }, + margin: { x: 23, y: 5} + } + }); + + ctx.clear(); + + ss.sprites[12].draw(ctx, 0, 0); + ss.sprites[24].draw(ctx, 60, 0); + ss.sprites[36].draw(ctx, 0, 60); + ss.sprites[48].draw(ctx, 60, 60); + + expect(ss.sprites.length).toBe(4 * 14); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/SpriteSheetSpec/NewSpriteSpacing.png'); + }); + + it('can be created from a legacy sprite sheet', async () => { + const texture = new ex.Texture('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + await texture.load(); + + const legacy = new ex.SpriteSheet({ + image: texture, + rows: 3, + columns: 16, + spWidth: 16, + spHeight: 16 + }); + + const ss = ex.Graphics.SpriteSheet.fromLegacySpriteSheet(legacy); + + expect(ss.sprites.length).toBe(3 * 16); + expect(ss.sprites[0].width).toBe(16); + expect(ss.sprites[0].height).toBe(16); + }); +}); diff --git a/src/spec/GraphicsSpriteSpec.ts b/src/spec/GraphicsSpriteSpec.ts new file mode 100644 index 00000000..ad398b4c --- /dev/null +++ b/src/spec/GraphicsSpriteSpec.ts @@ -0,0 +1,152 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A Sprite Graphic', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.Graphics.ExcaliburGraphicsContext; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement, smoothing: false }); + }); + + it('exists', () => { + expect(ex.Graphics.Sprite).toBeDefined(); + }); + + it('can be constructed', () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sut = new ex.Graphics.Sprite({ + image + }); + expect(sut).toBeDefined(); + }); + + it('can be cloned', () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sut = new ex.Graphics.Sprite({ + image, + sourceView: { + x: 0, + y: 0, + width: 16, + height: 16 + }, + destSize: { + width: 100, + height: 100 + } + }); + + const clone = sut.clone(); + + expect(clone.sourceView).toEqual(sut.sourceView); + expect(clone.destSize).toEqual(sut.destSize); + expect(clone.image).toEqual(sut.image); + }); + + it('can specify a source view of an image by default is same dimension as the source', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sut = new ex.Graphics.Sprite({ + image, + sourceView: { + x: 0, + y: 0, + width: 16, + height: 16 + } + }); + + expect(sut.width).toBe(16, 'Graphic width should be 16'); + expect(sut.height).toBe(16, 'Graphic height should be 16'); + expect(sut.sourceView.x).toBe(0); + expect(sut.sourceView.y).toBe(0); + expect(sut.sourceView.width).toBe(16, 'Graphic sourceView width should be 16'); + expect(sut.sourceView.height).toBe(16, 'Graphic sourceView height should be 16'); + expect(sut.destSize.width).toBe(16, 'Graphic destSize width should be 16'); + expect(sut.destSize.height).toBe(16, 'Graphic destSize height should be 16'); + expect(sut.localBounds.width).toBe(16, 'Graphic local bounds width should be 16'); + expect(sut.localBounds.height).toBe(16, 'Graphic local bounds height should be 16'); + + await image.load(); + await image.ready; + + ctx.clear(); + sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/source-view.png'); + }); + + it('can specify a source view of an image and a dest view dimension is destination', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sut = new ex.Graphics.Sprite({ + image, + sourceView: { + x: 0, + y: 0, + width: 16, + height: 16 + }, + destSize: { + width: 50, + height: 50 + } + }); + + expect(sut.width).toBe(50, 'Graphic width should be 50'); + expect(sut.height).toBe(50, 'Graphic height should be 50'); + expect(sut.sourceView.x).toBe(0); + expect(sut.sourceView.y).toBe(0); + expect(sut.sourceView.width).toBe(16, 'Graphic sourceView width should be 50'); + expect(sut.sourceView.height).toBe(16, 'Graphic sourceView height should be 50'); + expect(sut.destSize.width).toBe(50, 'Graphic destSize width should be 50'); + expect(sut.destSize.height).toBe(50, 'Graphic destSize height should be 50'); + expect(sut.localBounds.width).toBe(50, 'Graphic local bounds width should be 50'); + expect(sut.localBounds.height).toBe(50, 'Graphic local bounds height should be 50'); + + await image.load(); + await image.ready; + + ctx.clear(); + sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-size.png'); + }); + + it('can specify only a dest view dimension, infers native size for source view', async () => { + const image = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + const sut = new ex.Graphics.Sprite({ + image, + destSize: { + width: 100, + height: 100 + } + }); + + expect(sut.width).toBe(100, 'Graphic width should be 100'); + expect(sut.height).toBe(100, 'Graphic height should be 100'); + expect(sut.sourceView.x).toBe(0); + expect(sut.sourceView.y).toBe(0); + expect(sut.sourceView.width).toBe(0, 'Graphic sourceView width should be 0 before image load'); + expect(sut.sourceView.height).toBe(0, 'Graphic sourceView height should be 0 before image load'); + expect(sut.destSize.width).toBe(100, 'Graphic destSize width should be 100'); + expect(sut.destSize.height).toBe(100, 'Graphic destSize height should be 100'); + expect(sut.localBounds.width).toBe(100, 'Graphic local bounds width should be 100'); + expect(sut.localBounds.height).toBe(100, 'Graphic local bounds height should be 100'); + + await image.load(); + await image.ready; + + expect(sut.sourceView.width).not.toBe(0); + expect(sut.sourceView.height).not.toBe(0); + + ctx.clear(); + sut.draw(ctx, 50 - sut.width / 2, 50 - sut.width / 2); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsSpriteSpec/dest-view.png'); + }); +}); diff --git a/src/spec/GraphicsSystemSpec.ts b/src/spec/GraphicsSystemSpec.ts new file mode 100644 index 00000000..dcfbee09 --- /dev/null +++ b/src/spec/GraphicsSystemSpec.ts @@ -0,0 +1,90 @@ +import * as ex from '@excalibur'; +import { TransformComponent } from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; +import { GraphicsComponent } from '../engine/Graphics'; +import { TestUtils } from './util/TestUtils'; + +describe('A Graphics ECS System', () => { + let entities: ex.Entity[]; + let engine: ex.Engine; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + + engine = TestUtils.engine({ width: 100, height: 100 }); + entities = [ + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.Graphics.GraphicsComponent()), + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.Graphics.GraphicsComponent()), + new ex.Entity().addComponent(new ex.TransformComponent()).addComponent(new ex.Graphics.GraphicsComponent()) + ]; + entities[0].components.transform.z = 10; + entities[1].components.transform.z = 5; + entities[2].components.transform.z = 1; + }); + + it('exists', () => { + expect(ex.Graphics.GraphicsSystem).toBeDefined(); + }); + + it('sorts entities by transform.z', () => { + const sut = new ex.Graphics.GraphicsSystem(); + sut.initialize(engine.currentScene); + const es = [...entities]; + es.sort(sut.sort); + expect(es).toEqual(entities.reverse()); + }); + + it('draws entities with transform and graphics components', async () => { + const sut = new ex.Graphics.GraphicsSystem(); + engine.currentScene.camera.update(engine, 1); + sut.initialize(engine.currentScene); + + const rect = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Yellow + }); + + const circle = new ex.Graphics.Circle({ + radius: 13, + color: ex.Color.Green + }); + + const rect2 = new ex.Graphics.Rectangle({ + width: 25, + height: 25, + color: ex.Color.Red + }); + + entities[0].components.transform.pos = ex.vec(25, 25); + entities[0].components.transform.rotation = Math.PI / 4; + entities[0].components.graphics.show(rect); + + entities[1].components.transform.pos = ex.vec(75, 75); + entities[1].components.graphics.show(circle); + + entities[2].components.transform.pos = ex.vec(75, 25); + entities[2].components.transform.scale = ex.vec(2, 2); + entities[2].components.graphics.show(rect2); + + const offscreenRect = rect.clone(); + const offscreen = new ex.Entity().addComponent(new TransformComponent()).addComponent(new GraphicsComponent()); + offscreen.components.transform.pos = ex.vec(112.5, 112.5); + offscreen.components.graphics.show(offscreenRect); + + spyOn(rect, 'draw').and.callThrough(); + spyOn(circle, 'draw').and.callThrough(); + spyOn(rect2, 'draw').and.callThrough(); + spyOn(offscreenRect, 'draw').and.callThrough(); + + entities.push(offscreen); + + sut.update(entities, 1); + + expect(rect.draw).toHaveBeenCalled(); + expect(circle.draw).toHaveBeenCalled(); + expect(rect2.draw).toHaveBeenCalled(); + expect(offscreenRect.draw).not.toHaveBeenCalled(); + await expectAsync(engine.canvas).toEqualImage('src/spec/images/GraphicsSystemSpec/graphics-system.png'); + }); +}); diff --git a/src/spec/GraphicsTextSpec.ts b/src/spec/GraphicsTextSpec.ts new file mode 100644 index 00000000..c179d3c1 --- /dev/null +++ b/src/spec/GraphicsTextSpec.ts @@ -0,0 +1,538 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +/** + * + */ +async function runOnWindows(ctx: () => Promise): Promise { + if (navigator.platform === 'Win32' || navigator.platform === 'Win64') { + await ctx(); + return true; + } + return false; +} + +/** + * + */ +async function runOnLinux(ctx: () => Promise): Promise { + if (navigator.platform.includes('Linux')) { + await ctx(); + return true; + } + return false; +} +declare global { + const FontFace: FontFace; + + interface Document { + fonts: FontFaceSet; + } + + type CSSOMString = string; + type FontFaceLoadStatus = 'unloaded' | 'loading' | 'loaded' | 'error'; + type FontFaceSetStatus = 'loading' | 'loaded'; + + interface FontFace extends FontFaceDescriptors { + new (family: string, source: string | ArrayBuffer, descriptors?: FontFaceDescriptors): FontFace; + readonly status: FontFaceLoadStatus; + readonly loaded: Promise; + variationSettings: CSSOMString; + display: CSSOMString; + load(): Promise; + } + + interface FontFaceDescriptors { + family: CSSOMString; + style: CSSOMString; + weight: CSSOMString; + stretch: CSSOMString; + unicodeRange: CSSOMString; + variant: CSSOMString; + featureSettings: CSSOMString; + } + + interface FontFaceSet extends Iterable { + readonly status: FontFaceSetStatus; + readonly ready: Promise; + add(font: FontFace): void; + check(font: string, text?: string): Boolean; // throws exception + load(font: string, text?: string): Promise; + delete(font: FontFace): void; + clear(): void; + } +} + +/** + * + */ +export async function waitForFontLoad(font: string, timeout = 2000, interval = 100) { + return new Promise((resolve, reject) => { + // repeatedly poll check + const poller = setInterval(async () => { + try { + await document.fonts.load(font); + } catch (err) { + reject(err); + } + if (document.fonts.check(font)) { + clearInterval(poller); + resolve(true); + } + }, interval); + setTimeout(() => clearInterval(poller), timeout); + }); +} + +describe('A Text Graphic', () => { + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + beforeAll(async () => { + const fontface = document.createElement('link'); + fontface.href = 'base/src/spec/images/GraphicsTextSpec/fonts.css'; + fontface.rel = 'stylesheet'; + document.head.appendChild(fontface); + await waitForFontLoad('18px Open Sans'); + await waitForFontLoad('bold 18px Open Sans'); + await waitForFontLoad('italic bold 18px Open Sans'); + await waitForFontLoad('italic 18px Open Sans'); + }); + + it('exists', () => { + expect(ex.Graphics.Text).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.Graphics.Text({ + text: 'yo' + }); + expect(sut).toBeDefined(); + }); + + it('can be cloned', () => { + const sut = new ex.Graphics.Text({ + text: 'some text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ family: 'some-font-family' }) + }); + + const clone = sut.clone(); + + expect(clone.text).toBe('some text'); + expect(clone.color).toEqual(ex.Color.Green); + expect((clone.font as ex.Graphics.Font).family).toBe('some-font-family'); + }); + + it('can write text', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + ctx.clear(); + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/text.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/text-linux.png'); + }); + }); + + it('can have width and height', () => { + const sut = new ex.Graphics.Text({ + text: 'some extra long text that we want to measure', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + expect(sut.width).toBeCloseTo(386.9, -1); + expect(sut.height).toBeCloseTo(18, 0); + expect(sut.localBounds.width).toBeCloseTo(386.9, -1); + expect(sut.localBounds.height).toBeCloseTo(18, 0); + }); + + it('can flip text vertically and horizontally', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.flipHorizontal = true; + sut.flipVertical = true; + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/flipped.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/flipped-linux.png'); + }); + }); + + it('can rotate text around the middle', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.rotation = Math.PI / 2; + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated-linux.png'); + }); + }); + + it('can rotate text around the left', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.origin = ex.Vector.Zero; + sut.rotation = Math.PI / 2; + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated-left.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated-left-linux.png'); + }); + }); + + it('can rotate text around the right', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.origin = ex.vec(sut.width, 0); + sut.rotation = -Math.PI / 2; + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated-right.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/rotated-right-linux.png'); + }); + }); + + it('can be bold', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + bold: true, + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/bold.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/bold-linux.png'); + }); + }); + + it('can be italic', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + style: ex.Graphics.FontStyle.Italic, + size: 18, + quality: 1 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/italic.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/italic-linux.png'); + }); + }); + + it('can have a shadow', async () => { + const sut = new ex.Graphics.Text({ + text: 'green text', + color: ex.Color.Green, + font: new ex.Graphics.Font({ + family: 'Open Sans', + size: 18, + quality: 1, + shadow: { + blur: 5, + offset: ex.vec(4, 4), + color: ex.Color.Blue + } + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 10, 50); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/shadow.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/shadow-linux.png'); + }); + }); + + describe('with a SpriteFont', () => { + it('can be cloned', () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + const spriteFontSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: spriteFontImage, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + const spriteFont = new ex.Graphics.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spacing: -5, + spriteSheet: spriteFontSheet + }); + + const sut = new ex.Graphics.Text({ + text: 'Some Sprite Text!?', + font: spriteFont + }); + + const clone = sut.clone(); + + expect(sut.font).not.toBe(clone.font); + }); + + it('can specify a spritefont', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + await spriteFontImage.load(); + + const spriteFontSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: spriteFontImage, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + const spriteFont = new ex.Graphics.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spacing: -5, + spriteSheet: spriteFontSheet + }); + + const sut = new ex.Graphics.Text({ + text: 'Some Sprite Text!?', + font: spriteFont + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 200; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 0, 50); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/spritefont-text.png'); + }); + }); + + it('will log warnings when there are issues', async () => { + const logger = ex.Logger.getInstance(); + spyOn(logger, 'warn'); + + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + await spriteFontImage.load(); + + const spriteFontSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: spriteFontImage, + grid: { + rows: 1, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + const spriteFont = new ex.Graphics.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spacing: -5, + spriteSheet: spriteFontSheet + }); + + const sut = new ex.Graphics.Text({ + text: 'a', + font: spriteFont + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 200; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + sut.text = '~'; + sut.draw(ctx, 0, 0); + expect(logger.warn).toHaveBeenCalledWith( + 'SpriteFont - Cannot find letter \'~\' in configured alphabet \'0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- \'' + ); + + sut.text = '?'; + sut.draw(ctx, 0, 0); + expect(logger.warn).toHaveBeenCalledWith('SpriteFont - Cannot find sprite for \'?\' at index \'42\' in configured SpriteSheet'); + }); + + it('can do some simple shadowing', async () => { + const spriteFontImage = new ex.Graphics.ImageSource('base/src/spec/images/GraphicsTextSpec/spritefont.png'); + + await spriteFontImage.load(); + + const spriteFontSheet = ex.Graphics.SpriteSheet.fromGrid({ + image: spriteFontImage, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + const spriteFont = new ex.Graphics.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spacing: -5, + spriteSheet: spriteFontSheet, + shadow: { + offset: ex.vec(3, 3) + } + }); + + const sut = new ex.Graphics.Text({ + text: 'Some Sprite Text!?', + font: spriteFont + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 200; + canvasElement.height = 100; + const ctx = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 0, 50); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/spritefont-shadow.png'); + }); +}); diff --git a/src/spec/LabelSpec.ts b/src/spec/LabelSpec.ts index 41f63bb0..119229af 100644 --- a/src/spec/LabelSpec.ts +++ b/src/spec/LabelSpec.ts @@ -37,8 +37,7 @@ xdescribe('A label', () => { fontSize: 12, fontStyle: ex.FontStyle.Normal, fontUnit: ex.FontUnit.Px, - textAlign: ex.TextAlign.Left, - maxWidth: 200 + textAlign: ex.TextAlign.Left }); expect(label.text).toBe('test text'); diff --git a/src/spec/ParticleSpec.ts b/src/spec/ParticleSpec.ts index 9f59bd86..e384d5ff 100644 --- a/src/spec/ParticleSpec.ts +++ b/src/spec/ParticleSpec.ts @@ -1,4 +1,4 @@ -import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; +import { ExcaliburMatchers, ExcaliburAsyncMatchers } from 'excalibur-jasmine'; import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; @@ -11,15 +11,32 @@ const drawWithTransform = (ctx: CanvasRenderingContext2D, actor: ex.Actor, delta ctx.restore(); }; +/** + * + */ +function flushWebGLCanvasTo2D(source: HTMLCanvasElement): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = source.width; + canvas.height = source.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(source, 0, 0); + return canvas; +} + describe('A particle', () => { let engine: ex.Engine; let texture: ex.Texture; beforeEach(() => { jasmine.addMatchers(ExcaliburMatchers); - engine = TestUtils.engine({ - width: 800, - height: 200 - }); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + engine = TestUtils.engine( + { + width: 800, + height: 200, + backgroundColor: ex.Color.Transparent + }, + ['use-webgl'] + ); texture = new ex.Texture('base/src/spec/images/SpriteFontSpec/SpriteFont.png', true); }); @@ -89,7 +106,7 @@ describe('A particle', () => { expect(emitter.random.seed).toBe(1337); }); - it('should emit particles', (done) => { + it('should emit particles', async () => { const emitter = new ex.ParticleEmitter({ pos: new ex.Vector(400, 100), width: 20, @@ -117,17 +134,15 @@ describe('A particle', () => { randomRotation: false, random: new ex.Random(1337) }); - + engine.backgroundColor = ex.Color.Transparent; + engine.add(emitter); emitter.emitParticles(10); - emitter.update(engine, 100); - emitter.update(engine, 100); - emitter.update(engine, 100); - drawWithTransform(engine.ctx, emitter, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.update(engine, 100); + engine.currentScene.draw(engine.ctx, 100); - ensureImagesLoaded(engine.canvas, 'src/spec/images/ParticleSpec/Particles.png').then(([canvas, image]) => { - expect(canvas).toEqualImage(image); - done(); - }); + await expectAsync(flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('src/spec/images/ParticleSpec/Particles.png'); }); }); diff --git a/src/spec/PoolSpec.ts b/src/spec/PoolSpec.ts new file mode 100644 index 00000000..9b7daa4e --- /dev/null +++ b/src/spec/PoolSpec.ts @@ -0,0 +1,93 @@ +import * as ex from '@excalibur'; + +class MockPoolable implements ex.Poolable { + _pool = null; + constructor(public recycled: boolean = false) {} + dispose() { + this.recycled = true; + return this; + } +} + +describe('An object Pool', () => { + + it('should exist', () => { + expect(ex.Pool).toBeDefined(); + }); + + it('can be constructed', () => { + const pool = new ex.Pool(() => new MockPoolable(), (m) => m.dispose(), 10); + expect(pool).toBeDefined(); + }); + + it('can get instances up to the maximum', () => { + const pool = new ex.Pool(() => new MockPoolable(), (m) => m.dispose(), 10); + for (let i = 0; i < 10; i++) { + const instance = pool.get(); + } + expect(pool.totalAllocations).toBe(10); + expect(() => { + pool.get(); + }).toThrow(); + }); + + it('can get instances and return them to get recycled', () => { + const recycler = jasmine.createSpy().and.callFake((m) => m.dispose()); + const pool = new ex.Pool(() => new MockPoolable(), recycler, 10); + const intances: MockPoolable[] = []; + for (let i = 0; i < 10; i++) { + const instance = pool.get(); + intances.push(instance); + } + expect(pool.index).toBe(10); + pool.done(); + + expect(pool.index).toBe(0); + for (let i = 0; i < 10; i++) { + const instance = pool.get(); + expect(instance.recycled).toBeTrue(); + } + + expect(recycler).toHaveBeenCalledTimes(10); + }); + + it('can borrow a single instance temporarily', () => { + const pool = new ex.Pool(() => new MockPoolable(), (m) => m.dispose(), 10); + pool.get(); + pool.borrow(instance => { + expect(instance).toBeDefined(); + expect(pool.index).toBe(2); + }); + expect(pool.index).toBe(1); + }); + + it('can be used in a using which are then reclaimed', () => { + const pool = new ex.Pool(() => new MockPoolable(), (m) => m.dispose(), 10); + for (let i = 0; i < 10; i++) { + pool.using(pool => { + for (let i = 0; i < 10; i++) { + pool.get(); + } + }); + } + expect(pool.index).toBe(0); + expect(pool.totalAllocations).toBe(10); + }); + + it('can have instances unhooked from the pool', () => { + const pool = new ex.Pool(() => new MockPoolable(), (m) => m.dispose(), 10); + const is = pool.using(pool => { + const instances = []; + for (let i = 0; i < 10; i++) { + const i = pool.get(); + expect(i._pool).toBe(pool); + instances.push(i); + } + return instances; + }); + + for (let i = 0; i < 10; i++) { + expect(is[i]._pool).toBe(undefined); + } + }); +}); \ No newline at end of file diff --git a/src/spec/ScreenSpec.ts b/src/spec/ScreenSpec.ts index 97417aff..f9c8292c 100644 --- a/src/spec/ScreenSpec.ts +++ b/src/spec/ScreenSpec.ts @@ -3,7 +3,7 @@ import { ExcaliburMatchers } from 'excalibur-jasmine'; import { Camera } from '@excalibur'; describe('A Screen', () => { let canvas: HTMLCanvasElement; - let context: CanvasRenderingContext2D; + let context: ex.Graphics.ExcaliburGraphicsContext; let browser: ex.BrowserEvents; beforeEach(() => { jasmine.addMatchers(ExcaliburMatchers); @@ -13,7 +13,9 @@ describe('A Screen', () => { }); document.body.style.margin = '0'; canvas = document.createElement('canvas'); - context = canvas.getContext('2d'); + context = new ex.Graphics.ExcaliburGraphicsContext2DCanvas({ + canvasElement: canvas + }); document.body.appendChild(canvas); browser = new ex.BrowserEvents(window, document); }); @@ -266,7 +268,7 @@ describe('A Screen', () => { sut.applyResolutionAndViewport(); - expect(context.imageSmoothingEnabled).toBeFalse(); + expect(context.smoothing).toBeFalse(); expect(canvas.style.imageRendering).toBe('pixelated'); }); @@ -303,7 +305,7 @@ describe('A Screen', () => { sut.applyResolutionAndViewport(); - expect(context.imageSmoothingEnabled).toBeFalse(); + expect(context.smoothing).toBeFalse(); expect(canvasStub.style.imageRendering).toBe('crisp-edges'); }); @@ -319,7 +321,7 @@ describe('A Screen', () => { sut.applyResolutionAndViewport(); - expect(context.imageSmoothingEnabled).toBeTrue(); + expect(context.smoothing).toBeTrue(); expect(canvas.style.imageRendering).toBe('auto'); }); diff --git a/src/spec/TileMapSpec.ts b/src/spec/TileMapSpec.ts index 700948e3..7b1b2f5c 100644 --- a/src/spec/TileMapSpec.ts +++ b/src/spec/TileMapSpec.ts @@ -2,7 +2,7 @@ import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; -const drawWithTransform = (ctx: CanvasRenderingContext2D, tm: ex.TileMap, delta: number = 1) => { +const drawWithTransform = (ctx: CanvasRenderingContext2D | ex.Graphics.ExcaliburGraphicsContext, tm: ex.TileMap, delta: number = 1) => { ctx.save(); ctx.translate(tm.pos.x, tm.pos.y); ctx.rotate(tm.rotation); @@ -61,13 +61,13 @@ describe('A TileMap', () => { cols: 7 }); const spriteTiles = new ex.SpriteSheet(texture, 1, 1, 64, 48); - tm.registerSpriteSheet('default', spriteTiles); tm.data.forEach(function (cell: ex.Cell) { cell.solid = true; - cell.pushSprite(spriteTiles.sprites[0]); + cell.addSprite(spriteTiles.sprites[0]); }); + tm._initialize(engine); - drawWithTransform(engine.ctx, tm, 100); + drawWithTransform(engine.graphicsContext, tm, 100); ensureImagesLoaded(engine.canvas, 'src/spec/images/TileMapSpec/TileMap.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); @@ -87,14 +87,15 @@ describe('A TileMap', () => { cols: 20 }); const spriteTiles = new ex.SpriteSheet(texture, 1, 1, 64, 48); - tm.registerSpriteSheet('default', spriteTiles); tm.data.forEach(function (cell: ex.Cell) { cell.solid = true; - cell.pushSprite(spriteTiles.sprites[0]); + cell.addSprite(spriteTiles.sprites[0]); }); + tm._initialize(engine); tm.update(engine, 100); - drawWithTransform(engine.ctx, tm, 100); + + drawWithTransform(engine.graphicsContext, tm, 100); ensureImagesLoaded(engine.canvas, 'src/spec/images/TileMapSpec/TileMapCulling.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/2d-drawgraphic.png b/src/spec/images/ExcaliburGraphicsContextSpec/2d-drawgraphic.png new file mode 100644 index 0000000000000000000000000000000000000000..fd1ddc2c942d82d2a5985478c151abacad71b42f GIT binary patch literal 583 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vC_&i-4Ln`9l&T;f|2^2W; z=ezwEd4cVDL7_K1?ks=d`Rt)v&Ason`~O%bKQKHoQ|x}b#FH@oW|`NC0?jeT1#jf) z+a;dNXiR?o!-=Qp%z=%?HHvJOX%B8#?h|O1F)nx`6W`(3CuvdEr+-A@IFF3=aq*-H z$!vXdgXgERFNy-yC=NYkw-!%s64fnfSS89B;Fy9}Y7xFfe$!`njxgN@xNA7N+)S literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/2d-drawline.png b/src/spec/images/ExcaliburGraphicsContextSpec/2d-drawline.png new file mode 100644 index 0000000000000000000000000000000000000000..d0958da4cd7774bcde8476fe707c3c6f4720c8d9 GIT binary patch literal 667 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCl001;Ln`9l-nH&KWFX@9 zaCWHA%t@&Mo6@|dIkKy+KB3Vhs4;oMbou@3`DKNK!uHL{=5jYli!Ha95v!MGZ+}et zvuMVxZLi;z8wVJ9@7cF+=iOcJDomJNS(dz>*V>@Oa!H?6#84oB#nItp|D`Y0CkiBc z*4G#;R+ys1!Q>@3<)qWUX@`G!aT{|xX*MY=SmGQR!tvN!q$TLl;Wb(fG#4pMG2)%P zRKQD+b>Wo%29ewgMFIu9Di3>zfaQ|ZLOGUpG+l}5N>QA@bdF!z^=#Yo7rmRm81x_i zJn?N*z!S$TXHBLhYEzONvK%$Jmat8E{+mb>0`qqz9l6h(-l+~?_kvwJ!s+==nY=^rN>nNFOkn;^wHfmOQ1OT;A;ir(K~}*qfZ-;mkQD0#1_i}H22K_sF4hSR3W@;?oIIPv zStoEPR0lA0vTPD%ozS5W9l+4Zvq_9~0*gX+0D~vXCK1*NEehTN44ynj?MxnQ6Y@10 qQkZ5sF{&t^kl<37>DZ>RUu)rYwy9fhYB4Y{FnGH9xvXrrTj3@b6Q^mD5Od0< zsIpLd@zd)Umss{~uG@SrYI)@DbC>^IORcxBe*5=!bzSw90|yQ;+&rXVpw-xt_|bYI zi%_>a6X!AIMwJN}Tr8*6uTNkR@@Y(XnbN8Fx-%z^^${fA~6e|YL`S$V9t`JCU4-R5R9COEVSAGt4~ov>?n;;k)36Li-c zX8ie2w(PCeszaM{Z?mcUADUq}E%4QMS*{bmzi+5Ay|Xj8-rYp(?c0K{T0V1EZQQnz zlljSU{)lZn@9u4VnPIx#d%9SDuC8uxZ?tZdgu zi`V`8BDzq>)@kYvrkh(h&&};-Ts*1uJJWlvlz%l9pH##qRDOQ&F?s)-I}IMo#dSWs z{;^-lzKX>t!e*BAgv0F*A2{~wUGJ-WAs}NB@Y}Lm{Z7N0NapZ$5hW}4oG*R-;L*`f zbtZCm8gwF=zQ4;1+O|D%pG@ApE&awJEanQEBx^n%T7G%!qAI;Nc@9tJ-mfl?<2|#) z^TWqv-5j&}a%Q7SqnckSmsf6zb61(rQ9Xg{grH^W+6cz&c~fOin|)I*3XI<`rx&|P zaLbzOf3`eHxfzdCzv?mw4P|qZQ26w;Q)c;{ zeRppd?kZA^+0VDw-P|m1Yer&&M^pa&LyKHLyb4V|GSiS*&W^?MZto>0M!`U-QrX1M zX)oTMea-RTR^Vy%{khid|COy~&U&)=oo@6$>#1#a66>7UzQ^smX;sE^ZoakSXlS@~;@Y*`vb?)ZD*;Wc?Ro1CpXIT_@_JUfYMAz!Vxeg|) zByXt*I2W3(6Yg*`b)Lzpo&XD#$) zG874aY}J?%V!Xvuq3uytjJiN?R+|~;k${-q*Hkq8C#y(G#^+Skpsx%J3=E#GelF{r G5}E+J<2pkC literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/2d-snap-to-pixel.png b/src/spec/images/ExcaliburGraphicsContextSpec/2d-snap-to-pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..1ff25fd5e293fe01ee6e5826f8d3cce785830321 GIT binary patch literal 592 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vC#64XcLn`9lPP)j+Y$$Sg z*5CGX7yA-U7l=H)|48xao$B|;ze_Pcmwqzm`ki3ePyF$x6|RauIL`KPZkS7R$gdw8 z9V?cLUR0QBSC=DjFVoe;V`;ywR13KGl(5;r9PD|$h|YJJ*?b1Vz=_67WS8hdea|HegFMq zpi^^7&+%l2$n}pGx3E?oJ>lN|LPOE<-=PR5FN+D~5*GuV8mA<=xN$On(mZB->f?+{ zW!8_3Gn#j|Fi3V!{Cy`U$Vw(@!2`#)k%bLp?$w1?mS zDlJHGTz0IZ=!a9&nN~qYR(2zEHd$8IAnxX#CdQS$j;@XkTMj8KGGWi`b#xYd;@0}OuY2`K0WdPrzY5YsT>k@4^qQDPHX!p0@a%x$F3CCSWd#Lgwl%s(m6 z#?kcb&jlytjt1|T9BO%&x!1L`U`ACQ=jWpvTHXau$&gUI=Nz}tEAW@MmvZdC$?@qc zTAy5aAD|R}Us`BM`^Ti$lbUOu-u_YCa;kN@&0PB%nwcv<<^J)EetPKt<;$(L!SPy? p6bvFASsG0lx*t2ykV;U9Kbs%*S@ejHJ_7>-gQu&X%Q~loCIB1M`mg{1 literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawline.png b/src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawline.png new file mode 100644 index 0000000000000000000000000000000000000000..c586486d66154c8d61e0c463bfa024d706c8cdfa GIT binary patch literal 594 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCBt2amLn`9l-i^#VY{26h zSpC1d?4g<7qyvl9>?U2;Rw$|Ysp|0AMECS2pBg1~gY`0>_iXC$nNrgeKjABn!^I!M z{~R3zSs0JXJzYE@SLWJu*)w_^OF5bvlA4PmI9_{;v;-YH6eH%OxkzD(5!;A(3?mj=huEsdFB97{Wz0=#5>K09f+inIisO4ua| zR{n`+SvACj?+I-Xp&~xxJrEOWB{oBp+a;diUMLbM;Ptb4qGn5wi{qy0O5q;gf>Ine zO;h5XB|K*(GIrGjz@ATQeGSrJTwv= z7ATD3=J7VqSA#h$J(Q(hB7PgNaLUKo>y|@JYH8LIcELg(yv0)OQi>#_@hmlv5 zKRW>k8cw4sp#A36CJ&hV`^d0s&tO;be$+*#l@d?8bF4hUc z0^A}FdJb-j8H$)wRJat(9o-f(d}2sp;ZopttXj+v#BfQNb%N7`IE{vohA$3`ON5q~ zaw+gSL@i>t#IZz_OM%xhY5~I~i6vsJ6ZjNNH5y8oG+h~&D0^^nDa>`8w}`=r;Rz>~ zLbJoWMGQs)Px!bLnjO~#Fc>meIx`;Oc*4Od;5@-r#G%J=Um$}ZL!~>T3rmXgPsRmL XgT7w={ke;Qfq}u()z4*}Q$iB}dBo98 literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawpoint.png b/src/spec/images/ExcaliburGraphicsContextSpec/webgl-drawpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..905db2b905dab4ef1152aecb12f428b74949bdc3 GIT binary patch literal 1155 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uXygXeTLn`9l&b9Xl4-`3W zFBa(S>Ftu(Y#MPfAwb2$dx@I@uauc6uP&ckfP$m5fRodc3zq~eMV|^drnq)@Q(NIFN zapA&8pP!y~I<(xszI}IAsABqUvl}~w%iinRnddY-J^i?gQ#&PzX`Wr{@&3dFhI#hi zRsV2qK6!H^^S5`cwZ9Y2%SG%qTQ;S@DJ|{6Rqcu|9PjS8tNSNZ?Xvk0w!Uqbl|=!= zxA*O9qZ4m#`XsYE#+_63$?54y5wBi1TbFnJOfsBxR=b_g(c9awXKlnri9j#wGzmqu zUco!=>1`pWg=6NQ{Qc!LigE_f?-XCJRw{gMo`{w>=M zeSEwjJG|$icFP2D{Rh8FcN8Xn)P5X(F-+6F@4&|74Yk!>^AFxnP7zPKdf_&A_&S|? za~>vcvs}7YwomSC{nx3X9czu{>{?Dw?>;|g?LtPsc`U!r?DRXS_2e-7i!Matm)Bsr#m5V*dPg+DWyPA|buF z7Vqg^@r~1?7p;BaKex#^eZi(pciiJ|eB8vjk#)geso%fFr)8|lH#HRv&E`HPqdR*- z^!5i|GAsVu9nbu4`D~f*g}c1%{LfEn&z|u4`NGxO=L$^Q@3*Zz=Vwu#*U7oDb;eqI zC9~-M8&0O~r^6J@!t5(1Jf8A*MsM%WXWMd|axT?;JJUY-vGmrhN7ohiP4n$|D}D3s z{*JTnjBfpl>Qhtv91uUr$t2}$eEm%=m&sKIio4$Ms=06|>@msZl z;x0!~pN^%Aqj&80KDmu=W5|~Y&IXFp`sA2@MS5N1n(6Jzf9mf9A*T*GRbI6tmo6T@ z);dK%)ad)~3$Hm2w#<;&yFvI%yxhyF+ho3L&z({A(s+T0x956ScG1lum$*u&ZWU>$ zey*V?xA4ecp-B=u#QmIXoH~E2su&jN&uOV>5&G`!k@O(Wg7YJX>bvO^jvUxqAoNJk z>yEx+XJhq4l_SbicEmdgF@Hbg+2O8HxW7e(?Op3+f&PF;b)23&cevFlH@KGBbK#@U VLIsa)7#J8BJYD@<);T3K0RYET4%7eu literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/webgl-rect.png b/src/spec/images/ExcaliburGraphicsContextSpec/webgl-rect.png new file mode 100644 index 0000000000000000000000000000000000000000..f5949ecde674867573e1107508a701c69e944d7e GIT binary patch literal 603 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vClssJ=Ln`9l&JE-`WFT>D z+i&?QBmWB`(shb<_1RvFIQCW8pZ^@Q_n5>EZlh&KzbmoMyt-K7aFxgsh08oVyZ!Gg zvPz~U+$jCkB9bzrG4Xojua-OCH$?w`SQ=K(AIZ0=>Vf<&tGcJ(dH23ruW-CLCft$b zBuk(`lJ^ZUmkB-stcu%Oaz}+|6%rpmE9n2R-Ew_j@?(=j`2(JCzE|zlt?nHI_I*oImHD2~? RW?*1o@O1TaS?83{1OV1f+oAvf literal 0 HcmV?d00001 diff --git a/src/spec/images/ExcaliburGraphicsContextSpec/webgl-transform.png b/src/spec/images/ExcaliburGraphicsContextSpec/webgl-transform.png new file mode 100644 index 0000000000000000000000000000000000000000..aa66fc285d221f3cc2fb67556acee7a05ab02b12 GIT binary patch literal 619 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCj67W&Ln`9l-n^Z~6ez$N z__yfeX>}h54n^_O-E&_#|GeiL-QCPTZ+q@-zd8ffb4>|4cKm`ZPZc@NI14ytuqJ%} zA+5x*hT)A&e22mYrVYh4icJT&4xIbg%E6?~V0~PiLdiL*aVNf3S;gvY*yKOYST5!G zj@mE>N#0MQ$)Zy>D%dBkX)qFcqTlmUi7M2d*W32Va@8WIzf4LUS9CMX+bXtXyA z7(bny-2nsi0`3|COctTiGk zE5m1qE)g(|)bt2iZ=mg=n6)Wr!jibeunA6AQkE)s?me_dp>@ScFULt$t&xsgfs>~; nseI+y+$6F{O`Da92Ff>mzVf8NVRs$_0|SGntDnm{r-UW|GRy0U literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicSpec/base.png b/src/spec/images/GraphicSpec/base.png new file mode 100644 index 0000000000000000000000000000000000000000..ece78882facec04d1d748b46c2cd77c6fdc40efe GIT binary patch literal 545 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsUwFDWhE&A8y>-x+iBZDk z;Zm+OXVf;x>1SMuacH)p!HZVKh3f1_t-)Z0d`1${CrT@A8{^{F)U%p#Bb>cW_D>P}dRg4Ft z_a?50NmCC`S~F*Ais%}?lUf;`VSL`3iozy^ZkV)&ZCc9HEh^gSs*$YPC$kbf!~DpQ zs;#y8-(NIccuGwE{Il8rU)~I!V3QHs#(;jbd61ddk zu(>cokt5S>!A*-b0xc%W7jk66IGkn)W|;A5vnZbBFp+K-ZJLnAGOPD67essK`U$_! z$M*IY?}*FK%dSybzhlDD+X0um7jE19e~jE;GI6PPMq-Qo3Wd7rJz6W5f^1%0V5 z(@u4t2=Ww+6w_2Xedy?N9ZB(ts*4{5PiyWvQIzrVXuxvUl$UFMBxP(++QbtvCslGo zhYqt?dZd+(l6U&ikGl_plokHbyR5V+C8XV{?|x{U%QUa9$K~BuBqF=NAN)0Yzli^# z|3_l4?b2Jm`}2-KKE+?rZ&a!|oCFpL3ut%h2wusTtnT3G@r`9E)k)?TtQ3q?(oj0h z;+j%ZY z6ZbKz<%nQ4ufU^^UE#_qeK)cj1p34GNGTQ`iDPc5*z@QrN2l^PDFR0RU`w&<_4j|; Sb)SKOfx*+&&t;ucLK6TTCM}`> literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicSpec/flipped.png b/src/spec/images/GraphicSpec/flipped.png new file mode 100644 index 0000000000000000000000000000000000000000..e288c710ec092e25faffb3a64756e7d7b39b81c0 GIT binary patch literal 555 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsKY6-1hE&A8y?xR5Z~%kz z#S*Rvo@pA74+zcWS*9T*e8XdX@;kndLe^yObZeOa|z-p-gsk89@3N9r9;+kOR{ zh>+(BJR#M%(dL{$YmcBrcd{avq_Tn7aVHTYw*;NOR*jUN0}(RZfhQyyH(H1-nqbJ3 zT&Uxsn9OqQQACH+F^-<28(|v%Z=A5cfBNP6Z+~~p`6qk+`t6tZKTc@XPA(R))-#H6X4C#`9^su;YS3l`2_F=7pdX=hv7!pLWkS76C)TH7#KWV{an^LB{Ts5nr`m; literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicSpec/rotated.png b/src/spec/images/GraphicSpec/rotated.png new file mode 100644 index 0000000000000000000000000000000000000000..8aa94852efb4b71f50e4851023686e2e70e09fd3 GIT binary patch literal 1487 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uXr+KY5uF`KTz_IG=$hN6g5u3@fJ z)83U+sw71`9UiGZLv%C7A?fJ@c7S*QPK3A93-9P_+&hs}# z`O8-4@8J8`?aMTi7#*vvA+SAE?Cjzc<(X_q4fw3p0V|L^;u7}N7QaerU3@CTg_RlNBvtLk|xv%b?Z zF*C0V3PQ=h&b<$sa8CXmpPXvMgkOs^J+|#*6LQYu(>%3MT%kC1;UX5+MO}hMUIq%gBo;1WVNB{0RN|_ha8vca z!=~K$(`@Ui47ipq-@qm)@$_wT!0w;oSMNNRRS)1|k88?FT{*c*!u3Yej$;!ZKCRm; z+V!4igKHvZQ>!2om$g6+&pVAZ4l4{S7H?pj8T&2$S5e1mwsp4(c77ERQ_wuNnK|I~ ztbnr@U+xe|T_L||`ug&y>q};o3*46|zI{E#T;#sgm%c}$PmXypNo%z~oZ7$7uyV$n z_|KoVe?M?>qWZ({`?=0#uNM2lrexsO&+Fv>)4XE+c9z=X=U1JROuciVf^*(WOYX;$ zwG$rSX1*7&r@K{8cI<89N@{N0SUWQ*V9|oc9S0^j?)jO)Iq(0GcmGAAd9*}C1g1+E zb?lAyx=??F=cp(fYge%V50A9Sb%`4qevSr)J^}Y6^bZ!>rmgmR%F%8dpW9AXyRT>8t{<7<;A`r_F>=MNuu`$u0D z5Km58(6qy;a?NiA9{-%Jd_f``Tn`>|VBhFk*s1918>nI6;h@s>?skKLfluHHgA)!% z4?f(9i?1I8G z*6OrYUXFsb#-!kCvWpEmwJ5wIh1LzLnb4H?6TrX{^{ zl}ihbCU7@(9+GS}}O=ia~*lN-}+wTFR$fx*+&&t;ucLK6UJ*PDs} literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicSpec/scaled.png b/src/spec/images/GraphicSpec/scaled.png new file mode 100644 index 0000000000000000000000000000000000000000..48a34c02c355626f661edf830a0f8d543f23e90c GIT binary patch literal 580 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCxIJAQLn`9l&N=AI94K<6 zZ1n=;Sqo${l%}?7bxh&9W*M$-sM)2lrslz6z`hQ)FUZT?I#{I!H_xm{7WY;qqKxKma!g= z$9Q^dmvd{Ln9;cLd5I#cq_M%bIeIY7e~v5czdt{D|F`V(hW{1^?3~aP^PfTF$*$Fl zxfwU{Y*A=S-4H0CaN2=8DS}O!br=cVS3B|dx$VBdcOv@?y?-)oZi?A|zbU4?v_#Qi xTBF#J4LoLCOevBXjy$Im7Aw#~s7HZc++@xDtDQSa7#J8BJYD@<);T3K0RXLl(^3Ec literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsAnimationSpec/frame-1.png b/src/spec/images/GraphicsAnimationSpec/frame-1.png new file mode 100644 index 0000000000000000000000000000000000000000..9872cc449c88fa276c1381788d72b4bd492a7965 GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsvproLLn`9lUOmXk;J|Uj z;qCqm{&ih8$GDshJvw)%_q0TWqmiy%hr(L9rXy7xN*-q&dXDo8s-!8jB`Y=^;W<*E z<1%4JqeNdThmf(r;f(^0No+|LVoDxo9C(g9v2;pyJdEg2Fl08A;U4jtZ2a7h&pq|H SX9WWT1B0ilpUXO@geCy4posDS literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsAnimationSpec/frame-2.png b/src/spec/images/GraphicsAnimationSpec/frame-2.png new file mode 100644 index 0000000000000000000000000000000000000000..e4aa87165e586d9ebd7829332fad1ff5f1dc3ac6 GIT binary patch literal 578 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCI6Yk)Ln`9lPQ1w5U?9+X zOS8m%?i%lFqO%(6?|g5ae&9~^n~Q(*9@JgGcueQSMD;%*0&6a?iX8dcnaO&PE7rj= zX8w$@QK7*U5@j+^s;(W&W;9{mtT4-)HAKL5F}r5R^B_f0my0hPxgPBd@n8)!shZGK op(h&TDAFspL}8wH>len|ti2Am&$>i1FfcH9y85}Sb4q9e0Lb0x&j0`b literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsAnimationSpec/frame-3.png b/src/spec/images/GraphicsAnimationSpec/frame-3.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5d20a7b82509b0f44bf5ca668db5a54d5d1c0d GIT binary patch literal 576 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vC*gahwLn`9l&fUm(KtZ6T zq$|bUyx4QIgVw?)zZW+!*3C0K_hKFIp6vMi|81KU&ZIleu&<33h}C2kYx#NkI_t%$ zMhT-rG!2PYj`OzuUVd)Yir0|>SJm4@S+q+SSqp2Eq8v9ki3bQ=S6@cC&@J{jPECi! U`BqmM7#J8lUHx3vIVCg!02N}}6#xJL literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsCanvasSpec/draw.png b/src/spec/images/GraphicsCanvasSpec/draw.png new file mode 100644 index 0000000000000000000000000000000000000000..1f3df3e9a013c28373080d2e0bb7f870283b006d GIT binary patch literal 557 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tszj(ShhE&A8o#U8w*g@pz zG4txk zlG%=(d)X?Ia^}Dd%e@ntdL$*Rmx~8YFl0`C{>6#QC@ta5oH!51V>~@`FLPtnTo`wv z)-L(}-`keoKi8Xu3QSt2aAev6F_#HGYFth^oH>pxCsP&;H=#%QO%wX-EMNa|-#Oh| zZGOB!l6L~Pl82fwtKv46Z3;~(Mu9XF`o_Kga!Ts+6WRd`3=9mOu6{1-oD!Mo-LaFYV?}@+%hHdZHA>yp-SL7OUVkolZN4 zaKR;R9>+SAJZ@P$u1H>@;FWmpjBQ0lW!m3)?a%+!6~CLyuYb)>ph4f5#fHo9ane$b z)*GK36_3>faI{7!C#>P0uPBn#)3DL%jsTaT+kpt#JdRcg<%BhS<}FS=f(Bvj(oTxU zI3!jd=3OX|%+ho9VOv0lA=9x}1af^4}Jk!w>x}%dv`^~4k^7<$63ZsoA^KM^e}Hq|J-!?|Nj-` zJ4BwwNiVwnU3ALi_Zy6zma$t;ken0on{l4@!oUOeQN5>*Mep=$sX8aA*f~%5K$4n_ zg&_Y%ZvR6kg_c<;r(~^QJ@~=@;nVeXcW#F^t!nD?XdLeJC^2VT`Ly8Bj{yBSkI$Uu)7d+N zA&dKr6!RbD+W|K#HXm5<=h)oJHkIS|lFse!e(=+&PNmMjvfV4-UA)tlZTl9?scD}p zIEmH3xB9$3+7y0amVWo<;4$X#PdpkmXEYTfWwhf3_NO-ZI$2C+cqDk_TzdVl z*!|Osox4%dnxX!Ir>sW$!(dn!(e3YXNXoUb1=8~wS$3yfx*+&&t;uc GLK6UKQqUy; literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsGroupSpec/graphics-group.png b/src/spec/images/GraphicsGroupSpec/graphics-group.png new file mode 100644 index 0000000000000000000000000000000000000000..848764acea71d9505f9506d95e14552f82fa2ca2 GIT binary patch literal 561 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tse|Wk$hE&A8opmsmDNuyj zS#*ivC6+0ZO&EJsLs>ex-W={*lVTCQ>3`3>Z--vi?ESy*=WNA-pc6ga*9BU0eg>SF z!QZNp(tF^B+{$g&>(73tx_x@>_P=XtW$rnOD2Okb5VO>&D9j^Bk>lAO z7ez~Lm6ZZ4#c_)!%n_Xw!qH@@ALwz8%TtTx;GF3iD(S5%qD=|syhSE$cACWHXz*N> z>tv*&XRE@D&qA$F)=XI9Bygs(ldy40P94*0>g#0UKG*G*zqfD#(?%z*h&T_%?hdV% z1u-QWOB!5VM4bZktVDtiC@tbr)DV~D(o7HxY!#T&&DScjp<{&;$0^r#C$0#W5Ji@! zi<}i%b(B^KG!-3)KmX^ep)GK`bi2#B4Xe(i}$H~ Vhf@I@UJMKj44$rjF6*2UngIH;&vyU- literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsPolygonSpec/poly.png b/src/spec/images/GraphicsPolygonSpec/poly.png new file mode 100644 index 0000000000000000000000000000000000000000..cf88cab9811851d62f159b6405b126f014baff5a GIT binary patch literal 918 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCV?A9QLn`9l-nGuV8X(e| zxO*`Zmxcf1|9-B_p7rhKdhP8uuV>yAueen5JO2Hi`pa8li$AA) zzjt|A?dd(jwG-U0@2j=2vbyo|);h}_|9?IH{Qi2mQpaWS7o~fwZtne+-@;yf-2TSY z0PdHiHT$Z|yzW+Q_g~*)c4AwfoaSz!TMD5+JY9n6La{= zR7)J6m2A*n$TewIb0lZzgb>*?UKveKUL}M&39E#&uNLZ3SZXCPHGoC)>cJH(b{=c_ z%v3)(W|eNxY~)i~+U&_$BA_Yj8baA&4~nCv{SGff*_eh5kuYw>NLE;LzTjg`%m87zrcA1-vU z<=D-at@_IGMrnk0Anzs-*=b%O%{s2_ONF*7cwbIj>Y{t%Qb~knAm^qKSuwAWCY{js zR-umy-q#ndb+I{dsWd}dkav@q>?|*r<~6SE%Y-&6WM5vm)J5mSt&$8)LC#HKvQl0y zO>08i+a|qI$iA+)c8S%ATcsY_E4hTMe#xZgcg~hv)zs2=Rzb+zScIKZcDCV?f2v0v zosJhwh*EV0(L9`Bx@3Zs>P<(d<0U;&Alf}@LXc{tqmz47j}(}Wn;@im($UF1t|tgg zM^1=Q^>lP{kL(cw)3Fm|R5v?1xySazfarFg2_dSJ9i7^JdSt+K&IA$F#g0zxIXxj@ z+HZoTYO|wLyI+q8n9iG^sVd&o!gp7q*h`OvQ`XG!*pwU)ZMY0fZ*y#$GKZa0cAMcb zFm30!ZR#90PFXv{wkaTbpX0yqOKp7jC2o7&y3i=NB>a-yo{vwze|*^OEs?yox@+(B d_WFNJzg?_(=KT8bkb!}L!PC{xWt~$(69C=aa=QQk literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsRasterSpec/raster.png b/src/spec/images/GraphicsRasterSpec/raster.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e2df46b7d066c30a5df7bb410495f163c30377 GIT binary patch literal 570 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vC7(HDaLn`9l&bi2WC_#ex zf8L_W5$eI}n`ggJ>w3W;9&82;!D0f|8jB9=VG7Pbsh0PUt0BF zFTM9|am+Nsyly80G0~>LkGhKlTCZ^YkPmX6&8n^PIBa*22XC&}nh8D8Z@m;vw?uAm zGRoVk-FoFh+Ci?9JFc!18KMfacGbrvl&yKXUdYD2>HeFggx8v)D1?AlyiiZ0(hzK=iEprn2v}<$hyX}cz%qL$FxZ(Ef zi^bjOrA~g&|CKSdu1UT>H&T)7r=md>U*ktB+jE=Rj)?tM{_!&Y`+UC($K?8ES*_Go z*=Qj$sUg%bL{W5#aIuC8T<@uu39I@ks9a+xNP*+Snf7WWOS(gLh`!UQzK4V3+6! zKRRfTxH4(_Qh`<#jstDC1X>Sp9C#FBvZS2vW#W!or&)IKgxM}%H=)k>Jy+!SS^UzU zYFwnh`02H3uxxAoxyYjU{K|Skj#J@*97m7j^R;TQxG{(wZ=(kFoYCh*W68gRSFbWK PFfe$!`njxgN@xNA8T9M4 literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsRectSpec/rect.png b/src/spec/images/GraphicsRectSpec/rect.png new file mode 100644 index 0000000000000000000000000000000000000000..14fc67e3689450ed9131b8dacb05093f929cf5e6 GIT binary patch literal 556 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsKYO}3hE&A8o#U9r>?m*~ z?ce_wU!_wvXzV!^8L&6#nOKp(eK6a*E2gr=Tt?r#112=}NJ>~Q7Y~|X$ejHAixZbo zTEd$-aUPDxczWhu=GHthqj6(#l_IO8u|b)i{t|^`wqxgBwu+>jIdH>rFILSm&s%Mt zOa6ZM{@l*}|2MB}nqb1JxQ%6-LQ{%Spg@v$0=JTfn((j_suyml`Eok)x&5iVF-AZC wGzmmAMGH6@&D7x7sJekwP-Su_?S$kN->0Nn+3u-hU|?YIboFyt=akR{0Mu=>9RL6T literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsSpriteSpec/dest-size.png b/src/spec/images/GraphicsSpriteSpec/dest-size.png new file mode 100644 index 0000000000000000000000000000000000000000..116196db663687312e12e68b78bcf79ee1d12f7a GIT binary patch literal 538 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_tsA9}hthE&A8y>ro%IZ%Q% zG1%2=bLW+0;g@H$L~2fXpV6z=JJWjJ{bp>R@l5IF2T!YZ|F8LRN??b;q{5HpEfc2k zJ0;n1C@yhR*mk^{L(!0Fqa~N3ikpI&yJE``jw8=BS|&&|-jHb(5KV9vl{rrUb@1v9bZR@)E+a{k^X_-@HJ7<4{ z;`5ohdz&XLtw`tp&b~@=|MvU7CvHzXC+BgZXZ~(}fgA6x-($AFd~Ldb&}DaOc^qF5 dCppzITK0Q8yr_L8%fP_E;OXk;vd$@?2>|p^&7}YU literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsSpriteSpec/dest-view.png b/src/spec/images/GraphicsSpriteSpec/dest-view.png new file mode 100644 index 0000000000000000000000000000000000000000..e1ff109873774563e27e468f0cef6e9d5c72457e GIT binary patch literal 1135 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uX%sgEjLn`9l&V5~Y$AG8h zt=65$Sp~M4hvqKrN_pb4u}?{Ew@5}--sfOO&J25oC!SY+e0}@(@%Qe3x{D92ZqPaW z$XI|evb()O=a{ntONuG0Shst2M}y9?14$28II*M{vWjuLi*7QS5N}_QZ6aBp))hGPNz4yN@t~Nj?R_dDKhoV0$`i=5{_;}w}c&ES(O30rOUuxf*RLxg00>h!H@5i|4dc9id(9{Kx^ z$2T|8OaJz>9=UudT6rax;Dq(OXLTD5B=g^vRNZ{Km2ExO#Mo)-Hk%4Ihu;1rkrl~Q zyauGQK{cJUuG|FwGly;RN<@vMtdG}s?)ZH!yJ(W`boJFmn>_vEihPUVp^civj! zR+qkXe(Kk%H}5LG9X`68G3l*KN$otQDtROS*HhFPJx`jP_i%WYIro!_g67O;lAI@O z)&|dGQBl2Yqs%ySdFiRi4NFpHrK>1>-r`p&G+~O*=N^`ndDk@knI=ujoHw!I%w?;m zo(@4qXN`p>R7Neg;q>rYR_V6fylwXD$I=JhS8d;OR8aP)BVT+>W!o9+X}4aL@97uI ze*e#p{i#v2(a)x?r*luekmbLXd}7|S4=yf#b1OKrUN-#@n@}&WnR>p()^kJJIUr*oar>Oo?P^Ys{0(0aosjt&YtU-hysiAz4w+~81jRhrvl@39H&jEzrLnTt)R>Q&@tVmcX` zucPo)%2|$uWzy<<5e~0R+iW;ERKoXuykK4W#bsx?b@<-TX)2%j6b(N2_6Q0*);H_v zurk`lazadCR;fqpf@0&TmP)Tq+z{wcUw3uWIu@g%;&K^nh3-IZ{%B*-Lt8{jc(VFm zy$iLs5>L`sXp7p;k~A@WQ~9+Sac6q+RbC6;UdSaV;PPrtE=S8xnT1x5CtQAFDS2(9 z(1g;68aJW5>YH3Q)xQ6$3t7z^75bv_-R?gYA_9+AI;94_%*&P7@r;ad77YD7Y zs?Wp(oVJ`&WC~iTXSlsVU{NTa-~_MI&Yi3+0&9yp8bVfipV3ul$(ZWQvLq-rDUVUn wWwnfw!qitn#oQcE9o(|ytz7#J8lUHx3vIVCg!0K+l;@&Et; literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsSpriteSpec/source-view.png b/src/spec/images/GraphicsSpriteSpec/source-view.png new file mode 100644 index 0000000000000000000000000000000000000000..32ba66ae1931cd1736db715d435086fbb48a1ca9 GIT binary patch literal 572 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_vCm_1z_Ln`9lUOnjR#3;fV zxbo1soTjRrrgLkYnmLb2n5#G!*;}rOnefr*@Z3+GD7Ws%zWRa}3fKF8eOG_=-T0}UsPucgnhEy<*VW7aY^=NcU&PUD z-|e$pj#B-St`mGxnT3{ooDs-!GQ><_NDD2{k4XM&SUvOpO$G)A22WQ%mvv4FO#qNU B+=l=F literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsSystemSpec/graphics-system.png b/src/spec/images/GraphicsSystemSpec/graphics-system.png new file mode 100644 index 0000000000000000000000000000000000000000..d5df02eecd072553660c11a920dfeaa82f466535 GIT binary patch literal 1149 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 literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/bold.png b/src/spec/images/GraphicsTextSpec/bold.png new file mode 100644 index 0000000000000000000000000000000000000000..aa3139c04912c9b85d64cdca035da1f4fe156dfc GIT binary patch literal 1604 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_v?xIJAQLn`9l&WO&K6Dn|A z-PdgPgb7QoM{LRZ$QHG+X*>H$4Z9yfSDcnE__|OlBzo)BRjmQt)13A$4%1noyTWzl z1Mk`neXs5ZOSwc{H`;9p+GFB-$*j}tcdOLQ;PWTnY`M;k6YBGSfvZGS>}-Pa)C30iV>@l zH@hs8!bDAhNUIkD44poWCoVa*GkBzMxlH4;VsTIkR@hW>L6JddCey^F&3ud#PKb2q z$d*vS2oJ6kAKBv;TnhcY;^}PHD?i`8+T7%QjAg=p11VU z)8CnUYBe@j?FxUtXtH^YpY!)Uo|oCuF2Cu`7g#SD`(x{EjsNG)uYCE}WYdqsM)9rA z(*vU4&kgzWahB2jWbuEmawh*|pTqCfb*1J*(5s67j#)Nl+Bf8GR!#qH^Z4l5FXtEe ztJkQzX5Fm+E2+J2%Y$gCeSev)OurW#-DlzVI^2Bs`|PKOeoIwk)m~^Xepvg_c*nZq zYo9Ow`}paVE9TpkmT`5I-1za_Mk-%!X??m}DWjB|)rudNowjH4x9rdV_teWv@3&&k zq{f0v&DmycTQgaAZL^hJup!`h_?{{$msbyG7|*{gw4g#EhQm&O=M$gg6QXBJtBt$Q zFrS>=eaHT_N%m*&)L8}!Y*R&z%>~3Z)?aqkd-&-4^`Ps~{yWasM!ze+`k?tkxsf7g zx4rR(g-dV0eB!$|uQvLR?M#-c-OYVNI_u2ik?@Aot2 z@C!HgG%JcR&)NL%tE@xow*9-!rYog|c7z;U|M1B!`D*z?bs{`Zapa=xAjY zUTWW^CLVr3`dU~?+3R_?S3F)Us#tT^A*@Q z89joQ9BZ_kS+)GRUjOXO!*fNq`PM#f`S|khvg2n%N;>WY&x^6R_(gJ}-DfpkmD=Wn zb{ns#{X0zWIDKOH$aHnq%Qvf|uBHcWVOeyxH9xqy-{!TO*!#=(y)GXO`hMb3%b$hE zlozJn?{yUZ7aeDxUD{!DrE=~5eMTQHuHIi#SG)b>?74IH?|FEs>HUg=7mo^*XL~8^ zyI?VW+5U6It;%Ayan)aDhK8=+ zyQwQ>X=-Wfo&+B;kFCu&j-Qy_tz)rYe0y1J|LvxF`^V>B&e+-b!Kv;hw|=_hs~@ui zEWCoUbb6G+)s2K zeSK@A+L0AgyJxE;g`E7ZQn~EJqREEE%pY}6EGpl(*{nyy@57PW^*hWjtciAc*4eAS zT5E!I$9lV;K`)}u?Jr$^Uv$ClxW+pUeYSfv|8Te5-l3RTc=6Exx+Aw&+wrdX!1aXf z(SOMZv(NwQ4Yg(Fy-(Y@C)4rKFOd_xOYFW!EDdFF*wQ6=o4I4-fvE2ci$c%bkaOr^ zy|#;8U~ST|ZwyH~8?M8YfAYQqK@{wWjqp*u(q1v zqE=cCzrr!5?7PezVTQ@o42EJ6ujPh<(f{QSYjK=r3bFahz`(%Z>FVdQ&MBb@0QzD2 AMF0Q* literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/flipped-linux.png b/src/spec/images/GraphicsTextSpec/flipped-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..3a643ffc93d3d208f6f70d5afd85d9fdb02241b3 GIT binary patch literal 1874 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 NJYD@<);T3K0RU!&==1;p literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts.css b/src/spec/images/GraphicsTextSpec/fonts.css new file mode 100644 index 00000000..e61f92ff --- /dev/null +++ b/src/spec/images/GraphicsTextSpec/fonts.css @@ -0,0 +1,80 @@ + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url(./fonts/italic-400.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: url(./fonts/italic-600.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url(./fonts/italic-700.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: url(./fonts/italic-800.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(./fonts/normal-400.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(./fonts/normal-600.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(./fonts/normal-700.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(./fonts/normal-800.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} \ No newline at end of file diff --git a/src/spec/images/GraphicsTextSpec/fonts/italic-400.woff2 b/src/spec/images/GraphicsTextSpec/fonts/italic-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..10031c02259c75aaa8e09662239eeb53ad385f9b GIT binary patch literal 13780 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 E0EEM@bpQYW literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts/italic-600.woff2 b/src/spec/images/GraphicsTextSpec/fonts/italic-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c2450f3e6410d0f2790a003087b694c5100f3a14 GIT binary patch literal 13852 zcmXT-cQayOWME)mFq2^r1ktGy3=F2bAz~mtDwYzH7LX}nOJHc?;b>Ij>^BkQ;cA@2 z(by8l-R#G}XvU<%+%CYPz}h0l-crHSBq3cHX8K00Znx}_{_^{3@~%rWQaAnopIv0s zwt;6xRlK9#q7@m*Y}#`Y+ONMa`5e>qae8FOnL}nm_b=$Y%C5aVCs!zGwUGU;M^2YH z(!`eqziB@D`RM15(^pjS#s}+eY?J8!GLd_tuITS(!KzrVYYSG2=kb+2{QafD$3M_n z^Uc~rkKH|txX)j&`*UvVUHz^ds`+7_CKtJGdTmnaUHGW?RLS1J3b zdiO^LhwF;XW+91^4Y^&0EOYk+!;0_ev(b>QxYM_+<hC%Or4%4-SS&+5l^#$&HVQv zXC;`wJ~ltgeJ^%eU)73cu8OTm(>A*HnN$_65VuJABx|31`hSI0_}f$7A_9){S~#lm z?6bI89vCi2cyacF^$)I#?yb_3KW){$(5zpjuP(;GfPHR5x}VCx3EpDUyvcE;A!B-V>8u1bX%*{J=(VMdF^Ux7MAlX#MO5#Ru%ZWXwsB7 zRts-k>fh?2d+qV6I;jIr3~!hYC^2lPx~<4|fIZ@Az3WoP6Yp0BtdC^aXU6d0$pgj& zF{YW4WqY+B2}`fE-_OEmEK{H|*>>G0`*W>}x_7Po{^g*K{1)+B{IbW_-Z@!$&E{P3 zo+C1Hw^lsO{{R2Ho^XO#pGD!s+I5{9CvaZxeVUe6zaiP?cl+8J=U}_jqECu6PaE%A zS!TbR|L*GPMpxp?xfx7}|Vc-I;EcS|(q z9-8Oy{o}VqRRJg7<*9@(>fStgF*9TT;iL;1)z;C{OSWE>^yV%$PF8kizHqvG^5eya zoB!@Td*iyRpP@zem;7z#JJlGNdA$Rb*GCjzJ+C_siI@>o`)Qi|hYe*&XFA za`N81D{-pG&SG=HmlI}bEc1RO^p{C^-=?KMWc81omEC*iicsC5+qK)yO_{P*WHuK| zQ_-cV+Wy)5-)y=k-Re>^F`E0fg1VE<#_qRSN>xAV4$RXId==EcZ$pZL;62rnO$%06 zzS$~jHQDRtKAVOT#oPDTyT4pJ7~AqZVEx_hHg!H``?VpP7CoLM``ISmS2`jwZo~Fz ze;>z&OkObE*k}Krc)r-Z4UO$<-0Tl8OloouzIex9wP|sZ+JA0|54WT*3hdm`vfb)} z(uV^pZhOrve)ngwcksJh#XoVTs+RR<_RaCJyO-tOV9UH@-NVN#d9}5lpV(zJYjY9% z6Jgd@$CmSWq^wxS6g2mr*6QLQQ@;7jH`bam-BXGwkX;%Q5VNjB!?x$po7u)kj8BA4 zSlr3VYCXNS%=At~+Lv9K$5(w4S^9C}TDCW5cCknDC3-!0m9{=YX1DX-r8lmdz4#Lt zt?Rd;V!dRcNeq*Gnb@{-ve(!*ZJK<>pO3rOwq#y<<&>kIv$i@<^?$AA(|IUyjVjlM z2PbY!TFV@islEL0iNI~KJ9n+0{`JXCgEQX)&#z2d=v#SLo40P!xs`$UnON?Ma69v_ zyt|}!f+>HF#p`YM3465WDE-WgnZqDAgYr8VHw2++hs-p>0e4D>dc*wdf=Dx8}nw|bCDXs!1L6LVY^OkM% zp0!Bh7W;|ZZNUL4Y}%a}XD+VN54~2yvDP@m`<}kY(&aftVcHH^3<>*`i`FS#PknO6 z@SL__beCIA3X#9N1}87T!vR!<+-z!qf=JDSs=2NHAZ~3)(2+BqXK(WmVMxu z`JpA+G5DG4Q^%uUx<$9W+;Q{E$0a)#G1NYKo~*Ifw#LIeThGZW?DC$8SN3e%%D-l@ z=t4s!(`u!Jhg}Re%@|Mmx_vz4)a9ahS9F)uPN|}a?Njec#6ES4>`qqAsDGxp*n;i- z->GkAOVJ2h$j z(k~PH%gc_=^^TK0E884B&uZ@+^{dtP{BeTSAu(tFK95_y=IoiMe0KS!HK!$dwWl>W z-));}9`?Dyz&!Mg(|s0ed4H|HJyZ}_s;_Th2c?~k)yTA$P3 z`#tyE)L^e?GCchEKL2ct+!J;2*3mPy&kTQEn~|{3qPwia#&WIi^VL!N<#TE_S!|!t zIx+j~-prH!B?sp|;r`F6Zy7KALP>DYn+U#+0k|WW$^rWwwW_KI?g` z+VW@KW@GjsnFE#%9Mh*2WwZCMT9TT%d18iB1ox!nmmj^KFe5|c&v$L>TK~uPjgwXW zc&h7*{;;o@b;w@BZb@^?C5u)3753RZ+n#@%6ROs5xSHp{q;offngd=tJULR+A!NVx z{)#73u6fh89TaSL5c?ykvC8m7gnsUmbDDLA;zfJXwM|(jE`D>Yv58gjbIm5ziv=53>DL;4oOCauFvsoTw)re(?xN`;j!*o2k3}AeJ9gwT zgN!tDi$RNx+s@-X>zpK$XSQ2uZl1i-yzTYR%L@cUvJT{{rT+BMV0=DB=v3HTvC@At zUNFei?Y=48|2k~v*F}=sT(lIwPc+=QtU$2rl)*O1f1%&%B(ARS)p}R+b?esadG^)( zQrBiT{!yJNab(+uU%@X0lMI%eIwfDI?3%vK`oR?&j!W04+}&pXf90mR@>5+WJlXi| z&Gox=x3iLC4SX0Y_%h=AQ@VmrrDAOSL{kMOA z;7*C?J=X1~-TPl|m@Cmaed5N;Ma$TiYz%Soe*fyhYrFEClvdZ|`64 zWWQMwr;Hc3>_Ud<^i!*4jMhEgD9gi4o$eoc=&{>kJYDB!j~UinJ@NO z{HR1$`m9Tu%|RDp^){Y%RejRB*gwt0^z)ocm!3W03Ap-TvbUda9>=?wD?J}Ktl$5# z{?3DqjHkF1R0=lk5!h=h;J4GLDkiVhj9c6yxVlB^>^al+9Tr*_zXj}MFqS;c5hDDe zX##(u#epLU%U;|zZp-=F;MsZczFCyN^}4`Jrf+tBORiXKK2y(Sl$AT>%`+>T-F|F* zHzt2)H{sb;)3qu5i^d!O9kI*wqaS9xY&l=H@zYY5%H@Z3S8+eQqbjTOJ1I=XTdn@$ z(==sr+H*m$225- z4B~CfdVOP=SIYfl!@QPxTV9KKwE2EmI8`@UY@*d6_bFbLe74*zA#CqYL`wGFI;hhi zHfh1)bg56Q4mx=`_Z#KdOvFw;-pH$PV)~1oB%y<*)l5A>W^00FUmm;V&osRykk9S) z9DnN>#|~ag@76PyDCE#g=gvLi=N5a|;EeIL^u57L*ybORJJEfhP4}MEx_}QtbCjK5 z3EwrToqf^5Y^{5{Tdm=dx-PGWw?E&XdOy)gG3m5#XX#q$bt|vdd=vRBXnpF=ce{(5 zrT^|Yegv6m@Xvi#*e$=S+r*AurcTdcL}(3+mJw`Tm@ zxFPIxieH}Covq2L97g>OyRYp0RXOMC^8K6vw_dR5s@4mrZ8TLgnmxJjxI`|y->c8; zvt+-_mV0^j_cEd2-5VCOw;6Wrdtk$?Aqp<_T7UcsvwGp5>dEHKk}3P_cB~Fo`X^k%esp(@-L?~} ze{wC56q}G|%iU(?IIYF-RcBeC(S7BED|XA<$gNGc+Hkes|EA@J&vqXVXFpY%Wy0QI z>QuCTn|H&OJIYdw<#iE4s+XpCtj^q9dm`WO!G->apJ}T&%l+(<*Oy2>;5p=c(Y@9! z;_i2*$3~pr;{R6~81IqejbVAK{ebIGae-jBkp6+nwml``%r;}q{(Y_f50XqJDr zVZ)Sg%|%OE4GZr1dzR{*J*(bP#g?4!{3&_IjiifQ0W#|y7j-HK-q5)CMnf@eznEX( zG=cY~K6&%Ka{uQ&mwCA5|CBeO3pAQls!S*ER`>c6-E-j3k*eO~{{KtD|8;JV{BQB) zcWdrVx$IMqh4;Of zoyDfSMg5h~=B`(!yk<*m|1R2-l=H&W@1cy&tcL0Pj;c(XX*r8~>2qr~u}c|iGX6ij z)p+|h&-u1LE7i9@(>u3i`V*fwZ+`AM>bjX@&ti*>GiTOXM()4EajCTC*Qegl;0Npa z4FZpSw*J+B;cNYsVp-t>#->dPThH&?qQ9-?!B+`I$1j%|Sj>*gZcfgA;_@x|>#xJ# zCn|ED*SWPi;ZU99kI-WlI>A4JlWv7X9Sd+0rp+;mk$f&W~N#zim1DRln=vx|Q{>Gg6P$?qrhC-}KrmmaX&U!z31;T3OF z=evHI{(j1Cxo<4JeCtcDyDdpgV|%iqGHKhjvM=lY_H=xj+H_n`RwX<>xG8jf@k%Z$ zZI+uem=)`)6%6*)KmT*$Sb_V-H-V|2Uv2f5{q-)hamK+5Z#i40vwiDPjJr9VU-q=l zh4Th2vZXKHlsi`QIv)}JvYcOSeV_T0?d!fRoYe79nNxG+>GOx9zsKc#&D_jYTd?ly zyvhIX3Wr=@Z>()0{qA@w>qhU*hh8-P?5eH%s1wg+cuH>Gg30s59>_gd+?)8{CZSyC z6eG7fw^HajwbvWO@7Hb=Vmr1q_T9W&v+d&*{%rho=jq>m@sg7>>t?(=z){sBzP)y# znw3Iy{Fi4T@y9(H70+{ZvaetHb)ROwvi_|Z|KA2oJ<+>LF7nC^=hbs2ityBJHhXQE z%^~f;MT@#PtIJucf`QmD*O)fyW^_IIEsE+3$7Nt`O<0{$L_mx&+{zrwqR=B zef-j=^V7}6nCz~H&aC3F_PXHDF0Og}_3ueCmp*;0U%HQ>rl!1o*G`$QE)I?_uYdD? zwX^!(`@A_XUvA!^S7&4U+c_(G?>g7I`QFyjFYQFHNxA#2xwLBQZ*Gv%7KhgIg4v(;bLY+s$0vD8yiWa*R}0<|lC`#zb=GgaLA zzE-wR8n5jfW->bKB+S04Fnawwrc!#`J3j1@_J)?{0Ybk~a^XN-P-YcHCpy0=xgO&@fRYothb3D4v zXn{f4K0iI-J!wI2H~WS>v~cdo;t|}Q|NHm8HP_FI{aorfJ)NIrN{NHg>&;gdAC51& zWpMw}@##jkW$o5tSzhuw%O|bp*`jwczWTZS#;S#CZNgI~x!nHeCuY{;8zB5)`pVa* zuiriuXC8fl$$$P24%Jof1VS???F;Sc3Dv`1betd4VYz zi;P(1U0A&o=KZ*EWXF_O|GGbD2A0V`IlldAVaYqqvXk22x}KCtLh+$Y5oM;Bdhe8L{a%EfVfdCh#zKed0j6;%CC z*~;!zxW3+NQiJ!B%t;1oCjUSF)bF z8S5p_^ern^bk_F&J6`#&w2IE2`r9x`zAVh z!*y#FzTgJ#l623+sn{cVBIn3p%^MlC;b{iMwajSZ!`HJx{E2`OE8^i=$%)7t!RY@uztShE+U%y@cl zR>+3CJ2^f0pUB>O+V$~|`-Jis+upFdb*f-eC6r-o0ZYI{aUHmjq>>4{i*# zo@JkM?fiOz&12sUNsSPfo zZ??pzr@GCRXLh_#dULjyoqxMow2eb?>|5bE$Eu3znBowWr@+=DBg!w-4pUGldp!>fO2W^{?$uzsX&F-a7S~x$>N^CoJwi zP-u4Ae(>vKf9pIIN!2yB-ddW6L`7dux?m|R+VsIrSecKE30k+>`#!r=61}f7xF0Q}A+`KR^EC&1swUih3@63Z9Us zHqAqE#zLR7oGRs0B>4{hcYVJ1htLQ z>-K&@31MY!zQ|8ExwmFK*lO*{sMqOxHeg1=mi5!rR;>83?aMsI*#%DWEB3KkZk#TB zn9FPSsW+*XsZZZ)-Cpwdp{3@<&-Y8dHP3$`se1Qo_l}L9|G&JOaOaVInb;=%nBOjo zwePo;#q~Ky+Pv-yy7=yVno+rl(V7j*Hp-|U*V403y|q#1sK>J3S3`EmPTx2qrp-T8 z@a2!%k9k`!&)+!jw10d~PN>!Av)|U=t=H|JH1nGbD>L&_8|HN~ClBSOD{>!tzFse6 ztNfWa(+&UMGdR$mv0I?#kvID~PR_X>CE0Ip^*iw>w|`Dedd_~Y0zxQNc zRg~9f&ePkqbsIa^)E~cHc3sYj-qWJ>-K00a_0~P}ozK&!?EENluKS11ul;e8b{$r9 z^S5ogfA)UFIp_DLmD>#`d04PqQxuxn$EV9{QC>Y|dz^ovoNn>Ck}r;tuNN06K6@B8 zPo!3@WV5`toh_qpkc(HnwerImHOrWVHb0s7cNg17S2;Vq*-3j>F4SJT@{;Shv^jd_ zx+jWOboB3B7;|t>sY;ytyyiE}cWyqunAm!?cWT+EmEJ2^mR?r~{v=(z>PBmjqN@9; zk{y;`m%AB6-f^{J+;C=pXyUBI-F#2%)$B!dA1rQ+YFrUGm*M6K`%5K`|CANvU#@dJ zQ@k%Z!yxjf@4CQsKWtBjbH%T+SZ;ag_lEqc_|(RTPtwsZxi+d>D17Y`WKR3+{+;E( z?$>F-Q@Nw6E_xn1qS={t+-RY8%-^b2ZpA@4Y#0ao_fdDlc-oZ>|oD zX+N(XUUh;k+EZJ!Li}3va>cD_Oqmjz<#Sfngt%En3*A^|&M;l!^_re}E;pnFo-fcU zILYboH{B`Xlj`|zvZATtp?Z0GOPiFWQ?l3GG7U6XY9oB`dG}PMnH42&r`&g5-EiGW zXzAv=d3(Ivzs>o!Z>dgT=>^_f9Em@~ zCu~*!vhGcG%)Lv-b*lG$U%zUW_K@wpIbHkM^}HiWe>ZOy7B)6^o*;P9=k}R5DSl;U zlO*Rll%_5V{}Fvu_41l`9fvO;E>G6)o;UBI_j%9HpHH7$@OxRhX2uHbzIp%a9^GCT z)&1pVRAxr^l9$ih)#fj)3isL>wn_8uoj>1>xh<`)^!L;8nKNrb=Krk~`X49z3at7P zc>RuxvEb!j3j*3bxQ{56%3jcU?9+YZl*9cG*ECW(jJJP1{o?9TpD#{}^Yz=`x?NDU zYLR#rQLO#)TZC&-%i3Oj-OHbr1)B(+{Bp_Srfkm<%iEHnQVT!yg|+n?`P%)H4-M3o zu&~?van3*Y!!lDlxn(AA>75;)f4BBq?P?S5)|H(G+gF&~x%cVw?VEQ$-Mztk=VsLM z%iq_gdE(b3z)#Gc$QdUgJ@wVpPsddh`@bS6w>*+r>9q*3( z^v|?z^1176u7T#IrRtOS`FK9PC;Z@UKMO~2%_W;F`32k9HM9@W-9dUSfhxk=vLN8`@UoAg|s%R4l(>;J$oA zz(xP`%ge<3>z^(Q7Fr(U@AuXB;4j(Qsa&q|M}JxGTrK#3El0UZpmR!>;cDaW>Ei0F z4|tNAlXuu17R|{_KKS15;vEB5$3;_=ndff4X%ld2Q^x57%YPZj7Jf9n==HPi-b9HI zgX`u8AzmXXv<)qwM4K`d4>(cK(j4>9Wz@ z%~IB7d;Cj}SpjpS%#_=KW^xtt%Ac;iB(lQTYXeV!llW14&8Ho&r|hn_ue;g)U-n_j zJx9R=r;8CZ^^7H)s=ii!+T<-JzIQd+; zdHRn{?x!D#ytv9Je9qkMXlrZB`KMYT?%y|`oVZTUMdDK@LK=hc>XPy9{i2t z*z$Yb=C4yXt~R+CG|7A2Dc46PGTwWnZf-QKicaq~o-UkeKhJ1p;HP(WiznzN{BE0B zS+%Qm<gW5}v-c(L)!Vn-b?$-*?oW~|{T83mIHCM#(f0bp!w0|kaXx+=*BUP7d)@uU zwyl1R$rqkU#MEunOy2bE;)U(c)YLZ`t7&>GuM-oWzEW45`N*f2dWPq&>@xe*xrxL5 z*v#__HZ(d$>^ybo>d)|8Mr&_MtbXaoZGpS#d zk5&A>`tJOE^Y)|M+_#)%-y+zxwN3t&|9o7_)3M6?km_A@}3V zEMF#m;@(lTgoSC3aZSpfTPZA8HksCZdvxzko#5xQZ1->NanG)1STVtqfBD6%Su35c z9(;Fj)*`RX`@$L0VwhzDQZl^P#a?yU`Ce>w9siC~#@~$mzNsIL>qz!HF5i9EBYNkY zR+aOYcwZj9EdPu@J~w7%;ZNgBpBoEL9!ys^S)98>Fa22N`6B_-MW+k)pAPLdjrw%) zW$1Os4>qSaXB@ve=YUAnRe?9M-6u7tbx6nstPP0VB`2C>lH5DbDyL}MSC13DoX1Nv zr=_Hde5<>jAojNN_QPj~KL;r*8wVdcR1|dh5cj0w!@g;}Uw`}beB^MRw@C6^zOEN{Fgxk^-1l*8b029}-_a5D%ITZ=Rw zzF8CK9CzMo>ekHHc6CA;6E1`o{#{>pGxKKiYg1XKlLux_E?{0KQu$R`)XvISU(s*l zkH)8Nn>=S3K5ESRwqN7YA6wqt;R36-Ha-%*mw)wUzV)}$7hJC2yQgvGuHxa<8T)q5 zHc4;1UM8Qf{PTva)GSF$!7~~X+S4X~^~|)kVeBs}*sxXIB0**Y%hScHelK;netI(Z zg;@FbZ=cF(n%&Of$n#!nQS;}g>FbzTQY-lu==#qR&wkgv@&AWu@$L!R_Sb9g^Rt^Z z`GAn-j>R*i^TNwbnA!`TRWkpP+r(+PDSOc=A^pQo_c~j>(m8wik`sFa*NcZ*yuRzQ zbs5AaRLTG7D6NlunWp5q@b>Y!zI!f*{B&x|+BKEw$)kn?B?oV$|C~R;n|HFVnBAYI zyUh2wPZYoE6fAhSuKg90fLHg{zx}J1rYne*nHp^FeKnmSC)q5*XJY!s!}@&L8xo^8 zJUk_$Dz-D(eZGiA+mZa@_nZGw!RrE5ZeEFkMC~$OAvJlULGBL*R=6UWFI@5O* zX77wKygccu+|>;IC9?yr)!hDjUT;mqZO8i$mI~V4oR=`Y;kcu+V3#lZznca3gr-h$ zeHWC)<9f6E0pF4ep^d!U?Q_5H^_$vrx#GeP$yM{1K2|6_NnzM4yz$aD2djStzn3zU zf4cshfBuKZ%8wlG?|3<5akS=XuWLe5UwYO=s(fWgQm$CSps{?f(A{ha>&TMe^0jYX z1Z;V5WACdCb7ON?ZxK3V>apUO+z+OwvK(DEn{oyDS2Mf*w9`r`S|Z2|wVeRg^MToALTU3KcBlfpV?-cijKGZl4ice&Y>PjlFjYpW$;J|RmoY}JXH zhSNSRBC8T^cS#+}vtjUUsXEX#IpfG~^$oY;o-&E9pZ+$**lzww)1(BEE8b45hn!L! zW!$!YQVn+bpnLxoXLtXC*21L>yS~XyU&zt=R?GL6QJ1dYv0F(sQzl&QS)#uob4ld8 zO^0>n&JT{Q3$J`Re=+lHuVwBf75>qRf4OuV_Cy`K9X`E=Z)w(_8nwHL_S5X=@rCAR zoEDn;h&_DEN;}0O#;zqgisFCwom?JjUtYCPUe;9SlEe10qw3GktTbNZY#CIDrPal~>&Cr-Qt`f{f?9etV{N`~TqK=!gsdaOK^E3snV*h_3Wfkfz??i z85<@op1fHFykX4Gm(H-h^YBV~i4D)u_Y6Yo-JYpVk-p4px;9Gt>7;t|&6Y8F#j`@o zcV)3k`N#{dKYLW$LdjyvoB9XSX5K0HlBtgf{JFU#gl$WIb#MB&=s8th&(3-m{W?~4 zsxIz8Mf~>9`L3tp^1f}I?0qDLL7G)%&xNe(3*2j*dSU`!TJQ=lyW_VrEbnfNSFDp> z;G4^QacY@snQpD!5w7=it>m_qo8C-QSye5RdS~)7g^7lVv2GbpTa#DbT@slnec0&o z-Fer-7jBen_^|KIx0o~U_O3|UwJd6F>(rLs*Rt9!vmAK-Zs_NqwNv11LCazRtt)}& zR*3dUz7%nslCD$SaQ&U?^35+VuZun=rga}Mvne$MYkVU_0m<&du7dccyb{4{Y0UHP*-V(XnqT$K^M4PT6{+^4rtT+v_+lMG8#HW}YDPIMw-NPX*5ewI4gHtQvyKE>Eyp z31TXO@dpl&szSb`R8eacgC`zDF#Ot{hj7FY0Iq2RL_VFuV(6m$Q^syTQ42U*zz;Z zqW$JQdA8w|eT|N26iqb!mj z$#pCG(##i|+jBp+zS;b-zhtW2>6;c!6W-^q-jx@6dCiO8cNsS}H=nupeT!pEla}NY|o*rMa_iaovef z^I10M!3D5j*um3niSN^{!D;IY6(Vx|3(|*nsd(wUA!ONGgS3OSt`bJ0b^Tzo# zOl#7G-+NDcJAJP9zCT9CUiq2tG_>D+>@)W>$=7Eenp7_P){yDQqqvq~=gTRA3Rh?C zT+!Xl7GC$S@}9e5(5AJY;YWerpfv|lvvy=bszdRw$+*`YP3!j3yews^X&IVkhs%R-&sTN~{b?0$EqefRDg ze0pM?=knFpUC(%GV*2KX-E=wr36mFy3p*ASK7IO>uYc)FuXU%sylYxrF@MkNlX^be zpGK$`*2%5g!hSzTe?^DNpKbe-c7J`h`0tsz=-?lF=1A4vQ;g{9-S(o1uW!Qc>B(~f zd&NFWYR33q4wF7ntQ*I&C~&PCCU(atLrxt>1Wy;$zZyIhS1vxJZr9eq0%cUZi+-?ufe z=F-;I^Nfd2$SXXQ%e#K(ZhUj)d)>V||3)v3ei{5G@eu!si3|c2Ra(3381@RQ&-MM# z$?#9*q}cku4Wc^j2_Dl|^{@I_U|r%^VPUqkv+D%2?)jZ-BX@{}O1|<5dl42h^_-aQ z!2r{rk{)HPhs1yEzjUrYE-~jwMQJvV~Ut&vFA?f6`OOnln0P19M#9p(5=X@9fJyf&r1N_N5n z!JpTyZ?bbY-#IrsVco$tEg2>L;>mAK=c+O&{E_m+ZBigzZfWm&SwGW^+>68o@Bb4HGi=^Qs|wH+47 zYpl4}jb;~Q0Z?`edr^jTdOD>>!a zgyq-v+pWH^;>IlZe}xAd^!sYBCf?}NRi4o_M>NO3BX@US%dVHRUc0sajNWRUw^R3y zwfvM05k;?_jE#Cpo#N_@(&FxmRPt55rz-ijm2AG1dUA^H@oM?AzfaF7EVnJmIHWR9 z^7S&G+W{#X95_$wMXqt$>EC!^for=QvzyP1W{)ZJR)3S-u_+?2WQ%~g*`bWKQ!SC5 zik$zG%x<3fwEmphQ~S87f9k85k9lslx7npPXZD%+%&Y(2>E{II%c%xj;hcNt%eUvZ zqu2Dz-;uN5@@A01_SHY_gX;g+eb?W7Gc;dY=H4{xm{$vyJiPrZX~FaMr#}kfd%m~- zw77Hl{ojNC3*ERw3qvP;eNwgTo?MfL`g^TQ(W%}uJD6TZS1J7Z{&;?Wz3slx|BK%6 zJ+08R_g>GxV}JI&wA;6;H|XxUJ>y`xHn;cA?x z(%4$f-R#G}XvU<%+%CYPz}j5K-crHS#3(Ho?#iBY!-65<-TfUJO})$>75nUd?ALj~ z`*N|~GKnye+;`W?zrJ=o_Te%G4FVEOMt@6#`nTsF45j+)<8QmK4Lci50TRQUn)ooJUud+X1*e>>S_pYjE z0#D|;dNQ)GHVDaZ9iJ3i65;mLRiY*I`KI8*m(BhgJl}2YBo??U$N2u;#Vn#SRt6XK ze~6us3ZGaf{!-~|^Wld|pZ}MMVAB$g?K&~fdsbXN!1-?Om-n}Amdu+m^HeMYqP|E}dNDP3tDk**bDrRHZN+Cv zH&af3Ji;UN(|3-VrQ$uA8#%H+<}7;e(sn`q)miCfc1B9^Y!U1Yu?#mD7%WN;YDzBP z4%@FjmvvQ&v+oZ%mX5vb3!OHP71?|La;PZSM9s z?M~;hW}a<=+a$NK|9Z&B)4;iT0~6npLzR1NFZ}zTUm6>l(9-#I%kO9Qk|!P?xqE?o z`@FY2I{#}|*IkR!&ua4O4J|#Yzw5R9>-*QE?bq1Mt~dMsW>VeruP@4cHcKU5t1JKE zxzWwP^QV#XyM?DxJ2-^mj#YfP*ni(Kvh2%`+!e3>{+Cz31~41%`WjDo!uo`uD*;^>gR_{QLU(_`iKGD;N5h zJKef0=k%>R5%JNnk#SKmGv?2mHAg~TTFRui+G4@|w{Lq^<*qg1{4?uM{P(?+9u;#* z3zRZHkvLLX`CxD5^SYqEb^ZbjqRMK=9@ka~y+84M+4g4j!xPptZW7F18@g=8hGW+v zo(jI0{aK?X?43L}XRQ27#=9E7J3Au1b?2?qURk2Bl`-O#-C6H<*5ONoIHj8}o-?_{ z8D{7!li!q^$-G|c)P=StqlJq5K6z|v_*WTGIFCKk%F1iU;+MP1MTM_)otwe3*>k?f zrDay~T;4bG!{kDb?K}1Ayq5Q`#b+~p*!fvooAqa%Hkkg&)YUyxVy;!i&)qjpf8goe zsOh)(%JFjlA6MUQ`nOC&g-6u!pX-aqXXi7TItZy}aBHNL2dg{%4_dYJ#jl`A3^AUc z4?bUII`jM5MQcpVZ{I&qt5Bxm@p6CPOoIb-qO)N4;xxGUBlnzep! z?DK3dtC=+lyRVP-DrR@><0 zAl zGb=^_ zlQCSG;=2CMpRV{zdUD(?)_)QPn$oBVX5 zP=$t(9B;O%)gRe?Gv-yledP0I-lKOPYd5Oc$VlFN-6b(chRu7m*UDoHsutHJDJ^N; z%ev0YreG#RayIAr1y(VW^OlGm=*wSY<#I8?;qd*_x(AHUmAv2+7B>};+4?vnaS_Mm z!*jPRtA5>Z;ed0p8HdA^MfU{!YIu}nJl->;-SGLpD15i5f8)fQ7`>S4~yZA z^tDS@TvHc0UdgnJFc$QiwE7#A>^B!p zs9f~ZY5SrxzW1CjKI#6?ao*HXMDdW$`G7cOVWIAV<%cI#s02Lw(p}H8>h(3ri+?juldsRYa(v5|kDuOP)}zk# zbKj@Q+wCH1V$;pVmdfSqW0|q(yUoX4{C3BeX*(322s_PXbL46Dg9%6V+)pgAH94)H zFW+|hJMZbuCw!)PB!1wkIX=Vr+rP=qB`x3lZTIbX!|(o;e-{6*{gG#ClRh1Y*}i`c z3kw_LjK|4*`@Z~{+HUXnAnyP4ACv=?;78lWlL$vhNAKbmY!4=G^!E+;c;Z+=;%C(^9ptFC^7h z_O{~7`2sPfWw+TH`I~M@WVf01rRaA)R`5xZUC&Z^Cn}doPGj9>i~d(em;QchF)clj z`{HL+=0yGE{ddgg?by2X=7ZOw<>G9$rgQq8g{R$HzA(t>+P&g@-SyW?9&4?wn;5ui zV%ASq4ZG3L5eRuby z_0{B!eY)AU2e#~-qJCPYWh!%-dKhD`hMi@b$&wTY&ZG`EcMVU;okAHZf(OF*oEqk= zS>7CIHMc29PEp9K@ zLigOqZn0|TuWR~joO^(=u3?E}^r}6V(lf<%X5?C2aDTNp@c9DSX%hnd_OGA+dvWHM z4JQxE=44L^T)%g@_6atf%ZExVLrz5GT6qOaX)wO^th&IW%C}{$1xLkFp-zQM?ki^= zy78?^$ckgxZu!ZdgWt~S-g>pPyWnCN;5V(#KOl z+kbzYm!MGG?Gn;nCdGF7vRTB6DF=9B)Vdq&SET)Y8D(-P+AY{;$w5B@S?LXDRZVX^ zo5V5aSkPPJmtUfmUR}M*XKj?e>uUeCUMw?CoIcLPnYiw@mM<$4cj7hWdG0DJnM#l3 z|74RD+;$*)LxZs9$rINa+X}XSIN%yFze`TZ?XP<|&-SVF{$J7fRy1XWOt2BxWihX{ z1zapLQ!*osgFZ5<9?@Lh%P?a$-?l2tPY)XY&Quq1kvuVTmY;iK)xTh4l@mofU3wG+ zbZ%RC3i^6(iec*xxugBc#^G?oFQF+@rd$fp;<~IA<$U_h8i&0PI4VT_zpPATeEHI8 z(^L^hpT5N=HcU5#4(wE`6w{oZck*CW*OR+aQBHH;E}j2u$J*ZO=2LTPof>z>%}jpV z=WMyu;;@$PRrVEcH|Tivnoc;d#6?z`K}K%Uleua=hZPqYES++~|=O z%=+P&6uqit+Zy#9+bTHI8 zeFMIG?oV|Z7%p$xTdFB{Xx^lxZzXvN#>cr0HMc#SILR))%X#a8-J&MXXPgZDF3qm1 z&%pY07k5D5;+uXOlsOx3wkqD)qw8lsMS0D2XUXrWag+WkaBOa!scU>mP3-n=GcS#^ zb*T%z3^tyutI74156wSKXb6O&+RLf4WZw}9M)t484 z@;7rNWH>)*{3Ed}Gdg~LZCsDxC@NWsr}lqhyvx>kL}RX#QiO-LH{+wqvU%#BCkg|eZ444v zcd6CYtag-7X@>or|2^V%BL4=CMs-U2z||?;ylgg#d=n`y2;BIEL{=D z9e|oDSZeGL7zxVfz-hXr51CN@VQRMIoTh1jVVh}22yLhs5 z>$h}Cws}sCDtF)e@rfK@{&6XlFP34iLFJ#W8}gbDZxtSSvEh)@Q7_}U4o)K66Q`}W zw`y7C#Ols;$-{g0nY9YmNl)9_Sf|_cXFU4zlJReUt7GmLHMQ+0KhI%J2v}abzVTB! zv!>T&+g*JJ*C!pkC^9*1!IO)dBu+e9&E|A?rQ>q0*AaV}P6pR^C28EgW4B4t<^;pK zn@gMTF$PDh@wwV(GWQvmiGuOdq-RkbSC-s$a67bXaiY?x3&y*6ECP@Fr8hJSITX80 zyLM`3((*aFd*1&#Frgcg2Cn3A;LVf=7c^l$7N_R+AMB1KL-p$OEYWe?0QaPKW zuaNX7pXlKQ5_H@Dtl4_NF?EMd;8B<+<(o`; z%3bN9zCqooJ}C5=7)Qxz{p!fJEi=UT&Gyre*Vjq-B6Qp!fAJ2j*(W34_=Pd{ypHic zF0q{doAkNAf7l#kPdh5CcrI9b3E9b2xi({H5v^M;l#`@e@oRjbAEH>4Lzoce_?Kr;E{rw$9rlZ-MSJ%w( zthoL^`NC`6^Ey}7sLQ9m%1->g{9u=7+m;DOUIa|Zo#3wKR{HKtM_89$Yv;E$0P+ZH3kspr4zt+-j@=@(m_Cx8F{bRLKG`W3HsELuHt z>YT4Nzka+?ORm0caQTPun*Y^%I(X%!Iha%y&s99Q_;IW?%i$9HW2}tRmh;UQc(GUG z>*|fx+Yis&HE9#8b=doJnbnUt=Izc&UTpiL;%B?S$)fX1<7^L2X41NRsqHJ5KgXU) z(|);DU%T)%q&fBCqS9`@^isRa29@tNl`?vMZn3g?>u_=Mt!nZA@q5<2O3Er(x%Jc4 z=GD9PbD}R?iYULhT*&k=a1ElmrJ{<}Yzd4_rCZ|i3#?_7MgqxjR^pF7{hEZ#l)=iPeO z|6+Ev5<3@L@O`<{`CBtQZhFaSrpsp+982wB(-Z&t;s1V~maP32ciFz|FM9nsZvAF| z>lz8`O~2C*EzI)0DR=pTQ`Dlpai*VR-o4YQOz;q!c|+vZco$=SKabDXE#KUz9@ z@6Ka$-ObNfaxWHs?9}|mQL^A;@5`O#dU3Ko! z`q8T+m+Kl&zvjh#{GNWV@?`c9J|D}S)mi`S_^Ta~pPVbUP@mD+GkdF-^;b2&g5K@( zpZ4=g`oGzJ+;GF~8wV`9r~lo(X8R(xwQttSef+yBw05q4!m5HDCpY|lzD@hRG)mxTlYu4YWTC%F<_@ig>+E#hjYWM1yIZn4JS!mrgskLX`2?<+M ziMCYMfcfV?7pbju7D{r8T&r!-0bj+Iw#&fTM=~8>HeR` z?6dd!uAS*$+uzp3wr|Uo*oS$^duA0*ua{72me&f*KCfP~=Kql&OIF3{c+<{>K7X8xA77O^J)>{mUlxg@)22-FkL-*7_v^t~+glwc zo?n!E`G4gLF(uy0+{^FJN^RVJ{MEcy#}_Z(?5B#MMH(zB=?egOBxy-p#nbj&|RmSJg>gwX_DXaN3d~DZR6$<~| zFW@0LvE#~=nFsk-yvd#Vdc_VM9na3J{g+y|=j{pq^ucG}Tl;kFb0*jCKVZl&diN(Y zbrHubX}%j36Ri`cWvKs|XS(T9z}4gxZn>{^Bzz5Ddc`}0w{833f@taM(VHG#zn$eD zzT`w{$O^Yb?qUZX)C)V_O+J0U{IM~^v)U7X{{B9GbWhsX)eGmg+R0Tv`+cT?H=?nn z%3O8lQ}3he|KG6`*ZLuNbMe7L&tDvWTWrAmJlo&Am?^YV?e~M7GcIkpI?0H`=uP|{G)ftoDFMu&MLXCFWB;EdTX;?eYKWgl}*Xng~v`OY|WOmF5Le$IqXq~ z-M(6RE(gKJ^-cwf>|cXcrhopo>MGyEeSEVfyxFAV7@MTYsL}m_-LH14pUy%-kA65;wFFgM4 z`_i(yqkF&2e}A?8&%DL*3$g=Zo7SzfyR>x2y9F1NPuNZh4V>0yW$V>f_5IzwGbh)H zAJ=L8I44r%j^Fo7OiAD0KaQ;HdR!QLa_2mqVBV%Ijm<_TnMykie!p`ox_m1j?VgsTs8tuMw6jh5nvqLoVcz$Pu52?j-0j+? zU5;OQ;0Rw|s(j|v%!!xd`9ci~>%Q#nb=kD{XU_@OSKTYxSA z?aFJ;HIYl0FYpxx&9|v-`dfByl~&@xq?3kUFYQziUVYwbi!*n=ipWpx^W8U7g+jg- zl+7=jx^4UA^V`)q)=t_uY2OA_Qx>zJ7q58VYxo;)_b_JnpBlY-o1XMNH`6_r5B{~i z{+aL9(`f%@#ZR+do$7Df#~&{Gao*|&-_E)uoGc2etK6C6@To|CUc}b$R_Ub{Hpp>B57LCtMhB)_Q%Qn%3D`< zX>aVUw1Z|_q`OKk82&o05_-#WNkiT?@h0xr?WgX}IOtN8rusVhqT=QEm#nq5FMId? z`?UDCYw4E;-uD`@lfqr?o_jLhpa1@xrpfXbwXu@RwQgOSuD*=FegCe=#}kho`RSQm zrLIx6NqNGBycIR;r#`#;jORA*teAs!k#CN1smaN2jd7YGw*9A^^-?VdIqUP!*|c@y zDt7#>DO-0rxVE4mVcFjg9iL;pbws2DB+q_dX~?s^;^yiu-WWgSGRcYf>Zwe zS#OMXN=5A5xLI^USw?c)u|-#d_x_ywY|@FYUsl}9cADQcH##(@q3Fcd{a+uqcE4V@ z=%S>Qviy5jubDZPubww5NVuQ&o}FjSzIE9W_l=cnofPvJBLnx&?>XD|+;p$WPI05% zzsk*p>TXsvZP2h)Th=OGw_@jSiD1<*?ktIS!cSfwPO-kR>u1ZSs3Q&f6CW&9STgDU zqkRt*r~b12J-zCzs8qNm)5%XQk&SJ>ocCW|E?>)G9_Vg0SMtDG=fn1r0o&480&G`C zZPR%dbZXa0?|_0s8w{2`D)85KS6!vT9;x~4rc-6Fzpp&YdGEDtO)C1e+*aICe{K{w zJ~TXcDCl0QME}E?%5S##T$_Do_1QOHbS~9DW9_~gGV|d7ajFAp=6o9_6k?x#_Wz>aew)?$I>=d`{y zomgO=mM_ZRq*KXrR8L0cZ`s?pJ13b%_U6BS@-|P9B|BKnQed_tpWfrIcDuekI5%^_ zg{fM3vv@_5yM8qOO0!fmIdEj2z|X4l!iC3YtuFR9NM`B{Tl-5h@8fLGE7rbW+S(kH z1E#hHaPSA+S6=JdFDJL^wT;}0S?ekfp4>b#fP5Ly=PGDJl>%`?PRXaRS&Ud~j@qbbOK@qNJ ztd9?8U%i+0ir2F5dP3Q~*>?`j-=9=)dt=qX9Oc!woM+~*PrXt6^+JRCtC{m>NHd;M zk(zlnFVnMnRkh0Q8I791mP07_w?~I0sJngj<9M2E$T)k8Mr=^#^+wKp z@f+B0=hr%(;me+GCK>fBVby)@%H6Zhq_gkV_;|+L=j=6K#hAw@^R5YccRpXCKlQIp z*wn?x&R#nn7+>vIzluwuTB4I*>%{8y=TCn=e)QOn=x3rPvU(Z^eySaN$W&DHD(iDm zlYyXSjE`T~+An1XKc_98^H})q9bw^^JClTCVudE3c_?&|DSh?(^?8Tb>$a}^b9E|9 zWB(Uno8E0Ud49h4D{|kgxU=MFRpQ?ZyqUk^*X4DnUXN_OZL;^(T)X?vAKS&9*InOh zTB=-i*giE@PFB1k;c)KxKWhr^>!;bi?taJrdHF4cjwKE$t4iyfCC;5Vc>mI++r~Vc z$5sY%N@bm%s5fujxuqX{()0U2?P3vMx0-umPHXb6irv$jbDPVHzm}S&c22#x%=K2S zdDoim5PzkDcJHc?^6p0`ILW`{KF z$QD^8`SMMp^|{WkscrW?{DT%pUq2U@A6%%%{m1CzufMt;ci+s=nEHR+*JHKplWv=D z|M>COhCbu%H*Tw+=Qfl5nx5ys>0_AAy80bC98IY^tSxR_%(?qf_IJzO)x3&ATIprJ z>qHmG@I0Aow|I_Ng_Xerk@NbGrql;K(6qm|a-)NKeZA62{R0!(8eNjgH=n9czsDud zCv;7?;Pv6xt0te}+N;<4U+C}gyv&&jU-l~BIqYY!*7}Xm=GVo}8Osb0KD;W(>d1I% z#?ti~%Ao}e6r<9i7Ii2WOw*1n)i4C9D#~MX*MRM7C-)xCtS?Xt+Xjx!A;R;Ky z6&LSQ$LQ%(-z-0LW7mN)rYF~8?Kvvgc0c&}e((GJjD9tTgD3r&x65|>(b+4C@=hCG za(+LtyX3>g)df|iUcuo(A$#*zf6V@5bG72_1pE3fAtftOgWkQYe|~Phcrx{=uc&cx z^>1(OS6f9n?lpIM=K0;Zx5lD!lf%_H-@CPxpKiBxy%%X1bz#bpb6*xzL@wQXN9KdZ zbIDIPa+?-hnf~12>)*M{1>gKSy!Y|rKQ=o`K2|l(-zL`J$MWT$_<;>Cg?2vud9k{1 zv)TN!p9{M`C-$tb^yg43&OWwo`5Ccqn*=r}-SZW>-Fl8o5K?dOiHdAedI=cy%I zIa`g4^K&W_3X6lgljiNceEQ~=9bJo?dY(-<^_A!6T(wh4?&7A)mtFfceNpXaxeh*| z`~yog?)WS2{e8A^-JI!j9WB4JO?sPHV`a+e@A$@Xq2TL;4_O~H`|e%1|99V(Z}F>F zy_@vqbg+)>ooVO(Okb?cqIjHZ2G3(v-K(eDlWZeW@7!A-K3AFZoBZ;lVy-MgAKffg z?RHj}JIm?gm(xC~LcA8|%=+|r^y{jYME&~Lt2ue*&eMPYu?7cZ8CM)E*dg`q%*W-1{KqW#j_$Pn9ep!v>9Gx8AAWuDT}G|jCCS1#)2Cc% z;!~a!V@nmIyS;j+^DK3yn#3%4V`9xD9v>z<{hC96u2#rB-lGS@LgwFM$ulu5<9R=C zx^v{Y$=&nazQl^mnXprZ!I1vOJNkpL)-9*0qKP-JUSP zWr4dg%uDq5teEi8?tZVxnFY(lH{BEawW&s6lbq$f>7K@Yr9q zru*LCQ~bI1g~(d|P1WA0ADOS%`ITo@Rco_~^`!Gfo80C0?)sar>Rj~VgwvIkiA(2_lPqCzh#es-nzx=sR7$QGf60(Y0=ubyC$t-=d}FWg{F$1BIM^wpIOFuJuk0? zYf{I%eK*mst*je-xCmwYL^S?e-q-9>?{%bP_mon7)kNI~yS^_M1t zPe)JA=NI*!X!V>gApZUU*Yt$C`haI;)rL<7iy-*zRrJ+iyW7 z&onLH{4J@gl$+janIA zcZzkLFkZy}ME~i5st-jcI^9-#$^A+CB3Wr3+|0O@=l+@%m(<)p#vK2BegB%QC!d#k z7Vk`!pH%dIe&Sm3!~P0;*A&mISX{FuLRI#KPiJ1S==n>lv+onnI=%h(|4f7Aj4uVVbyJ^Bk^TK?&$`QESISmx z|1&$dKi9qg@zVK!4VJMmXUj{*+g%*i@%0U2J=#_5I(HHPIzi%hz6bKQVgF%6H!E*Y>u4 z*R88w{GfbF{i`sk_w|t(#v(6|ijTf)sO2ru$W>U`&hY3?_5)WHtFwn?w7z7&*fC|{WH0{S ziE%T0Wa{_K{4h7om^piHM__#J3OBEHSt+*-yu*5OudF&{&boGz1h1&shYN*^oz-nO zB-?BYo#hm`HpkWA#@vL$r*m?*SF1)V?ay%f)nw}N?noW1gG~@&C=sQ-;XXnh9 zH>s<-kTTQoan<~u4*$E}5+?iho2Shd3ebOYILVI1K=J-r6O{`~k0?mans-7ge$wR> zi*+aaa)XN)oy-L{&kx`9(fhN4!(WcJKJm|-hEMN3@MR7D_vrlI`{CbH>lyqjzh&%H zzqPZYx9n)*9kbseiVUAlDPFpCJLu)Z^*z?5EcMBLon5+D!uH!2%zA(8$IUly^HsI0 z8~Ij$Wcb%`Zyx{2d%jQR&D7t0uP<~4e@NY#Xx{kdosKofg0iaY}Y*RYy3}8th^) z``(>R?`~ziX5>H2cud#g`uWRew|Ga+Ikr;Mc#@jzal!fqL*t#(e7GlVHU6mmrdN9R zsnmU8&%d%>+QXY#A$W1+J?Cvp>{fPd|HhD(V&L=H{P<%Qrrb4Vy4(G8%Z?s=CbImp z+nJ=E$Gaw$vhIx9we|mRl|_=IZz2=DTXUwr>! z7u&%nx6-WU=H;$jRP)X^>x>*@)3wufi(hI*U7X@C?>)OD=?!boq8^nM`U*XBK1Qw! z@Lppybwi}M@tf*ex%C=+22DvzCW)x&?(<3wojQGks9Q+r!$(=wjiI7y|0X5Y_pLZ1 zJ-=2z$e+tqO;4$G+Qdmm%1Vqv?H4F;-@Iea>@YR8c3#k`7ffA7^8V9B7G3%sc&uM- zslYQPr>R?ISET$|ZO<;c@$&>3SG&x0tAqs)oKkggb-pBUwtU;p6WyF~m-t1lm;JT5 z&-rq-z>hluR6lirazqfNSd`{iS~1~(lWI1Gay>*sV@-#Ki#PU9(y@upu}=jZIrKkq5WExfUK zsoH`aO5qxQ_S&k=wnlkVOW(9L{9EC}KUrmyqmu8mCG*aE^>{Gr3Fov4N)_EZ!M@>% z#e_+|hCFvyA6TKDlE}EID?51B#+WaMQ@hVBWD*UiFRx}~Yc!Y?z#?v#IOYEwHGvAw zh_(wnrzKh!s?6;(2>5-#f03)k#amm}wm7=U2^-yc%qTCFvF*Di&m1+L8mXg3kNoo_ zi{@1eL=;_ds?Uy{V`bWKT6^Imm2cganxUMn^L-PdLihdG%lValtI5uN>)FFs*s3GE z60N7ND)4Z#WMvbNJpXDR3$TOjJ1b=eN_!M@}DXSvE2l7VxVrms{lbTk@dC zBdfDg{2srS3#bPK&lfuo$?I!YQ((z zGHd4A(t9dufdY+Z73cd_nO^7>|KM|G5-)qU--?bUcUEuwu!MURIksV-n(X=1 z&#X&fshHeeKWV|toYl|D8b9s1r}%wI`5A|!amIQt55>(<*()og{vd=^+jCRt)BIPf zUI;PgO^p+b+;($MiM7{50rk&bCrmoqo`>cYFfN~)!Fj~FPw&E{f02TpoSM5Yy*3Oi z?tJ;At4yT5jP1}y@rqb(<&w_-nc{0Z;Ml)}js?~|(Z7(Z~H?<#UXx>A^9LViq@VD4@Q-qqY29zO3s z-oN_ILaT3yWyO28?kK4(US07acjn$HuU2_6w$!d$5L7;I%OX`y&-|LrXKK!_5oe_R?tv#hpB+sppR9<%MWJ*Lij+ zY+F*yve19G&)XHh<;|X*elxvOIDFS;b-Tx}JN7~MzY>=ztOhfm zG0S(GI&@9=^@aDP&EJY&zrW01>2=x7s{7owZw_{8>u=ZJ*mUaF)6B!iPe;!3&R^!q z{4J0BamXB}h?QkJ-`~b3?0b7BKhSp4B#{QiB*o`t!3zW=vX3efiQU31qjW-nW`7AT(`pe+^bVclh?%>G^TH5ep@cIz@Fav5bc!zFT|U%UP#{3m)k(WbSm1%9%IuT(Sw7R>4WJ110W`R#8iBe(T|Ju5P`-+#&EdM=b`v^M`& z{N>i~OU|8)vJZeX! zL*Y@6qSm|A#9d(@TK@0(w7ObjwqvnmQFGY=6W&vLI;S2PIYy=}7K}+)vgwCSq`i*g zG4;ARk{d2>+Fg^_)4zCyxWcD8q2n=vgq36>XCU>?*r25Ucl)T$<`~MHZvUeuxKi8K! zCh+;!wA;V8KaVV57yW9tZs42l$y=wbo&7Y4(|O`Iwt~vF)|#>FV(ob>R0{&HdmLD@ zQ?`TUh=k~)d=Js=7Nr}iS9g7>|GNC;g9kT%tozSuwW46Jo>sOm`*SB| z{_Q8b7e3R}c9vmc+3>M|$>IO=NpW|%6uxQB`;~mp>|o$NcY~dmv{!z={I18Y$5^T? zYO+yPpwqRv8po}CR<}H{e4ja0!0f6RZ%?yetoe#v#!@E*4z9M;ZCskWHLTfj=X_n<_bqMa4yMNY4?SP@MsW2`^9hs9y>hRZ>F%1od8e&Y+dCcZ{7GLI zT)p__${)>1d_9X-+nb-d`eegH$wZ6mtF*dfy~BKu_C>wV>d)NxGL!k%M%U9zzG!#2_mY1SCX=JimiXnd zXPvF-(YV6DSl#=;eR*5Q=PuLJ)GhVDP1}E_-PB6PgvGh=wNAe?#rKu6b1}|EoRNZX~$cAf=k&*I)lMBf0t`@06`2QadtZXSp)f^ybIi^NWdP z&OW2C`0=ODix~IqU75u2A-sLX2Sv|0Ox!jMb7a*W-^3_g`cRZ?|HGF-y*p6FB63dG zq{*r`vaBUf9JzFI(v?4!k}UT^q8cmBsznSJEt#?_ZyLwLgpC3#=O!Fd@to%QVA`U| z2DTP*%&fV}ymxlqoN(FM=A?YL_`#EN#5}?#oKEg>QYv0|-iC=)V%>ykK8{5*K3W^h z>^<(Dlz3r-*!JK#9&>UsB&&)ia3rpqs>iXWINx6U#|=5z8@HYxsS4os=T*s#s_Ku@(;QO+O680baGQh{+?jXupSG}N2*r3Cmm0+1aU0&n*44nPoz=m>8Z&# zB!7Mq(fD$$`udt(DF^n;MH^>yoDjXN_xpHHQLXPL-*vkaw|K7nw$L~Cnp59w|{f7y+-aKFav#u#G-uLdm+0|E% zE^}_%u--rMKP%rB=@0xb8K1Lq{jsV0^Wsg7&Aq?j$ETO`e?7X}x#s`ELf5BjFRSxE z&HR}2|J%yr{Q~yCj=XkXce8fu-S2C)&D$5Zs_*&|WWB#2=} N%P+3>zpffH000eA!Z`o{ literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts/italic-800.woff2 b/src/spec/images/GraphicsTextSpec/fonts/italic-800.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..82a1f00d3a4c7aa4bb7e475b5e7a87b676f71fd4 GIT binary patch literal 13960 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 literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts/normal-400.woff2 b/src/spec/images/GraphicsTextSpec/fonts/normal-400.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e9f58b775ec05aedecb58eaa34d0fd2a8d5d1aae GIT binary patch literal 14380 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 JqBmR&3;>-?%t!zL literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts/normal-600.woff2 b/src/spec/images/GraphicsTextSpec/fonts/normal-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5c5d54e2f292590b493385ac8694f163a9d6b2b7 GIT binary patch literal 14880 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 Vg~nBX7hWj|&cCl`?678F005R{s literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/fonts/normal-700.woff2 b/src/spec/images/GraphicsTextSpec/fonts/normal-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3a38286c672d781462fb22dc0b27393c8972ad13 GIT binary patch literal 15056 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 Z=W}iLqgp+`4u#&)V5zPuXj{j`008~l1GxYI literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/italic-linux.png b/src/spec/images/GraphicsTextSpec/italic-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..5f7562eee7dde23903e4a5a8630367a1aef6733f GIT binary patch literal 2144 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_u1)ID7sLn`9l&ZsU3xhi{n z|L+s~?t3l}ovL}1QPRjRO3+)otB)gS?t*KJni?*139a^UR#NhOD#`3|kY@qYwv|4t zUE3D$u65wPHf>dp#)=g`PRJ)s&e?P`r)+ZVowswEt4j^N%lPNbG=7}_|McGd_rHJN zegFL5xy41i%+GVYo9j+lBq%7H+VG%PKwxWyfWXlT0Rg5(vzWWSI#+Wr2A*L&dTSx4 zqC%JDf(UC9MHUxBc9HCMwU!2rXAMbjFSG~<9F+-JBRflgY0(VkuC30(P7Wf)4jaod zoH#m?_%yEZ`BK46u@}C$D^XJEHT%6SprhpX4PbrCEIQ zYx?|j_AisjVi$o~q3uT|JdXUn*Z*wu+Y?X!eiM@}Wq7!fS7T)?dvF0mzyHtM0&CVa zeZ6tC;Qfq?%l{qd%B^(P=kPcs>-4+HYsT#M17D|WI^1gbu!YUR_~8C!Y{z!=ioaVC zac2F}KaLT7{Vsu@zXx8iVR?4`ylc*vx|aNUp>NFB5B_@R`a-r+V&nWjyp6}FeYW^^ zTD|SM$IOL04{z9G&ou3E>WjTU1d@`!DQ#FI=Q^MF-z?6|WT9Ehg%{q`PUxw4khy32 zaf!om5+=p_OD``|xg~a=`}V?(e~u>Cm!Dbdcx#2?hF|6lX>Wq>i)c+_Rw+7M+!6OR ze2UE;M{|3lLhD7zvkewx3G2^tlUn=Lc;dG5!@s{YZ`f-Ym9O?R+d2B|--7)G>N5Z5 z99XZ;R^RdM6zhleu`H*5D^K+I3F|$}f1vD#drY@){)TViKVC`K?-J(mI=yQ550Ra5 ze5^~JBB-TydUAz(YxvU3MkK0m*3zW&4}wU*qFs0F-@Q@u<7 zUeJF%Q7g~!6`fhF<-!c75B|8%wKv>`EgyJmXqi zdNL=XzsKplYx{%$xx6~^{Fh6MUV1YMN$wZE`0@PV?Rko4{!6#qc>4drYjf$^-}T4m zMdj7~;5u~q`l0<H)uJz)?c*hM);&<|-h1`k+~A*|E?r+=`%Y;e z%TH;!bGy#UEy;bUvt#@D3*Y-MfBxK=Xt~Gj5YvBQ?#(KDAbRH@h9}AEmw9+`A+7t4TzE+^?;BmoAv$!&}yK&-Cf(?RRdrUfZ?% z;XO0qKIZGszbsh#F7HOX>~Vk3)+?zS=LSm)$o~^#(tb5T=1Nb%Q6UliSkBKUKbNlE z_hl*L%M}q#PaX*G+PCpCfBNMV3%=Qe;Y<6Px+v6U$ z7kSh@H$HOAY~l7dr7W+eFMM%%VMpvn{)g6PACe27Nz5>HT&A*1{ST+@bXk{Eml@0K z(uEJ3f1NS8ebeT@Gn0z0cEs#&e$G4Nd$q(QSIhbP%b!TU*j38C{=D7nihn=4>t1h~ z9K0e)Ge-Xo=ktA@yUMY$UTdgOEPdJpS>3oHeN$J&`B{ob-CnEwQM?8Dae`eh>kKBYz=NAkN3=E#G KelF{r5}E*Ga2qZF literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/italic.png b/src/spec/images/GraphicsTextSpec/italic.png new file mode 100644 index 0000000000000000000000000000000000000000..28ec7e64a147b852fb7d1d78685a04590d3c5d92 GIT binary patch literal 1823 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_v?o_V@BhE&A8ot>Ez5-M{1 z{Fbt`U6(F3GIbqdSLN8c;)<%G&_sUU78g&|I)=r)YFs-OCrPQ?5-7Z{HEBhHi*OKc zoj|g^21~Apu3BghciRGyDPG_GUY;p2em<*u@B6QNAMR~7-^uyBR`cbZ;&+w*@84X; zIk9W2@8bjgrd%!(9i}=i3Oq;j!_-dNU<`tbu#qQW%Igh-1RMV=!@tUBE7 z+AWO{%NkN27PJ}&oEF)jBePC`EommxwARC-ha7Z*4{R#f;Uv+K$`v8T7tX=F(TDN$ zp@&>lFvK!ldr5VztxNsNmPNMP|GoG4m4B0O@nt`Uo7vLNzwOi;dGvltOwukp^yIm@ z^S$@t?P9vK4}Ms4f5Gp4%NG>9?^U_6uyM(A`IdvT`Wf`g>^Ht&z3|If_XY2_xk=2c z6gjocj?XrGUfac&Zx!sFrML5oq+Yts@0927wA1CHpMGB3#!tZvGbN7H?eqCDt-sFw z|0;6@Ik|5BW!)7Ug^sn?Bp)zS$+ z{@5qHYTGVTsC4JhGf@Mhy|R}=*YZVbT{CTElH=-FGXMMY)_^|`-#%J&&pJ-betAw+ zZ0w!L{~LaMuP|Y)Jf80O=7Q4+Pci2=$6J})^6zRl*nNtgb|_;JGh23<^Pk(V6E5w3 z-4a`LQ}65U7Y{2Zth3RG+wx>@wZi`cUmx|24pCASwp(%bKTOU-Qm z1B<4n^fY;~n{#hRGB%sP5j?5lD*Ar^J~k2tcwX1CUu5g_`gR5I?3-4X^f*i~{?(`TinT`>CFT|KsO|sN^5Xd}`!7@H{g>qlt$(?~ zX}|TJ13Oyuv=k9d=eG!SD&uVF>K3hHg#1V#QrnJ3t z4C~5IR0!;_>o0NqvSdQKippP^bNx5`xkIF{+B;V~zPM%atPjQI-rUSi^LSZVdN=qS zUBKEfXPsuihW&25E`MU*$DZ$ev#KKFc}v9wvH6Sqv#)nmA zJNk3hwkLgiUglf+CFX8i+A6yTdpDhV&$s^PlWoVBoRHNJHFmJuwCS8l^%H^q?XkLP zn*XMBpO`txFk@>AQ`2FA>*051{n>OqJK_s1-D z)z*6#uC2fB@ybALz|~&izkav##f_GJUOgyuy>0=3h1Yx-@A0yafSFTXoK8mG7S- zo{}Bt*!-aQzor0>$Kz9+)z-T>ZmC6lc)-QN)O6chMOIil@7znJhqC{}TI1(z%Jn^X z_`&3$MhAxt`_-nu7pi}L@Lsq7yLjWH7tU_GxZ%u0Q4SWBJ<;11XdQ4^aY}MivO-(S z(p{+vAy0X38VWcc3c70~u&Su-jYNlH;>tWpj!;YIGM*z7HiT^FX<0Q#(V8vEBVtuF zn^Wky39`(FDmqtnnHOrMpXh6p5SsRCT4TUebP0l+XkKlto#_ literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/rotated-left-linux.png b/src/spec/images/GraphicsTextSpec/rotated-left-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..03d3ae0811c8423889d92c21ee0304674e49dfe0 GIT binary patch literal 1536 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uXk9)c}hE&A8ol~15Tq<#V z|K76Lm$%lvj?>Z?Vq`V!nqA1W=y$uzWL8r*r45EY1|7wZmOYYTJnGwdTVRSI>*f~+ za@?j%oVa3tOBQgqV?ZSr~ zIxHQ0_|#h(1&SMH$oO$ED?Vc|w45Wr=5&TJsmMZ+r)387ktYRC5*&u?9VZ{Q7ziZs z2~0lBNpAyuW(mBXwEki2CtenJu}gkpPES3R)$Bh$abZz9^rAY)++_cGR|k8Bt;*7p z`FY1rEPB6ir;YrpI%Br5t%(-zd05W#yg0YE;olK0g%w^l(;rzZ?r@m%^y-C@^X(r! zI2U|7ymKetzZ~g5V%99{`EGSr3M_oNFSc*NG4}j>k`jGu+W&OQ67>+_BjYxWs; zaU8j}K`her_kwp@y^7Y&JG3U+Zi=7xw_4W|x2~}yYLvusF0sD&`;yobo4Z2pKRd&< zikx1VANq66Y)Q#o0j8qYkzaDFc(1A7Uh?-|xL4x6{mfFgb295o-kmQ93G3ZEuOjSd zy4%F{ilWvF_s;0vlFaYrWpmp9z~2jrPjYN^-tO+=KQR9$^I`RDfso$@1^<6b%q{P1 z)OaE$*zx>ba)JGYt}oNn7yNr1wWEuVX_bObb-bLltEG4L!`Yv^x9Hb#e~zw_I8nmb z_`v=CG4ls!-%jva(UyMT&$rJP;fKgo$<-^}UG4pnP6yuW$CVHxiUd-*rBul6=< zl%J)2-BH58FDh@v1LGI|_g4o574bi>jy&??G@H?uPsx#&z8-%+`Kw)Ba0Pn`^F~uU zo0aX4Tw@DnC*1t2RlW8A<4uP{S2?owemMK?m_wp;iz2hn`>BpkuWL*=>@3PCx!M22 z;lQ}wDeqVf>b`L7UUi`H27{pV@!fXQXB~cXDvhP=I>-pzd_bV@c z=(^-jm2FM&t`Fg%OH1BsCA@0>yfSM^iEhivr6xONLoKRrI#gw!TJTz<;o)V|ppzdR zj{JRidw)xHsqv=O_X0IEcebBqOUjO5|GZoDlBSl-go#I=U);V^&Qf&k$+s&G-8{{f zbU&KuGW+ZW*3klD9jkvV_;R~r={moKOP>+4^773uo__GS>F_Um>J65z z{VTgy`~6s*_l9rDipC>;O!b#4*q5=iSbu$KUy;K6(69DaVuvV*F;_z1-IY$phkjTX z788D)@ZP7j>9;_fm(=`bXR`_aqrFy1U3JQx-FoKMuOAxz7qm7tw#?oCp#A=v31Ze2 zVIBLozi+uUbLN7uqv=tn8hS3jh^zJZCGS~PTv;vDNilp)0gA+fVy-{0L~+F#8sJ{jT69MLTZSn5!qE zzwn)4dlYqBWLuY(g2RjR4zGSLz3@k}s)Hf_qP2pw#~0O#Cm}Kk3c-cieD6~g3Jof? cRR6Js*`4$Mw~Qs9fq{X+)78&qol`;+07FdD^8f$< literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/rotated-left.png b/src/spec/images/GraphicsTextSpec/rotated-left.png new file mode 100644 index 0000000000000000000000000000000000000000..03943824f97dd5b1d55a9d54e3c75d534c8f8a81 GIT binary patch literal 1302 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uX?s~d7hE&A8o#UAy94K?_ ze=Pgf%;4KQl(lxqiY#5n;S_k#n_o`kAnTe(OBvBGBxFX9o zwf)bN)zPk&x=Rl|`Cng@(`e9h%~f3Ud3D1hx0X#xY@+gOm-T(@o$@=C<>_8&?}J;} zR~Db~zC88M^WYUP4lVnCexXUda zq~qWBAu8wo#})K4?LMF7b?VI{%|@PkA+rmX*ItX|(zW-zmd^9${^y!R|M{ug_P*O+ z>!r{--7H}LucFP@<~0;V{fIr}{(QytBmG-bJl?6MmT^Wa-2a}VzBtiJG$#Jm+li@? z=bnAO`2OLa{m=6Qu3zY9SX60$lPNQ{X5Ovz`>OG|s!#XNaj_4VUpPbGV`-?mP$$Q_ z^@j1EYEu4dnf!Ni`qHsProF)H$GYu(_3@9crT@_|o*We4SKcAVy?M*yE1oHrgu8e- zRFBE6y!B`2CyQNsX9dVKW?bukq$eA|cBo%}>f)6DiBnG(^!75}a#*AquQ^ZNaQA-8 z`1VH=msYOUWcIwu_x;47)czCoS>Z`Bi&knUcULpD+|9nZr$4FH@sP6j`?ya$9-lno z{KKA2`^LlJZ@2CIuJ)FUd*1UHFJ5VUR4-e=+am8f@npZI)Q_aFiI;6!dfGBOJv}T| zKX1@h`19zQsB_$cQ+c{eGCYfu_`09(?&^PgHMsfGxnT8a?l!tY^FB8Gx*zYcZ`Y~3 z%OB^=x~BSkW73O*yi4Z@#9cc3*QD!DdfBYAYTsAYg~lxVX1RT1(h5#jNsjew%c3?o zZ4aN^a(ntNVF4ADvktTP*Sy?z^|%+yay6-pMcY2ju4u0~;PP|g(drF5R%iS6MmXs3 zzDTWfa87MJwNCQhl?!Iyr|q0QaXVM!Y}q{%r|#UUp{`P_>-FBeoR>xU>ps>eTpVt% zYPp-U!`Yf9R^2~bz{;Xrn&>cxtxaNOHM7spiBDNrl)suVAN05@eRytGpdW|ZE5ER+ z;y6ioN%<07$FQvdGR zgcg)amd$_hF<`!x=T*NgKcu(jtW3fKCrt5>yepH}9Rv0%^k#~Z2xKZgagJ-YJt zsp;26#nB(-n=i4tY?c)XFLAI`?E3o9$d{eL<+8*2?*EJ)o-6%XT^8ChFfcH9y85}S Ib4q9e0QdkzJOBUy literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/rotated-linux.png b/src/spec/images/GraphicsTextSpec/rotated-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..aeaf77552b439f808bac6517d9c8e302aed3f536 GIT binary patch literal 1991 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_v?`#fD7Ln`9l&aEsEaTPhX zf9_qK*~#X!qx%A6m=+aH<2aExbwz~80*)fqfaVTG8Ad@zCW)3w&H_myB2%>kIU<$_ z=pKBe;lj$oJ;90lN&1Cx9vo%H#B>+}69U%piT{ktpu+1Wks zF1j3DUVXqkOXNaP|3=m43@# zJY9!r@rS2dFZ{hL_Krb&3#%YUWxb(HY+d8KDE5r*GaLB)-7D8m7RdQ$Fh{nbLHRIa z|FVlpnw3h$4m!n?7My;?@Oa+zg#VFO*t$&i}_$)!@nSXfsD5KzYi}iK6LN3 zO4B!Arvn=ky#8`L`#L6Cm##$t5M!k8Mw$Vboz!Bk-Lwc4mx+NlOui$ zt5f9D+M}{Lj`wD77O*rA^^BFy?Y30dnVppQFG29ny0TR)mmZsNE#KR`V&=<7b@#d~ zX59B~(c8UsNBqTa{vtXN5w&|RdTEw`JjHo>!Tu}4jqProLN|ZOJDz;;wqwrz*gN`t zH(A0X^qTm3n%!Twls-vckx}2uoVkuKRl0;j)qlC7MJd}B^TmNSg~kg$DwO<_e0?=#FFuX**~<4b?76VblZ zS%bSN)7{2IZ?1~t6}=~u9ag!D#%Ro#%(8z?pF!QnJ-4E~H@&~@;PJrXL$skqPsE1M zNeWrwpK2BU_Jz59TAv#no$dLpR*oa^;NsN{$6l&?3T);6$s*8yuRZu)U!@;n9q!zRRsj+Xl|Ryp7gq$M^v`HS@Z4GD2Ee$X3JH3 zeyfMYpIgGQs7&%%;~hN(PNtLQrIShzom!lC*6fFjz}D)D;J3e71msqlZTd}ytg_#!CN%4tvSUg%$IBF!S%ap68=qc6=VIdD|2oEYqX0*imu3{t0xW}vzhSd zWAvU^YYMpqx|}XNs)>J*7pkXp;LtG}0Wp`nj(!;r=PN-Abl4X)oZvM)*l6OS%X+Fv zfve+=fG|XA&3R@;?imwR=P>P;T-z|m*ZiTdgNMtzo^vg?_kZDYJz@Cz#QCGCjDL-+ zgZE3QeRen@RPU;<(!Azgz(pVaxYsY-#;>$@t{%iKn zXTG&az(P6y&^y2iY literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/rotated-right-linux.png b/src/spec/images/GraphicsTextSpec/rotated-right-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..ce4c50f37fc336527d3ea2418564d936b4bec953 GIT binary patch literal 1419 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uXgFIavLn`9l&d&CUPL(+} zKQ2RaODhwzm}%dN?pdw6g?%f2Z}8Z2ggfzM2+soNqdh$Wv5%gy&&@N4eRikd-J{ev zC1(+HQ$0hext+zYrp-*X;_Z_1b$GsF>4hTA)@{#Z77JP~ZNDVG} z&f0@`j{Hus{j|-Vu{(cW+S3oI@ya`1i9Rvko;6Q>f(Wza_TOb9Ik#B5w?{~yI2tRF zk>mJ~`7O(&r1$DmyPy2J^8bHMro+8+XAQDdRG#rDMBMCKalHM{v$-sj{1om@Gwpcx zO+6=(>-NOYS;BAb$9O7m-ko)pA(-c}>fI0K+YbD^H`jo@cv*;Xf7;<&!HX*2%|E!O z?F?Jfu{#f?`44`4y48Tc>QT7a(S{kFoSqxM&wARVefxn;4C`f=wr7>8k z>urHeV>7#X}AEYMnc`cK0TmMty;3?(}-_E+L9Ou8h zgZJGxwi|VOYHszhOyX1U^7UqEyd8J!#i#x&eDBO(J+vr`J(R~X>B6F!{4*^6hx1%} z=cuxue_i>zLoS8-Zee@)f6rW6P=4q9?Rzt$XZ`6}IQ!nA54`z@znym4aa-zt?Q+q5 zPpnz8zAL41AG}&AZ{#0)G~45o+3Wc?uSc!D_bsiq==7BI-iT)!O{a{)zG-$zS#!GV zUwg8D&W!uauWxcM(=`aInzY_;(sqlVGXt+HEQ&n*YLn=D$B+N7RtEatd%wf@__R3= z8y=OqaGhV3{Aqh$^VaglU*!vaS*HA{(UdtJUU1T@`FpUJ*6E85f6H#{58zmT3 z0ygtM=HB)2=2wX`jdwh~ul;DXKX`wOWS{=2hgK!#jS`G5JFmX2FHm0ej`7Am{nkw` zsq=P8U3{Z(hay~tEX#V$N4 zN#Rt!+`p~S|LUHutJxyFF{|vsa}G^2ySSbIO8ZwC6vbC$+_K&C@tLurCfn8TSF)aL zF+WhZOK0UNmPu1`KdM)5zq)evnz!leJXRSss-#B$@oX+VzruU!>j$rvXbF1+Z96(& za&fT|Jps#_i8BdUz0OCkAZ=K N!PC{xWt~$(69C{kl~Moz literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/rotated-right.png b/src/spec/images/GraphicsTextSpec/rotated-right.png new file mode 100644 index 0000000000000000000000000000000000000000..24efb2ae9825289d4c70b7e9e401224283be4aac GIT binary patch literal 1296 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 ChEwza literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/shadow-linux.png b/src/spec/images/GraphicsTextSpec/shadow-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..65a52bc42c9a8cff116021424ffc45dcbdbbb412 GIT binary patch literal 3853 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 O7(8A5T-G@yGywqnOCmS` literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/spritefont-shadow.png b/src/spec/images/GraphicsTextSpec/spritefont-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..a21ca13fa20e928d53a58ffba1d1a8d21f91f8f4 GIT binary patch literal 1313 zcmeAS@N?(olHy`uVBq!ia0y~yU^v0Rz>vbh#=yXExUHU>fq~_Pr;B4qMcmsN7bjjd z;BmdF^`b{@o7}7g#;Y1mx~DZA-?ZfSycx1DKd?%l{%HHDa8FIm|36=!OV=wfH0n0; zvm~ZF98haec*Dmb!=b=&o0*B@kX%E9TZ4YbvEEgRECMMWi#t~eItcVihdOg8oS5Jy z9IV`+AZ5O)O~7G-Vz1gVHwH(OybxZ+1_fs+U!Puvri?o)q?{NW+e~K7kYZ%HxFgWC zg`tTj)5Oq}iDOaW;w%nE7RgH)Nm(ocE|1-=3NUdPExCB)3WvhTv~Wh#pMO6e%d_0K zub=60ZR7O=cYjph`F!(TdidYGbJdK?x4QfiJDaxl;gsCk`nK7oX1n_SpFH!*HH~-X z--27hg2(R7t6cZnvP}ELu7Ye~d-1tKh-k z{>fkORJVV>GwI*Y&o46H_XhLd(motG;f&~UtBK`}wvO|P=N?N-cqjFC?c;TwYaX)A zl+2#HC{8c`?nKi&%fGH}dh_af^ud?1yPeZxXRFRWe$c8x>C?L6-TRkBcSo8Z<2&~` z{n~xo3)ypT-RRL+o^$PbihB9C(~nIJKJ(@4O?|xk`i9%_Ghc6?emQ#mchmCq5!nr2 zHiTUX}6ke%AjdbJU}s$NfGnWP{7w@3!5Q)mzW;Xl?n^ z-S4u$ca`2sSib&bRsZ*WHp?$N7{+J*3w?aIS~)0f)}z`-Yh~nw-|sx7zWLr8sYUS< z&Pc^cR)4)E`XuG&ZkCfLzVscNckg?iqu-^ues229xBuMp{NA65&+l39dA|E>_1`nU zWnJ!mndtLcB78!6^ZcXNJf&<|MLw1>Dy#qEJfr>k?3{P^(ju=ve0u7f_{-Qb=Do$d zGyg8&dbGB@{4N*gB-Y~<93_ek3b9t6eL_qeK}}OVcMCX7(3`8`&dJE4dGOSdSPlh` z>3)+Iw=gtKNl3k<$0D%ATm5996N97I2BS=ICXP!>CqL0pY*0{*n7M_Uk>%wj&!Q;; z4ikjd_}pq`XexQBV(G=9;K3QD_ST8PaaYMC8C4d6B`n&L%LdxoddBRePPT8GlrtC@ P7#KWV{an^LB{Ts5wSz!% literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/spritefont-text.png b/src/spec/images/GraphicsTextSpec/spritefont-text.png new file mode 100644 index 0000000000000000000000000000000000000000..f16b28e173d105f85857d54a45556aeae6c5a899 GIT binary patch literal 1132 zcmeAS@N?(olHy`uVBq!ia0y~yU^v0Rz>vbh#=yXExUHU>fq})?)5S5QBJS<d&BSr&Wy66shNc1=g$9K=j2sy(70nrUW~4eXIQn#{EpuaVG|4+-q}ZUK<|-Vl z+@K(3o;Fj!L16Nt&Xs}=0=?3keK-_4JTD%(!lBS{tT$4PMc~MijHE0U0hh%nJ!_zGcO*2G%eR>(1Mx1pUM4o(KKmWhNk3YZS zly>&j^6rtY@3D^G{rh(Izq_-Jvz1k!{4(wAwp)kwZh!qJnzwr6&DuSaZ?5V-C8zZ~ zv0TllXR&edz3-K8)+$`@UC;Ni^72*Z|5E#JUCFw8=3)Oao4LA&AMQCWdpmntMox)s z(&U|Tca?Y5P5)c5;N7{y!Dsq5m%V-a!&dbKU(8d@5BvS6`!rsZSbE^h!u16etKN8R z>$v=?s-*wLFRx#p?{D`1oiXRvg4)y@T$`%q?)|)3^LOMzk=VZ~XKKt={$4AyB4f?2 zcCN$f-zL-+%lz*7w!p68-95FJ^_RkDud=Z`pUm&A{W`2`P0!mKhfQyPyeaT@@#Z(D zqAu=}xv%it{P;xszn!6$F8;lt-o5iQrv6Ri_xrXX?Rcu4xK{Q`oyFBI*YE$e@0eAr z?eSE2wNY5*?K`YzYO3EI2(jO2^nG>8?m8`*{qEm8`R>0G*8XjFT4vcfTa7bD{9P{^ zcT}H|0|NtNage(c!@6@aFBupZ zSkfJR9T^xl_H+M9WMyDr;4JWnEM{QfI}E~%$MaXDFfcGkmAFQf1m~xflqVLYG6W=M z=9TFAxrQi|8S9zq85+8Ob~0yRVAUpVuwY2nPftj@-}x!cmp#CguX(UWL9KmX%(`Cofu z@7BICdVA)DSw{ZS&1Yx(RBPYe`_AZX*sk3Remh!g-_5<3dMowu+eF0%b>4kB&1}lsS9!_hyX@aiRxiIWZ4*~%`F7{6*K^dqfBww(D%fmst?l`Y z@blWsYp>oc<`56uws`mHM}6mP^WS{>-1kEJZK&UG?d9KH?ejlcSq>uS+O`)6rdsdXt+?&3^{(o-uXjFv()U8^?b{c5$=|QpW%LJ3-{e&?^_$l6 zTG#4RS{Flat$v}Gq5oK*ZOYqakAMr?k{#FWpSGc#*nXoPBS)dyZduVB$(RK3fpPEIxrIWn3cxOzWAHKpHq#_hb`R&r4yE_A_>6FGf%h@Mum)@QF z&TsA=;k%Q2tCGF9c$ZAhoDPejE6dji#V!TA_U6T#m)bUYl`LMLen_!LV|BpvO|o-k zb2U~!$*Z%wc$c?w_Wom^z1|%wJ{PvaJM!rg?@xB~inmSL{qtw%J6n*#*`0OE=JFhN zJYF?#p^M*?{SrPI*p$5qDnu7Rb+O|a3YGUp$W{kiVPu1V&bbJTv#EGsM7 zzVY;y&B^8M82Jc2(iYCV+j`i4&41-iPy(8Lao@c3Mc!L}ueYsyFMa#nKi;2r=bkq- zbKIZ){*gf4&ncmcq-$;8U)VR#|H!u8^^aawTSwn5c~{|F_x@%2g?}&aoh$g!`B;4Z z{$ro}UZmfed9in1`O$ToYY$H@xBq!&V{Os#;GcK>?T=sHd$#aLr}nM6FW))cD}Pt9 zsrGdFzW>(s@66}^lUeM)CB42-WB&o4-J*ASZ{56j_t?74y#C)0OP7}%vHtqn|8ubY zcmKl=)%i-|F7BJ>UsATw^6o_QcR%ibF)puf+xR-~ZTdrje~bZP;LzTEL*VVXIo~{= z@6GS{er)&W&+E=T%f0{iUo<@7;s~VKHGhBcF6!wzpqtcV&A`CG;OXk;vd$@?2>?1- B!W94j literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/text-linux.png b/src/spec/images/GraphicsTextSpec/text-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..fd8f062f18cdfa5d8255c76c78e3c8234c0e7a8c GIT binary patch literal 1955 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_v?vpiiKLn`9l&Zx`@2^DFt z-&~ev_0}idi&Jj_i=vi@sQEOPi5)vObT)Y@8187;)a4|);#Lx)rUqkEprXJ*r(W*~ zR~w{`Mlf;;iWIQ3I^}vV-&_)vcF*|zq&~?PSBz(Vd8Rh!pQUYa{qx%Uw(s|uFY7xW z_UeIsD#wh74kJAmWgQn?c^wx+d7YNl6>bh2>wfSgDs~A5tdX53Aht+?@#w9GoC6}T z^NG4kd*7^|lOJf;^G>U(-~GZ<{qZF6OEq5&=ghU_|5+Tqb@qf3qf0_bGxGKnTPyu7 zSDshTw!u{W?TR_R!ViA^l51hlb^Ed`|6z1g*M$z=j6H9?4=h=HxcmJ9k(?)$C+^vC^~vY?M)e1&Z_2yL{62cKfXq(Dv)AQBBClNzKeRWM=Z*b$ zpPjFro$XvIKBzbB)eKDLt8$rt?Ee3!$F2Uh-n!{};$IHSo!fGLYXrZCE-riLW3gX~ zC-}YRV?o7-R=bQ|Noe7h9X; z>&pCQ;}ATzs`0{`z$>C>*-VT#mG?ARzH$3_~V&(u5Zk~@0ydDlbj zXr9|E^*o(EoqB%wU;6hYcWy0;+;F9&<@?>4p7Yz;>vfi`cfM$C&~CHf+E$K}`f;4z zvF%Gt|7*@|S>tT3yuHZjmeY!#kFKTNSrk{r*!P!NU~eUV% zwU2Tro{ zQLVScmR$HdTR`s9#h=daE4EJwT2VTaD|6R3g;(=+oAVzk-T1f9>LR~se6hz_id!F4)-Q@^j+(ww;G;>Hk>t1g)e6@T z!B-fQ9ZJGEdbihi-gtU^;{Q)YGY;>3WP7ASB-gj2;VqA1&HeU{Z}+&*MzaT>;d*`Y zT*14Y(&yg4^ZDYr@^natMG5zJC6hf&h4rR7TV*H2|9bY*_oIESTlHJ#2QQC)STmFF zcw=4XzS8OtpWcdDF^2AUJcCkWG+O`23GI5%Z1U}P@1IAa2AS8ElqRt*4~Vvrz4x?u zVyVv8o0py~JosOP<%APca=7BPqZOZZ_jAO4Y13kv7r_-DX_>R!%J>!!$As1n=D#b8 zc9ikk{txz8%w6TjJZrbS*#4^$=YNaudy{(My6W%HO%tY7=cYU;J7P7r;T;#Z=CS^Y zzW2Wm|2(^(JMN5LDP^MCvymkH apGm{lBX_cgLjeN=1B0ilpUXO@geCx|E_qr2 literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/text.png b/src/spec/images/GraphicsTextSpec/text.png new file mode 100644 index 0000000000000000000000000000000000000000..6e22a5fda872efbbdb4f563a561d06ad5094a97f GIT binary patch literal 1589 zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_uX|9HAMhE&A8os*j}HC5)= z|J!#gkH5`1n`_mr;~G>o{Y`3<#?OUpl8q}kQjch?*z(=n(ILrkTLd`7_?Z|VJ1Vd=J!6<5_f3F_v6#V!PuNKzfv@3Y zq7w(p8Abz3ACATbO9r)ePDKF&_5)806j_*_G0c!rYjHRr!!Y@9ivY(A=7gddlxY^U z{2eg)$hWi8JFD~cH0A}*em7M!``+1IY>gcrueG(8ZrZtVfy1QmzZ$aN_bZgHo_$=* zCHu-#ePn(oHpYB7n3Wm*yJgC}8H%aZc~j3>Z$I&%Rei~m`I$kxKR*3p z`_9I-)2s8J+3`EC_aExXzPl$z%f7~eZJF%!eLL?j-SX94wWwy=&%IA_au;yk?Jo29 zt~)=?ru$y<mEnGjQr60 zN?g;yz zkNovrHR$i9V-5!JDtn>hj zs?h!04)#dSxuW*={F;fJxuq{lt6uLnES|PqXX1}@qKjSzSnTDFKEt*#JfQOocgHmc z(;KE|_1?YHVqLWILi?IOJ1!=>U8s;vkdHg7{r9mEr?ubvtqxi9?oZk2z5ed>%|16; zcCxL!nEP68@{PvXHYYzv7%*jSQqWSbywr(3;`Aw@6e=^W}9D^Ht(3;_kDf3 z_mpKZm*49re*1Y$q$BoEzjJW`851R=k_n<^S&En?*4W zZOXMm|7$WI9QOP1J;`|bIl;7;g^fE*?~BIFSux?o!q=DWX9j)yP`s?LKdtx)>k+-E ztzQNECH8N=*>GbD>r~_Nc^e*cu^ySWW|gjgdTp5b&s~j++MmsO_ICez1=ioxzMFYUoNRK^N-tp{fp@AIWZ1j>~d%QHRfL)x8uwIPOp%(IS&r?N$U!jZq2?~ z{c^hfc9raxujYPVYk6#Wncu5c<9*4kFH4u3uQ9UUoq1t@Zq}XajJCJFpZR}kPsgjz zY_)Ejb0U<{Z!P&^-Tz7(8A5T-G@yGywp&XZWcA literal 0 HcmV?d00001 diff --git a/src/spec/images/ParticleSpec/Particles.png b/src/spec/images/ParticleSpec/Particles.png index 0db043482aa39cd94ebf5a54dfa6c746e48c89de..97fa3e248b27efb74c34702a17d2257052436fa5 100644 GIT binary patch literal 10530 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 literal 0 HcmV?d00001 diff --git a/src/spec/images/SpriteSheetSpec/kenny-cards-license.txt b/src/spec/images/SpriteSheetSpec/kenny-cards-license.txt new file mode 100644 index 00000000..6b6e95af --- /dev/null +++ b/src/spec/images/SpriteSheetSpec/kenny-cards-license.txt @@ -0,0 +1,22 @@ + + + Playing Cards Pack + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 01-12-2020 + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + This content is free to use in personal, educational and commercial projects. + Support us by crediting Kenney or www.kenney.nl (this is not mandatory) + + ------------------------------ + + Donate: http://support.kenney.nl + Patreon: http://patreon.com/kenney/ + + Follow on Twitter for updates: + http://twitter.com/KenneyNL \ No newline at end of file diff --git a/src/spec/images/SpriteSheetSpec/kenny-cards.png b/src/spec/images/SpriteSheetSpec/kenny-cards.png new file mode 100644 index 0000000000000000000000000000000000000000..69773596829c04d7db5cde0213b3cb20d592bf6e GIT binary patch literal 3512 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 literal 0 HcmV?d00001 diff --git a/src/spec/tsconfig.json b/src/spec/tsconfig.json index 8659fefd..9ed804cc 100644 --- a/src/spec/tsconfig.json +++ b/src/spec/tsconfig.json @@ -8,6 +8,7 @@ "module": "es2015", "noImplicitAny": false, "noUnusedLocals": false, + "downlevelIteration": true, "lib": [ "dom", "es5", diff --git a/src/spec/util/TestUtils.ts b/src/spec/util/TestUtils.ts index 86ab728a..279f0b07 100644 --- a/src/spec/util/TestUtils.ts +++ b/src/spec/util/TestUtils.ts @@ -5,7 +5,7 @@ export namespace TestUtils { * Builds an engine with testing switches on * @param options */ - export function engine(options: ex.EngineOptions = {}): ex.Engine { + export function engine(options: ex.EngineOptions = {}, flags: string[] = ['use-legacy-drawing']): ex.Engine { options = ex.Util.extend( false, { @@ -23,6 +23,7 @@ export namespace TestUtils { ); ex.Flags._reset(); ex.Flags.enable('suppress-obsolete-message'); + flags.forEach(f => ex.Flags.enable(f)); const game = new ex.Engine(options); return game; diff --git a/webpack.config.js b/webpack.config.js index 6500a940..f4be7533 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,10 @@ module.exports = { } } ] + }, + { + test: /\.glsl$/, + use: ['raw-loader'] } ] }, -- 2.51.2