Source: models/movable-object.class.js

// movable-object.class.js

/**
 * @class MovableObject
 * @extends DrawableObject
 *
 * Base class for all objects that can move and be affected by gravity or collision.
 */
class MovableObject extends DrawableObject {
  /**
   * Current health value of the object (0–100).
   * @type {number}
   */
  health = 100;

  /**
   * Timestamp of the last time the object was damaged.
   * Used for invincibility or damage cooldown.
   * @type {number}
   */
  lastHit = 0;

  /**
   * Timestamp when temporary invincibility was triggered.
   * Used to track invincibility duration.
   * @type {number}
   */
  invincibleTrigger = 0;

  /**
   * Horizontal acceleration value (e.g. for running, knockback).
   * @type {number}
   */
  accelerationX = 1;

  /**
   * Duration (in seconds) the object remains invincible after taking damage.
   * @type {number}
   */
  invincibleTime = 1.5;

  /**
   * Time threshold (in seconds) after which the object enters long idle state.
   * Used to trigger special animations or behaviors.
   * @type {number}
   */
  longIdleThreshold = 10;

  /**
   * Counter used to control animation frame skipping.
   * @type {number}
   */
  skipFrame = 0;

  /**
   * Indicates whether the object is currently in cooldown and cannot perform certain actions.
   * @type {boolean}
   */
  hitOnCooldown = false;

  /**
   * Vertical margin to adjust how precisely top collisions are detected.
   * Helps avoid flickering or false positives.
   * @type {number}
   */
  TOP_COLLISION_MARGIN = 20;

  /**
   * Minimum height required to consider side collisions.
   * Prevents unintended side collision logic for very small objects.
   * @type {number}
   */
  SIDE_COLLISION_IGNORE_HEIGHT = 30;

  /**
   * Counter for frame-based animation timing and update control.
   * Used to synchronize updates with display refresh rate.
   * @type {number}
   */
  frameCount = 0;

  /**
   * Applies gravity to the object by continuously updating its vertical position.
   * Gravity affects the object only when it is in the air or falling.
   * Synchronized with display refresh rate via requestAnimationFrame.
   */
  applyGravity() {
    if (this.isAboveGround() || this.speedY > 0) {
      this.y -= (this.speedY / 2) * 0.6 * world.deltaTime;
      this.speedY -= this.acceleration / 2 * 0.58 * world.deltaTime;
    }

    setStoppableRAF(() => this.applyGravity());
  }

  /**
   * Applies horizontal force to the object based on its current speed and direction.
   * Used for knockback or sliding effects. Updates every second frame to control motion smoothness.
   * Movement speed is scaled by deltaTime for consistent motion across different frame rates.
   * Stops movement at level boundaries and gradually reduces speed through acceleration.
   */
  applyHorizontalForce() {
    const updateEvery = 2;

    if (this.frameCount === 0) {
      if (this.speedX < 0 && this.x <= this.world.getLeftBoundary() + 2) {
        this.speedX = 0;
      } else if (this.speedX > 0 && this.x >= this.world.level.level_end_x - 2) {
        this.speedX = 0;
      } else if (this.speedX < 0) {
        this.x += this.speedX * 0.58 * world.deltaTime;
        this.speedX += this.accelerationX * 0.58 * world.deltaTime;
      } else if (this.speedX > 0) {
        this.x += this.speedX * 0.58 * world.deltaTime;
        this.speedX -= this.accelerationX * 0.58 * world.deltaTime;
      }
    }

    this.frameCount = (this.frameCount + 1) % updateEvery;
    setStoppableRAF(() => this.applyHorizontalForce());
  }

  /**
   * Checks whether the object is currently in the air.
   * Returns true unless it is on top of an object or resting on the ground.
   * Throwable objects are always considered above ground.
   * @returns {boolean} True if the object is in the air.
   */
  isAboveGround() {
    if (this.isBroken) {
      return false;
    } else if (this instanceof ThrowableObject) {
      return true;
    } else if (this instanceof Endboss) {
      return this.y < 145;
    } else if (this instanceof Chonk) {
      return this.y < 320;
    } else if (this instanceof Chicken) {
      return this.y < 370;
    } else if (this.isOnTop()) {
      return false;
    } else {
      return this.y < 220;
    }
  }

  /**
   * Processes a hit on the object, applying damage, triggering cooldown,
   * and handling rebound effects if the object survives.
   *
   * @param {number} damage - The amount of damage to apply.
   * @param {string} [direction="left"] - The direction the object is knocked back toward after the hit.
   * @param {boolean} [isBoss=false] - Whether the hit originated from a boss, which increases knockback.
   */
  hit(damage, direction = "left", isBoss = false) {
    if (!this.hitOnCooldown && !world.endscreenTriggered) {
      this.takeDamage(damage);
      this.handleHitCooldown();
    }

    if (this.health > 0) {
      this.updateHitTimestamps();
      this.rebound(direction, 15, isBoss);
    }
  }

  /**
   * Applies damage to the object and ensures health does not drop below zero.
   *
   * @param {number} damage - The amount of damage to subtract from health.
   */
  takeDamage(damage) {
    this.health -= damage;
    if (this.health < 0) {
      this.health = 0;
    }
  }

  /**
   * Activates a temporary cooldown during which the object cannot be hit again.
   */
  handleHitCooldown() {
    this.hitOnCooldown = true;
    setTimeout(() => {
      this.hitOnCooldown = false;
    }, 1000);
  }

  /**
   * Updates all relevant timestamps for hit, invincibility, and input.
   */
  updateHitTimestamps() {
    const now = Date.now();
    this.lastHit = now;
    this.invincibleTrigger = now;
    lastInput = now;
  }

  /**
   * Disables the object's hitbox by moving its top offset far outside the visible range.
   * Used to prevent further collisions after death or collection.
   */
  disableHitbox() {
    this.offset.top = 504;
  }

  /**
   * Applies a rebound force to the object based on the given knockback direction.
   * Simulates pushback after being hit.
   *
   * @param {string} direction - The direction to push the object ("left", "right", or "up-left").
   * @param {number} [momentum=15] - Base impulse applied to speed components.
   * @param {boolean} [isBoss=false] - If true, uses a stronger momentum for boss hits.
   */
  rebound(direction, momentum = 15, isBoss = false) {
    if (isBoss) momentum = 23;
    switch (direction) {
      case "up-left":
        this.speedY = momentum;
        this.speedX = -1 * momentum;
        break;
      case "right":
        this.speedX = momentum;
        break;
      case "left":
        this.speedX = -1 * momentum;
        break;
    }
  }

  /**
   * Checks if the object is currently in a hurt (stunned) state.
   * Based on the time passed since the last hit.
   *
   * @returns {boolean} True if the stun duration is still active.
   */
  isHurt(stunTime = 1) {
    let timePassed = new Date().getTime() - this.lastHit;
    timePassed = timePassed / 1000;
    return timePassed < stunTime;
  }

  /**
   * Checks if the object is currently invincible.
   * Based on the time passed since invincibility was triggered.
   *
   * @returns {boolean} True if the invincibility duration is still active.
   */
  isInvincible() {
    let timePassed = new Date().getTime() - this.invincibleTrigger;
    timePassed = timePassed / 1000;
    return timePassed < this.invincibleTime;
  }

  /**
   * Checks whether the object has no health left.
   *
   * @returns {boolean} True if health is 0.
   */
  isDead() {
    return this.health == 0;
  }

  /**
   * Checks whether the object has been idle for longer than the defined threshold.
   * Based on the time passed since the last user input.
   *
   * @returns {boolean} True if idle duration exceeds the threshold.
   */
  isLongIdle() {
    let timePassed = new Date().getTime() - lastInput;
    timePassed = timePassed / 1000;
    return timePassed > this.longIdleThreshold;
  }

  /**
   * Checks whether this object is currently above another object,
   * using multiple vertical position snapshots for more accurate detection.
   *
   * @param {DrawableObject} other - The object to compare against.
   * @returns {boolean} True if this object was above the other in recent frames.
   */
  isHigher(other) {
    return (
      this.lastY + this.height - this.offset.bottom <= other.getHitboxBorderTop() ||
      this.lastY2 + this.height - this.offset.bottom <= other.getHitboxBorderTop() ||
      this.lastY3 + this.height - this.offset.bottom <= other.getHitboxBorderTop()
    );
  }

  /**
   * Checks whether the object is currently falling (i.e. moving downward).
   *
   * @returns {boolean} True if vertical speed is negative.
   */
  isFalling() {
    return this.speedY < 0;
  }

  /**
   * Checks whether this object is colliding with another object from the left side.
   * Used to detect left-side obstruction or wall collisions.
   *
   * @param {DrawableObject} other - The object to test collision against.
   * @returns {boolean} True if touching the other object from the left.
   */
  isTouchingFromLeft(other) {
    return (
      this.getHitboxBorderRight() >= other.getHitboxBorderLeft() &&
      this.getHitboxBorderRight() <= other.getHitboxBorderRight() &&
      this.getHitboxBorderBottom() >
        other.getHitboxBorderTop() + this.SIDE_COLLISION_IGNORE_HEIGHT &&
      this.getHitboxBorderBottom() < other.getHitboxBorderBottom()
    );
  }

  /**
   * Checks whether this object is colliding with another object from the right side.
   *
   * @param {DrawableObject} other - The object to test collision against.
   * @returns {boolean} True if touching the other object from the right.
   */
  isTouchingFromRight(other) {
    return (
      this.getHitboxBorderLeft() <= other.getHitboxBorderRight() &&
      this.getHitboxBorderLeft() >= other.getHitboxBorderLeft() &&
      this.getHitboxBorderBottom() >
        other.getHitboxBorderTop() + this.SIDE_COLLISION_IGNORE_HEIGHT &&
      this.getHitboxBorderBottom() < other.getHitboxBorderBottom()
    );
  }

  /**
   * Checks whether this object is touching the top surface of another object.
   * Typically used to determine whether the object is standing on something.
   *
   * @param {DrawableObject} other - The object to test collision against.
   * @returns {boolean} True if this object is touching the other from above.
   */
  isTouchingFromTop(other) {
    return (
      this.getHitboxBorderRight() > other.getHitboxBorderLeft() &&
      this.getHitboxBorderLeft() < other.getHitboxBorderRight() &&
      this.getHitboxBorderBottom() >= other.getHitboxBorderTop() &&
      this.getHitboxBorderBottom() <= other.getHitboxBorderTop() + this.TOP_COLLISION_MARGIN &&
      this.speedY <= 0
    );
  }

  /**
   * Moves the object to the right by increasing its x position based on speed.
   */
  moveRight(speed = this.speed) {
    this.x += speed * 0.58 * world.deltaTime;
  }

  /**
   * Moves the object to the left by decreasing its x position based on speed.
   */
  moveLeft(speed = this.speed) {
    this.x -= speed * 0.58 * world.deltaTime;
  }

  /**
   * Makes the object jump by setting its vertical speed.
   * Also plays the jump sound via the SoundManager.
   *
   * @param {number} [speedY=15] - The upward speed to apply when jumping.
   */
  jump(speedY = 15) {
    this.speedY = speedY;
    SoundManager.playOne(SoundManager.CHARACTER_JUMP, 1, 0.1, 500);
  }

  /**
   * Plays an animation sequence at the defined frame delay.
   * Increments the frame counter after each call.
   *
   * @param {string[]} images - Array of image paths representing the animation.
   * @param {number} frameDelay - Delay in frames between each animation step.
   */
  playStateAnimation(images, frameDelay) {
    if (this.skipFrame % frameDelay === 0) {
      this.playAnimation(images);
    }
    this.skipFrame += 1;
  }

  /**
   * Calculates the number of seconds that have passed since a given timestamp.
   *
   * @param {number} startTimestamp - Timestamp in milliseconds to compare against the current time.
   * @returns {number} Seconds that have passed since the given timestamp.
   */
  secondsSince(startTimestamp) {
    return (new Date().getTime() - startTimestamp) / 1000;
  }

  /**
   * Resets the skipFrame counter to 0.
   * Used to synchronize animation timing.
   *
   * @returns {number} The new skipFrame value (0).
   */
  resetSkipFrame() {
    return (this.skipFrame = 0);
  }

  /**
   * Resets the animation frame index to 0.
   * Typically called when switching animations.
   *
   * @returns {number} The new frame index (0).
   */
  resetCurrentImage() {
    return (this.currentImage = 0);
  }

  /**
   * Executes the throwing behavior of the object.
   * Initializes size and speed, applies direction, gravity and plays sound.
   * Movement is synchronized with display refresh rate.
   */
  throw() {
    if (world.endscreenTriggered) return;
    this.setThrowValues();
    SoundManager.playOne(SoundManager.CHARACTER_THROW, 1, 0.2, 500);
    this.applyGravity();

    if (world.character.otherDirection) {
      this.speedX = this.speedX * -1;
    }

    this.setHorizontalMovement();
  }

  /**
   * Updates horizontal position of a thrown object.
   * Movement is synchronized with display refresh rate for smooth animation.
   */
  setHorizontalMovement() {
    this.x += this.speedX * 0.45 * world.deltaTime;

    setStoppableRAF(() => this.setHorizontalMovement());
  }

  /**
   * Sets default size and initial speed values for a thrown object.
   */
  setThrowValues() {
    this.width = 50;
    this.height = 50;
    this.speedY = 15;
    this.speedX = 5;
  }
}