import { 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
*
*
*
*
*
*
* ```
*
* ```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();
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 {
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 {
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: (parent).innerWidth,
height: (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();
}
}
}