stage-js
Version:
2D HTML5 Rendering and Layout
452 lines (379 loc) • 11.8 kB
text/typescript
import stats from "../common/stats";
import { Matrix } from "../common/matrix";
import { Component } from "./component";
import { Pointer } from "./pointer";
import { FitMode, 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;
};
export class Root extends Component {
canvas: HTMLCanvasElement | null = null;
dom: HTMLCanvasElement | null = null;
context: CanvasRenderingContext2D | null = null;
/** @internal */ pixelWidth = -1;
/** @internal */ pixelHeight = -1;
/** @internal */ pixelRatio = 1;
/** @internal */ drawingWidth = 0;
/** @internal */ drawingHeight = 0;
mounted = false;
paused = false;
sleep = false;
/** @internal */ devicePixelRatio: number;
/** @internal */ backingStoreRatio: number;
/** @internal */ pointer: Pointer;
/** @internal */ _viewport: Viewport;
/** @internal */ _viewbox: Viewbox;
/** @internal */ _camera: Matrix;
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) {
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);
}
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
/** @internal */
onFrame = (now: number) => {
this.frameRequested = false;
if (!this.mounted || !this.canvas || !this.context) {
return;
}
this.requestFrame();
const newPixelWidth = this.canvas.clientWidth;
const newPixelHeight = this.canvas.clientHeight;
if (this.pixelWidth !== newPixelWidth || this.pixelHeight !== newPixelHeight) {
// viewport pixel size is not the same as last time
this.pixelWidth = newPixelWidth;
this.pixelHeight = newPixelHeight;
this.drawingWidth = newPixelWidth * this.pixelRatio;
this.drawingHeight = newPixelHeight * this.pixelRatio;
if (this.canvas.width !== this.drawingWidth || this.canvas.height !== this.drawingHeight) {
// canvas size doesn't math
this.canvas.width = this.drawingWidth;
this.canvas.height = this.drawingHeight;
console.debug && console.debug(
"Resize: [" +
this.drawingWidth +
", " +
this.drawingHeight +
"] = " +
this.pixelRatio +
" x [" +
this.pixelWidth +
", " +
this.pixelHeight +
"]",
);
this.viewport({
width: this.drawingWidth,
height: this.drawingHeight,
ratio: this.pixelRatio,
});
}
}
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.drawingWidth > 0 && this.drawingHeight > 0) {
this.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.clearRect(0, 0, this.drawingWidth, this.drawingHeight);
if (this.debugDrawAxis > 0) {
this.renderDebug(this.context);
}
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;
};
/** @hidden */
debugDrawAxis = 0;
private renderDebug(context: CanvasRenderingContext2D): void {
const size = typeof this.debugDrawAxis === "number" ? this.debugDrawAxis : 10;
const m = this.matrix();
context.setTransform(m.a, m.b, m.c, m.d, m.e, m.f);
const lineWidth = 3 / m.a;
context.beginPath();
context.moveTo(0, 0);
context.lineTo(0, 0.8 * size);
context.lineTo(-0.2 * size, 0.8 * size);
context.lineTo(0, size);
context.lineTo(+0.2 * size, 0.8 * size);
context.lineTo(0, 0.8 * size);
context.strokeStyle = 'rgba(93, 173, 226)';
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = lineWidth;
context.stroke();
context.beginPath();
context.moveTo(0, 0);
context.lineTo(0.8 * size, 0);
context.lineTo(0.8 * size, -0.2 * size);
context.lineTo(size, 0);
context.lineTo(0.8 * size, +0.2 * size);
context.lineTo(0.8 * size, 0);
context.strokeStyle = 'rgba(236, 112, 99)';
context.lineJoin = "round";
context.lineCap = "round";
context.lineWidth = lineWidth;
context.stroke();
}
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.viewbox();
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
// TODO: use css object-fit values
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;
}
camera(matrix: Matrix) {
this._camera = matrix;
this.rescale();
return this;
}
/** @internal */
rescale() {
const viewbox = this._viewbox;
const viewport = this._viewport;
const camera = this._camera;
if (viewport && viewbox) {
const viewportWidth = viewport.width;
const viewportHeight = viewport.height;
const viewboxMode = isValidFitMode(viewbox.mode) ? viewbox.mode : "in-pad";
const viewboxWidth = viewbox.width;
const viewboxHeight = viewbox.height;
this.pin({
width: viewboxWidth,
height: viewboxHeight,
});
this.fit(viewportWidth, viewportHeight, viewboxMode);
const viewboxX = viewbox.x || 0;
const viewboxY = viewbox.y || 0;
const cameraZoomX = camera?.a || 1;
const cameraZoomY = camera?.d || 1;
const cameraX = camera?.e || 0;
const cameraY = camera?.f || 0;
const scaleX = this.pin("scaleX");
const scaleY = this.pin("scaleY");
this.pin("scaleX", scaleX * cameraZoomX);
this.pin("scaleY", scaleY * cameraZoomY);
this.pin("offsetX", cameraX - viewboxX * scaleX * cameraZoomX);
this.pin("offsetY", cameraY - viewboxY * scaleY * cameraZoomY);
} 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;
}
}