@hiddentao/clockwork-engine
Version:
A TypeScript/PIXI.js game engine for deterministic, replayable games with built-in rendering
319 lines (318 loc) • 11.2 kB
JavaScript
import { DisplayNode } from "../platform/DisplayNode";
import { GameState } from "../types";
/**
* Abstract base implementation for game object renderers.
* Provides common functionality for managing visual representations of game entities
* including creation, updating, removal, and lifecycle management.
*
* Platform-agnostic: works with any RenderingLayer implementation.
*
* Child classes must implement create() to define how their specific items are rendered
* and getId() to provide unique identification for tracking.
*
* @template T The type of game object being rendered
*/
export class AbstractRenderer {
constructor(gameNode) {
this.itemNodes = new Map();
this.items = new Map();
this.gameState = GameState.READY;
this.gameNode = gameNode;
this.rendering = gameNode.getRendering();
}
/**
* Updates the game state for this renderer.
* Allows renderers to react to game state changes (e.g., pause animations, change visual appearance).
*
* @param state The new game state
*/
updateGameState(state) {
this.gameState = state;
}
/**
* Adds a new item to the renderer, creating its visual representation.
* If the item already exists, delegates to update() instead of creating a duplicate.
*
* @param item The game object to render
*/
add(item) {
try {
const id = this.getId(item);
if (this.itemNodes.has(id)) {
this.update(item);
return;
}
const node = this.create(item);
this.gameNode.addChild(node);
this.itemNodes.set(id, node);
this.items.set(id, item);
// Call updateNode for newly created items to handle initial paint
this.updateNode(node, item);
}
catch (error) {
console.error(`Error adding item in ${this.constructor.name}:`, error);
}
}
/**
* Updates an existing item's visual representation.
* Modifies the node properties without recreating the entire display object.
* If the item doesn't exist, it will be created.
*
* @param item The game object with updated state
*/
update(item) {
try {
const id = this.getId(item);
const node = this.itemNodes.get(id);
if (node) {
this.updateNode(node, item);
this.items.set(id, item);
}
else {
this.add(item);
}
}
catch (error) {
console.error(`Error updating item in ${this.constructor.name}:`, error);
}
}
/**
* Removes an item from the renderer by its unique identifier.
* Destroys the node and cleans up all references.
*
* @param id Unique identifier of the item to remove
*/
remove(id) {
try {
const node = this.itemNodes.get(id);
if (node) {
this.gameNode.removeChild(node);
node.destroy();
this.itemNodes.delete(id);
this.items.delete(id);
}
}
catch (error) {
console.error(`Error removing item in ${this.constructor.name}:`, error);
}
}
/**
* Replaces all rendered items with a new set in a single operation.
* Efficiently handles additions, updates, and removals by comparing
* the new item list against currently rendered items.
*
* @param items Array of all items that should be currently rendered
*/
setItems(items) {
try {
const existingItemIds = Array.from(this.itemNodes.keys());
const newItemIds = new Set();
for (const item of items) {
const id = this.getId(item);
newItemIds.add(id);
if (this.itemNodes.has(id)) {
this.update(item);
}
else {
this.add(item);
}
}
for (const id of existingItemIds) {
if (!newItemIds.has(id)) {
this.remove(id);
}
}
}
catch (error) {
console.error(`Error setting items in ${this.constructor.name}:`, error);
}
}
/**
* Calculates transparency for objects based on their health status.
* Objects become progressively transparent as health decreases below the threshold.
*
* @param currentHealth Current health of the object
* @param maxHealth Maximum health of the object
* @param threshold Health threshold below which transparency begins (defaults to half max health)
* @returns Alpha value for visual transparency
*/
calculateHealthBasedAlpha(currentHealth, maxHealth, threshold = maxHealth * 0.5) {
if (currentHealth <= 0)
return 0.3;
if (currentHealth >= threshold || currentHealth >= maxHealth)
return 1.0;
return 0.3 + 0.7 * (currentHealth / threshold);
}
/**
* Creates a filled rectangle as a DisplayNode.
*
* @param width Width of the rectangle
* @param height Height of the rectangle
* @param color Fill color
* @param x X position (defaults to center horizontally)
* @param y Y position (defaults to center vertically)
* @returns A new DisplayNode with the rectangle drawn
*/
createRectangle(width, height, color, x, y) {
const node = this.rendering.createNode();
const displayNode = new DisplayNode(node, this.rendering);
x = x ?? -width / 2;
y = y ?? -height / 2;
this.rendering.drawRectangle(node, x, y, width, height, color);
return displayNode;
}
/**
* Creates a filled circle as a DisplayNode.
*
* @param radius Radius of the circle
* @param color Fill color
* @param x X position (defaults to origin)
* @param y Y position (defaults to origin)
* @returns A new DisplayNode with the circle drawn
*/
createCircle(radius, color, x = 0, y = 0) {
const node = this.rendering.createNode();
const displayNode = new DisplayNode(node, this.rendering);
this.rendering.drawCircle(node, x, y, radius, color);
return displayNode;
}
/**
* Creates a filled polygon as a DisplayNode.
*
* @param points Array of coordinates in sequence
* @param color Fill color
* @returns A new DisplayNode with the polygon drawn
*/
createPolygon(points, color) {
const node = this.rendering.createNode();
const displayNode = new DisplayNode(node, this.rendering);
this.rendering.drawPolygon(node, points, color);
return displayNode;
}
/**
* Creates a rectangular border outline without fill.
*
* @param width Width of the rectangle
* @param height Height of the rectangle
* @param strokeWidth Width of the border line
* @param color Border color
* @param alpha Transparency of the border (defaults to opaque)
* @returns A new DisplayNode with the border rectangle drawn
*/
createBorderRectangle(width, height, strokeWidth, color, alpha = 1) {
const node = this.rendering.createNode();
const displayNode = new DisplayNode(node, this.rendering);
this.rendering.drawRectangle(node, 0, 0, width, height, undefined, color, strokeWidth);
displayNode.setAlpha(alpha);
return displayNode;
}
/**
* Creates a positioned node with a body graphic as its child.
*
* @param x X position
* @param y Y position
* @param rotation Rotation in radians (optional)
* @param bodyNode The node to use as the body
* @returns A DisplayNode with the body node as a child
*/
createStandardNode(x, y, rotation = undefined, bodyNode) {
const node = this.rendering.createNode();
const displayNode = new DisplayNode(node, this.rendering);
displayNode.setPosition(x, y);
if (rotation !== undefined) {
displayNode.setRotation(rotation);
}
displayNode.addChild(bodyNode);
return displayNode;
}
/**
* Updates a node's visual properties based on the item's current state.
* Checks the needsRepaint flag and only calls repaintNode if needed.
*
* @param node The DisplayNode to update
* @param item The game object with current state
*/
updateNode(node, item) {
// Check if item has needsRepaint property (for GameObject instances)
if (typeof item === "object" &&
item !== null &&
"needsRepaint" in item &&
item.needsRepaint) {
this.repaintNode(node, item);
item.needsRepaint = false;
}
else if (typeof item !== "object" ||
item === null ||
!("needsRepaint" in item)) {
// For backwards compatibility with non-GameObject items, always repaint
this.repaintNode(node, item);
}
}
/**
* Repaints a node's visual properties based on the item's current state.
* Default implementation performs no updates. Child classes should override
* this method to implement specific repaint behavior.
*
* @param _node The DisplayNode to repaint
* @param _item The game object with current state
*/
repaintNode(_node, _item) {
// Default implementation performs no updates
}
/**
* Completely clears the renderer and cleans up all resources.
*/
clear() {
const entries = Array.from(this.itemNodes.entries());
for (const [id, node] of entries) {
this.gameNode.removeChild(node);
node.destroy();
this.itemNodes.delete(id);
this.items.delete(id);
}
}
/**
* Retrieves the DisplayNode for a specific item.
*
* @param id Unique identifier of the item
* @returns The DisplayNode or undefined if not found
*/
getNode(id) {
return this.itemNodes.get(id);
}
/**
* Retrieves a specific game object by its identifier.
*
* @param id Unique identifier of the item
* @returns The game object or undefined if not found
*/
getItem(id) {
return this.items.get(id);
}
/**
* Returns a map of all currently tracked game objects.
*
* @returns Map containing all items keyed by their identifiers
*/
getAllItems() {
return this.items;
}
/**
* Forces a complete visual refresh of all currently rendered items.
* Calls the update logic for every tracked item without changing
* the set of rendered items.
*/
rerender() {
try {
for (const [id, item] of this.items.entries()) {
const node = this.itemNodes.get(id);
if (node) {
this.updateNode(node, item);
}
}
}
catch (error) {
console.error(`Error rerendering items in ${this.constructor.name}:`, error);
}
}
}