UNPKG

@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
/** * 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]; } }