@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
399 lines (398 loc) • 12.1 kB
JavaScript
/**
* Memory Rendering Layer
*
* Headless rendering implementation that tracks state without actual rendering.
* Used for testing, replay validation, and server-side game logic.
*/
import { asNodeId, asSpritesheetId, asTextureId } from "../types";
import { EventCallbackManager } from "../utils/EventCallbackManager";
import { calculateBoundsWithAnchor } from "../utils/boundsCalculation";
import { screenToWorld, worldToScreen } from "../utils/coordinateTransforms";
import { withNode } from "../utils/nodeHelpers";
export class MemoryRenderingLayer {
constructor() {
this.nextNodeId = 1;
this.nextTextureId = 1;
this.nextSpritesheetId = 1;
this.nodes = new Map();
this.textures = new Map();
this.spritesheets = new Map();
this.tickCallbackManager = new EventCallbackManager();
this.tickerSpeed = 1;
this.canvasSize = { width: 800, height: 600 };
}
// Node lifecycle
createNode() {
const id = asNodeId(this.nextNodeId++);
this.nodes.set(id, {
id,
parent: null,
children: [],
position: { x: 0, y: 0 },
rotation: 0,
scale: { x: 1, y: 1 },
anchor: { x: 0, y: 0 },
alpha: 1,
visible: true,
zIndex: 0,
size: { width: 0, height: 0 },
graphics: [],
});
return id;
}
destroyNode(id) {
this.nodes.delete(id);
}
// Hierarchy
addChild(parent, child) {
const parentNode = this.nodes.get(parent);
const childNode = this.nodes.get(child);
if (parentNode && childNode) {
parentNode.children.push(child);
childNode.parent = parent;
}
}
removeChild(parent, child) {
const parentNode = this.nodes.get(parent);
const childNode = this.nodes.get(child);
if (parentNode && childNode) {
const index = parentNode.children.indexOf(child);
if (index !== -1) {
parentNode.children.splice(index, 1);
}
childNode.parent = null;
}
}
// Transform
setPosition(id, x, y) {
withNode(this.nodes, id, (node) => {
node.position = { x, y };
});
}
setRotation(id, radians) {
withNode(this.nodes, id, (node) => {
node.rotation = radians;
});
}
setScale(id, scaleX, scaleY) {
withNode(this.nodes, id, (node) => {
node.scale = { x: scaleX, y: scaleY };
});
}
setAnchor(id, anchorX, anchorY) {
withNode(this.nodes, id, (node) => {
node.anchor = { x: anchorX, y: anchorY };
});
}
setAlpha(id, alpha) {
withNode(this.nodes, id, (node) => {
node.alpha = alpha;
});
}
setVisible(id, visible) {
withNode(this.nodes, id, (node) => {
node.visible = visible;
});
}
setZIndex(id, z) {
withNode(this.nodes, id, (node) => {
node.zIndex = z;
});
}
// Size
setSize(id, width, height) {
withNode(this.nodes, id, (node) => {
node.size = { width, height };
});
}
getSize(id) {
return withNode(this.nodes, id, (node) => node.size, {
width: 0,
height: 0,
});
}
// Visual effects
setTint(id, color) {
withNode(this.nodes, id, (node) => {
node.tint = color;
});
}
setBlendMode(id, mode) {
withNode(this.nodes, id, (node) => {
node.blendMode = mode;
});
}
setTextureFiltering(id, filtering) {
withNode(this.nodes, id, (node) => {
node.textureFiltering = filtering;
});
}
// Bounds query
getBounds(id) {
return withNode(this.nodes, id, (node) => calculateBoundsWithAnchor(node.position, node.size, node.anchor), { x: 0, y: 0, width: 0, height: 0 });
}
// Visual content
setSprite(id, textureId) {
const node = this.nodes.get(id);
if (node) {
node.spriteTexture = textureId;
}
}
setAnimatedSprite(id, textureIds, ticksPerFrame) {
const node = this.nodes.get(id);
if (node) {
node.animationData = {
textures: textureIds,
ticksPerFrame,
loop: false,
playing: false,
};
}
}
playAnimation(id, loop) {
const node = this.nodes.get(id);
if (node && node.animationData) {
node.animationData.loop = loop;
node.animationData.playing = true;
}
}
stopAnimation(id) {
const node = this.nodes.get(id);
if (node && node.animationData) {
node.animationData.playing = false;
}
}
setAnimationCompleteCallback(_id, _callback) {
// Memory layer doesn't play animations, so this is a no-op
}
// Primitives
drawRectangle(id, x, y, w, h, fill, stroke, strokeWidth) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "rectangle",
data: { x, y, w, h, fill, stroke, strokeWidth },
});
}
}
drawCircle(id, x, y, radius, fill, stroke, strokeWidth) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "circle",
data: { x, y, radius, fill, stroke, strokeWidth },
});
}
}
drawPolygon(id, points, fill, stroke, strokeWidth) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "polygon",
data: { points, fill, stroke, strokeWidth },
});
}
}
drawRoundRect(id, x, y, w, h, radius, fill, stroke, strokeWidth) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "roundRect",
data: { x, y, w, h, radius, fill, stroke, strokeWidth },
});
}
}
drawLine(id, x1, y1, x2, y2, color, width) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "line",
data: { x1, y1, x2, y2, color, width },
});
}
}
drawPolyline(id, points, color, width) {
const node = this.nodes.get(id);
if (node) {
node.graphics.push({
type: "polyline",
data: { points, color, width },
});
}
}
clearGraphics(id) {
const node = this.nodes.get(id);
if (node) {
node.graphics = [];
}
}
// Textures
async loadTexture(url) {
const id = asTextureId(this.nextTextureId++);
this.textures.set(id, url);
return id;
}
async loadSpritesheet(imageUrl, jsonData) {
const id = asSpritesheetId(this.nextSpritesheetId++);
const frames = new Map();
// Parse spritesheet frames
if (jsonData?.frames) {
for (const frameName of Object.keys(jsonData.frames)) {
const textureId = asTextureId(this.nextTextureId++);
frames.set(frameName, textureId);
this.textures.set(textureId, `${imageUrl}#${frameName}`);
}
}
this.spritesheets.set(id, {
url: imageUrl,
data: jsonData,
frames,
});
return id;
}
getTexture(spritesheet, frameName) {
const sheet = this.spritesheets.get(spritesheet);
return sheet?.frames.get(frameName) ?? null;
}
// Viewport
setViewport(id, options) {
const node = this.nodes.get(id);
if (node) {
node.viewport = {
screenWidth: options.screenWidth,
screenHeight: options.screenHeight,
worldWidth: options.worldWidth,
worldHeight: options.worldHeight,
position: { x: 0, y: 0 },
zoom: 1,
options,
};
}
}
getViewportPosition(id) {
return withNode(this.nodes, id, (node) => node.viewport?.position ?? { x: 0, y: 0 }, { x: 0, y: 0 });
}
setViewportPosition(id, x, y) {
withNode(this.nodes, id, (node) => {
if (node.viewport) {
node.viewport.position = { x, y };
}
});
}
setViewportZoom(id, zoom) {
withNode(this.nodes, id, (node) => {
if (node.viewport) {
node.viewport.zoom = zoom;
}
});
}
getViewportZoom(id) {
return withNode(this.nodes, id, (node) => node.viewport?.zoom ?? 1, 1);
}
worldToScreen(id, x, y) {
return withNode(this.nodes, id, (node) => node.viewport
? worldToScreen(x, y, node.viewport.position, node.viewport.zoom)
: { x, y }, { x, y });
}
screenToWorld(id, x, y) {
return withNode(this.nodes, id, (node) => node.viewport
? screenToWorld(x, y, node.viewport.position, node.viewport.zoom)
: { x, y }, { x, y });
}
// Game loop
onTick(callback) {
this.tickCallbackManager.register(callback);
}
setTickerSpeed(speed) {
this.tickerSpeed = speed;
}
getFPS() {
return 60;
}
// Manual rendering
render() {
// No-op for headless rendering
}
// Canvas resize
resize(width, height) {
this.canvasSize = { width, height };
}
// Cleanup
destroy() {
this.nodes.clear();
this.textures.clear();
this.spritesheets.clear();
this.tickCallbackManager.clear();
}
// Test helpers (not part of RenderingLayer interface)
hasNode(id) {
return this.nodes.has(id);
}
getChildren(id) {
return withNode(this.nodes, id, (node) => [...node.children], []);
}
getPosition(id) {
return withNode(this.nodes, id, (node) => ({ ...node.position }), {
x: 0,
y: 0,
});
}
getRotation(id) {
return withNode(this.nodes, id, (node) => node.rotation, 0);
}
getScale(id) {
return withNode(this.nodes, id, (node) => ({ ...node.scale }), {
x: 1,
y: 1,
});
}
getAnchor(id) {
return withNode(this.nodes, id, (node) => ({ ...node.anchor }), {
x: 0,
y: 0,
});
}
getAlpha(id) {
return withNode(this.nodes, id, (node) => node.alpha, 1);
}
getVisible(id) {
return withNode(this.nodes, id, (node) => node.visible, true);
}
getZIndex(id) {
return withNode(this.nodes, id, (node) => node.zIndex, 0);
}
getTint(id) {
return withNode(this.nodes, id, (node) => node.tint, undefined);
}
getBlendMode(id) {
return withNode(this.nodes, id, (node) => node.blendMode, undefined);
}
getTextureFiltering(id) {
return withNode(this.nodes, id, (node) => node.textureFiltering, undefined);
}
getSpriteTexture(id) {
return withNode(this.nodes, id, (node) => node.spriteTexture, undefined);
}
getAnimationData(id) {
return withNode(this.nodes, id, (node) => node.animationData, undefined);
}
isAnimationPlaying(id) {
return withNode(this.nodes, id, (node) => node.animationData?.playing ?? false, false);
}
getGraphics(id) {
return withNode(this.nodes, id, (node) => [...node.graphics], []);
}
getViewportData(id) {
return withNode(this.nodes, id, (node) => node.viewport, undefined);
}
getTickerSpeed() {
return this.tickerSpeed;
}
getCanvasSize() {
return { ...this.canvasSize };
}
// Manual tick for testing
tick(deltaTicks) {
this.tickCallbackManager.trigger(deltaTicks);
}
}