UNPKG

stage-js

Version:

2D HTML5 Rendering and Layout

441 lines (371 loc) 11.3 kB
import stats from "../common/stats"; import { Matrix } from "../common/matrix"; import { renderAxis } from "./debug"; import { Component } from "./component"; import { Pointer } from "./pointer"; import { fit, FitMode, getIID, isValidFitMode } from "./pin"; /** @internal */ const ROOTS: Root[] = []; export function pause() { for (let i = ROOTS.length - 1; i >= 0; i--) { ROOTS[i].pause(); } } export function resume() { for (let i = ROOTS.length - 1; i >= 0; i--) { ROOTS[i].resume(); } } export function mount(configs: RootConfig = {}) { const root = new Root(); // todo: root.use(new Pointer()); root.mount(configs); // todo: maybe just pass root? or do root.use(pointer) root.pointer = new Pointer().mount(root, root.dom as HTMLElement); return root; } type RootConfig = { canvas?: string | HTMLCanvasElement; }; /** * Geometry of the rectangular that the application takes on the screen. */ export type Viewport = { width: number; height: number; ratio: number; }; /** * Geometry of a rectangular portion of the game that is projected on the screen. */ export type Viewbox = { x?: number; y?: number; width: number; height: number; mode?: FitMode; }; let DEFAULT_CANVAS_MOUNTED = false; export class Root extends Component { canvas: HTMLCanvasElement | null = null; dom: HTMLCanvasElement | null = null; context: CanvasRenderingContext2D | null = null; /** @internal */ clientWidth = -1; /** @internal */ clientHeight = -1; /** @internal */ pixelRatio = 1; /** @internal */ canvasWidth = 0; /** @internal */ canvasHeight = 0; mounted = false; paused = false; sleep = false; /** @internal */ devicePixelRatio: number; /** @internal */ backingStoreRatio: number; /** @internal */ pointer: Pointer; /** @internal */ _viewport: Viewport; /** @internal */ _viewbox: Viewbox; constructor() { super(); this.label("Root"); } mount = (configs: RootConfig = {}) => { if (typeof configs.canvas === "string") { this.canvas = document.getElementById(configs.canvas) as HTMLCanvasElement; if (!this.canvas) { console.error("Canvas element not found: ", configs.canvas); } } else if (configs.canvas instanceof HTMLCanvasElement) { this.canvas = configs.canvas; } else if (configs.canvas) { console.error("Unknown value for canvas:", configs.canvas); } if (!this.canvas) { this.canvas = (document.getElementById("cutjs") || document.getElementById("stage")) as HTMLCanvasElement; } if (!this.canvas) { if (DEFAULT_CANVAS_MOUNTED) { throw new Error( "Default canvas element is already mounted. Please provide a canvas element or an id of a canvas element to mount.", ); } DEFAULT_CANVAS_MOUNTED = true; console.debug && console.debug("Creating canvas element..."); this.canvas = document.createElement("canvas"); Object.assign(this.canvas.style, { position: "absolute", display: "block", top: "0", left: "0", bottom: "0", right: "0", width: "100%", height: "100%", }); const body = document.body; body.insertBefore(this.canvas, body.firstChild); } if (this.canvas["__STAGE_MOUNTED"]) { console.error("Canvas element is already mounted: ", this.canvas); } this.canvas["__STAGE_MOUNTED"] = true; this.dom = this.canvas; this.context = this.canvas.getContext("2d"); this.devicePixelRatio = window.devicePixelRatio || 1; this.backingStoreRatio = this.context["webkitBackingStorePixelRatio"] || this.context["mozBackingStorePixelRatio"] || this.context["msBackingStorePixelRatio"] || this.context["oBackingStorePixelRatio"] || this.context["backingStorePixelRatio"] || 1; this.pixelRatio = this.devicePixelRatio / this.backingStoreRatio; // resize(); // window.addEventListener('resize', resize, false); // window.addEventListener('orientationchange', resize, false); this.mounted = true; ROOTS.push(this); this.requestFrame(); }; /** @internal */ frameRequested = false; /** @internal */ requestFrame = () => { // one request at a time if (!this.frameRequested) { this.frameRequested = true; requestAnimationFrame(this.onFrame); } }; /** @internal */ _lastFrameTime = 0; /** @internal */ _mo_touch: number | null = null; // monitor touch resizeCanvas() { const newClientWidth = this.canvas.clientWidth; const newClientHeight = this.canvas.clientHeight; // canvas display size is not changed if (this.clientWidth === newClientWidth && this.clientHeight === newClientHeight) return; this.clientWidth = newClientWidth; this.clientHeight = newClientHeight; const notStyled = this.canvas.clientWidth === this.canvas.width && this.canvas.clientHeight === this.canvas.height; let pixelRatio: number; if (notStyled) { // If element is not styled, changing canvas rendering size will change its display size, // which creates a loop of resizing. So we ignore pixel ratio and keep current rendering size. pixelRatio = 1; this.canvasWidth = this.canvas.width; this.canvasHeight = this.canvas.height; } else { pixelRatio = this.pixelRatio; this.canvasWidth = this.clientWidth * pixelRatio; this.canvasHeight = this.clientHeight * pixelRatio; if (this.canvas.width !== this.canvasWidth || this.canvas.height !== this.canvasHeight) { // canvas rendering size is changed this.canvas.width = this.canvasWidth; this.canvas.height = this.canvasHeight; } } console.debug && console.debug( "Resize: [" + this.canvasWidth + ", " + this.canvasHeight + "] = " + pixelRatio + " x [" + this.clientWidth + ", " + this.clientHeight + "]", ); this.viewport({ width: this.canvasWidth, height: this.canvasHeight, ratio: pixelRatio, }); } /** @internal */ onFrame = (now: number) => { this.frameRequested = false; if (!this.mounted || !this.canvas || !this.context) { return; } this.requestFrame(); this.resizeCanvas(); const last = this._lastFrameTime || now; const elapsed = now - last; if (!this.mounted || this.paused || this.sleep) { return; } this._lastFrameTime = now; this.prerender(); const tickRequest = this._tick(elapsed, now, last); if (this._mo_touch != this._ts_touch) { // something changed since last call this._mo_touch = this._ts_touch; this.sleep = false; if (this.canvasWidth > 0 && this.canvasHeight > 0) { this.context.setTransform(1, 0, 0, 1, 0, 0); this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.render(this.context); } } else if (tickRequest) { // nothing changed, but a component requested next tick this.sleep = false; } else { // nothing changed, and no component requested next tick this.sleep = true; } stats.fps = elapsed ? 1000 / elapsed : 0; }; renderDebug(ctx: CanvasRenderingContext2D, m: Matrix) { if (!this._debug) return; ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); ctx.lineWidth = 3 / m.a; renderAxis(ctx, 10); } resume() { if (this.sleep || this.paused) { this.requestFrame(); } this.paused = false; this.sleep = false; this.publish("resume"); return this; } pause() { if (!this.paused) { this.publish("pause"); } this.paused = true; return this; } /** @internal */ touch() { if (this.sleep || this.paused) { this.requestFrame(); } this.sleep = false; return super.touch(); } unmount() { this.mounted = false; const index = ROOTS.indexOf(this); if (index >= 0) { ROOTS.splice(index, 1); } this.pointer?.unmount(); return this; } background(color: string) { if (this.dom) { this.dom.style.backgroundColor = color; } return this; } /** * Set/Get viewport. * This is used along with viewbox to determine the scale and position of the viewbox within the viewport. * Viewport is the size of the container, for example size of the canvas element. * Viewbox is provided by the user, and is the ideal size of the content. */ viewport(): Viewport; viewport(width: number, height: number, ratio?: number): this; viewport(viewbox: Viewport): this; viewport(width?: number | Viewport, height?: number, ratio?: number) { if (typeof width === "undefined") { // todo: return readonly object instead return Object.assign({}, this._viewport); } if (typeof width === "object") { const options = width; width = options.width; height = options.height; ratio = options.ratio; } if (typeof width === "number" && typeof height === "number") { this._viewport = { width: width, height: height, ratio: typeof ratio === "number" ? ratio : 1, }; this.rescale(); const data = Object.assign({}, this._viewport); this.visit({ start: function (component) { if (!component._flag("viewport")) { return true; } component.publish("viewport", [data]); }, }); } return this; } /** * Set viewbox. */ viewbox(viewbox: Viewbox): this; viewbox(width?: number, height?: number, mode?: FitMode): this; viewbox(width?: number | Viewbox, height?: number, mode?: FitMode): this { // TODO: static/fixed viewbox if (typeof width === "number" && typeof height === "number") { this._viewbox = { width, height, mode, }; } else if (typeof width === "object" && width !== null) { this._viewbox = { ...width, }; } this.rescale(); return this; } /** @hidden */ camera(matrix: Matrix) { this._xf = matrix.clone(); this._pin._ts_transform = getIID(); this.touch(); return this; } /** @internal */ rescale() { const viewbox = this._viewbox; const viewport = this._viewport; if (viewport && viewbox) { const viewboxMode = isValidFitMode(viewbox.mode) ? viewbox.mode : "in-pad"; const fitted = fit( viewbox.width, viewbox.height, viewport.width, viewport.height, viewboxMode, ); this.pin({ width: fitted.width, height: fitted.height, scaleX: fitted.scaleX, scaleY: fitted.scaleY, offsetX: -(viewbox.x || 0) * fitted.scaleX, offsetY: -(viewbox.y || 0) * fitted.scaleY, }); } else if (viewport) { this.pin({ width: viewport.width, height: viewport.height, }); } return this; } /** @hidden */ flipX(x: boolean) { this._pin._directionX = x ? -1 : 1; return this; } /** @hidden */ flipY(y: boolean) { this._pin._directionY = y ? -1 : 1; return this; } }