Back to Browser Side Quest
SOURCE / PINNED RELEASE

Made of little things.

Browser Side Quest

Release
b9d42f5768e4…
Author-recorded commit
c267690145fa…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest

Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/client/entity.ts
// Port of BrowserQuest client/js/{entity,character}.js (MPL-2.0).
import {
  Orientations,
  getKindAsString,
  getOrientationAsString,
  isMob,
  isPlayer,
  type Orientation,
} from '../shared/gametypes.js';
import { Animation, Timer, Transition } from './timing.js';
import type { DrawableSprite, Sprite } from './sprite.js';

export type Path = [number, number][];

export class Entity {
  id: number;
  name = '';
  sprite: DrawableSprite | null = null;
  normalSprite: DrawableSprite | null = null;
  hurtSprite: DrawableSprite | null = null;
  flipSpriteX = false;
  flipSpriteY = false;
  animations: Record<string, Animation> | null = null;
  currentAnimation: Animation | null = null;
  shadowOffsetY = 0;
  x = 0;
  y = 0;
  gridX = 0;
  gridY = 0;
  isLoaded = false;
  isHighlighted = false;
  visible = true;
  isFading = false;
  fadingAlpha = 1;
  startFadingTime = 0;
  isDirty = true;
  private blinking: number | null = null;
  private readyFunc: (() => void) | null = null;

  constructor(
    id: number,
    public kind: number,
  ) {
    this.id = id;
    this.setGridPosition(0, 0);
  }

  setName(name: string): void {
    this.name = name;
  }

  setPosition(x: number, y: number): void {
    this.x = x;
    this.y = y;
  }

  setGridPosition(x: number, y: number): void {
    this.gridX = x;
    this.gridY = y;
    this.setPosition(x * 16, y * 16);
  }

  setSprite(sprite: Sprite | undefined): void {
    if (!sprite) throw new Error(`${this.id}: sprite is null`);
    if (this.sprite && (this.sprite as Sprite).name === sprite.name) return;

    this.sprite = sprite;
    this.normalSprite = sprite;
    if (isMob(this.kind) || isPlayer(this.kind)) this.hurtSprite = sprite.getHurtSprite();
    this.animations = sprite.createAnimations();
    this.isLoaded = true;
    this.readyFunc?.();
  }

  getSprite(): DrawableSprite | null {
    return this.sprite;
  }

  getSpriteName(): string {
    return getKindAsString(this.kind) ?? '';
  }

  getAnimationByName(name: string): Animation | null {
    return this.animations?.[name] ?? null;
  }

  setAnimation(name: string, speed: number, count?: number, onEndCount?: () => void): void {
    if (!this.isLoaded) return;
    if (this.currentAnimation && this.currentAnimation.name === name) return;

    const a = this.getAnimationByName(name);
    if (a) {
      this.currentAnimation = a;
      if (name.substring(0, 3) === 'atk') a.reset();
      a.setSpeed(speed);
      a.setCount(count ? count : 0, onEndCount || (() => this.idle()));
    }
  }

  idle(_orientation?: Orientation): void {}

  hasShadow(): boolean {
    return false;
  }

  ready(f: () => void): void {
    this.readyFunc = f;
  }

  clean(): void {
    this.stopBlinking();
  }

  setHighlight(value: boolean): void {
    const base = this.normalSprite as Sprite | null;
    if (value && base?.silhouetteSprite) {
      this.sprite = base.silhouetteSprite;
      this.isHighlighted = true;
    } else {
      this.sprite = this.normalSprite;
      this.isHighlighted = false;
    }
  }

  setVisible(value: boolean): void {
    this.visible = value;
  }

  isVisible(): boolean {
    return this.visible;
  }

  toggleVisibility(): void {
    this.setVisible(!this.visible);
  }

  getDistanceToEntity(entity: Entity): number {
    const distX = Math.abs(entity.gridX - this.gridX);
    const distY = Math.abs(entity.gridY - this.gridY);
    return distX > distY ? distX : distY;
  }

  isCloseTo(entity: Entity | null): boolean {
    if (!entity) return false;
    return Math.abs(entity.gridX - this.gridX) < 30 && Math.abs(entity.gridY - this.gridY) < 14;
  }

  /** Returns true if the entity is adjacent to the given one. */
  isAdjacent(entity: Entity | null): boolean {
    return entity ? this.getDistanceToEntity(entity) <= 1 : false;
  }

  isAdjacentNonDiagonal(entity: Entity | null): boolean {
    return (
      !!entity &&
      this.isAdjacent(entity) &&
      !(this.gridX !== entity.gridX && this.gridY !== entity.gridY)
    );
  }

  isDiagonallyAdjacent(entity: Entity): boolean {
    return this.isAdjacent(entity) && !this.isAdjacentNonDiagonal(entity);
  }

  forEachAdjacentNonDiagonalPosition(callback: (x: number, y: number, o: Orientation) => void): void {
    callback(this.gridX - 1, this.gridY, Orientations.LEFT);
    callback(this.gridX, this.gridY - 1, Orientations.UP);
    callback(this.gridX + 1, this.gridY, Orientations.RIGHT);
    callback(this.gridX, this.gridY + 1, Orientations.DOWN);
  }

  fadeIn(currentTime: number): void {
    this.isFading = true;
    this.fadingAlpha = 0;
    this.startFadingTime = currentTime;
  }

  blink(speed: number): void {
    this.stopBlinking();
    this.blinking = window.setInterval(() => this.toggleVisibility(), speed);
  }

  stopBlinking(): void {
    if (this.blinking !== null) window.clearInterval(this.blinking);
    this.blinking = null;
    this.setVisible(true);
  }

  setDirty(): void {
    this.isDirty = true;
  }
}

export class Character extends Entity {
  nextGridX = -1;
  nextGridY = -1;
  orientation: Orientation = Orientations.DOWN;
  atkSpeed = 50;
  moveSpeed = 120;
  walkSpeed = 100;
  idleSpeed = 450;
  attackCooldown = new Timer(800);
  movement = new Transition();
  path: Path | null = null;
  step = 0;
  newDestination: { x: number; y: number } | null = null;
  destination: { gridX: number; gridY: number } | null = null;
  adjacentTiles: Record<number, boolean> = {};
  target: Entity | null = null;
  unconfirmedTarget: Entity | null = null;
  previousTarget: Character | null = null;
  attackers: Record<number, Character> = {};
  hitPoints = 0;
  maxHitPoints = 0;
  isDead = false;
  isDying = false;
  attackingMode = false;
  followingMode = false;
  interrupted = false;
  private hurting: number | null = null;
  private requestPathCallback: ((x: number, y: number) => Path) | null = null;
  private startPathingCallback: ((path: Path) => void) | null = null;
  private stopPathingCallback: ((x: number, y: number) => void) | null = null;
  private beforeStepCallback: (() => void) | null = null;
  private stepCallback: (() => void) | null = null;
  private aggroCallback: ((character: Character) => void) | null = null;
  private checkAggroCallback: (() => void) | null = null;
  private deathCallback: (() => void) | null = null;
  private hasMovedCallback: ((c: Character) => void) | null = null;

  clean(): void {
    this.forEachAttacker((attacker) => {
      attacker.disengage();
      attacker.idle();
    });
  }

  setMaxHitPoints(hp: number): void {
    this.maxHitPoints = hp;
    this.hitPoints = hp;
  }

  setDefaultAnimation(): void {
    this.idle();
  }

  hasWeapon(): boolean {
    return false;
  }

  getWeaponName(): string | null {
    return null;
  }

  hasShadow(): boolean {
    return true;
  }

  animate(animation: string, speed: number, count?: number, onEndCount?: () => void): void {
    const oriented = ['atk', 'walk', 'idle'];
    const o = this.orientation;

    // don't change animation if the character is dying
    if (!(this.currentAnimation && this.currentAnimation.name === 'death')) {
      this.flipSpriteX = false;
      this.flipSpriteY = false;

      if (oriented.includes(animation)) {
        animation += `_${o === Orientations.LEFT ? 'right' : getOrientationAsString(o)}`;
        this.flipSpriteX = this.orientation === Orientations.LEFT;
      }
      this.setAnimation(animation, speed, count, onEndCount);
    }
  }

  turnTo(orientation: Orientation): void {
    this.orientation = orientation;
    this.idle();
  }

  setOrientation(orientation?: Orientation | null): void {
    if (orientation) this.orientation = orientation;
  }

  idle(orientation?: Orientation): void {
    this.setOrientation(orientation);
    this.animate('idle', this.idleSpeed);
  }

  hit(orientation?: Orientation): void {
    this.setOrientation(orientation);
    this.animate('atk', this.atkSpeed, 1);
  }

  walk(orientation?: Orientation): void {
    this.setOrientation(orientation);
    this.animate('walk', this.walkSpeed);
  }

  moveTo_(x: number, y: number): void {
    this.destination = { gridX: x, gridY: y };
    this.adjacentTiles = {};
    if (this.isMoving()) {
      this.continueTo(x, y);
    } else {
      this.followPath(this.requestPathfindingTo(x, y));
    }
  }

  requestPathfindingTo(x: number, y: number): Path {
    return this.requestPathCallback ? this.requestPathCallback(x, y) : [];
  }

  onRequestPath(callback: (x: number, y: number) => Path): void {
    this.requestPathCallback = callback;
  }

  onStartPathing(callback: (path: Path) => void): void {
    this.startPathingCallback = callback;
  }

  onStopPathing(callback: (x: number, y: number) => void): void {
    this.stopPathingCallback = callback;
  }

  followPath(path: Path): void {
    // Length of 1 means the player has clicked on himself
    if (path.length > 1) {
      this.path = path;
      this.step = 0;
      if (this.followingMode) path.pop(); // following a character
      this.startPathingCallback?.(path);
      this.nextStep();
    }
  }

  continueTo(x: number, y: number): void {
    this.newDestination = { x, y };
  }

  updateMovement(): void {
    const p = this.path!;
    const i = this.step;
    if (p[i][0] < p[i - 1][0]) this.walk(Orientations.LEFT);
    if (p[i][0] > p[i - 1][0]) this.walk(Orientations.RIGHT);
    if (p[i][1] < p[i - 1][1]) this.walk(Orientations.UP);
    if (p[i][1] > p[i - 1][1]) this.walk(Orientations.DOWN);
  }

  updatePositionOnGrid(): void {
    const [x, y] = this.path![this.step];
    this.setGridPosition(x, y);
  }

  nextStep(): void {
    if (!this.isMoving()) return;
    let stop = false;

    this.beforeStepCallback?.();
    this.updatePositionOnGrid();
    this.checkAggro();

    if (this.interrupted) {
      // if Character.stop() has been called
      stop = true;
      this.interrupted = false;
    } else {
      if (this.hasNextStep()) {
        this.nextGridX = this.path![this.step + 1][0];
        this.nextGridY = this.path![this.step + 1][1];
      }

      this.stepCallback?.();

      if (this.hasChangedItsPath()) {
        const { x, y } = this.newDestination!;
        const path = this.requestPathfindingTo(x, y);
        this.newDestination = null;
        if (path.length < 2) stop = true;
        else this.followPath(path);
      } else if (this.hasNextStep()) {
        this.step += 1;
        this.updateMovement();
      } else {
        stop = true;
      }
    }

    if (stop) {
      // Path is complete or has been interrupted
      this.path = null;
      this.idle();
      this.stopPathingCallback?.(this.gridX, this.gridY);
    }
  }

  onBeforeStep(callback: () => void): void {
    this.beforeStepCallback = callback;
  }

  onStep(callback: () => void): void {
    this.stepCallback = callback;
  }

  isMoving(): boolean {
    return this.path !== null;
  }

  hasNextStep(): boolean {
    return this.path!.length - 1 > this.step;
  }

  hasChangedItsPath(): boolean {
    return this.newDestination !== null;
  }

  isNear(character: Entity, distance: number): boolean {
    return (
      Math.abs(this.gridX - character.gridX) <= distance &&
      Math.abs(this.gridY - character.gridY) <= distance
    );
  }

  onAggro(callback: (character: Character) => void): void {
    this.aggroCallback = callback;
  }

  onCheckAggro(callback: () => void): void {
    this.checkAggroCallback = callback;
  }

  checkAggro(): void {
    this.checkAggroCallback?.();
  }

  aggro(character: Character): void {
    this.aggroCallback?.(character);
  }

  onDeath(callback: () => void): void {
    this.deathCallback = callback;
  }

  /** Changes the character's orientation so that it is facing its target. */
  lookAtTarget(): void {
    if (this.target) this.turnTo(this.getOrientationTo(this.target));
  }

  go(x: number, y: number): void {
    if (this.isAttacking()) {
      this.disengage();
    } else if (this.followingMode) {
      this.followingMode = false;
      this.target = null;
    }
    this.moveTo_(x, y);
  }

  /** Makes the character follow another one. */
  follow(entity: Entity | null): void {
    if (entity) {
      this.followingMode = true;
      this.moveTo_(entity.gridX, entity.gridY);
    }
  }

  /** Stops a moving character. */
  stop(): void {
    if (this.isMoving()) this.interrupted = true;
  }

  /** Same as follow but with an auto-attacking behavior. */
  engage(character: Entity): void {
    this.attackingMode = true;
    this.setTarget(character);
    this.follow(character);
  }

  disengage(): void {
    this.attackingMode = false;
    this.followingMode = false;
    this.removeTarget();
  }

  isAttacking(): boolean {
    return this.attackingMode;
  }

  getOrientationTo(character: Entity): Orientation {
    if (this.gridX < character.gridX) return Orientations.RIGHT;
    if (this.gridX > character.gridX) return Orientations.LEFT;
    if (this.gridY > character.gridY) return Orientations.UP;
    return Orientations.DOWN;
  }

  isAttackedBy(character: Entity): boolean {
    return character.id in this.attackers;
  }

  addAttacker(character: Character): void {
    if (!this.isAttackedBy(character)) this.attackers[character.id] = character;
  }

  removeAttacker(character: Character): void {
    if (this.isAttackedBy(character)) delete this.attackers[character.id];
  }

  forEachAttacker(callback: (attacker: Character) => void): void {
    for (const attacker of Object.values(this.attackers)) callback(attacker);
  }

  setTarget(character: Entity): void {
    if (this.target !== character) {
      if (this.hasTarget()) this.removeTarget(); // Cleanly remove the previous one
      this.unconfirmedTarget = null;
      this.target = character;
    }
  }

  removeTarget(): void {
    if (this.target) {
      if (this.target instanceof Character) this.target.removeAttacker(this);
      this.target = null;
    }
  }

  hasTarget(): boolean {
    return this.target !== null;
  }

  waitToAttack(character: Entity): void {
    this.unconfirmedTarget = character;
  }

  isWaitingToAttack(character: Entity): boolean {
    return this.unconfirmedTarget === character;
  }

  canAttack(time: number): boolean {
    return this.canReachTarget() && this.attackCooldown.isOver(time);
  }

  canReachTarget(): boolean {
    return this.hasTarget() && this.isAdjacentNonDiagonal(this.target);
  }

  die(): void {
    this.removeTarget();
    this.isDead = true;
    this.deathCallback?.();
  }

  onHasMoved(callback: (c: Character) => void): void {
    this.hasMovedCallback = callback;
  }

  hasMoved(): void {
    this.setDirty();
    this.hasMovedCallback?.(this);
  }

  hurt(): void {
    this.stopHurting();
    if (this.hurtSprite) this.sprite = this.hurtSprite;
    this.hurting = window.setTimeout(() => this.stopHurting(), 75);
  }

  stopHurting(): void {
    this.sprite = this.normalSprite;
    if (this.hurting !== null) window.clearTimeout(this.hurting);
    this.hurting = null;
  }

  setAttackRate(rate: number): void {
    this.attackCooldown = new Timer(rate);
  }
}