Source: sprites.js

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

}