Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/excaliburjs/Excalibur. 🎮 Your friendly TypeScript 2D game engine for the web 🗡️ excaliburjs.com
excalibur excaliburjs game-development game-engine game-framework gamedev games html5-canvas typescript
Something went wrong. Try again.
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319import { vec, Vector } from './math/vector';import { Logger } from './util/log';import type { Camera } from './camera';import type { BrowserEvents } from './util/browser';import { BoundingBox } from './collision/index';import type { ExcaliburGraphicsContext } from './graphics/context/excalibur-graphics-context';import { getPosition } from './util/util';import { ExcaliburGraphicsContextWebGL } from './graphics/context/excalibur-graphics-context-webgl';import { ExcaliburGraphicsContext2DCanvas } from './graphics/context/excalibur-graphics-context-2d-canvas';import { EventEmitter } from './event-emitter';
/** * Enum representing the different display modes available to Excalibur. */export enum DisplayMode { /** * Default, use a specified resolution for the game. Like 800x600 pixels for example. */ Fixed = 'Fixed',
/** * Fit the aspect ratio given by the game resolution within the container at all times will fill any gaps with canvas. * The displayed area outside the aspect ratio is not guaranteed to be on the screen, only the {@apilink Screen.contentArea} * is guaranteed to be on screen. * * Behaves like {@apilink DisplayMode.FitScreenAndFill} but driven by the parent element size * instead of the window. Same C-frame rooting rules apply: * `contentArea.topLeft === (0, 0)` and `unsafeArea` spans the full resolution in the C-frame. */ FitContainerAndFill = 'FitContainerAndFill',
/** * Fit the aspect ratio given by the game resolution the screen at all times will fill the screen. * This displayed area outside the aspect ratio is not guaranteed to be on the screen, only the {@apilink Screen.contentArea} * is guaranteed to be on screen. * * Screen space (the {@apilink CoordPlane.Screen} frame, "C") is rooted at the top-left of the * safe {@apilink Screen.contentArea}: `contentArea.topLeft === (0, 0)` and * `contentArea.bottomRight === (contentAreaWidth, contentAreaHeight)`. The unsafe area spans the * full resolution but is expressed in the same C-frame, so it extends symmetrically past the * content area by half the clip on each clipped axis. {@apilink Screen.contentAreaOffset} is * the resolution-space ("R") location of the content area top-left, used to convert C<->R. * * Horizontal-clip example (window wider than the content aspect ratio): * * ``` * Canvas / resolution frame (R) Screen / CoordPlane.Screen (C) * * (0,0) (0,0) * +-------+------------------+-------+ +--------------------------+ * |unsafe | contentArea |unsafe | | | * |(clip, | (safe area) |(clip, | | contentArea | * | off- | | off- | ====> | (0,0)..(contentW,H) | * |screen)| R-frame (0,0) is |screen)| | | * | | here ----------> | | | | * +-------+------------------+-------+ +--------------------------+ * ^-clip -^ ^-clip -^ * * contentAreaOffset.x = clip * * unsafeArea (C-frame): left = -clip, * right = contentRes.width + clip, * width = resolution.width * contentArea.width = contentRes.width * resolution.width = contentRes.width + 2*clip * ``` * * Vertical-clip behaves analogously on the Y axis. {@apilink DisplayMode.FitContainerAndFill} * follows the same rules but driven by the parent element size instead of the window. */ FitScreenAndFill = 'FitScreenAndFill',
/** * Fit the viewport to the parent element maintaining aspect ratio given by the game resolution, but zooms in to avoid the black bars * (letterbox) that would otherwise be present in {@apilink FitContainer}. * * **warning** This will clip some drawable area from the user because of the zoom, * use {@apilink Screen.contentArea} to know the safe to draw area. * * The safe content area is centered on the (zoomed) unsafe area; the C-frame origin * ({@apilink CoordPlane.Screen}) is at `contentArea.topLeft`. {@apilink Screen.contentAreaOffset} * holds the per-axis half-clip in resolution space, and {@apilink Screen.unsafeArea} spans the * full resolution in the same C-frame (was uninitialized before the fix). */ FitContainerAndZoom = 'FitContainerAndZoom',
/** * Fit the viewport to the device screen maintaining aspect ratio given by the game resolution, but zooms in to avoid the black bars * (letterbox) that would otherwise be present in {@apilink FitScreen}. * * **warning** This will clip some drawable area from the user because of the zoom, * use {@apilink Screen.contentArea} to know the safe to draw area. * * The safe content area is centered on the (zoomed) unsafe area; the C-frame origin * ({@apilink CoordPlane.Screen}) is at `contentArea.topLeft`. {@apilink Screen.contentAreaOffset} * holds the per-axis half-clip in resolution space, and {@apilink Screen.unsafeArea} spans the * full resolution in the same C-frame (was uninitialized before the fix). */ FitScreenAndZoom = 'FitScreenAndZoom',
/** * Fit to screen using as much space as possible while maintaining aspect ratio and resolution. * This is not the same as {@apilink Screen.enterFullscreen} but behaves in a similar way maintaining aspect ratio. * * You may want to center your game here is an example * ```html * <!-- html --> * <body> * <main> * <canvas id="game"></canvas> * </main> * </body> * ``` * * ```css * // css * main { * display: flex; * align-items: center; * justify-content: center; * height: 100%; * width: 100%; * } * ``` */ FitScreen = 'FitScreen',
/** * Fill the entire screen's css width/height for the game resolution dynamically. This means the resolution of the game will * change dynamically as the window is resized. This is not the same as {@apilink Screen.enterFullscreen} */ FillScreen = 'FillScreen',
/** * Fit to parent element width/height using as much space as possible while maintaining aspect ratio and resolution. */ FitContainer = 'FitContainer',
/** * Use the parent DOM container's css width/height for the game resolution dynamically */ FillContainer = 'FillContainer'}
/** * Convenience class for quick resolutions * Mostly sourced from https://emulation.gametechwiki.com/index.php/Resolution */export class Resolution { /* istanbul ignore next */ public static get SVGA(): Resolution { return { width: 800, height: 600 }; }
/* istanbul ignore next */ public static get Standard(): Resolution { return { width: 1920, height: 1080 }; }
/* istanbul ignore next */ public static get Atari2600(): Resolution { return { width: 160, height: 192 }; }
/* istanbul ignore next */ public static get GameBoy(): Resolution { return { width: 160, height: 144 }; }
/* istanbul ignore next */ public static get GameBoyAdvance(): Resolution { return { width: 240, height: 160 }; }
/* istanbul ignore next */ public static get NintendoDS(): Resolution { return { width: 256, height: 192 }; }
/* istanbul ignore next */ public static get NES(): Resolution { return { width: 256, height: 224 }; }
/* istanbul ignore next */ public static get SNES(): Resolution { return { width: 256, height: 244 }; }}
export type ViewportUnit = 'pixel' | 'percent';
export interface Resolution { width: number; height: number;}
export interface ViewportDimension { widthUnit?: ViewportUnit; heightUnit?: ViewportUnit; width: number; height: number;}
export interface ScreenOptions { /** * Canvas element to build a screen on */ canvas: HTMLCanvasElement;
/** * Graphics context for the screen */ context: ExcaliburGraphicsContext;
/** * Browser abstraction */ browser: BrowserEvents; /** * Optionally set antialiasing, defaults to true. If set to true, images will be smoothed */ antialiasing?: boolean;
/** * Optionally set the image rendering CSS hint on the canvas element, default is auto */ canvasImageRendering?: 'auto' | 'pixelated'; /** * Optionally override the pixel ratio to use for the screen, otherwise calculated automatically from the browser */ pixelRatio?: number; /** * Optionally specify the actual pixel resolution in width/height pixels (also known as logical resolution), by default the * resolution will be the same as the viewport. Resolution will be overridden by {@apilink DisplayMode.FillContainer} and * {@apilink DisplayMode.FillScreen}. */ resolution?: Resolution; /** * Visual viewport size in css pixel, if resolution is not specified it will be the same as the viewport */ viewport: ViewportDimension; /** * Set the display mode of the screen, by default DisplayMode.Fixed. */ displayMode?: DisplayMode;}
/** * Fires when the screen resizes, useful if you have logic that needs to be aware of resolution/viewport constraints */export interface ScreenResizeEvent { /** * Current viewport in css pixels of the screen */ viewport: ViewportDimension; /** * Current resolution in world pixels of the screen */ resolution: Resolution;}
/** * Fires when the pixel ratio changes, useful to know if you've moved to a hidpi screen or back */export interface PixelRatioChangeEvent { /** * Current pixel ratio of the screen */ pixelRatio: number;}
/** * Fires when the browser fullscreen api is successfully engaged or disengaged */export interface FullscreenChangeEvent { /** * Current fullscreen state */ fullscreen: boolean;}
/** * Built in events supported by all entities */export interface ScreenEvents { /** * Fires when the screen resizes, useful if you have logic that needs to be aware of resolution/viewport constraints */ resize: ScreenResizeEvent; /** * Fires when the pixel ratio changes, useful to know if you've moved to a hidpi screen or back */ pixelratio: PixelRatioChangeEvent; /** * Fires when the browser fullscreen api is successfully engaged or disengaged */ fullscreen: FullscreenChangeEvent;}
export const ScreenEvents = { ScreenResize: 'resize', PixelRatioChange: 'pixelratio', FullscreenChange: 'fullscreen'} as const;
/** * The Screen handles all aspects of interacting with the screen for Excalibur. */export class Screen { public graphicsContext: ExcaliburGraphicsContext; /** * Listen to screen events {@apilink ScreenEvents} */ public events = new EventEmitter<ScreenEvents>(); private _canvas: HTMLCanvasElement; private _antialiasing: boolean = true; private _canvasImageRendering: 'auto' | 'pixelated' = 'auto'; private _contentResolution: Resolution; private _browser: BrowserEvents; private _camera!: Camera; private _resolution!: Resolution; private _resolutionStack: Resolution[] = []; private _viewport!: ViewportDimension; private _viewportStack: ViewportDimension[] = []; private _pixelRatioOverride: number | undefined; private _displayMode: DisplayMode; private _isFullscreen = false; private _mediaQueryList!: MediaQueryList; private _isDisposed = false; private _logger = Logger.getInstance(); private _resizeObserver!: ResizeObserver;
constructor(options: ScreenOptions) { this.viewport = options.viewport; this.resolution = options.resolution ?? { ...this.viewport }; this._contentResolution = this.resolution; this._displayMode = options.displayMode ?? DisplayMode.Fixed; this._canvas = options.canvas; this.graphicsContext = options.context; this._antialiasing = options.antialiasing ?? this._antialiasing; this._canvasImageRendering = options.canvasImageRendering ?? this._canvasImageRendering; this._browser = options.browser; this._pixelRatioOverride = options.pixelRatio;
this._applyDisplayMode();
this._listenForPixelRatio();
this._canvas.addEventListener('fullscreenchange', this._fullscreenChangeHandler); this.applyResolutionAndViewport(); }
private _listenForPixelRatio() { if (this._mediaQueryList && !this._mediaQueryList.addEventListener) { // Safari <=13.1 workaround, remove any existing handlers this._mediaQueryList.removeListener(this._pixelRatioChangeHandler); } this._mediaQueryList = this._browser.window.nativeComponent.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
// Safari <=13.1 workaround if (this._mediaQueryList.addEventListener) { this._mediaQueryList.addEventListener('change', this._pixelRatioChangeHandler, { once: true }); } else { this._mediaQueryList.addListener(this._pixelRatioChangeHandler); } }
public dispose(): void { if (!this._isDisposed) { // Clean up handlers this._isDisposed = true; this.events.clear(); this._browser.window.off('resize', this._resizeHandler); this._browser.window.clear(); if (this._resizeObserver) { this._resizeObserver.disconnect(); } if (!(this.parent instanceof Window)) { this.parent.removeEventListener('resize', this._resizeHandler); } // Safari <=13.1 workaround if (this._mediaQueryList.removeEventListener) { this._mediaQueryList.removeEventListener('change', this._pixelRatioChangeHandler); } else { this._mediaQueryList.removeListener(this._pixelRatioChangeHandler); } this._canvas.removeEventListener('fullscreenchange', this._fullscreenChangeHandler); this._canvas = null as any; } }
private _fullscreenChangeHandler = () => { if (this._isDisposed) { return; } this._isFullscreen = !this._isFullscreen; this._logger.debug('Fullscreen Change', this._isFullscreen); this.events.emit('fullscreen', { fullscreen: this.isFullscreen } satisfies FullscreenChangeEvent); };
private _pixelRatioChangeHandler = () => { if (this._isDisposed) { return; } this._logger.debug('Pixel Ratio Change', window.devicePixelRatio); this._listenForPixelRatio(); this._devicePixelRatio = this._calculateDevicePixelRatio(); this.applyResolutionAndViewport();
this.events.emit('pixelratio', { pixelRatio: this.pixelRatio } satisfies PixelRatioChangeEvent); };
private _resizeHandler = () => { if (this._isDisposed) { return; } const parent = this.parent; this._logger.debug('View port resized'); this._setResolutionAndViewportByDisplayMode(parent); this.applyResolutionAndViewport();
// Emit resize event this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); };
private _calculateDevicePixelRatio() { if (window.devicePixelRatio < 1) { return 1; }
const devicePixelRatio = window.devicePixelRatio || 1;
return devicePixelRatio; }
// Asking the window.devicePixelRatio is expensive we do it once private _devicePixelRatio = this._calculateDevicePixelRatio();
/** * Returns the computed pixel ratio, first using any override, then the device pixel ratio */ public get pixelRatio(): number { if (this._pixelRatioOverride) { return this._pixelRatioOverride; }
return this._devicePixelRatio; }
/** * This calculates the ratio between excalibur pixels and the HTML pixels. * * This is useful for scaling HTML UI so that it matches your game. */ public get worldToPagePixelRatio(): number { if (this._canvas) { const pageOrigin = this.worldToPageCoordinates(Vector.Zero); const pageDistance = this.worldToPageCoordinates(vec(1, 0)).sub(pageOrigin); const pixelConversion = pageDistance.x; return pixelConversion; } else { return 1; } }
/** * Get or set the pixel ratio override * * You will need to call applyResolutionAndViewport() affect change on the screen */ public get pixelRatioOverride(): number | undefined { return this._pixelRatioOverride; }
public set pixelRatioOverride(value: number | undefined) { this._pixelRatioOverride = value; }
public get isHiDpi() { return this.pixelRatio !== 1; }
public get displayMode(): DisplayMode { return this._displayMode; }
public get canvas(): HTMLCanvasElement { return this._canvas; }
public get parent(): HTMLElement | Window { switch (this.displayMode) { case DisplayMode.FillContainer: case DisplayMode.FitContainer: case DisplayMode.FitContainerAndFill: case DisplayMode.FitContainerAndZoom: return this.canvas.parentElement || document.body; default: return window; } }
public get resolution(): Resolution { return this._resolution; }
public set resolution(resolution: Resolution) { this._resolution = resolution; }
/** * Returns screen dimensions in pixels or percentage */ public get viewport(): ViewportDimension { if (this._viewport) { return this._viewport; } return this._resolution; }
public set viewport(viewport: ViewportDimension) { this._viewport = viewport; }
public get aspectRatio() { return this._resolution.width / this._resolution.height; }
public get scaledWidth() { return this._resolution.width * this.pixelRatio; }
public get scaledHeight() { return this._resolution.height * this.pixelRatio; }
public setCurrentCamera(camera: Camera) { this._camera = camera; }
public pushResolutionAndViewport() { this._resolutionStack.push(this.resolution); this._viewportStack.push(this.viewport);
this.resolution = { ...this.resolution }; this.viewport = { ...this.viewport }; }
public peekViewport(): ViewportDimension { return this._viewportStack[this._viewportStack.length - 1]; }
public peekResolution(): Resolution { return this._resolutionStack[this._resolutionStack.length - 1]; }
public popResolutionAndViewport() { if (this._resolutionStack.length && this._viewportStack.length) { // FIXME we should probably bomb if this is ever undefined this.resolution = this._resolutionStack.pop()!; this.viewport = this._viewportStack.pop()!; } }
public applyResolutionAndViewport() { if (this.graphicsContext instanceof ExcaliburGraphicsContextWebGL) { const scaledResolutionSupported = this.graphicsContext.checkIfResolutionSupported({ width: this.scaledWidth, height: this.scaledHeight }); if (!scaledResolutionSupported) { this._logger.warnOnce( `The currently configured resolution (${this.resolution.width}x${this.resolution.height}) and pixel ratio (${this.pixelRatio})` + ' are too large for the platform WebGL implementation, this may work but cause WebGL rendering to behave oddly.' + ' Try reducing the resolution or disabling Hi DPI scaling to avoid this' + ' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).' );
// Attempt to recover if the user hasn't configured a specific ratio for up scaling if (!this.pixelRatioOverride) { let currentPixelRatio = Math.max(1, this.pixelRatio - 0.5); let newResolutionSupported = false; while (currentPixelRatio > 1 && !newResolutionSupported) { currentPixelRatio = Math.max(1, currentPixelRatio - 0.5); const width = this._resolution.width * currentPixelRatio; const height = this._resolution.height * currentPixelRatio; newResolutionSupported = this.graphicsContext.checkIfResolutionSupported({ width, height }); } this.pixelRatioOverride = currentPixelRatio; this._logger.warnOnce( 'Scaled resolution too big attempted recovery!' + ` Pixel ratio was automatically reduced to (${this.pixelRatio}) to avoid 4k texture limit.` + ' Setting `ex.Engine({pixelRatio: ...}) will override any automatic recalculation, do so at your own risk.` ' + ' (read more here https://excaliburjs.com/docs/screens#understanding-viewport--resolution).' ); } } }
this._canvas.width = this.scaledWidth; this._canvas.height = this.scaledHeight;
if (this._canvasImageRendering === 'auto') { this._canvas.style.imageRendering = 'auto'; } else { this._canvas.style.imageRendering = 'pixelated'; // Fall back to 'crisp-edges' if 'pixelated' is not supported // Currently for firefox https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering if (this._canvas.style.imageRendering === '') { this._canvas.style.imageRendering = 'crisp-edges'; } }
const widthUnit = this.viewport.widthUnit === 'percent' ? '%' : 'px'; const heightUnit = this.viewport.heightUnit === 'percent' ? '%' : 'px';
this._canvas.style.width = this.viewport.width + widthUnit; this._canvas.style.height = this.viewport.height + heightUnit;
// After messing with the canvas width/height the graphics context is invalidated and needs to have some properties reset this.graphicsContext.updateViewport(this.resolution); this.graphicsContext.resetTransform(); this.graphicsContext.smoothing = this._antialiasing; if (this.graphicsContext instanceof ExcaliburGraphicsContext2DCanvas) { this.graphicsContext.scale(this.pixelRatio, this.pixelRatio); } // Add the excalibur world pixel to page pixel document.documentElement.style.setProperty('--ex-pixel-ratio', this.worldToPagePixelRatio.toString()); }
/** * Get or set screen antialiasing, * * If true smoothing is applied */ public get antialiasing() { return this._antialiasing; }
/** * Get or set screen antialiasing */ public set antialiasing(isSmooth: boolean) { this._antialiasing = isSmooth; this.graphicsContext.smoothing = this._antialiasing; }
/** * Returns true if excalibur is fullscreen using the browser fullscreen api */ public get isFullscreen() { return this._isFullscreen; }
/** * Requests to enter fullscreen using the browser fullscreen api, requires user interaction to be successful. * For example, wire this to a user click handler. * * Optionally specify a target element id to go fullscreen, by default the game canvas is used * @param elementId */ public enterFullscreen(elementId?: string): Promise<void> { if (elementId) { const maybeElement = document.getElementById(elementId); // workaround for safari partial support if (maybeElement?.requestFullscreen || (maybeElement as any)?.webkitRequestFullscreen) { if (!maybeElement?.getAttribute('ex-fullscreen-listener')) { maybeElement!.setAttribute('ex-fullscreen-listener', 'true'); maybeElement!.addEventListener('fullscreenchange', this._fullscreenChangeHandler); } if (maybeElement?.requestFullscreen) { return maybeElement.requestFullscreen() ?? Promise.resolve(); } else if ((maybeElement as any).webkitRequestFullscreen) { return (maybeElement as any).webkitRequestFullscreen() ?? Promise.resolve(); } } } if (this._canvas?.requestFullscreen) { return this._canvas?.requestFullscreen() ?? Promise.resolve(); } else if ((this._canvas as any).webkitRequestFullscreen) { return (this._canvas as any).webkitRequestFullscreen() ?? Promise.resolve(); } this._logger.warnOnce('Could not go fullscreen, is this an iPhone? Currently Apple does not support fullscreen on iPhones'); return Promise.resolve(); }
public exitFullscreen(): Promise<void> { return document.exitFullscreen(); }
private _viewportToPixels(viewport: ViewportDimension) { return { width: viewport.widthUnit === 'percent' ? this.canvas.offsetWidth : viewport.width, height: viewport.heightUnit === 'percent' ? this.canvas.offsetHeight : viewport.height } satisfies ViewportDimension; }
/** * Takes a coordinate in normal html page space, for example from a pointer move event, and translates it to * Excalibur screen space. * * Excalibur screen space is rooted at the top-left (0, 0) of the {@apilink Screen.contentArea} (the safe * content area), and extends to (contentArea.width, contentArea.height). Anywhere the safe area differs from * the full resolution (e.g. {@apilink DisplayMode.FitScreenAndFill}, {@apilink DisplayMode.FitContainerAndFill}, * {@apilink DisplayMode.FitScreenAndZoom}, {@apilink DisplayMode.FitContainerAndZoom}) the screen-space origin * is shifted into the safe area, not the raw canvas top-left. * * This matches how {@apilink ScreenElement | `screen elements`} and {@apilink CoordPlane.Screen} entities are * drawn: their local (0, 0) sits at {@apilink Screen.contentArea}.topLeft. * @param point */ public pageToScreenCoordinates(point: Vector): Vector { let newX = point.x; let newY = point.y;
if (!this._isFullscreen) { newX -= getPosition(this._canvas).x; newY -= getPosition(this._canvas).y; }
const viewport = this._viewportToPixels(this.viewport);
// if fullscreen api on it centers with black bars // we need to adjust the screen to world coordinates in this case if (this._isFullscreen) { if (window.innerWidth / this.aspectRatio < window.innerHeight) { const screenHeight = window.innerWidth / this.aspectRatio; const screenMarginY = (window.innerHeight - screenHeight) / 2; newY = ((newY - screenMarginY) / screenHeight) * viewport.height; newX = (newX / window.innerWidth) * viewport.width; } else { const screenWidth = window.innerHeight * this.aspectRatio; const screenMarginX = (window.innerWidth - screenWidth) / 2; newX = ((newX - screenMarginX) / screenWidth) * viewport.width; newY = (newY / window.innerHeight) * viewport.height; } }
newX = (newX / viewport.width) * this.resolution.width; newY = (newY / viewport.height) * this.resolution.height;
newX = newX - this.contentAreaOffset.x; newY = newY - this.contentAreaOffset.y;
return new Vector(newX, newY); }
/** * Takes a coordinate in Excalibur screen space, and translates it to normal html page space. For example, * this is where html elements might live if you want to position them relative to Excalibur. * * Excalibur screen space is rooted at the top-left (0, 0) of the {@apilink Screen.contentArea} (the safe * content area), the inverse of {@apilink Screen.pageToScreenCoordinates}. * @param point */ public screenToPageCoordinates(point: Vector): Vector { let newX = point.x; let newY = point.y;
newX = newX + this.contentAreaOffset.x; newY = newY + this.contentAreaOffset.y;
const viewport = this._viewportToPixels(this.viewport);
newX = (newX / this.resolution.width) * viewport.width; newY = (newY / this.resolution.height) * viewport.height;
if (this._isFullscreen) { if (window.innerWidth / this.aspectRatio < window.innerHeight) { const screenHeight = window.innerWidth / this.aspectRatio; const screenMarginY = (window.innerHeight - screenHeight) / 2; newY = (newY / viewport.height) * screenHeight + screenMarginY; newX = (newX / viewport.width) * window.innerWidth; } else { const screenWidth = window.innerHeight * this.aspectRatio; const screenMarginX = (window.innerWidth - screenWidth) / 2; newX = (newX / viewport.width) * screenWidth + screenMarginX; newY = (newY / viewport.height) * window.innerHeight; } }
if (!this._isFullscreen) { newX += getPosition(this._canvas).x; newY += getPosition(this._canvas).y; }
return new Vector(newX, newY); }
/** * Takes a coordinate in Excalibur screen space, and translates it to Excalibur world space. * * The screen coordinate is rooted at the top-left of the {@apilink Screen.contentArea} (the safe content area), * matching how {@apilink ScreenElement | `screen elements`} and {@apilink CoordPlane.Screen} entities are drawn. * * World space is where {@apilink Entity | `entities`} in Excalibur live by default {@apilink CoordPlane.World} * and extends infinitely out relative from the {@apilink Camera}. * @param point Screen coordinate to convert */ public screenToWorldCoordinates(point: Vector): Vector { point = point.add(this.contentAreaOffset);
// the only difference between screen & world is the camera transform if (this._camera) { return this._camera.inverse.multiply(point); } return point.sub(vec(this.resolution.width / 2, this.resolution.height / 2)); }
/** * Takes a coordinate in Excalibur world space, and translates it to Excalibur screen space. * * Screen space is where {@apilink ScreenElement | `screen elements`} and {@apilink Entity | `entities`} with * {@apilink CoordPlane.Screen} live. The returned coordinate is rooted at the top-left of the * {@apilink Screen.contentArea} (the safe content area), matching how `CoordPlane.Screen` entities are drawn. * @param point World coordinate to convert */ public worldToScreenCoordinates(point: Vector): Vector { let screenPoint: Vector; if (this._camera) { screenPoint = this._camera.transform.multiply(point); } else { screenPoint = point.add(vec(this.resolution.width / 2, this.resolution.height / 2)); }
return screenPoint.sub(this.contentAreaOffset); }
public pageToWorldCoordinates(point: Vector): Vector { const screen = this.pageToScreenCoordinates(point); return this.screenToWorldCoordinates(screen); }
public worldToPageCoordinates(point: Vector): Vector { const screen = this.worldToScreenCoordinates(point); return this.screenToPageCoordinates(screen); }
/** * Returns a BoundingBox of the top left corner of the screen * and the bottom right corner of the screen. * * World bounds are in world coordinates, useful for culling objects offscreen that are in world space */ public getWorldBounds(): BoundingBox { const bounds = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Half) .scale(vec(1 / this._camera.zoom, 1 / this._camera.zoom)) .rotate(this._camera.rotation) .translate(this._camera.drawPos); return bounds; }
/** * Returns a BoundingBox of the top left corner of the screen and the bottom right corner of the screen. * * Screen bounds are in screen coordinates, useful for culling objects offscreen that are in screen space */ public getScreenBounds(): BoundingBox { const bounds = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero, Vector.Zero); return bounds; }
/** * The width of the game canvas in pixels (physical width component of the * resolution of the canvas element) */ public get canvasWidth(): number { return this.canvas.width; }
/** * Returns half width of the game canvas in pixels (half physical width component) */ public get halfCanvasWidth(): number { return this.canvas.width / 2; }
/** * The height of the game canvas in pixels, (physical height component of * the resolution of the canvas element) */ public get canvasHeight(): number { return this.canvas.height; }
/** * Returns half height of the game canvas in pixels (half physical height component) */ public get halfCanvasHeight(): number { return this.canvas.height / 2; }
/** * Returns the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ public get drawWidth(): number { if (this._camera) { return this.resolution.width / this._camera.zoom; } return this.resolution.width; }
/** * Returns the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ public get width(): number { if (this._camera) { return this.resolution.width / this._camera.zoom; } return this.resolution.width; }
/** * Returns half the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ public get halfDrawWidth(): number { return this.drawWidth / 2; }
/** * Returns the height of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ public get drawHeight(): number { if (this._camera) { return this.resolution.height / this._camera.zoom; } return this.resolution.height; }
public get height(): number { if (this._camera) { return this.resolution.height / this._camera.zoom; } return this.resolution.height; }
/** * Returns half the height of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ public get halfDrawHeight(): number { return this.drawHeight / 2; }
/** * Returns screen center coordinates including zoom and device pixel ratio. */ public get center(): Vector { return vec(this.halfDrawWidth, this.halfDrawHeight); }
/** * Returns the content area in screen space where it is safe to place content. * * Screen space is rooted at the top-left of the content area, so * `contentArea.topLeft` is always `(0, 0)` and `contentArea.bottomRight` is * `(contentArea.width, contentArea.height)`. To convert a screen-space point * back to the raw canvas/resolution frame, add * {@apilink Screen.contentAreaOffset}. * * // TODO(phase-3): a `CoordPlane.Canvas` could expose the raw resolution * frame to users that need true canvas-corner coordinates. */ public get contentArea(): BoundingBox { return this._contentArea; }
/** * Returns the unsafe area in screen space, this is the full screen and some space may not be onscreen. * * The unsafe area spans the full resolution, expressed in the same * content-area-rooted screen space as {@apilink Screen.contentArea}: when the * content area is inset from the canvas (e.g. {@apilink DisplayMode.FitScreenAndFill}), * `unsafeArea.topLeft` is negative by the clip amount and `unsafeArea.width` * equals {@apilink Screen.resolution}.width. */ public get unsafeArea(): BoundingBox { return this._unsafeArea; }
/** * Resolution-space offset of the safe content area's top-left corner. `(0, 0)` * when the content area fills the canvas (no clipping); positive when the * safe area is inset (e.g. under {@apilink DisplayMode.FitScreenAndFill}). * * Use this to convert between the {@apilink CoordPlane.Screen} frame (rooted * at {@apilink Screen.contentArea}.topLeft) and the raw canvas/resolution * frame (rooted at the canvas top-left). This is the value the engine * translates by before drawing {@apilink CoordPlane.Screen} entities, and the * inverse offset applied by {@apilink Screen.pageToScreenCoordinates} and * {@apilink Screen.screenToWorldCoordinates}. */ public get contentAreaOffset(): Vector { return this._contentAreaOffset; }
private _contentArea: BoundingBox = new BoundingBox(); private _unsafeArea: BoundingBox = new BoundingBox(); private _contentAreaOffset: Vector = Vector.Zero;
private _computeFit() { document.body.style.margin = '0px'; document.body.style.overflow = 'hidden'; const aspect = this.aspectRatio; let adjustedWidth = 0; let adjustedHeight = 0; if (window.innerWidth / aspect < window.innerHeight) { adjustedWidth = window.innerWidth; adjustedHeight = window.innerWidth / aspect; } else { adjustedWidth = window.innerHeight * aspect; adjustedHeight = window.innerHeight; }
this.viewport = { width: adjustedWidth, height: adjustedHeight }; this._contentAreaOffset = Vector.Zero; this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero); this._unsafeArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _computeFitScreenAndFill() { document.body.style.margin = '0px'; document.body.style.overflow = 'hidden'; const vw = window.innerWidth; const vh = window.innerHeight; this._computeFitAndFill(vw, vh); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _computeFitContainerAndFill() { this.canvas.style.width = '100%'; this.canvas.style.height = '100%';
this._computeFitAndFill(this.canvas.offsetWidth, this.canvas.offsetHeight, { width: 100, widthUnit: 'percent', height: 100, heightUnit: 'percent' }); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _computeFitAndFill(vw: number, vh: number, viewport?: ViewportDimension) { this.viewport = viewport ?? { width: vw, height: vh }; // if the current screen aspectRatio is less than the original aspectRatio if (vw / vh <= this._contentResolution.width / this._contentResolution.height) { // compute new resolution to match the original aspect ratio this.resolution = { width: (vw * this._contentResolution.width) / vw, height: (((vw * this._contentResolution.width) / vw) * vh) / vw }; const clip = (this.resolution.height - this._contentResolution.height) / 2; this._contentAreaOffset = vec(0, clip); this._contentArea = new BoundingBox({ top: 0, left: 0, right: this._contentResolution.width, bottom: this._contentResolution.height }); this._unsafeArea = new BoundingBox({ top: -clip, left: 0, right: this._contentResolution.width, bottom: this._contentResolution.height + clip }); } else { this.resolution = { width: (((vh * this._contentResolution.height) / vh) * vw) / vh, height: (vh * this._contentResolution.height) / vh }; const clip = (this.resolution.width - this._contentResolution.width) / 2; this._contentAreaOffset = vec(clip, 0); this._contentArea = new BoundingBox({ top: 0, left: 0, right: this._contentResolution.width, bottom: this._contentResolution.height }); this._unsafeArea = new BoundingBox({ top: 0, left: -clip, right: this._contentResolution.width + clip, bottom: this._contentResolution.height }); } }
private _computeFitScreenAndZoom() { document.body.style.margin = '0px'; document.body.style.overflow = 'hidden'; this.canvas.style.position = 'absolute';
const vw = window.innerWidth; const vh = window.innerHeight;
this._computeFitAndZoom(vw, vh); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _computeFitContainerAndZoom() { this.canvas.style.width = '100%'; this.canvas.style.height = '100%'; this.canvas.style.position = 'relative'; const parent = this.canvas.parentElement; parent!.style.overflow = 'hidden'; const { offsetWidth: vw, offsetHeight: vh } = this.canvas;
this._computeFitAndZoom(vw, vh); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _computeFitAndZoom(vw: number, vh: number) { const aspect = this.aspectRatio; let adjustedWidth = 0; let adjustedHeight = 0; if (vw / aspect < vh) { adjustedWidth = vw; adjustedHeight = vw / aspect; } else { adjustedWidth = vh * aspect; adjustedHeight = vh; }
const scaleX = vw / adjustedWidth; const scaleY = vh / adjustedHeight;
const maxScaleFactor = Math.max(scaleX, scaleY);
const zoomedWidth = adjustedWidth * maxScaleFactor; const zoomedHeight = adjustedHeight * maxScaleFactor;
// Center zoomed dimension if bigger than the screen if (zoomedWidth > vw) { this.canvas.style.left = -(zoomedWidth - vw) / 2 + 'px'; } else { this.canvas.style.left = ''; }
if (zoomedHeight > vh) { this.canvas.style.top = -(zoomedHeight - vh) / 2 + 'px'; } else { this.canvas.style.top = ''; }
this.viewport = { width: zoomedWidth, height: zoomedHeight };
let offsetX = 0; let offsetY = 0; let contentWidth = this.resolution.width; let contentHeight = this.resolution.height; if (this.viewport.width > vw) { const clip = ((this.viewport.width - vw) / this.viewport.width) * this.resolution.width; offsetX = clip / 2; contentWidth = this.resolution.width - clip; } if (this.viewport.height > vh) { const clip = ((this.viewport.height - vh) / this.viewport.height) * this.resolution.height; offsetY = clip / 2; contentHeight = this.resolution.height - clip; }
this._contentAreaOffset = vec(offsetX, offsetY); this._contentArea = new BoundingBox({ left: 0, top: 0, right: contentWidth, bottom: contentHeight }); // `|| 0` keeps the left/top as +0 (not -0) on the non-clipping axis so // downstream `Object.is(x, 0)` checks behave consistently. this._unsafeArea = new BoundingBox({ left: -offsetX || 0, top: -offsetY || 0, right: this.resolution.width - offsetX, bottom: this.resolution.height - offsetY }); }
private _computeFitContainer() { const aspect = this.aspectRatio; let adjustedWidth = 0; let adjustedHeight = 0; let widthUnit: ViewportUnit = 'pixel'; let heightUnit: ViewportUnit = 'pixel'; const parent = this.canvas.parentElement; if (parent!.clientWidth / aspect < parent!.clientHeight) { this.canvas.style.width = '100%'; adjustedWidth = 100; widthUnit = 'percent'; adjustedHeight = this.canvas.offsetWidth / aspect; } else { this.canvas.style.height = '100%'; adjustedHeight = 100; heightUnit = 'percent'; adjustedWidth = this.canvas.offsetHeight * aspect; }
this.viewport = { width: adjustedWidth, widthUnit, height: adjustedHeight, heightUnit }; this._contentAreaOffset = Vector.Zero; this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero); this._unsafeArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero); this.events.emit('resize', { resolution: this.resolution, viewport: this.viewport } satisfies ScreenResizeEvent); }
private _applyDisplayMode() { this._setResolutionAndViewportByDisplayMode(this.parent);
// watch resizing if (this.parent instanceof Window) { this._browser.window.on('resize', this._resizeHandler); } else { this._resizeObserver = new ResizeObserver(() => { this._resizeHandler(); }); this._resizeObserver.observe(this.parent); this.parent.addEventListener('resize', this._resizeHandler); } }
/** * Sets the resolution and viewport based on the selected display mode. */ private _setResolutionAndViewportByDisplayMode(parent: HTMLElement | Window) { if (this.displayMode === DisplayMode.FillContainer) { this.canvas.style.width = '100%'; this.canvas.style.height = '100%'; this.viewport = { width: 100, widthUnit: 'percent', height: 100, heightUnit: 'percent' }; this.resolution = { width: this.canvas.offsetWidth, height: this.canvas.offsetHeight }; }
if (this.displayMode === DisplayMode.FillScreen) { document.body.style.margin = '0px'; document.body.style.overflow = 'hidden'; this.resolution = { width: (<Window>parent).innerWidth, height: (<Window>parent).innerHeight };
this.viewport = this.resolution; }
this._contentAreaOffset = Vector.Zero; this._contentArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero); this._unsafeArea = BoundingBox.fromDimension(this.resolution.width, this.resolution.height, Vector.Zero);
if (this.displayMode === DisplayMode.FitScreen) { this._computeFit(); }
if (this.displayMode === DisplayMode.FitContainer) { this._computeFitContainer(); }
if (this.displayMode === DisplayMode.FitScreenAndFill) { this._computeFitScreenAndFill(); }
if (this.displayMode === DisplayMode.FitContainerAndFill) { this._computeFitContainerAndFill(); }
if (this.displayMode === DisplayMode.FitScreenAndZoom) { this._computeFitScreenAndZoom(); }
if (this.displayMode === DisplayMode.FitContainerAndZoom) { this._computeFitContainerAndZoom(); } }}