@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
275 lines (274 loc) • 9.38 kB
JavaScript
import { EventEmitter } from "./EventEmitter";
import { GameEngineEventType } from "./GameEngine";
import { Vector2D } from "./geometry/Vector2D";
import { DisplayNode } from "./platform/DisplayNode";
/**
* Events emitted by the game canvas for user interactions and viewport changes.
*/
export var GameCanvasEvent;
(function (GameCanvasEvent) {
GameCanvasEvent["POINTER_MOVE"] = "pointerMove";
GameCanvasEvent["POINTER_CLICK"] = "pointerClick";
GameCanvasEvent["VIEWPORT_MOVED"] = "viewportMoved";
GameCanvasEvent["VIEWPORT_ZOOMED"] = "viewportZoomed";
})(GameCanvasEvent || (GameCanvasEvent = {}));
/**
* Abstract base class for creating interactive game canvases using platform abstraction.
* Platform-agnostic: works with Web (PIXI.js) or Memory (headless) platforms.
*/
export class GameCanvas extends EventEmitter {
constructor(options, platform) {
super();
this.options = options;
this.gameEngine = null;
this.renderers = new Map();
this.updateCallback = null;
this.handlePointerMove = (event) => {
const worldPos = this.rendering.screenToWorld(this.viewportNodeId, event.x, event.y);
const worldPosition = new Vector2D(worldPos.x, worldPos.y);
this.emit(GameCanvasEvent.POINTER_MOVE, worldPosition);
};
this.handleClick = (event) => {
const worldPos = this.rendering.screenToWorld(this.viewportNodeId, event.x, event.y);
const worldPosition = new Vector2D(worldPos.x, worldPos.y);
this.emit(GameCanvasEvent.POINTER_CLICK, worldPosition);
};
this.handleGameStateChange = (newState, _oldState) => {
for (const renderer of this.renderers.values()) {
try {
renderer.updateGameState(newState);
}
catch (error) {
console.error("Error updating renderer game state:", error);
}
}
};
this.rendering = platform.rendering;
this.audio = platform.audio;
this.input = platform.input;
this.initialWidth = options.width;
this.initialHeight = options.height;
this.options = { ...GameCanvas.DEFAULT_OPTIONS, ...options };
}
/**
* Initialize the canvas - platform-agnostic (no container parameter).
*/
async initialize() {
const viewportNodeId = this.rendering.createNode();
const gameNodeId = this.rendering.createNode();
const hudNodeId = this.rendering.createNode();
this.viewportNode = new DisplayNode(viewportNodeId, this.rendering);
this.gameNode = new DisplayNode(gameNodeId, this.rendering);
this.hudNode = new DisplayNode(hudNodeId, this.rendering);
this.viewportNodeId = viewportNodeId;
this.viewportNode.addChild(this.gameNode);
this.rendering.setViewport(this.viewportNodeId, {
screenWidth: this.options.width,
screenHeight: this.options.height,
worldWidth: this.options.worldWidth,
worldHeight: this.options.worldHeight,
enableDrag: this.options.enableDrag,
enablePinch: this.options.enablePinch,
enableZoom: this.options.enableWheel,
clampWheel: this.options.enableWheel,
minScale: this.options.minZoom,
maxScale: this.options.maxZoom,
});
this.rendering.setViewportPosition(this.viewportNodeId, this.options.worldWidth / 2, this.options.worldHeight / 2);
if (this.options.initialZoom) {
this.rendering.setViewportZoom(this.viewportNodeId, this.options.initialZoom);
}
this.setupEventListeners();
this.setupUpdateLoop();
}
/**
* Set up input event listeners via platform.input
*/
setupEventListeners() {
this.input.onPointerMove((event) => this.handlePointerMove(event));
this.input.onClick((event) => this.handleClick(event));
}
/**
* Start the update loop using platform.rendering.onTick()
*/
setupUpdateLoop() {
this.rendering.onTick((deltaTicks) => {
this.update(deltaTicks);
});
}
/**
* Register a renderer
*/
registerRenderer(name, renderer) {
this.renderers.set(name, renderer);
}
/**
* Unregister a renderer
*/
unregisterRenderer(name) {
this.renderers.delete(name);
}
/**
* Clear all renderers
*/
clearRenderers() {
for (const renderer of this.renderers.values()) {
try {
renderer.clear();
}
catch (error) {
console.error("Error clearing renderer:", error);
}
}
}
/**
* Get the game engine
*/
getGameEngine() {
return this.gameEngine;
}
/**
* Set the game engine
*/
setGameEngine(gameEngine) {
if (this.gameEngine) {
this.gameEngine.off(GameEngineEventType.STATE_CHANGE, this.handleGameStateChange);
}
this.gameEngine = gameEngine;
if (this.gameEngine) {
this.gameEngine.on(GameEngineEventType.STATE_CHANGE, this.handleGameStateChange);
}
this.clearRenderers();
this.setupRenderers();
// Trigger initial render so renderers are positioned correctly
if (this.gameEngine) {
this.render(0);
}
}
/**
* Update game state and render
*/
update(deltaTicks) {
if (this.gameEngine) {
this.gameEngine.update(deltaTicks);
}
this.render(deltaTicks);
}
/**
* Move viewport to position
*/
moveViewportTo(x, y, _animate = false) {
this.rendering.setViewportPosition(this.viewportNodeId, x, y);
const center = this.rendering.getViewportPosition(this.viewportNodeId);
this.emit(GameCanvasEvent.VIEWPORT_MOVED, new Vector2D(center.x, center.y));
}
/**
* Set viewport zoom
*/
setZoom(zoom, _animate = false) {
this.rendering.setViewportZoom(this.viewportNodeId, zoom);
const currentZoom = this.rendering.getViewportZoom(this.viewportNodeId);
this.emit(GameCanvasEvent.VIEWPORT_ZOOMED, currentZoom);
}
/**
* Get viewport center
*/
getViewportCenter() {
const pos = this.rendering.getViewportPosition(this.viewportNodeId);
return new Vector2D(pos.x, pos.y);
}
/**
* Get viewport zoom
*/
getZoom() {
return this.rendering.getViewportZoom(this.viewportNodeId);
}
/**
* Convert world coordinates to screen coordinates
*/
worldToScreen(worldPos) {
const screenPos = this.rendering.worldToScreen(this.viewportNodeId, worldPos.x, worldPos.y);
return new Vector2D(screenPos.x, screenPos.y);
}
/**
* Convert screen coordinates to world coordinates
*/
screenToWorld(screenPos) {
const worldPos = this.rendering.screenToWorld(this.viewportNodeId, screenPos.x, screenPos.y);
return new Vector2D(worldPos.x, worldPos.y);
}
/**
* Resize canvas
*/
resize(width, height) {
this.rendering.resize(width, height);
if (this.options.scaleWithResize) {
const scaleX = width / this.initialWidth;
const scaleY = height / this.initialHeight;
const uniformScale = Math.min(scaleX, scaleY);
this.gameNode.setScale(uniformScale);
this.gameNode.setPosition((width - this.initialWidth * uniformScale) / 2, (height - this.initialHeight * uniformScale) / 2);
}
else {
this.options.width = width;
this.options.height = height;
}
const worldAspectRatio = this.options.worldWidth / this.options.worldHeight;
const canvasAspectRatio = width / height;
let targetZoom;
if (canvasAspectRatio > worldAspectRatio) {
targetZoom = height / this.options.worldHeight;
}
else {
targetZoom = width / this.options.worldWidth;
}
targetZoom = Math.max(this.options.minZoom, Math.min(this.options.maxZoom, targetZoom));
this.rendering.setViewportZoom(this.viewportNodeId, targetZoom);
this.rendering.setViewportPosition(this.viewportNodeId, this.options.worldWidth / 2, this.options.worldHeight / 2);
}
/**
* Destroy canvas
*/
destroy() {
this.input.removeAllListeners();
this.clearListeners();
if (this.viewportNode) {
this.viewportNode.destroy();
}
if (this.hudNode) {
this.hudNode.destroy();
}
}
/**
* Get the game node (DisplayNode)
*/
getGameNode() {
return this.gameNode;
}
/**
* Get the HUD node (DisplayNode)
*/
getHudNode() {
return this.hudNode;
}
/**
* Get canvas options
*/
getOptions() {
return { ...this.options };
}
}
GameCanvas.DEFAULT_OPTIONS = {
backgroundColor: 0x1e1e1e,
resolution: 1,
antialias: true,
minZoom: 0.1,
maxZoom: 5.0,
initialZoom: 1.0,
clampBuffer: 100,
enableDrag: true,
enablePinch: true,
enableWheel: true,
enableDecelerate: true,
scaleWithResize: false,
};