Home / Modules / Gameplay / CombatPlay

CombatPlay

Pure logic

Owns team combat player state, health and armor changes, death events, winner resolution, and reset behavior.

CategoryGameplay
Dependency tierPure logic
Internal depsnone
Related modulesnone
Demo scenearena-combat · Featured in this demo scene (coming soon)
Take just this module
modules/gameplay/CombatPlay.js

Copy it together with its internal dependencies, preserving the relative directory structure.

Source

export const COMBAT_STATES = Object.freeze({
  WAITING: 'WAITING',
  STARTED: 'STARTED',
  FINISHED: 'FINISHED',
});

export const COMBAT_PLAY_EVENTS = Object.freeze({
  COMBAT_FINISHED: 'combat.finished',
  PLAYER_KILLED: 'combat.player.killed',
});

function clonePlayer(player) {
  return {
    playerId: player.playerId,
    teamId: player.teamId,
    maxHealth: player.maxHealth,
    health: player.health,
    maxArmor: player.maxArmor,
    armor: player.armor,
    alive: player.alive,
  };
}

function createPlayer({
  playerId,
  teamId,
  maxHealth,
  health,
  maxArmor,
  armor,
}) {
  return {
    playerId,
    teamId,
    maxHealth,
    health,
    maxArmor,
    armor,
    alive: health > 0,
  };
}

export class CombatPlay {
  constructor({
    maxHealth = 100,
    maxArmor = 100,
    armorAbsorption = 0.6,
  }) {
    this.maxHealth = maxHealth;
    this.maxArmor = maxArmor;
    this.armorAbsorption = armorAbsorption;

    this.players = new Map();
    this.combatState = COMBAT_STATES.WAITING;
    this.winnerTeamId = null;
    this._events = [];
  }

  addPlayer({
    playerId,
    teamId,
    health = this.maxHealth,
    armor = 0,
  }) {
    if (this.combatState !== COMBAT_STATES.WAITING) {
      throw new Error('players can only be added while combat is waiting');
    }
    if (this.players.has(playerId)) {
      throw new Error(`player already exists: ${playerId}`);
    }

    this.players.set(playerId, createPlayer({
      playerId,
      teamId,
      maxHealth: this.maxHealth,
      health: health,
      maxArmor: this.maxArmor,
      armor: armor,
    }));
  }

  removePlayer(playerId) {
    if (!this.players.delete(playerId)) {
      throw new Error(`unknown player: ${playerId}`);
    }
  }

  updatePlayer({ playerId, health, armor }) {
    const player = this._getPlayer(playerId);
    if (health !== undefined) {
      player.health = Math.min(health, player.maxHealth);
      player.alive = player.health > 0;
    }
    if (armor !== undefined) {
      player.armor = Math.min(armor, player.maxArmor);
    }
  }

  startGame() {
    if (this.combatState !== COMBAT_STATES.WAITING) {
      throw new Error('combat can only be started from WAITING');
    }
    if (this.players.size === 0) {
      throw new Error('combat requires at least one player');
    }

    this.winnerTeamId = null;
    this.combatState = COMBAT_STATES.STARTED;
  }

  reset() {
    this._resetPlayers();
    this._clearEvents();
    this.combatState = COMBAT_STATES.WAITING;
  }

  getPlayer(playerId) {
    return clonePlayer(this._getPlayer(playerId));
  }

  getCombatState() {
    return this.combatState;
  }

  getAliveTeamIds() {
    return Array.from(new Set(
      Array.from(this.players.values())
        .filter((player) => player.alive)
        .map((player) => player.teamId)
    ));
  }

  _getPlayer(playerId) {
    const player = this.players.get(playerId);
    if (!player) throw new Error(`unknown player: ${playerId}`);
    return player;
  }

  _resetPlayers() {
    this.winnerTeamId = null;
    for (const player of this.players.values()) {
      player.health = this.maxHealth;
      player.armor = 0;
      player.alive = player.health > 0;
    }
  }

  _queueEvent(event) {
    this._events.push(event);
  }

  _clearEvents() {
    this._events = [];
  }

  _drainEvents() {
    const events = this._events;
    this._events = [];
    return events;
  }

  damage({
    playerId,
    amount,
    sourceId = null,
    bypassArmor = false,
  }) {
    if (this.combatState !== COMBAT_STATES.STARTED) return;

    const player = this._getPlayer(playerId);
    if (!player.alive) {
      return;
    }

    const armorDamage = bypassArmor ? 0 : Math.min(player.armor, amount * this.armorAbsorption);
    const healthDamage = Math.min(player.health, amount - armorDamage);

    player.armor -= armorDamage;
    player.health -= healthDamage;
    player.alive = player.health > 0;

    if (!player.alive) {
      this._queueEvent({
        type: COMBAT_PLAY_EVENTS.PLAYER_KILLED,
        playerId,
        sourceId,
      });
    }
  }

  heal({ playerId, amount }) {
    if (this.combatState !== COMBAT_STATES.STARTED) return;

    const player = this._getPlayer(playerId);
    if (!player.alive) {
      return;
    }

    player.health = Math.min(player.maxHealth, player.health + amount);
  }

  addArmor({ playerId, amount }) {
    if (this.combatState !== COMBAT_STATES.STARTED) return;

    const player = this._getPlayer(playerId);
    player.armor = Math.min(player.maxArmor, player.armor + amount);
  }

  step() {
    this._finishCombatIfResolved();
    return this._drainEvents();
  }

  _finishCombatIfResolved() {
    if (this.combatState !== COMBAT_STATES.STARTED) return;

    const aliveTeamIds = this.getAliveTeamIds();
    if (aliveTeamIds.length > 1) return;

    this.combatState = COMBAT_STATES.FINISHED;
    this.winnerTeamId = aliveTeamIds[0] ?? null;
    this._queueEvent({
      type: COMBAT_PLAY_EVENTS.COMBAT_FINISHED,
      winnerTeamId: this.winnerTeamId,
    });
  }
}