src/node/scene.js
import {ENV} from '@mesh.js/core';
import {requestAnimationFrame} from '../utils/animation-frame';
import Node from './node';
import Layer from './layer';
import LayerWorker from './layer-worker';
import Group from './group';
import createPointerEvents from '../event/pointer-events';
import Event from '../event/event';
import {loadTexture, loadFrames} from '../utils/texture';
import ownerDocument from '../document';
const _enteredTargets = Symbol('enteredTargets');
function wrapLayer(layer) {
// append dom element
layer.id = layer.id || `_layer${Math.random().toString(36).slice(2, 12)}`;
if(!layer.dataset) {
layer.dataset = {};
}
layer.dataset.layerId = layer.id;
// fixed layer replacer
layer.connect = (parent, zOrder) => {
layer.parent = parent;
Object.defineProperty(layer, 'zOrder', {
value: zOrder,
writable: false,
configurable: true,
});
};
layer.disconnect = (parent) => {
delete layer.zOrder;
};
layer.canvas = layer;
layer.getResolution = () => { return {width: 0, height: 0} };
layer.setResolution = () => false;
layer.options = {handleEvent: false};
return layer;
}
function getRefCanvas(scene, layer) {
const children = scene.children;
let ref = null;
for(let i = 0; i < children.length; i++) {
const child = children[i];
if(layer === child || ref != null) {
ref = child;
}
if(ref && ref !== layer && !ref.offscreen) {
return ref.canvas;
}
}
return null;
}
function drawImage(layer, offscreenLayer) {
const [left, top] = layer.renderOffset;
const {width, height} = layer.getResolution();
const displayRatio = layer.displayRatio;
layer.renderer.drawImage(offscreenLayer.canvas, -left / displayRatio, -top / displayRatio, width / displayRatio, height / displayRatio);
}
const touchEventCapturedTargets = {};
function delegateEvents(scene) {
const events = ['mousedown', 'mouseup', 'mousemove', 'mousewheel', 'wheel',
'touchstart', 'touchend', 'touchmove', 'touchcancel',
'click', 'dblclick', 'longpress', 'tap', 'contextmenu'];
const container = scene.container;
container.addEventListener('mouseleave', (event) => {
const enteredTargets = scene[_enteredTargets];
if(enteredTargets.size) {
const leaveEvent = new Event('mouseleave');
leaveEvent.setOriginalEvent(event);
[...enteredTargets].forEach((target) => {
target.dispatchEvent(leaveEvent);
});
scene[_enteredTargets].clear();
}
}, {passive: true});
events.forEach((eventType) => {
container.addEventListener(eventType, (event) => {
const {left, top, displayRatio} = scene.options;
const layers = scene.orderedChildren;
const pointerEvents = createPointerEvents(event, {offsetLeft: left, offsetTop: top, displayRatio});
pointerEvents.forEach((evt) => {
// evt.scene = scene;
const id = evt.identifier;
if(evt.type === 'touchmove' || evt.type === 'touchend') {
const capturedTarget = touchEventCapturedTargets[id];
if(capturedTarget) capturedTarget.dispatchEvent(evt);
if(evt.type === 'touchend') delete touchEventCapturedTargets[id];
} else {
for(let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if(layer.options.handleEvent !== false) {
const ret = layer.dispatchPointerEvent(evt);
if(ret && evt.target !== layer) break;
else evt.cancelBubble = false; // prepare passing to next layer
}
}
if(evt.target === layers[0]) {
// trigger event on top layer
for(let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i];
if(layer.options.handleEvent !== false) {
evt.target = layer;
break;
}
}
}
}
const target = evt.target;
if(evt.type === 'touchstart') {
touchEventCapturedTargets[id] = evt.target; // set captured event target
}
if(evt.type === 'mousemove') {
const enteredTargets = scene[_enteredTargets];
let enterSet;
if(target) {
const ancestors = target.ancestors || [];
enterSet = new Set([target, ...ancestors]);
} else {
enterSet = new Set();
}
const entries = Object.entries(event);
if(!enteredTargets.has(target)) {
if(target) {
const enterEvent = new Event('mouseenter');
enterEvent.setOriginalEvent(event);
entries.forEach(([key, value]) => {
enterEvent[key] = value;
});
enteredTargets.add(target);
target.dispatchEvent(enterEvent);
const ancestors = target.ancestors;
if(ancestors) {
ancestors.forEach((ancestor) => {
if(ancestor instanceof Node && !enteredTargets.has(ancestor)) {
enteredTargets.add(ancestor);
ancestor.dispatchEvent(enterEvent);
}
});
}
}
}
const leaveEvent = new Event('mouseleave');
leaveEvent.setOriginalEvent(event);
entries.forEach(([key, value]) => {
leaveEvent[key] = value;
});
[...enteredTargets].forEach((target) => {
if(!enterSet.has(target)) {
enteredTargets.delete(target);
target.dispatchEvent(leaveEvent);
}
});
}
});
}, {passive: eventType !== 'contextmenu'});
});
}
function setViewport(options, canvas) {
if(canvas && canvas.style) {
let {width, height, mode, container} = options;
const {clientWidth, clientHeight} = container;
width = width || clientWidth;
height = height || clientHeight;
if(mode === 'static') {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// canvas.style.top = '50%';
// canvas.style.left = '50%';
// canvas.style.transform = 'translate(-50%, -50%)';
// canvas.style.webkitTransform = 'translate(-50%, -50%)';
} else {
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = `${clientWidth}px`;
canvas.style.height = `${clientHeight}px`;
canvas.style.transform = '';
canvas.style.webkitTransform = '';
}
}
}
const _offscreenLayerCount = Symbol('offscreenLayerCount');
export default class Scene extends Group {
/**
width
height
mode: 'static', 'scale', 'stickyWidth', 'stickyHeight', 'stickyTop', 'stickyBottom', 'stickyLeft', 'stickyRight'
*/
constructor(options = {}) {
super();
if(!options.container) {
if(typeof ENV.Container === 'function') {
options.container = new ENV.Container(options.width || 300, options.height || 150);
} else {
throw new Error('No container specified.');
}
}
this.container = options.container;
if(this.container.style) {
if(!this.container.style.overflow) {
this.container.style.overflow = 'hidden';
}
if(!this.container.style.position) {
this.container.style.position = 'relative';
}
}
this.options = options;
options.displayRatio = options.displayRatio || 1.0;
options.mode = options.mode || 'scale';
options.left = 0;
options.top = 0;
options.autoResize = options.autoResize !== false;
if(options.autoResize) {
let self; // cross platform
if(typeof globalThis !== 'undefined') {
self = globalThis;
} else {
self = typeof window !== 'undefined' ? window : global;
}
if(self.addEventListener) {
const that = this;
self.addEventListener('resize', function listener() {
if(typeof document !== 'undefined' && document.contains(that.container)) {
that.resize();
} else {
self.removeEventListener('resize', listener);
}
});
}
}
this[_enteredTargets] = new Set();
this.setResolution(options);
delegateEvents(this);
this[_offscreenLayerCount] = 0;
}
get hasOffscreenCanvas() {
return this[_offscreenLayerCount] > 0;
}
set displayRatio(value) {
const oldValue = this.options.displayRatio;
if(oldValue !== value) {
this.options.displayRatio = value;
this.resize();
}
}
get displayRatio() {
return this.options.displayRatio;
}
set height(value) {
const oldValue = this.options.height;
if(oldValue !== value) {
this.options.height = value;
this.resize();
}
}
get height() {
return this.options.height;
}
set mode(value) {
const oldValue = this.options.mode;
if(oldValue !== value) {
this.options.mode = value;
this.resize();
}
}
get mode() {
return this.options.mode;
}
set width(value) {
const oldValue = this.options.width;
if(oldValue !== value) {
this.options.width = value;
this.resize();
}
}
get width() {
return this.options.width;
}
/* override */
appendChild(layer) {
if(!(layer instanceof Layer) && !(layer instanceof LayerWorker)) {
wrapLayer(layer);
}
const ret = super.appendChild(layer);
const canvas = layer.canvas;
if(!layer.offscreen) {
this.container.appendChild(canvas);
} else {
this[_offscreenLayerCount]++;
}
setViewport(this.options, canvas);
layer.setResolution(this.getResolution());
return ret;
}
/* override */
forceUpdate() {
if(this.hasOffscreenCanvas && !this._requestID) {
this._requestID = requestAnimationFrame(() => {
delete this._requestID;
this.render();
});
}
}
/* override */
insertBefore(layer, ref) {
if(!(layer instanceof Layer) && !(layer instanceof LayerWorker)) {
wrapLayer(layer);
}
const ret = super.insertBefore(layer, ref);
const canvas = layer.canvas;
if(!layer.offscreen) {
const refChild = getRefCanvas(this, layer);
this.container.insertBefore(canvas, refChild);
}
setViewport(this.options, canvas);
layer.setResolution(this.getResolution());
return ret;
}
layer(id = 'default', options = {}) {
options = Object.assign({}, this.options, options);
options.id = id;
const layers = this.orderedChildren;
for(let i = 0; i < layers.length; i++) {
if(layers[i].id === id) return layers[i];
}
const worker = options.worker;
let layer;
if(worker) {
layer = new LayerWorker(options);
} else {
layer = new Layer(options);
// layer.id = id;
}
this.appendChild(layer);
return layer;
}
async preload(...resources) {
const loaded = [],
tasks = [],
ret = [];
for(let i = 0; i < resources.length; i++) {
const res = resources[i];
let task;
if(typeof res === 'string') {
task = loadTexture(res);
} else if(Array.isArray(res)) {
task = loadFrames(...res);
} else {
const {id, src} = res;
task = loadTexture(src, id);
}
/* istanbul ignore if */
if(!(task instanceof Promise)) {
task = Promise.resolve(task);
}
tasks.push(task.then((r) => {
loaded.push(r);
ret[i] = r;
const preloadEvent = new Event({type: 'preload', detail: {current: r, loaded, resources}});
this.dispatchEvent(preloadEvent);
}));
}
await Promise.all(tasks);
return ret;
}
/* override */
removeChild(layer) {
const ret = super.removeChild(layer);
if(ret) {
if(layer._prepareRenderFinished) layer._prepareRenderFinished();
const canvas = layer.canvas;
if(canvas && canvas.remove) canvas.remove();
if(layer.offscreen) this[_offscreenLayerCount]--;
}
return ret;
}
// for offscreen mode rendering
render() {
const layers = this.orderedChildren;
let hostLayer = null;
const offscreens = [];
for(let i = 0; i < layers.length; i++) {
const layer = layers[i];
const hasOffscreens = offscreens.length > 0;
if(layer instanceof Layer && !layer.offscreen) {
if(!layer.autoRender) {
if(hasOffscreens) {
console.warn('Some offscreen canvas will not be rendered.');
offscreens.length = 0;
}
} else {
hostLayer = layer;
if(hasOffscreens) {
layer.renderer.clear();
for(let j = 0; j < offscreens.length; j++) {
const ol = offscreens[j];
ol.render();
drawImage(layer, ol);
}
offscreens.length = 0;
layer.render({clear: false});
} else if(layer.prepareRender) {
layer.render();
}
}
} else if(layer.offscreen) {
if(hostLayer) {
if(layer.prepareRender) layer.render();
drawImage(hostLayer, layer);
} else {
offscreens.push(layer);
}
} else if(layer instanceof LayerWorker && hasOffscreens) {
console.warn('Some offscreen canvas will not be rendered.');
offscreens.length = 0;
}
}
}
/* override */
replaceChild(layer, ref) {
const ret = super.replaceChild(layer, ref);
if(ref.canvas.remove) ref.canvas.remove();
if(ref.offscreen) this[_offscreenLayerCount]--;
const canvas = layer.canvas;
if(!layer.offscreen) {
const refChild = getRefCanvas(this, layer);
this.container.insertBefore(canvas, refChild);
}
setViewport(this.options, canvas);
layer.setResolution(this.getResolution());
return ret;
}
resize() {
const options = this.options;
this.children.forEach((layer) => {
setViewport(options, layer.canvas);
});
this.setResolution(options);
this.dispatchEvent({type: 'resize'});
}
/* override */
setResolution({width, height} = {}) {
const container = this.container;
const {clientWidth, clientHeight} = container;
if(width == null || height == null) {
width = width == null ? clientWidth : width;
height = height == null ? clientHeight : height;
}
const {mode, displayRatio} = this.options;
width *= displayRatio;
height *= displayRatio;
this.options.left = 0;
this.options.top = 0;
if(mode === 'stickyHeight' || mode === 'stickyLeft' || mode === 'stickyRight') {
const w = width;
width = clientWidth * height / clientHeight;
if(mode === 'stickyHeight') this.options.left = 0.5 * (width - w);
if(mode === 'stickyRight') this.options.left = width - w;
} else if(mode === 'stickyWidth' || mode === 'stickyTop' || mode === 'stickyBottom') {
const h = height;
height = clientHeight * width / clientWidth;
if(mode === 'stickyWidth') this.options.top = 0.5 * (height - h);
if(mode === 'stickyBottom') this.options.top = height - h;
}
super.setResolution({width, height});
}
snapshot({offscreen = false, layers} = {}) {
const _canvas = offscreen ? 'snapshotOffScreenCanvas' : 'snapshotCanvas';
const {width, height} = this.getResolution();
this[_canvas] = this[_canvas] || ENV.createCanvas(width, height, {offscreen});
const context = this[_canvas].getContext('2d');
layers = layers || this.orderedChildren;
// context.clearRect(0, 0, width, height);
this[_canvas].width = width;
this[_canvas].height = height;
for(let i = 0; i < layers.length; i++) {
const layer = layers[i];
if(!layer.options.ignoreSnapshot) {
if(layer.render) layer.render();
const canvas = layer.canvas;
if(canvas && canvas !== layer) {
context.drawImage(canvas, 0, 0, width, height);
}
}
}
return this[_canvas];
}
}
if(typeof document !== 'undefined') {
Scene.prototype.layer3d = function (id, options = {}) {
const url = options.url || 'https://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.min.js';
return new Promise((resolve, reject) => {
const el = document.createElement('script');
el.async = false;
el.src = url;
el.onload = () => {
try {
resolve(this.layer3d(id, options));
} catch (ex) {
reject(ex);
}
};
document.documentElement.appendChild(el);
});
};
}
ownerDocument.registerNode(Scene, 'scene');