stage-js
Version:
2D HTML5 Rendering and Layout
441 lines (371 loc) • 11.3 kB
text/typescript
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;
}
}