playable.js
Version:
A lightweight HTML5 game engine.
693 lines (613 loc) • 17.3 kB
text/typescript
import {Stage} from './Stage';
import {Ticker} from '../system/Ticker';
import {Matrix} from '../geom/Matrix';
import {Vector} from '../geom/Vector';
import {Rectangle} from '../geom/Rectangle';
import {Event} from '../event/Event';
import {TouchEvent} from '../event/TouchEvent';
import {EventEmitter} from '../event/EventEmitter';
export class Layer extends EventEmitter {
public static pixelRatio: number = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
public name: string = '';
public tag: string = '';
public touchable: boolean = true;
protected $x: number = 0;
protected $y: number = 0;
protected $width: number = 0;
protected $height: number = 0;
protected $anchorX: number = 0;
protected $anchorY: number = 0;
protected $skewX: number = 0;
protected $skewY: number = 0;
protected $scaleX: number = 1;
protected $scaleY: number = 1;
protected $rotation: number = 0;
protected $alpha: number = 1;
protected $visible: boolean = true;
protected $smoothing: boolean = true;
protected $background: string = null;
protected $stage: Stage = null;
protected $parent: Layer = null;
protected $children: Array<Layer> = [];
protected $dirty: boolean = true;
protected $shouldEmitTap: boolean = true;
protected $touches: Array<boolean> = [];
protected readonly $canvas: HTMLCanvasElement;
protected readonly $context: CanvasRenderingContext2D;
public constructor() {
super();
this.$canvas = document.createElement('canvas');
this.$context = this.$canvas.getContext('2d');
}
public get x(): number {
return this.$x;
}
public set x(x: number) {
if (this.$x !== x) {
this.$x = x;
this.$markParentDirty();
}
}
public get y(): number {
return this.$y;
}
public set y(y: number) {
if (this.$y !== y) {
this.$y = y;
this.$markParentDirty();
}
}
public get width(): number {
return this.$width ? this.$width : this.$canvas.width / Layer.pixelRatio;
}
public set width(width: number) {
if (this.$width !== width) {
this.$width = width;
this.$resizeCanvas();
}
}
public get height(): number {
return this.$height ? this.$height : this.$canvas.height / Layer.pixelRatio;
}
public set height(height: number) {
if (this.$height !== height) {
this.$height = height;
this.$resizeCanvas();
}
}
public get anchorX(): number {
return this.$anchorX;
}
public set anchorX(anchorX: number) {
if (this.$anchorX !== anchorX) {
this.$anchorX = anchorX;
this.$resizeCanvas();
}
}
public get anchorY(): number {
return this.$anchorY;
}
public set anchorY(anchorY: number) {
if (this.$anchorY !== anchorY) {
this.$anchorY = anchorY;
this.$resizeCanvas();
}
}
public get skewX(): number {
return this.$skewX;
}
public set skewX(skewX: number) {
if (this.$skewX !== skewX) {
this.$skewX = skewX;
this.$markParentDirty();
}
}
public get skewY(): number {
return this.$skewY;
}
public set skewY(skewY: number) {
if (this.$skewY !== skewY) {
this.$skewY = skewY;
this.$markParentDirty();
}
}
public get scaleX(): number {
return this.$scaleX;
}
public set scaleX(scaleX: number) {
if (this.$scaleX !== scaleX) {
this.$scaleX = scaleX;
this.$markParentDirty();
}
}
public get scaleY(): number {
return this.$scaleY;
}
public set scaleY(scaleY: number) {
if (this.$scaleY !== scaleY) {
this.$scaleY = scaleY;
this.$markParentDirty();
}
}
public get rotation(): number {
return this.$rotation;
}
public set rotation(rotation: number) {
if (this.$rotation !== rotation) {
this.$rotation = rotation;
this.$markParentDirty();
}
}
public get alpha(): number {
return this.$alpha;
}
public set alpha(alpha: number) {
if (this.$alpha !== alpha) {
this.$alpha = alpha;
this.$markParentDirty();
}
}
public get visible(): boolean {
return this.$visible;
}
public set visible(visible: boolean) {
if (this.$visible !== visible) {
this.$visible = visible;
this.$markParentDirty();
}
}
public get smoothing(): boolean {
return this.$smoothing;
}
public set smoothing(smoothing: boolean) {
this.$smoothing = smoothing;
this.$resizeCanvas();
}
public get background(): string {
return this.$background;
}
public set background(background: string) {
if (this.$background !== background) {
this.$background = background;
this.$markDirty();
}
}
public get stage(): Stage {
return this.$stage;
}
public get parent(): Layer {
return this.$parent;
}
public get numChildren(): number {
return this.$children.length;
}
public get ticker(): Ticker {
return this.$stage ? this.$stage.ticker : null;
}
public get canvas(): HTMLCanvasElement {
return this.$canvas;
}
public addChild(child: Layer): this {
return this.addChildAt(child, this.$children.length);
}
public addChildAt(child: Layer, index: number): this {
let children = this.$children;
if (child.$parent) {
child.$parent.removeChild(child);
}
if (index < 0 || index > children.length) {
index = children.length;
}
child.$emitAdded(this);
children.splice(index, 0, child);
this.$resizeCanvas();
return this;
}
public replaceChild(oldChild: Layer, newChild: Layer): this {
let index = this.getChildIndex(oldChild);
this.removeChildAt(index);
this.addChildAt(newChild, index);
return this;
}
public getChildByName(name: string): Layer {
let children = this.$children;
for (let child of children) {
if (child.name === name) {
return child;
}
}
return null;
}
public getChildrenByTag(tag: string): Array<Layer> {
let result = [];
let children = this.$children;
for (let child of children) {
if (child.tag === tag) {
result.push(child);
}
}
return result;
}
public getChildAt(index: number): Layer {
return this.$children[index] || null;
}
public getChildIndex(child: Layer): number {
return this.$children.indexOf(child);
}
public hasChild(child: Layer): boolean {
return this.getChildIndex(child) >= 0;
}
public swapChildren(child1: Layer, child2: Layer): this {
let index1 = this.getChildIndex(child1);
let index2 = this.getChildIndex(child2);
if (index1 >= 0 && index2 >= 0) {
this.swapChildrenAt(index1, index2);
}
return this;
}
public swapChildrenAt(index1: number, index2: number): this {
let child1 = this.$children[index1];
let child2 = this.$children[index2];
if (index1 !== index2 && child1 && child2) {
this.$children[index1] = child2;
this.$children[index2] = child1;
this.$markDirty();
}
return this;
}
public setChildIndex(child: Layer, index: number): this {
let children = this.$children;
let oldIndex = this.getChildIndex(child);
if (index < 0) {
index = 0;
} else if (index > children.length) {
index = children.length;
}
if (oldIndex >= 0 && index > oldIndex) {
for (let i = oldIndex + 1; i <= index; ++i) {
children[i - 1] = children[i];
}
children[index] = child;
this.$markDirty();
} else if (oldIndex >= 0 && index < oldIndex) {
for (let i = oldIndex - 1; i >= index; --i) {
children[i + 1] = children[i];
}
children[index] = child;
this.$markDirty();
}
return this;
}
public removeChild(child: Layer): this {
let index = this.getChildIndex(child);
return this.removeChildAt(index);
}
public removeChildAt(index: number): this {
let children = this.$children;
let child = children[index];
if (child) {
children.splice(index, 1);
child.$emitRemoved();
this.$resizeCanvas();
}
return this;
}
public removeChildByName(name: string): this {
let children = this.$children;
for (let i = 0, l = children.length; i < l; ++i) {
let child = children[i];
if (child.name === name) {
this.removeChildAt(i);
break;
}
}
return this;
}
public removeChildrenByTag(tag: string): this {
let children = this.$children;
for (let i = children.length - 1; i >= 0; --i) {
let child = children[i];
if (child.tag === tag) {
this.removeChildAt(i);
}
}
return this;
}
public removeAllChildren(): this {
let children = this.$children;
for (let child of children) {
child.$emitRemoved();
}
this.$children.length = 0;
this.$resizeCanvas();
return this;
}
public removeSelf(): this {
if (this.$parent) {
this.$parent.removeChild(this);
}
return this;
}
protected $markDirty(sizeDirty?: boolean): void {
if (sizeDirty) {
this.$resizeParentCanvas();
} else if (!this.$dirty) {
this.$markParentDirty();
}
this.$dirty = true;
}
protected $markParentDirty(): void {
if (this.$parent) {
this.$parent.$markDirty();
}
}
protected $resizeCanvas(): void {
let width = this.$width;
let height = this.$height;
let canvas = this.$canvas;
let anchorX = this.$anchorX;
let anchorY = this.$anchorY;
let context = this.$context;
let smoothing = this.$smoothing;
let pixelRatio = Layer.pixelRatio;
if (width && height) {
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
} else {
let bounds = this.$getContentBounds();
canvas.width = (width || bounds.right + anchorX) * pixelRatio;
canvas.height = (height || bounds.bottom + anchorY) * pixelRatio;
bounds.release();
}
if (context.imageSmoothingEnabled !== smoothing) {
context.imageSmoothingEnabled = smoothing;
}
this.$markDirty(true);
}
protected $resizeParentCanvas(): void {
if (this.$parent) {
this.$parent.$resizeCanvas();
}
}
protected $getTransform(): Matrix {
let degToRad = Math.PI / 180;
let matrix = Matrix.create();
matrix.translate(-this.$anchorX, -this.$anchorY);
matrix.skew(this.skewX * degToRad, this.skewY * degToRad);
matrix.rotate(this.rotation * degToRad);
matrix.scale(this.scaleX, this.scaleY);
matrix.translate(this.x, this.y);
return matrix;
}
protected $getChildTransform(child: Layer): Matrix {
return child.$getTransform();
}
protected $getChildBounds(child: Layer): Rectangle {
let width = child.width;
let height = child.height;
let bounds = Rectangle.create();
let matrix = this.$getChildTransform(child);
let topLeft = Vector.create(0, 0).transform(matrix);
let topRight = Vector.create(width, 0).transform(matrix);
let bottomLeft = Vector.create(0, height).transform(matrix);
let bottomRight = Vector.create(width, height).transform(matrix);
let minX = Math.min(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
let maxX = Math.max(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x);
let minY = Math.min(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
let maxY = Math.max(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y);
bounds.top = minY;
bounds.bottom = maxY;
bounds.left = minX;
bounds.right = maxX;
matrix.release();
topLeft.release();
topRight.release();
bottomLeft.release();
bottomRight.release();
return bounds;
}
protected $getContentBounds(): Rectangle {
let bounds;
let children = this.$children;
for (let child of children) {
if (child.$visible) {
let childBounds = this.$getChildBounds(child);
if (bounds) {
bounds.top = Math.min(bounds.top, childBounds.top);
bounds.bottom = Math.max(bounds.bottom, childBounds.bottom);
bounds.left = Math.min(bounds.left, childBounds.left);
bounds.right = Math.max(bounds.right, childBounds.right);
childBounds.release();
} else {
bounds = childBounds;
}
}
}
bounds = bounds || Rectangle.create();
return bounds;
}
protected $emitTouchEvent(event: TouchEvent, inside: boolean): boolean {
let type = event.type;
let localX = event.localX;
let localY = event.localY;
let touches = this.$touches;
let identifier = event.identifier;
if (type === TouchEvent.TOUCH_START) {
this.$shouldEmitTap = true;
touches[identifier] = true;
} else if (!touches[identifier]) {
return false;
} else if (type === TouchEvent.TOUCH_TAP || type === TouchEvent.TOUCH_CANCEL) {
touches[identifier] = false;
}
if (type === TouchEvent.TOUCH_MOVE) {
this.$shouldEmitTap = false;
}
let children = this.$children;
for (let i = children.length - 1; i >= 0; --i) {
let child = children[i];
if (!child.$visible || !child.touchable) {
continue;
}
let matrix = this.$getChildTransform(child);
let localPos = Vector.create(localX, localY).transform(matrix.invert()).subtract(child.$anchorX, child.$anchorY);
let inside = child.$localHitTest(localPos);
localPos.release();
matrix.release();
if (inside || type !== TouchEvent.TOUCH_START) {
event.target = child;
event.localX = event.targetX = localPos.x;
event.localY = event.targetY = localPos.y;
if (child.$emitTouchEvent(event, inside)) {
break;
}
}
}
if (type === TouchEvent.TOUCH_TAP && (!inside || !this.$shouldEmitTap)) {
return true;
}
if (!event.cancelBubble) {
event.localX = localX;
event.localY = localY;
this.emit(event);
}
return true;
}
protected $emitAdded(parent: Layer): void {
let stage = parent.$stage;
this.$parent = parent;
this.emit(Event.ADDED);
if (stage) {
this.$emitAddedToStage(stage);
}
}
protected $emitRemoved(): void {
let stage = this.$stage;
this.$parent = null;
this.emit(Event.REMOVED);
if (stage) {
this.$emitRemovedFromStage();
}
}
protected $emitAddedToStage(stage: Stage): void {
let children = this.$children;
this.$stage = stage;
this.emit(Event.ADDED_TO_STAGE);
if (this.hasEventListener(Event.ENTER_FRAME)) {
stage.ticker.registerEnterFrameCallback(this);
}
for (let child of children) {
child.$emitAddedToStage(stage);
}
}
protected $emitRemovedFromStage(): void {
let stage = this.$stage;
let children = this.$children;
this.$stage = null;
this.emit(Event.REMOVED_FROM_STAGE);
if (this.hasEventListener(Event.ENTER_FRAME)) {
stage.ticker.unregisterEnterFrameCallback(this);
}
for (let child of children) {
child.$emitRemovedFromStage();
}
}
protected $localHitTest(vector: Vector): boolean {
return vector.x >= -this.anchorX && vector.x <= this.width - this.anchorX && vector.y >= -this.anchorY && vector.y <= this.height - this.anchorY;
}
protected $isChildVisible(child: Layer): boolean {
if (!child.visible || !child.alpha || !child.width || !child.height) {
return false;
}
let minX = -this.$anchorX;
let maxX = this.width + minX;
let minY = -this.$anchorY;
let maxY = this.height + minY;
let bounds = this.$getChildBounds(child);
let inside = bounds.left <= maxX && bounds.right >= minX && bounds.top <= maxY && bounds.bottom >= minY;
bounds.release();
return inside;
}
protected $drawChild(child: Layer): number {
let ctx = this.$context;
let canvas = child.$canvas;
let width = child.width;
let height = child.height;
let pixelRatio = Layer.pixelRatio;
let matrix = this.$getChildTransform(child).scale(pixelRatio);
let drawCalls = child.$render();
let globalAlpha = ctx.globalAlpha;
if (globalAlpha !== child.alpha) {
ctx.globalAlpha = child.alpha;
}
if (matrix.b === 0 && matrix.c === 0) {
let tx = (matrix.tx + 0.5) | 0;
let ty = (matrix.ty + 0.5) | 0;
width = (width * matrix.a) + 0.5 | 0;
height = (height * matrix.d) + 0.5 | 0;
ctx.drawImage(canvas, tx, ty, width, height);
} else {
ctx.save();
ctx.transform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.tx, matrix.ty);
ctx.drawImage(canvas, 0, 0, width, height);
ctx.restore();
}
if (globalAlpha !== child.alpha) {
ctx.globalAlpha = globalAlpha;
}
matrix.release();
return drawCalls + 1;
}
protected $render(): number {
if (!this.$dirty) {
return 0;
}
let drawCalls = 0;
let ctx = this.$context;
let canvas = this.$canvas;
let children = this.$children;
let canvasWidth = canvas.width;
let canvasHeight = canvas.height;
let anchorX = (this.$anchorX + 0.5) | 0;
let anchorY = (this.$anchorY + 0.5) | 0;
let background = this.$background;
let pixelRatio = Layer.pixelRatio;
ctx.globalAlpha = 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (background) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
}
ctx.translate(anchorX * pixelRatio, anchorY * pixelRatio);
for (let child of children) {
if (this.$isChildVisible(child)) {
drawCalls += this.$drawChild(child);
}
}
this.$dirty = false;
return drawCalls;
}
public on(type: string, listener: (...args: any[]) => void): this {
super.on(type, listener);
if (type === Event.ENTER_FRAME && this.ticker) {
this.ticker.registerEnterFrameCallback(this);
} else if (type === Event.ADDED && this.$parent) {
let event = Event.create(type);
listener.call(this, event);
event.release();
} else if (type === Event.ADDED_TO_STAGE && this.$stage) {
let event = Event.create(type);
listener.call(this, event);
event.release();
}
return this;
}
public off(type: string, listener?: (...args: any[]) => void): this {
super.off(type, listener);
if (type === Event.ENTER_FRAME && !this.hasEventListener(Event.ENTER_FRAME) && this.ticker) {
this.ticker.unregisterEnterFrameCallback(this);
}
return this;
}
}