@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
725 lines (724 loc) • 25.8 kB
JavaScript
/**
* PIXI Rendering Layer
*
* PIXI.js-based rendering implementation with viewport support.
*/
import { Viewport } from "pixi-viewport";
import * as PIXI from "pixi.js";
import { FRAMES_TO_TICKS_MULTIPLIER } from "../../lib/internals";
import { asNodeId, asSpritesheetId, asTextureId, } from "../types";
import { EventCallbackManager } from "../utils/EventCallbackManager";
import { calculateBoundsWithAnchor } from "../utils/boundsCalculation";
import { normalizeColor } from "../utils/colorUtils";
import { withNode } from "../utils/nodeHelpers";
export class PixiRenderingLayer {
constructor(canvas, options) {
this.viewportOptions = null;
this.nodes = new Map();
this.nextNodeId = 1;
this.nextTextureId = 1;
this.nextSpritesheetId = 1;
this.textures = new Map();
this.spritesheets = new Map();
this._needsRepaint = false;
this.initialized = false;
this.tickCallbackManager = new EventCallbackManager();
this.tickerCallbackAdded = false;
this.animationCompleteCallbacks = new Map();
this.canvas = canvas;
this.options = options;
}
async init() {
if (this.initialized)
return;
this.app = new PIXI.Application();
await this.app.init({
canvas: this.canvas,
width: this.canvas.width,
height: this.canvas.height,
autoDensity: true,
resolution: window.devicePixelRatio || 1,
});
this.viewport = new Viewport({
screenWidth: this.app.screen.width,
screenHeight: this.app.screen.height,
worldWidth: this.options.worldWidth ?? this.app.screen.width,
worldHeight: this.options.worldHeight ?? this.app.screen.height,
events: this.app.renderer.events,
});
const worldWidth = this.options.worldWidth ?? this.app.screen.width;
const worldHeight = this.options.worldHeight ?? this.app.screen.height;
this.viewport.moveCenter(worldWidth / 2, worldHeight / 2);
if (this.options.maxScale !== undefined ||
this.options.minScale !== undefined) {
this.viewport.clampZoom({
maxScale: this.options.maxScale,
minScale: this.options.minScale,
});
}
if (this.viewportOptions) {
if (this.viewportOptions.enableDrag)
this.viewport.drag();
if (this.viewportOptions.enablePinch)
this.viewport.pinch();
if (this.viewportOptions.enableZoom)
this.viewport.wheel();
}
this.app.stage.addChild(this.viewport);
this.initialized = true;
}
get needsRepaint() {
return this._needsRepaint;
}
destroy() {
this.tickCallbackManager.clear();
this.viewport.destroy();
this.app.destroy(true, { children: true, texture: false });
this.nodes.clear();
this.textures.clear();
this.spritesheets.clear();
}
createNode() {
const id = asNodeId(this.nextNodeId++);
const container = new PIXI.Container();
this.nodes.set(id, {
container,
size: { width: 0, height: 0 },
anchor: { x: 0, y: 0 },
tint: null,
blendMode: "normal",
textureFiltering: "linear",
currentSprite: null,
graphics: null,
graphicsCommands: [],
animationData: null,
isAnimationPlaying: false,
});
this.viewport.addChild(container);
this._needsRepaint = true;
return id;
}
destroyNode(id) {
const state = this.nodes.get(id);
if (!state)
return;
state.container.destroy({ children: true });
this.nodes.delete(id);
this.animationCompleteCallbacks.delete(id);
this._needsRepaint = true;
}
hasNode(id) {
return this.nodes.has(id);
}
addChild(parentId, childId) {
const parent = this.nodes.get(parentId);
const child = this.nodes.get(childId);
if (!parent || !child)
return;
if (child.container.parent) {
child.container.parent.removeChild(child.container);
}
parent.container.addChild(child.container);
this._needsRepaint = true;
}
removeChild(parentId, childId) {
const parent = this.nodes.get(parentId);
const child = this.nodes.get(childId);
if (!parent || !child)
return;
parent.container.removeChild(child.container);
this.viewport.addChild(child.container);
this._needsRepaint = true;
}
getChildren(id) {
const state = this.nodes.get(id);
if (!state)
return [];
const children = [];
for (const [nodeId, nodeState] of this.nodes) {
if (nodeState.container.parent === state.container) {
children.push(nodeId);
}
}
return children;
}
getParent(id) {
const state = this.nodes.get(id);
if (!state || !state.container.parent)
return null;
for (const [nodeId, nodeState] of this.nodes) {
if (nodeState.container === state.container.parent) {
return nodeId;
}
}
return null;
}
setPosition(id, x, y) {
withNode(this.nodes, id, (state) => {
state.container.x = x;
state.container.y = y;
this._needsRepaint = true;
});
}
getPosition(id) {
return withNode(this.nodes, id, (state) => ({ x: state.container.x, y: state.container.y }), { x: 0, y: 0 });
}
setRotation(id, radians) {
withNode(this.nodes, id, (state) => {
state.container.rotation = radians;
this._needsRepaint = true;
});
}
getRotation(id) {
return withNode(this.nodes, id, (state) => state.container.rotation, 0);
}
setScale(id, scaleX, scaleY) {
withNode(this.nodes, id, (state) => {
state.container.scale.set(scaleX, scaleY);
this._needsRepaint = true;
});
}
getScale(id) {
return withNode(this.nodes, id, (state) => ({ x: state.container.scale.x, y: state.container.scale.y }), { x: 1, y: 1 });
}
setAnchor(id, anchorX, anchorY) {
const state = this.nodes.get(id);
if (!state)
return;
state.anchor = { x: anchorX, y: anchorY };
if (state.currentSprite && "anchor" in state.currentSprite) {
state.currentSprite.anchor.set(anchorX, anchorY);
}
this._needsRepaint = true;
}
getAnchor(id) {
return withNode(this.nodes, id, (state) => state.anchor, { x: 0, y: 0 });
}
setAlpha(id, alpha) {
withNode(this.nodes, id, (state) => {
state.container.alpha = alpha;
this._needsRepaint = true;
});
}
getAlpha(id) {
return withNode(this.nodes, id, (state) => state.container.alpha, 1);
}
setVisible(id, visible) {
withNode(this.nodes, id, (state) => {
state.container.visible = visible;
this._needsRepaint = true;
});
}
getVisible(id) {
return withNode(this.nodes, id, (state) => state.container.visible, true);
}
setZIndex(id, z) {
withNode(this.nodes, id, (state) => {
state.container.zIndex = z;
this._needsRepaint = true;
});
}
getZIndex(id) {
return withNode(this.nodes, id, (state) => state.container.zIndex, 0);
}
setSize(id, width, height) {
withNode(this.nodes, id, (state) => {
state.size = { width, height };
if (state.currentSprite) {
state.currentSprite.width = width;
state.currentSprite.height = height;
}
this._needsRepaint = true;
});
}
getSize(id) {
return withNode(this.nodes, id, (state) => state.size, {
width: 0,
height: 0,
});
}
setTint(id, color) {
const state = this.nodes.get(id);
if (!state)
return;
state.tint = color;
if (state.currentSprite) {
state.currentSprite.tint = normalizeColor(color);
}
this._needsRepaint = true;
}
getTint(id) {
return withNode(this.nodes, id, (state) => state.tint, null);
}
setBlendMode(id, mode) {
const state = this.nodes.get(id);
if (!state)
return;
state.blendMode = mode;
const pixiBlendMode = this.toPixiBlendMode(mode);
if (state.currentSprite) {
state.currentSprite.blendMode = pixiBlendMode;
}
if (state.graphics) {
state.graphics.blendMode = pixiBlendMode;
}
this._needsRepaint = true;
}
getBlendMode(id) {
return withNode(this.nodes, id, (state) => state.blendMode, "normal");
}
setTextureFiltering(id, filtering) {
const state = this.nodes.get(id);
if (!state)
return;
state.textureFiltering = filtering;
if (state.currentSprite && state.currentSprite.texture) {
state.currentSprite.texture.source.scaleMode =
filtering === "nearest" ? "nearest" : "linear";
}
this._needsRepaint = true;
}
getTextureFiltering(id) {
return withNode(this.nodes, id, (state) => state.textureFiltering, "linear");
}
getBounds(id) {
return withNode(this.nodes, id, (state) => calculateBoundsWithAnchor({ x: state.container.x, y: state.container.y }, state.size, state.anchor), { x: 0, y: 0, width: 0, height: 0 });
}
async loadTexture(url) {
const id = asTextureId(this.nextTextureId++);
try {
const texture = await PIXI.Assets.load(url);
this.textures.set(id, texture);
}
catch (_error) {
const errorTexture = this.createErrorTexture();
this.textures.set(id, errorTexture);
}
return id;
}
async loadSpritesheet(imageUrl, jsonData) {
const id = asSpritesheetId(this.nextSpritesheetId++);
// Load the texture for the spritesheet image
const baseTexture = await PIXI.Assets.load(imageUrl);
// Normalize JSON format
let normalizedJson = jsonData;
if (Array.isArray(jsonData.frames)) {
normalizedJson = {
...jsonData,
frames: Object.fromEntries(jsonData.frames.map((frame) => [frame.filename, frame])),
};
}
// Create PIXI.Spritesheet from the texture and JSON data
const pixiSpritesheet = new PIXI.Spritesheet(baseTexture, normalizedJson);
await pixiSpritesheet.parse();
// Store frame mappings
const frames = new Map();
for (const frameName of Object.keys(pixiSpritesheet.textures)) {
const frameTexture = pixiSpritesheet.textures[frameName];
const textureId = asTextureId(this.nextTextureId++);
this.textures.set(textureId, frameTexture);
frames.set(frameName, textureId);
}
this.spritesheets.set(id, { pixiSpritesheet, frames });
return id;
}
setSprite(id, textureId) {
const state = this.nodes.get(id);
if (!state)
return;
this.clearCurrentVisual(state);
const texture = this.textures.get(textureId) ?? PIXI.Texture.EMPTY;
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(state.anchor.x, state.anchor.y);
if (state.size.width > 0) {
sprite.width = state.size.width;
sprite.height = state.size.height;
}
if (state.tint !== null) {
sprite.tint = normalizeColor(state.tint);
}
sprite.blendMode = this.toPixiBlendMode(state.blendMode);
if (texture.source) {
texture.source.scaleMode =
state.textureFiltering === "nearest" ? "nearest" : "linear";
}
state.currentSprite = sprite;
state.container.addChild(sprite);
this._needsRepaint = true;
}
setSpriteFromSpritesheet(id, _spritesheetId, _tileX, _tileY) {
const state = this.nodes.get(id);
if (!state)
return;
this.clearCurrentVisual(state);
this._needsRepaint = true;
}
setAnimatedSprite(id, textureIds, ticksPerFrame) {
const state = this.nodes.get(id);
if (!state)
return;
state.animationData = {
textures: textureIds,
ticksPerFrame,
loop: false,
};
this.clearCurrentVisual(state);
const textures = textureIds.map((tid) => this.textures.get(tid) ?? PIXI.Texture.EMPTY);
const animatedSprite = new PIXI.AnimatedSprite(textures);
animatedSprite.anchor.set(state.anchor.x, state.anchor.y);
animatedSprite.animationSpeed = 1 / ticksPerFrame;
if (state.size.width > 0) {
animatedSprite.width = state.size.width;
animatedSprite.height = state.size.height;
}
if (state.tint !== null) {
animatedSprite.tint = normalizeColor(state.tint);
}
animatedSprite.blendMode = this.toPixiBlendMode(state.blendMode);
state.currentSprite = animatedSprite;
state.container.addChild(animatedSprite);
this._needsRepaint = true;
}
playAnimation(id, loop) {
const state = this.nodes.get(id);
if (!state || !state.animationData)
return;
state.animationData.loop = loop;
state.isAnimationPlaying = true;
if (state.currentSprite &&
state.currentSprite instanceof PIXI.AnimatedSprite) {
state.currentSprite.loop = loop;
if (!loop) {
const callback = this.animationCompleteCallbacks.get(id);
if (callback) {
state.currentSprite.onComplete = () => callback(id);
}
}
else {
state.currentSprite.onComplete = undefined;
}
state.currentSprite.play();
}
this._needsRepaint = true;
}
setAnimationCompleteCallback(id, callback) {
const state = this.nodes.get(id);
if (!state)
return;
if (callback) {
this.animationCompleteCallbacks.set(id, callback);
}
else {
this.animationCompleteCallbacks.delete(id);
}
if (state.currentSprite &&
state.currentSprite instanceof PIXI.AnimatedSprite) {
if (callback && !state.currentSprite.loop) {
state.currentSprite.onComplete = () => callback(id);
}
else if (!callback) {
state.currentSprite.onComplete = undefined;
}
}
}
stopAnimation(id) {
const state = this.nodes.get(id);
if (!state)
return;
state.isAnimationPlaying = false;
if (state.currentSprite &&
state.currentSprite instanceof PIXI.AnimatedSprite) {
state.currentSprite.stop();
}
this._needsRepaint = true;
}
isAnimationPlaying(id) {
return withNode(this.nodes, id, (state) => state.isAnimationPlaying, false);
}
getAnimationData(id) {
return withNode(this.nodes, id, (state) => state.animationData, null);
}
drawRectangle(id, x, y, width, height, fill, stroke, strokeWidth) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "rectangle",
data: { x, y, width, height, fill, stroke, strokeWidth },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
drawCircle(id, x, y, radius, fill, stroke, strokeWidth) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "circle",
data: { x, y, radius, fill, stroke, strokeWidth },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
drawPolygon(id, points, fill, stroke, strokeWidth) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "polygon",
data: { points, fill, stroke, strokeWidth },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
drawRoundRect(id, x, y, width, height, radius, fill, stroke, strokeWidth) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "roundRect",
data: { x, y, width, height, radius, fill, stroke, strokeWidth },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
drawLine(id, x1, y1, x2, y2, color, width) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "line",
data: { x1, y1, x2, y2, color, width },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
drawPolyline(id, points, color, width) {
const state = this.nodes.get(id);
if (!state)
return;
this.ensureGraphics(state);
state.graphicsCommands.push({
type: "polyline",
data: { points, color, width },
});
this.redrawGraphics(state);
this._needsRepaint = true;
}
clearGraphics(id) {
const state = this.nodes.get(id);
if (!state)
return;
state.graphicsCommands = [];
if (state.graphics) {
state.graphics.clear();
}
this._needsRepaint = true;
}
getGraphics(id) {
return withNode(this.nodes, id, (state) => state.graphicsCommands, []);
}
getSpriteTexture(id) {
const state = this.nodes.get(id);
if (!state || !state.currentSprite)
return null;
for (const [textureId, texture] of this.textures) {
if (state.currentSprite.texture === texture) {
return textureId;
}
}
return null;
}
getViewportZoom() {
return this.viewport.scale.x;
}
resize(width, height) {
this.app.renderer.resize(width, height);
this.viewport.resize(width, height);
this._needsRepaint = true;
}
render() {
this.app.renderer.render(this.app.stage);
}
getDevicePixelRatio() {
return window.devicePixelRatio || 1;
}
getTexture(spritesheet, frameName) {
const sheet = this.spritesheets.get(spritesheet);
return sheet?.frames.get(frameName) ?? null;
}
setViewport(_id, options) {
this.viewportOptions = options;
}
getViewportPosition(_id) {
const center = this.viewport.center;
return { x: center.x, y: center.y };
}
setViewportPosition(_id, x, y) {
this.viewport.moveCenter(x, y);
this._needsRepaint = true;
}
setViewportZoom(_id, zoom) {
this.viewport.setZoom(zoom, true);
this._needsRepaint = true;
}
worldToScreen(_id, x, y) {
const point = this.viewport.toScreen(x, y);
return { x: point.x, y: point.y };
}
screenToWorld(_id, x, y) {
const point = this.viewport.toWorld(x, y);
return { x: point.x, y: point.y };
}
onTick(callback) {
this.tickCallbackManager.register(callback);
if (!this.tickerCallbackAdded) {
this.app.ticker.add((ticker) => {
const deltaTicks = ~~(ticker.deltaTime * FRAMES_TO_TICKS_MULTIPLIER);
this.tickCallbackManager.trigger(deltaTicks);
});
this.tickerCallbackAdded = true;
}
}
setTickerSpeed(speed) {
this.app.ticker.speed = speed;
}
getFPS() {
return this.app.ticker.FPS;
}
clearCurrentVisual(state) {
if (state.currentSprite) {
state.container.removeChild(state.currentSprite);
state.currentSprite.destroy();
state.currentSprite = null;
}
}
ensureGraphics(state) {
if (!state.graphics) {
state.graphics = new PIXI.Graphics();
state.graphics.blendMode = this.toPixiBlendMode(state.blendMode);
state.container.addChild(state.graphics);
}
}
redrawGraphics(state) {
if (!state.graphics)
return;
state.graphics.clear();
for (const cmd of state.graphicsCommands) {
switch (cmd.type) {
case "rectangle": {
const { x, y, width, height, fill, stroke, strokeWidth } = cmd.data;
if (fill !== undefined) {
state.graphics.rect(x, y, width, height);
state.graphics.fill(normalizeColor(fill));
}
if (stroke !== undefined) {
state.graphics.rect(x, y, width, height);
state.graphics.stroke({
width: strokeWidth ?? 1,
color: normalizeColor(stroke),
});
}
break;
}
case "circle": {
const { x, y, radius, fill, stroke, strokeWidth } = cmd.data;
if (fill !== undefined) {
state.graphics.circle(x, y, radius);
state.graphics.fill(normalizeColor(fill));
}
if (stroke !== undefined) {
state.graphics.circle(x, y, radius);
state.graphics.stroke({
width: strokeWidth ?? 1,
color: normalizeColor(stroke),
});
}
break;
}
case "polygon": {
const { points, fill, stroke, strokeWidth } = cmd.data;
if (fill !== undefined) {
state.graphics.poly(points);
state.graphics.fill(normalizeColor(fill));
}
if (stroke !== undefined) {
state.graphics.poly(points);
state.graphics.stroke({
width: strokeWidth ?? 1,
color: normalizeColor(stroke),
});
}
break;
}
case "roundRect": {
const { x, y, width, height, radius, fill, stroke, strokeWidth } = cmd.data;
if (fill !== undefined) {
state.graphics.roundRect(x, y, width, height, radius);
state.graphics.fill(normalizeColor(fill));
}
if (stroke !== undefined) {
state.graphics.roundRect(x, y, width, height, radius);
state.graphics.stroke({
width: strokeWidth ?? 1,
color: normalizeColor(stroke),
});
}
break;
}
case "line": {
const { x1, y1, x2, y2, color, width } = cmd.data;
state.graphics.moveTo(x1, y1);
state.graphics.lineTo(x2, y2);
state.graphics.stroke({
width: width ?? 1,
color: normalizeColor(color),
});
break;
}
case "polyline": {
const { points, color, width } = cmd.data;
if (points.length >= 2) {
state.graphics.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
state.graphics.lineTo(points[i], points[i + 1]);
}
state.graphics.stroke({
width: width ?? 1,
color: normalizeColor(color),
});
}
break;
}
}
}
}
createErrorTexture() {
const size = 32;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#FF00FF";
ctx.fillRect(0, 0, size / 2, size / 2);
ctx.fillRect(size / 2, size / 2, size / 2, size / 2);
ctx.fillStyle = "#000000";
ctx.fillRect(size / 2, 0, size / 2, size / 2);
ctx.fillRect(0, size / 2, size / 2, size / 2);
return PIXI.Texture.from(canvas);
}
toPixiBlendMode(mode) {
const modeMap = {
normal: "normal",
add: "add",
multiply: "multiply",
screen: "screen",
};
return modeMap[mode];
}
}