import {compose, Property} from '@avocado/core'; import {InputNormalizer} from '@avocado/input'; import {Vector} from '@avocado/math'; import {Container} from './container'; import {Renderer} from './renderer'; const decorate = compose( Property('camera', { track: true, }), ); export class Stage extends decorate(Container) { constructor(visibleSize, visibleScale) { super(); const size = Vector.mul(visibleSize, visibleScale); // Container element. this.element = window.document.createElement('div'); this.element.className = 'avocado-stage'; this.element.style.height = '100%'; this.element.style.lineHeight = '0'; this.element.style.position = 'relative'; this.element.style.width = '100%'; // DOM parent. this.parent = undefined; // Set scale. this.scale = visibleScale; // Canvas renderer. this.renderer = new Renderer(size); this.renderer.element.style.width = '100%'; this.renderer.element.style.height = '100%'; // "real" dimensions. this.size = size; // Precalc for position/dimension transformation. this._transformRatio = 1; // UI DOM node. this.ui = this.createUiLayer(); this._queuedFindSelectors = {}; // Event handlers. this.onWindowResize = this.onWindowResize.bind(this); window.addEventListener('resize', this.onWindowResize); // Normalize input. this.inputNormalizer = new InputNormalizer(); this.inputNormalizer.listen(this.element); this.inputNormalizer.on('keyDown', this.onKeyDown, this); this.inputNormalizer.on('keyUp', this.onKeyUp, this); this.inputNormalizer.on('pointerDown', this.onPointerDown, this); this.inputNormalizer.on('pointerMove', this.onPointerMove, this); this.inputNormalizer.on('pointerUp', this.onPointerUp, this); this.inputNormalizer.on('wheel', this.onWheel, this); // Put the renderer and UI in the container element, and mark it // focusable. this.element.appendChild(this.renderer.element); this.element.appendChild(this.ui); this.element.tabIndex = 0; } addToDom(parent) { // Had a parent? Remove. this.removeFromDom(); this.parent = parent; // Add to new parent (if any) and focus. if (parent) { parent.appendChild(this.element); } // Recalculate size and ratio. this.onWindowResize(); } createUiLayer() { const node = window.document.createElement('div'); node.className = 'ui'; node.style.overflow = 'hidden'; node.style.position = 'absolute'; node.style.width = `${this.size[0] / this.scale[0]}px`; node.style.height = `${this.size[1] / this.scale[1]}px`; node.style.left = 0; node.style.top = 0; node.style.transformOrigin = '0 0 0'; return node; } destroy() { window.removeEventListener('resize', this.onWindowResize); this.renderer.destroy(); super.destroy(); if (this.parent) { this.parent.removeChild(this.renderer.element); } this.inputNormalizer.off('keyDown', this.onKeyDown); this.inputNormalizer.off('keyUp', this.onKeyUp); this.inputNormalizer.off('pointerDown', this.onPointerDown); this.inputNormalizer.off('pointerMove', this.onPointerMove); this.inputNormalizer.off('pointerUp', this.onPointerUp); this.inputNormalizer.off('wheel', this.onWheel); this.inputNormalizer.destroy(); } get displaySize() { return [ this.element.style.width, this.element.style.height, ]; } findUiElement(selector) { const node = this.ui.querySelector(selector); if (node) { return Promise.resolve(node); } const queued = this._queuedFindSelectors[selector]; if (queued) { return queued.promise; } let resolve; const promise = new Promise((resolve_) => resolve = resolve_); this._queuedFindSelectors[selector] = {resolve, promise}; return promise } flushUiElements() { const selectors = Object.keys(this._queuedFindSelectors); for (let i = 0; i < selectors.length; i++) { const selector = selectors[i]; const {resolve} = this._queuedFindSelectors[selector]; resolve(this.ui.querySelector(selector)); } this._queuedFindSelectors = {}; } focus() { this.element.focus(); } onKeyDown(event) { this.emit('keyDown', event); } onKeyUp(event) { this.emit('keyUp', event); } onPointerDown(event) { event.position = this.transformCanvasPosition(event.position); this.emit('pointerDown', event); } onPointerMove(event) { event.position = this.transformCanvasPosition(event.position); this.emit('pointerMove', event); } onPointerUp(event) { event.position = this.transformCanvasPosition(event.position); this.emit('pointerUp', event); } onWheel(event) { this.emit('wheel', event); } onWindowResize() { if (!this.parent) { return; } // Find the biggest axe, width or height. const ratio = this.parent.clientWidth / this.parent.clientHeight; const biggest = ratio > (16 / 9) ? 'height' : 'width'; // Key parent client size by axe. const parentClient = { width: this.parent.clientWidth, height: this.parent.clientHeight, }; for (const key of ['height', 'width']) { // Biggest axe? Inherit parent axe size. if (key === biggest) { this.element.style[key] = `${parentClient[biggest]}px`; } // Otherwise, else { // Derive height from width. if ('width' === biggest) { this.element.style.height = `${parentClient[biggest] * 9 / 16}px`; } // Derive width from height. else { this.element.style.width = `${parentClient[biggest] * 16 / 9}px`; } } } // Precalc the transformation ratio and apply it to the UI layer. this._transformRatio = this.size[0] / this.element.clientWidth; const scaleFactor = 1 / this._transformRatio; const scaleX = scaleFactor * this.scale[0]; const scaleY = scaleFactor * this.scale[1]; this.ui.style.transform = `scaleX(${scaleX}) scaleY(${scaleY})`; this.emit('displaySizeChanged', this.displaySize); } removeFromDom() { if (this.parent) { this.parent.removeChild(this.element); this.parent = undefined; } } renderTick(elapsed) { super.renderTick(elapsed); this.renderer.render(this); if (this.camera) { const inverseOffset = Vector.mul( this.camera.realOffset, Vector.scale(this.scale, -1), ); this.position = inverseOffset; } } resolveUiRendered() { this._uiResolve(); } transformCanvasPosition(position) { const rect = this.renderer.element.getBoundingClientRect(); const topLeft = [rect.x, rect.y]; const offset = Vector.sub(position, topLeft); return Vector.div( Vector.scale(offset, this._transformRatio), this.scale, ); } get transformRatio() { return this._transformRatio; } } export class StageEvent { constructor() { this._position = [-1, -1]; this._target = undefined; } get position() { return this._position; } get target() { return this._target; } }