UNPKG

@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
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, };