/**
* @license MIT
* Copyright © 2025 Steve Butler (henspace.com)
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the “Software”),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
/**
* @module hcje/sprites
* @description
* Module providing handling for sprite sheets typically created by
* [TexturePacker]{@link https://www.codeandweb.com/texturepacker}. The sprite sheet must have been exported using the
* JSON (Hash) format: trimming and sprite rotation within the sheet is **NOT** supported. Only part of the information
* in the Hash format JSON file is used. CodeAndWeb, the creators of TexturePacker also have a free online version
* [Free Sprite Sheet Packer](https://www.codeandweb.com/free-sprite-sheet-packer) which can also be used but without
* the efficiency of the full version. If you are not using TexturePacker, you can create the JSON file manually by
* referring to
* [PackedSpritesTextureData]{@link module:hcje/sprites~PackedSpritesTextureData} for details of the object to be
* encoded in the file.
*/
/**
* Frame object encoded using JSON within a texture's data file to provide information about an individual frame.
* @typedef {Object} PackedSpritesFrameData
* @property {Object} frame - information locating the sprite image in the texture.
* @property {number} frame.x - x position of the sprite image in the texture.
* @property {number} frame.y - y position of the sprite image in the texture.
* @property {number} frame.w - width of the sprite image in the texture.
* @property {number} frame.h - height of the sprite image in the texture.
*/
/**
* Object encoded using JSON within a texture's data file and used for extrating individual frames from the texture
* file. The object is a subset of the object contained within the JSON (Hash) data file created by
* [TexturePacker]{@link https://www.codeandweb.com/texturepacker}.
* @typedef {Object} PackedSpritesTextureData
* @property {Object<string, module:hcje/sprites~PackedSpritesFrameData>} frames - root object containing individual
* data for extracting individual sprite images from a texture.
*/
import * as utils from './utils.js';
import * as domTools from './dom-tools.js';
/**
* Frame name generator for sprite names in a texture.
* @callback frameNameGenerator
* @param {string} baseName - The string from which the final name is created.
* @param {string} state - The state of the sprite. E.g. idle, walking etc.
* @param {number} index - The 0 based frame index.
* @returns {string} The frame name which corresponds to the key in the `frames` object in
* [PackedSpritesTextureData]{@link module:hcje/sprites~PackedSpritesTextureData} JSON file.
*/
/**
* Data describing position and dimensions of a frame.
* @typedef {Object} FrameData
* @property {number} x - left position.
* @property {number} y - top position.
* @property {number} width - width of sprite.
* @property {number} height - height of sprite.
* @property {boolean} [flipX] - sprite is flipped in its unrotated x direction.
* @property {boolean} [flipY] - sprite is flipped in its unrotated y direction.
*/
/** Type of animation cycle.
* @typedef {number} CycleTypeEnumValue
*/
/**
* Type of animation cycles.
* @enum {CycleType}
* @property {CycleTypeEnumValue} NONE - No cycling of the animation.
* @property {CycleTypeEnumValue} LOOP - When the last frame is reached, jump back to the first frame and continue.
* @property {CycleTypeEnumValue} OSCILLATE - When the last frame is reached, animate back down the frames to the
* first frame and continue.
* @property {CycleTypeEnumValue} STOP - Stop animating when the last frame is reached.
*/
export const CycleType = {
NONE: 0,
LOOP: 1,
OSCILLATE: 2,
STOP: 3
};
/**
* Automatic calculation of the animation interval. This adjusts the interval based on the current dynamics and is
* typically used to adjust a sprite's animation interval based on its walking speed.
* @callback deriveInterval
* @param {module:hcje/sprites.Dynamics} dynamics - The current dynamics.
* @returns {number} Required interval in ms.
*/
/**
* Configuration for an animation state.
* @typedef {Object} AnimationStateConfig
* @param {module:hcje/sprites~FrameData[]} frames - Data for the animation frames.
* @property {number|module:hcje/sprites~deriveInterval} interval - The animation interval in milliseconds or function to
* derive it.
* @property {CycleTypeEnumValue} cycleType - Type of loop. Defaults **CycleType.LOOP**.
*/
/**
* Interface for objects that can render a [Sprite]{@link module:hcje/sprites.Sprite}. Note the sprites cannot render
* themselves; they rely on a SpriteRenderer.
* @interface SpriteRenderer
*/
/**
* Render the sprite.
* @function module:hcje/sprites~SpriteRenderer#render
* @param {module:hcje/sprites.Sprite} sprite - The sprite to render.
*/
/**
* Function that is called by the sprite to indicate that the sprite is being killed and all resources should be
* released.
* @function module:hcje/sprites~SpriteRenderer#kill
*/
/**
* Renderer for a text sprite using the DOM.
* @implements {module:hcje/sprites~SpriteRenderer}
*/
export class DomTextSpriteRenderer extends domTools.TextElement {
/**
* Construct renderer.
* @param {Element|module:hcje/domTools.ElementWrapper} container - DOM element that holds the sprite. The sprite
* element is automatically appended to the container.
* @param {string} txt - The text content for the sprite.
* @param {Object} [options]
* @param {boolean} options.markdown - True if the txt property is markdown.
* @extends module:hcje/domTools.TextElement
*/
constructor(container, txt, options) {
super();
container.appendChild(this);
if (options?.markdown) {
this.setMarkdown(txt);
} else {
this.innerText = txt;
}
this.className = 'hcje-sprites-sprite hcje-sprites-sprite--text';
this.style.position = 'absolute';
}
/**
* @inheritdoc
*/
render(sprite) {
const frameData = sprite.frameData;
const position = sprite.position;
let transform = '';
if (frameData.flipX) {
transform += 'scaleX(-1) ';
}
if (frameData.flipY) {
transform += 'scaleY(-1) ';
}
if (position.angle !== 0) {
transform += ` rotateZ(${position.angle}rad)`;
}
this.style.opacity = sprite.opacity;
this.style.width = `${frameData.width}px`;
if (frameData.height) {
this.style.height = `${frameData.height}px`;
}
this.style.transform = transform;
this.style.left = `${position.x}px`;
this.style.top = `${position.y}px`;
}
/**
* @inheritdoc
*/
kill() {
this.remove();
}
}
/**
* Create a text sprite.
* @param {Element|ElementWrapper} container - The sprite is appended to the container.
* @param {string} txt - The contents of the sprite.
* @param {Object} options - Configuration options.
* @param {boolean} options.markdown - If true, the txt property is parsed as markdown.
* @param {module:hcje/utils~Dimensions} options.dimensions - Sprite size. Width must be provided. Use 0 for the height if you
* want this to size automatically based on the text to be rendered.
* @returns {module:hcje/sprites.Sprite}
*/
export function createTextSprite(container, txt, options) {
const sprite = new Sprite('Text');
const renderer = new DomTextSpriteRenderer(container, txt, options);
sprite.renderer = renderer;
const rect = {x: 0, y: 0, width: options.dimensions.width, height: options.dimensions.height};
sprite.setStateFrames('anon', {
cycleType: CycleType.NONE,
frames: [rect],
interval: 0
});
sprite.renderer.render(sprite);
console.debug(`${renderer.offsetWidth} ${renderer.offsetHeight} `);
sprite.dimensions = {width: renderer.offsetWidth, height: renderer.offsetHeight}
return sprite;
}
/**
* Renderer for sprites using the DOM and images from a texture.
* @implements {module:hcje/sprites~SpriteRenderer}
*/
export class DomImageSpriteRenderer extends domTools.ElementWrapper {
/** Texture used for the sprite.
* @type {HTMLIMageElement} */
#texture;
/**
* Construct renderer.
* @param {Element|module:hcje/domTools.ElementWrapper} container - DOM element that holds the sprite. The sprite
* element is automatically appended to the container.
* @param {HTMLImageElement} texture - The texture from which the sprite's image is taken.
* @extends module:hcje/domTools.ElementWrapper
*/
constructor(container, texture) {
super('div');
container.appendChild(this);
this.#texture = texture;
this.className = 'hcje-sprites-sprite hcje-sprites-sprite--texture';
this.style.backgroundImage = `url(${this.#texture.src})`;
this.style.position = 'absolute';
}
/**
* @inheritdoc
*/
render(sprite) {
const frameData = sprite.frameData;
const position = sprite.position;
let transform = '';
if (frameData.flipX) {
transform += 'scaleX(-1) ';
}
if (frameData.flipY) {
transform += 'scaleY(-1) ';
}
if (position.angle !== 0) {
transform += ` rotateZ(${position.angle}rad)`;
}
this.style.opacity = sprite.opacity;
this.style.width = `${frameData.width}px`;
this.style.height = `${frameData.height}px`;
this.style.transform = transform;
this.style.left = `${position.x}px`;
this.style.top = `${position.y}px`;
this.style.backgroundPosition = `-${frameData.x}px -${frameData.y}px`;
}
/**
* @inheritdoc
*/
kill() {
this.remove();
}
}
/**
* Sprite class.
* @implements module:hcje/sprites~AnimationTarget
*/
export class Sprite {
/** @type {module:hcje/sprites.BaseSpriteAdjuster} */
#adjuster;
/** Flip in x direction when velocity is negative.
* @type {boolean} */
autoFlipX;
/** Flip in y direction when velocity is negative.
* @type {boolean} */
autoFlipY;
/** @type {module:hcje/sprites.Dynamics} */
dynamics;
/** Flipped in x direction.
* @type {boolean} */
#flipX;
/** Flipped in yx direction.
* @type {boolean} */
#flipY;
/** Frame increment value.
* @type {number} */
#frameInc;
/** Current frames.
* @type {module:hcje/sprites~FrameData[]} */
#frames;
/** @type {number} */
#frameIndex;
/** @type {number} */
#height;
/** Id used for debugging purposes.
* @type {string} */
logId;
/** Animation interval.
* @type {number|module:hcje/sprites~deriveInterval} */
#interval;
/** Opacity of sprite 0 to 1.
* @type {number} */
opacity;
/** Position data for sprite.
* @type {module:hcje/utils~PositionData} */
#position;
/** Current state.
* @type {string}*/
#state;
/** @type {Map<string,module:hcje/sprites~AnimationStateConfig>} */
#stateData;
/** @type {boolean} */
#killed;
/** @type {module:hcje/sprites~CycleTypeEnumValue} */
#cycleType;
/** @type(HTMLImageElement} */
#texture;
/** @type {number} */
#width;
/**
* Construct a sprite.
* @param {string} logId - ID used for logging purposes only.
*/
constructor(logId) {
this.logId = logId;
this.#interval = 0;
this.#frameInc = 1;
this.#stateData = new Map();
this.#position = {x: 0, y: 0, angle: 0};
this.#killed = false;
}
/**
* Adjuster used by the sprite.
* When changing the property, if there is a current adjuster, it is automatically marked as complete with an
* undefined reason; however, its **onCompletion** function will not be called. If you need the function to be called,
* call the current adjuster's **markComplete** method first before changing this property.
* @type {module:hcje/sprites.BaseSpriteAdjuster}
*/
get adjuster() {
return this.#adjuster;
}
/**
* Set the adjuster. If there is a current adjuster, it is automatically marked as complete with an undefined
* reason; however, its **onCompletion** function will not be called. If you need the function to be called,
* call the adjuster's **markComplete** method first.
* @param {module:hcje/sprites.BaseSpriteAdjuster} adjuster - New adjuster to use.
* @ignore
*/
set adjuster(adjuster) {
if (this.#adjuster) {
this.#adjuster.onCompletion = undefined;
this.#adjuster.markComplete();
}
this.#adjuster = adjuster;
}
/**
* Interval derivation function to assist with calculation of automatic walk speeds. Typically used for
* {@link module:hcje/sprites~deriveInterval} callbacks. E.g. `(dynamics) => Sprite.deriveWalk(dynamics, DPF)` where
* DPF is the number of pixels the frame animation would move per frame.
* @param {module:hcje/sprites.Dynamics} dynamics - Sprite's current dynamics value.
* @param {number} distPerFrame - The number of pixels the animation frames are drawn to move.
*/
static deriveWalk(dynamics, distPerFrame) {
return 1000 * distPerFrame / Math.abs(dynamics.vx);
}
/**
* Set the frames for a specified state.
* @param {string} stateName - Key name for this animation.
* @param {module:hcje/sprites~AnimationStateConfig} stateConfig - State configuration.
*/
setStateFrames(stateName, stateConfig) {
if (stateConfig.frames?.length > 0) {
this.#stateData.set(stateName, stateConfig);
} else {
console.warn(`Cannot set state ${stateName} as there are no frames.`);
}
if (this.#stateData.size === 1) {
this.state = stateName;
}
}
/**
* Dimensions of the sprite.
* @type {module:hcje/utils~Dimensions}
*/
get dimensions() {
return {width: this.#width, height: this.#height};
}
/**
* Set the dimensions.
* @type {module:hcje/utils~Dimensions}
* @ignore
*/
set dimensions(dims) {
this.#width = dims.width;
this.#height = dims.height;
}
/**
* Bounding rectangle for the sprite.
* @type {module:hcje/utils~RectData}
* @readonly
*/
get bounds() {
return {x: this.#position.x, y: this.#position.y, width: this.#width, height: this.#height};
}
/**
* The current state property. When setting, the state must exist in the existing map of states, otherwise the
* attempt to set is ignored. It is not possible to set an illegal state.
* @type {string}
*/
get state() {
return this.#state;
}
/**
* Set the current state.
* If it does not existing in the map of states, it is ignored.
* @param {string} state - The new state.
* @ignore
*/
set state(state) {
console.debug(`Set ${this.logId} state to ${state}.`);
const stateConfig = this.#stateData.get(state);
if (stateConfig) {
const frames = stateConfig.frames;
this.#interval = stateConfig.interval;
this.#cycleType = stateConfig.cycleType ?? CycleType.LOOP;
this.#state = state;
this.#width = frames[0].width;
this.#height = frames[0].height;
this.#frames = frames;
this.#frameIndex = 0;
this.#updateFrame();
} else {
console.debug(`Could not find state ${state} for ${this.logId}`);
}
}
/**
* Check if the sprite has been killed.
* @returns {boolean}
*/
isKilled() {
return this.#killed;
}
/**
* The current frame data.
* @type {module:hcje/sprites~FrameData}
* @readonly
*/
get frameData() {
return this.#frames[this.#frameIndex]
}
/**
* Update the displayed frame.
* @private
*/
#updateFrame() {
const frameData = this.#frames[this.#frameIndex];
frameData.flipX = this.#flipX;
frameData.flipy = this.#flipY;
this.renderer?.render(this);
}
/**
* Handle dynamics. Do not call if **dynamics** property has not been set.
* @param {DOMHighResTimeStamp} timeStamp - Time stamp for frame.
* @param {number} deltaSeconds - Elapsed time since last update.
* @private
*/
#handleDynamics(timeStamp, deltaT) {
this.dynamics.adjustSprite(timeStamp, deltaT, this);
this.#flipX = this.autoFlipX && this.dynamics.vx < 0;
this.#flipY = this.autoFlipY && this.dynamics.vy < 0;
}
/**
* @inheritdoc
*/
update(timeStamp, deltaT) {
if (this.dynamics) {
this.#handleDynamics(timeStamp, deltaT);
}
if (this.#adjuster) {
this.#adjuster.adjust(timeStamp, deltaT);
if (this.#adjuster.isComplete()) {
this.#adjuster = undefined;
}
}
const interval = typeof this.#interval === 'function' ? this.#interval(this.dynamics) : this.#interval;
if (interval > 0 && this.#frames.length > 1) {
const index = Math.floor((timeStamp / interval)) % this.#frames.length;
if (index != this.#frameIndex) {
this.#frameIndex += this.#frameInc;
if (this.#frameIndex >= this.#frames.length) {
if (this.cycleType === CycleType.OSCILLATE) {
this.#frameIndex -= 2;
this.#frameInc = -1;
} else if (this.#cycleType === CycleType.STOP) {
this.#frameIndex--;
this.#interval = 0;
}
else {
this.#frameIndex = 0;
}
} else if (this.#frameIndex < 0) {
this.#frameIndex = 1;
this.#frameInc = 1;
}
}
}
this.#updateFrame();
}
/**
* The current sprite position. Changes are instantaneous and can later be modified by the dynamics. Position is
* the top-left corner of the sprite.
* @type {module:hcje/utils~PositionData}
*/
get position() {
return this.#position;
}
/**
* Set the position. Note this is instantaneous and can later be modified by the dynamics.
* @type {module:hcje/utils~PositionData}
* @ignore
*/
set position(position) {
this.#position = position;
if (position.angle === undefined) {
this.#position.angle = 0;
}
this.#updateFrame();
}
/**
* This just calls the renderer's kill method to allow any resources to be released.
* @inheritdoc
*/
kill() {
this.renderer?.kill();
this.#killed = true;
}
}
/**
* Limiter for a [Dynamics]{@link module:hcje/sprites.Dynamics} class.
* During the animation cycle a [Sprite]{@link module:hcje/sprites.Sprite} calls the
* [limit]{@link module:hcje/sprites~DynamicsLimiter#limit} method to modify the current dynamics if necessary.
* The function of a limiter can be replicated by using an object which extends the
* [BaseSpriteAdjuster]{@link module:hcje/sprites.BaseSpriteAdjuster} class; however, there are some subtle differences
* in use case.
*
* + Limiters normally exist for the lifetime of an object.
* + Limiters typically only alter the dynamics or position of a sprite.
* + Adjusters are often transient and can call a function to provide notice of its completion.
* + Adjusters are typically used to make more comprehensive modifications to a sprite.
*
* @interface DynamicsLimiter
*/
/**
* Function to limit the **Dynamics** instance. The limiter might be adjusting the dynamics to apply constraints such
* as boundarys within which to bounce, or even more complex dynamics adjustments beyond just simple velocity and
* acceleration. Note that the limit method is called **after** the standard application of velocity and acceleration.
* @function module:hcje/sprites~DynamicsLimiter#limit
* @param {module:hcje/sprites.Sprite} target - The target object to limit.
* @param {module:hcje/sprites.Dynamics} dynamics - The associated dynamics.
*/
/**
* Bouncer class which adjusts the [Dynamics]{@link module:hcje/sprites.Dynamics} of a [Sprite]{@link module:hcje/sprites.Sprite}
* so that it bounces within predefined bounds.
* @implements {module:hcje/sprites~DynamicsLimiter}
*/
export class Bouncer {
/* Boundary bottom.
* @type {number} */
#bottom;
/* Boundary left.
* @type {number} */
#left;
/* Boundary right.
* @type {number} */
#right;
/* Boundary top.
* @type {number} */
#top;
/**
* Construct bouncer with the specified boundary.
* @param {module:hcje/utils~Dimensions} actorDims - Dimensions of the object being moved.
* @param {module:hcje/utils~RectData} boundary - Movement boundary. The object will bounce within the boundary.
*/
constructor(actorDims, boundary) {
this.#left = boundary.x;
this.#right = boundary.x + boundary.width - actorDims.width;
this.#top = boundary.y;
this.#bottom = boundary.y + boundary.height - actorDims.height;
}
/**
* Implement limits.
* @inheritdoc
*/
limit(target, dynamics) {
if (target.position.x < this.#left) {
target.position.x = this.#left;
dynamics.vx = Math.abs(dynamics.vx);
}
if (target.position.x > this.#right) {
target.position.x = this.#right;
dynamics.vx = -Math.abs(dynamics.vx);
}
if (target.position.y < this.#top) {
target.position.y = this.#top;
dynamics.vy = Math.abs(dynamics.vy);
}
if (target.position.y > this.#bottom) {
target.position.y = this.#bottom;
dynamics.vy = -Math.abs(dynamics.vy);
}
}
}
/**
* Dynamics class. This handles velocity and acceleration calculations for a [Sprite]{@link module:hcje/sprites.Sprite}.
*/
export class Dynamics {
static #twoPI = 2 * Math.PI;
/** Acceleration angular.
* @type {number} */
aAngle;
/** Acceleration x.
* @type {number} */
ax;
/** Accleration y.
* @type {number} */
ay;
/** Motion limiter.
* @type {module:hcje/sprites~DynamicsLimiter} */
limiter;
/** Velocity angular.
* @type {number} */
vAngle;
/** Velocity x.
* @type {number} */
vx;
/** Velocity y.
* @type {number} */
vy;
/**
* Construct the instance setting all motion to zero.
* @param {module:hcje/sprites~DynamicsLimiter} [limiter] - Limiter to constrain dynamics.
*/
constructor(limiter) {
this.limiter = limiter;
this.vx = 0;
this.vy = 0;
this.vAngle = 0;
this.ax = 0;
this.ay = 0;
this.aAngle = 0;
}
/**
* Update velocities and reposition the sprite accordingly.
* @param {DOMHighResTimeStamp} timeStamp - Current timestamp in milliseconds.
* @param {number} deltaT - Elapsed time in **seconds** since last call.
* @param {module:hcje/sprites.Sprite} sprite - The sprite being adjusted.
*/
adjustSprite(timeStamp, deltaT, sprite) {
let position = sprite.position;
position.x += deltaT * this.vx;
position.y += deltaT * this.vy;
position.angle += deltaT * this.vAngle;
this.vx += deltaT * this.ax;
this.vy += deltaT * this.ay;
this.vAngle += deltaT * this.aAngle;
position.angle = position.angle % Dynamics.#twoPI;
position.angle = position.angle % Dynamics.#twoPI;
this.limiter?.limit(sprite, this);
}
/**
* Test if the object is in motion.
* @returns {boolean}
*/
isMoving() {
return this.vx || this.vy || this.vAngle || this.aX || this.aY || this.aAngle;
}
}
/**
* Factory for creating sprites which are rendered using images from a texture image.
* @interface ImageSpriteFactory
*/
/**
* Create a rendered sprite.
* @function module:hcje/sprites~ImageSpriteFactory#createSprite
* @param {string} logId - ID used for debugging.
* @param {HTMLImageElement} texture - Image used for the texture from which the individual sprite images are
* extracted.
* @returns {module:hcje/sprites.Sprite}
*/
/**
* Class for creating sprites with images from a texture that are rendered as elements in the DOM.
* @implements module:hcje/sprites~ImageSpriteFactory
*/
export class DomImageSpriteFactory {
/** @type {Element|module:hcje/domTools.ElementWrapper} */
parentElement;
/**
* Construct the factory.
* @param {Element|module:hcje/domTools.ElementWrapper} container - The container for sprites.
*/
constructor(parentElement) {
this.parentElement = parentElement;
}
/**
* @inheritdoc
*/
createSprite(logId, texture) {
const sprite = new Sprite(logId);
sprite.renderer = new DomImageSpriteRenderer(this.parentElement, texture);
return sprite;
}
}
/**
* Class to handle the management of textures.
*/
export class TextureManager {
/** Data to access sprites in the texture.
* @type {Object} */
#data;
/** @type {module:hcje/sprites~ImageSpriteFactory} */
spriteFactory;
/** @type {HTMLImageElement} */
#texture;
/**
* Construct the texture manager.
* @param {module:hcje/sprites~PackedSpritesTextureData} data - Texture data from the JSON file typically created
* from the file exported by TexturePacker in JSON (Hash) format.
* export. Rotation and trimming of sprites in the texture is not permitted.
* @param {HTMLImageElement} texture - The sprite sheet texture.
*/
constructor(data, texture, spriteFactory) {
this.#data = data;
this.#texture = texture;
}
/**
* Default frame name generator. This simply adds the state and index, unpadded, at the end of the name but in front of any
* extension. The state is prefixed with an underscore, so for a base name of "spaceman.png", state of "idle" and
* index of 5, the result would be "spaceman_idle5.png".
* @inheritdoc
*/
createFrameName(baseName, state, frameIndex) {
return baseName.replace(/^(?<prefix>.*)(?<suffix>\.[^.]*)$/, `\$<prefix>_${state}${frameIndex}\$<suffix>`);
}
/**
* Convert a texture packer entry to a rectangle.
* @param {Object} tpEntry - Sprite entry from a Texture Packer data file created using the JSON hash export.
* @returns {module:hcje/utils~RectData}
* @private
*/
#createRectData(tpEntry) {
return {
x: tpEntry.frame.x,
y: tpEntry.frame.y,
width: tpEntry.frame.w,
height: tpEntry.frame.h,
}
}
/**
* Create a sprite.
* @param {string} baseName - The base name for the sprite.
* @param {Object[]} stateConfigs - States that the sprite can be in. If stateConfigs is empty, then a single frame
* is returned using the baseName. Its state name is set to 'anon'.
* @param {string} stateConfigs[].name - Name of the state.
* @param {number} stateConfigs[].interval - Update interval in ms.
* @param {module:hcje/sprites~CycleTypeEnumValue} stateConfigs[].cycleType - Type of animation cycle
* @param {module:hcje/sprites~frameNameGenerator} [nameGen = module:hcje/sprites.TextureManager#createFrameName] -
* The frame name generator. If not provided, the default frame name generator is used.
* index is simply appended to the base name in front of the extension.
* @returns {module:hcje/sprites.Sprite}
* @throws {Error} Error thrown if **spriteFactory** property not set.
*/
createSprite(baseName, stateConfigs, frameNameGenerator) {
if (!this.spriteFactory) {
throw new Error(
"You must set the spriteFactory property before calling the TextureManager's createSprite method."
);
}
const nameGen = frameNameGenerator ?? this.createFrameName;
const sprite = this.spriteFactory.createSprite(baseName, this.#texture);
if (!stateConfigs || stateConfigs.length === 0) {
sprite.setStateFrames('anon', {
cycleType: CycleType.NONE,
frames: [this.#createRectData(this.#data.frames[baseName])],
interval: 0
});
return sprite;
}
for (const stateConfig of stateConfigs) {
let frameInfo;
/** @type {module:hcje/sprites~FrameData} */
const frameData = [];
let frameIndex = 0;
while(true) {
const frameName = nameGen(baseName, stateConfig.name, frameIndex++);
frameInfo = this.#data.frames[frameName];
if (!frameInfo) {
break;
} else {
frameData.push(this.#createRectData(frameInfo));
}
}
if (frameData.length === 0) {
frameInfo = this.#data.frames[baseName];
if (frameInfo) {
frameData.push(this.#createRectData(frameInfo));
}
}
sprite.setStateFrames(stateConfig.name, {
cycleType: stateConfig.cycleType ?? CycleType.LOOP,
frames: frameData,
interval: stateConfig.interval
});
}
return sprite;
}
}
/**
* Load an image.
* @param {string} source - Source url for the image.
* @returns {Promise} Fulfils to HTMLImageElement once the image has been loaded.
*/
function loadImage(source) {
const image = new Image();
return new Promise((resolve) => {
image.addEventListener('load', () => resolve(image), {once: true});
image.src = source;
});
}
/**
* Load the sprite sheet.
* @param {string} dataUrl - Url used to retrieve the spritesheet data file.
* @param {string} imagesUrl - Url used to retrieve the associated spritesheet.
* @param {module:hcje/domTools~BusyIndicator} [busyIndicator] -Indicator to show loading.
* @returns {Promise} Fulfils to {@link module:hcje/sprites.TextureManager}; undefined on error.
*/
export function loadSpriteSheet(dataUrl, textureUrl, busyIndicator) {
busyIndicator?.start();
let textureData;
let textureManager;
return utils.fetchJson(dataUrl)
.then((data) => {
textureData = data;
return loadImage(textureUrl);
})
.then((image) => textureManager = new TextureManager(textureData, image))
.catch((error) => {
console.error(`Failed to load spritesheet.: ${error}`);
})
.finally(() => {
busyIndicator?.end();
return textureManager;
});
}
/**
* Interface for objects that can be updated by an [Animator]{@link module:hcje/sprites~Animator}.
* @interface AnimationTarget
*/
/**
* Update function called by the **Animator**.
* @function module:hcje/sprites~AnimationTarget#update
* @param {DOMHighResTimeStamp} timestamp - Timestamp in milliseconds
* @param {number} deltaSeconds - Elapsed time in seconds since previous update called.
*/
/**
* Kill the target.
* @function module:hcje/sprites~AnimationTarget#kill
*/
/**
* Check whether the target has been killed.
* @function module:hcje/sprites~AnimationTarget#isKilled
* @returns {boolean}
*/
/**
* @callback onAdjustmentComplete
* @param {boolean} success - True if the adjustment was regarded as completed successfully.
* @param {*} [reason] - Argument containing information about the reason for completion. This reflects the value
* passed to the markComplete method. It could be undefined depending on the implementation.
*/
/**
* Base adjuster. An adjuster is an object that can manipulate a [Sprite]{@link module:hcje/sprites.Sprite}.The
* base adjuster's `adjust` method does nothing and is expected to be overridden. Unlike a
* [DynamicsLimiter]{@link module:hcje/sprites~DynamicsLimiter}, adjusters normally have a limited lifetime and can
* call a completion function, set by the `onCompletion` property, when its activity has completed.
*/
export class BaseSpriteAdjuster {
/** Completion flag.
* @type {boolean} */
#complete = false;
/** @type {module:hcje/sprites~onAdjustmentComplete} */
onCompletion;
/**
* The underlying sprite.
* @type {module:hcje/sprites.Sprite}
* @protected
*/
_sprite;
/**
* Construct base adjuster.
* @param {module:hcje/sprites.Sprite} sprite - The target sprite.
*/
constructor(sprite) {
this._sprite = sprite;
this._complete = false;
}
/**
* Get completion state.
* @return {boolean}
*/
isComplete() {
return this.#complete;
}
/**
* Set as complete.
* @param {*} reason - Reason for completion. This is passed to the `onCompletion` callback if provided.
*/
markComplete(reason) {
this._sprite = undefined; // releases circular references.
this.#complete = true;
this.onCompletion?.(reason);
}
/**
* Perform the adjustment. This method will be called in the animation cycle.
* @param {DOMHighResTimeStamp} timeStamp - Current time stamp in ms.
* @param {number} deltaT - Change in time in seconds.
*/
adjust(timeStamp, deltaT) {
}
}
/**
* Adjuster that completes when the sprite is out of bounds.
* When the target is fully out of the boundary, the adjuster is marked as complete and the target sprite is set
* to be stationary.
*/
export class TerminateOutOfBounds extends BaseSpriteAdjuster {
#bottom;
#kill;
#left;
#right;
#top;
/**
* Construct the adjuster
* @param {module:hcje/sprites.Sprite} sprite - The target sprite.
* @param {module:hcje/utils~RectData} bounds - Game area bounds.
* @param {boolean} [kill = false] - If true, the sprite is killed on completion.
* @extends module:hcje/sprites.BaseSpriteAdjuster
*/
constructor(sprite, bounds, kill = false) {
super(sprite);
this.#left = bounds.x;
this.#right = bounds.x + bounds.width;
this.#top = bounds.y;
this.#bottom = bounds.y + bounds.height;
this.#kill = kill;
}
/**
* @inheritdoc
*/
adjust(timeStamp, deltaT) {
const bounds = this._sprite.bounds;
if (bounds.x + bounds.width < this.#left ||
bounds.x > this.#right ||
bounds.y + bounds.height < this.#top ||
bounds.y > this.#bottom
) {
console.debug(`Terminate ${this._sprite.logId} as out of bounds`);
if (this.#kill) {
this._sprite.kill();
}
this._sprite.dynamics.vx = 0;
this._sprite.dynamics.vy = 0;
this._sprite.dynamics.ax = 0;
this._sprite.dynamics.ay = 0;
this.markComplete();
}
}
}
/**
* Adjuster that completes when the sprite reaches a point. Note the dynamics should have been configured first.
* If not moving, the adjuster is marked as complete. Once completed, the velocity and acceleration are set to zero.
*/
export class ReachTargetXY extends BaseSpriteAdjuster {
#targetX;
#targetY;
/**
* Construct the adjuster
* @param {module:hcje/sprites.Sprite} sprite - The target sprite.
* @param {number} targetX - Destination x position.
* @param {number} targetY - Destination y position.
* @extends module:hcje/sprites.BaseSpriteAdjuster
*/
constructor(sprite, targetX, targetY) {
super(sprite);
this.#targetX = targetX;
this.#targetY = targetY;
}
/**
* Check if point reached.
* @param {number} velocity - Approach velocity.
* @param {number} value - Current value.
* @param {number} target - Target value.
* @returns {boolean} True if reached.
*/
targetReached(velocity, value, target) {
if (velocity < 0) {
return value <= target;
} else {
return value >= target;
}
}
/**
* @inheritdoc
*/
adjust(timeStamp, deltaT) {
const position = this._sprite.position;
if (this.targetReached(this._sprite.dynamics.vx, position.x, this.#targetX)) {
this._sprite.dynamics.vx = 0;
this._sprite.dynamics.ax = 0;
position.x = this.#targetX;
}
if (this.targetReached(this._sprite.dynamics.vy, position.y, this.#targetY)) {
this._sprite.dynamics.vy = 0;
this._sprite.dynamics.ay = 0;
position.y = this.#targetY;
}
if (this._sprite.dynamics.vx === 0 && this._sprite.dynamics.vy === 0) {
this.markComplete();
}
}
}
/**
* Animator class for animating objects that implement the [AnimationTarget]{@link module:hcje/sprites~AnimationTarget}
* interface.
* When activated, the Animator requests animation frames and calls update on its children.
*/
export class Animator {
/** Active property. Set true to start requesting animation frames.
* @type {boolean} */
#active;
/** Animated children with adjusters. This is a map which uses the **AnimationTarget** as the key.
* @type {Map<Object, module:hcje/sprites~AnimationTarget>}
*/
#children;
/** Previous time stamp.
* @type {DOMHighResTimeStamp} */
#lastTimeStamp;
/** Flag to indicate that the animation is paused.
* @type {boolean} */
#paused;
/**
* Construct the animator.
*/
constructor () {
this.active = false;
this.#paused = false;
this.#children = new Map();
}
/**
* The current active state. When true, animation frames will start to be requested and children updated. Note
* if the animation has been paused even if the active property is set true. The paused state overrides the active
* state.
* @type {boolean}
*/
get active() {
return this.#active;
}
/**
* Set the current active state.
* When set true, animationFrames will start to be requested and children updated.
* @type {boolean}
* @ignore
*/
set active(state) {
if (!this.#active && state) {
this.#active = state;
this.#animate();
} else {
this.#lastTimeStamp = undefined;
this.#active = state;
}
}
/**
* Pause the animator. The active property is unchanged. The allows the **pause**
* and **resume** methods to be safely called without modifying the **active** state of the animator.
*/
pause() {
this.#paused = true;
this.#lastTimeStamp = undefined;
}
/**
* Resume (unpause) the animator. This will resume animations. Note the animations may still not actually start if the animator
* is not active. If the animator is not paused, the call is ignored.
*/
resume() {
if (this.#paused) {
this.#paused = false;
if (this.#active) {
this.#animate();
}
}
}
/**
* Perform the animation cycle and update all children. If the timestamp is not set, the children are not updated,
* but the animation cycle is initiated.
* @param {DOMHighResTimeStamp} timeStamp - End time of the previous frame.
* @private
*/
#animate(timeStamp) {
if (!this.active || this.#paused) {
return;
}
if (timeStamp) {
const deltaT = this.#lastTimeStamp ? (timeStamp - this.#lastTimeStamp) / 1000 : 0;
this.#lastTimeStamp = timeStamp;
this.#children.forEach((target) => {
target.update(timeStamp, deltaT);
if (target.isKilled()) {
this.#children.delete(target);
}
});
}
requestAnimationFrame((evtTimeStamp) => this.#animate(evtTimeStamp));
}
/**
* Add target for animation.
* @param {module:hcje/sprites~AnimationTarget} target - Animation target to update.
* @throws {Error} Thrown if the child does not implement the {@link module:hcje/sprites~AnimationTarget} interface.
*/
addTarget(target) {
if (!target.update) {
throw new Error('Target does not implement the hcje/sprites~AnimationTarget interface.');
}
this.#children.set(target, target);
}
/**
* Destroy the target. This kills the target and removes it from the animator.
* @param {module:hcje/sprites~AnimationTarget} target - Animation target to kill.
*/
killTarget(target) {
const child = this.#children.get(target);
if (child) {
child.kill();
}
this.#children.delete(target);
}
/**
* Clear the animator. This kills all children and deactivates the animator.
*/
clear() {
this.#active = false;
this.#children.forEach((child) => this.killTarget(child));
}
}