Source: monsters.js

/**
 * @fileoverview Uses string in base[3] to select a sprite.
 * @author Robert Laing
 * @module monsters
 */

import * as gm from "/js/modules/gm.js";

let currentState;
let monsterArr = [];
let secondHand = 0;
let mq = [];
let nexts = [];
let last_move;

/**
  * @namespace module:monsters.monster
  * @property {string} name - eg "bee", "hornet"
  * @property {string} action - "move", "attack", "death"
  * @property {integer} frame - 0 to monsters[name].frames[action].length - 1
  * @property {Array} base - base in state corresponding to monster
  * @property {pixels} x1 - sprite's current center X, used by ctx.translate
  * @property {pixels} y1 - sprite's current center Y, used by ctx.translate
  * @property {radians} angle1 - Math.atan2(y2 - y1, x2 - x1) - Math.PI/2 (a 90 degree subtraction is to keep the sprite facing up)
  * @property {pixels} x2 - sprite's destination center X, same as x1 if not moving
  * @property {pixels} y2 - sprite's destination center Y
  * @property {radians} angle2 - sprite's destination direction, same as angle1 if it's not rotating 
  * @property {pixels} velocity - 0.0 if the sprite is stationary
  * @property {pixels} deltaX - added to x1 each tick, Math.cos(angle1 + Math.PI/2) * velocity (the 90 degree addition is to remove the sprite facing up)
  * @property {pixels} deltaY - added to y1 each tick, Math.sin(angle1 + Math.PI/2) * velocity
  * @property {degrees} deltaAngle -- added to angle1 each tick, controls how fast sprites
  * @property {boolean} live - if false, gets removed from sprites array
  * @property {string} status - "idle", "selectable", "selected", "moving", "attacking", "defending", "killed"
  */

const monsters = {
  "bee": {
    "width": 64,
    "height": 64,
    "image": {
      "move": new Image(),
      "attack": new Image(),
      "death": new Image()
    },
    "src": {
      "move": "/sprite-sheets/bee.png",
      "attack": "/sprite-sheets/bee.png",
      "death": "/sprite-sheets/bee.png"
    },
    "loaded": {
      "move": false,
      "attack": false,
      "death": false
    },
    "frames": {
      "move": [[0,0],[64,0],[128,0],[192,0],[256,0],[320,0],[384,0],[448,0]],
      "attack": [[0,64],[64,64],[128,64],[192,64],[256,64],[320,64],[384,64]],
      "death": [[0,128],[64,128],[128,128],[192,128],[256,128],[320,128],[384,128],[448,128]]
    }
  },
  "hornet": {
    "width": 64,
    "height": 64,
    "image": {
      "move": new Image(),
      "attack": new Image(),
      "death": new Image()
    },
    "src": {
      "move": "/sprite-sheets/hornet-move.png",
      "attack": "/sprite-sheets/hornet-attack.png",
      "death": "/sprite-sheets/hornet-death.png"
    },
    "loaded": {
      "move": false,
      "attack": false,
      "death": false
    },
    "frames": {
      "move": [[0,0],[64,0],[128,0],[192,0],[256,0],[320,0],[384,0],[448,0]],
      "attack": [[0,0],[64,0],[128,0],[192,0],[256,0],[320,0],[384,0],[448,0]],
      "death": [
        [0,0],[64,0],[128,0],[192,0],
        [0,64],[64,64],[128,64],[192,64],
        [0,128],[64,128],[128,128],[192,128],
        [0,192],[64,192],[128,192],[192,192]
      ]
    }
  }
};

/**
 * @function module:monsters.send(message)
 */
export function send(message) {
  mq.push(message);

}

function load(name) {
  monsters[name].image.move.src = monsters[name].src.move;
  monsters[name].image.attack.src = monsters[name].src.attack;
  monsters[name].image.death.src = monsters[name].src.death;
  monsters[name].image.move.addEventListener("load", () => {monsters[name].loaded.move = true});
  monsters[name].image.attack.addEventListener("load", () => {monsters[name].loaded.attack = true});
  monsters[name].image.attack.addEventListener("load", () => {monsters[name].loaded.death = true});
}


/**
 * up is -90, down is +90 (vertically flipped cartesian plane)
 * offset by - Math.PI/2 because sprites are facing 90, not 0
 */
function calcAngle(x1, y1, x2, y2) {
  let angle = Math.atan2(y1 - y2, x1 - x2) - Math.PI/2;
  if (angle < 0.0) {
    angle += 2 * Math.PI;
  }
  return angle;
}

function base2monster(base) {
  const [dummy, col, row, role] = base;
  const [centerX, centerY] = gm.board.colrow2xy(col, row, gm.board.cellLength);
  const angle = calcAngle(centerX, centerY, window.canvas.width/2, window.canvas.width/2);
  return structuredClone({
    "name": gm.board.sprite_type[role],
    "action": "move",
    "frame": 0,
    "base": base,
    "x1": centerX,
    "y1": centerY,
    "width": gm.board.cellLength * 0.8,
    "height": gm.board.cellLength * 0.8,
    "angle1": angle,
    "x2": centerX,
    "y2": centerY,
    "angle2": angle,
    "velocity": 0.0,
    "deltaX": 0.0,
    "deltaY": 0.0,
    "live": true,
    "status": "idle"
  });
}

function state2monsters() {
  let monster;
  monsterArr = [];
  currentState.forEach(function(base) {
    if (base[0] === "cell") {
      monster = base2monster(base);
      monsterArr.push(monster);
    }
  });
  currentState.forEach(function(base) {
    if (base[0] === "control" && base[1] === gm.board.player) {
      statusSelectables();
    }
  });
}

/**
 * @function module:monsters.init()
 */
export function init() {
  Object.values(gm.board.sprite_type).forEach((name) => load(name));
  secondHand = 0;
  mq = [];
  nexts = [];
  currentState = gm.board.state;
  state2monsters();
}

/**
 * TODO make more elaborate to remove killed monsters on last death frame etc
 */
function nextFrame() {
  monsterArr.forEach(function(monster) {
    if (monster.status !== "idle") {
      monster.frame += 1;
    }
    if (monster.frame === monsters[monster.name].frames[monster.action].length) {
      monster.frame = 0;
    }
  });
}

function updateMoves() {
  monsterArr.forEach(function(monster) {
    if (monster.velocity > 0.0) {
      if (Math.hypot(monster.x2 - monster.x1, monster.y2 - monster.y1) <= monster.velocity) {
        monster.status = "idle";
        monster.velocity = 0.0;
        monster.deltaX = 0.0;
        monster.deltaY = 0.0;
        monster.x1 = monster.x2;
        monster.y1 = monster.y2;
        gm.mutateBoard("animationBusy", false);
      } else {
        monster.x1 += monster.deltaX;
        monster.y1 += monster.deltaY;
      }
    }
    if (Math.abs(monster.deltaAngle) > 0.0) {
// console.log(`${(monster.angle1 * 180/Math.PI) % 360} ${monster.angle2 * 180/Math.PI}`);
      if ((monster.angle1 % (2*Math.PI)) - monster.angle2 <= Math.abs(monster.deltaAngle)) {
        monster.angle1 = monster.angle2;
        monster.deltaAngle = 0.0;
      } else {
        monster.angle1 += monster.deltaAngle;
      }
    }
  });
}


/**
 * eg {"black":["move","e",1,"e",3]}
 */
function startMove(move) {
  const [[key, value]] = Object.entries(move);
  const [dummy, col1, row1, col2, row2] = value;
  const [x1, y1] = gm.board.colrow2xy(col1, row1, gm.board.cellLength);
  const [x2, y2] = gm.board.colrow2xy(col2, row2, gm.board.cellLength);
  const fromBase = JSON.stringify(JSON.parse(`["cell", "${col1}", ${row1}, "${key}"]`));
  monsterArr.forEach(function(monster) {
    if (JSON.stringify(monster.base) === fromBase) {
      monster.x2 = x2;
      monster.y2 = y2;
      monster.angle2 = calcAngle(x1, y1, x2, y2);
      monster.velocity = gm.board.cellLength/150;
      monster.deltaX = Math.cos(monster.angle2 - Math.PI/2) * monster.velocity;
      monster.deltaY = 	Math.sin(monster.angle2 - Math.PI/2) * monster.velocity;
      monster.deltaAngle = 0.1; 
      monster.action = "move";
      monster.status = "moving";
    }
    if (monster.base[1] === col2 && monster.base[2] === row2) {
      monster.angle2 = calcAngle(x2, y2, x1, y1);
      monster.deltaAngle = 0.1; 
      monster.action = "attack";
      monster.status = "defending";
    }
  });
  gm.mutateBoard("animationBusy", true);
}

function statusSelectables() {
  gm.board.legals.forEach(function(legal) {
    const [[key, value]] = Object.entries(legal);
      if (Array.isArray(value)) {
        const base = JSON.stringify(JSON.parse(`["cell", "${value[1]}", ${value[2]}, "${key}"]`));
        monsterArr.forEach(function(monster) {
          if (JSON.stringify(monster.base) === base) {
            monster.action = "move";
            monster.status = "selectable";
          }
        });
      }
  });
}

function statusSelected(base) {
  monsterArr.forEach(function(monster) {
    if (JSON.stringify(monster.base) === JSON.stringify(base)) {
      monster.action = "attack";
      monster.status = "selected";
    } else {
      monster.status = "idle";
    }
  });
}


function messageHandler(action) {
  switch (action[0]) {
    case "phase_change":
      if (gm.board.phase === gm.board.player) {
        statusSelectables();
      }
      if (JSON.stringify(currentState) !== localStorage[`${gm.board.game}_state`]) {
        nexts.push(JSON.parse(localStorage[`${gm.board.game}_state`]));
      }
      const new_move = JSON.parse(localStorage[`${gm.board.game}_moves`]).pop();
      if (new_move && JSON.stringify(new_move) !== JSON.stringify(last_move)) {
        last_move = new_move;
        Object.entries(new_move).forEach(function([key, value]) {
          if (key !== gm.board.player && value !== "noop") {
            const move = {};
            move[key] = value;
            startMove(move);
          }
        });
      }
      break;
    case "player_move":
      startMove(action[1]);
      break;
    case "selected":
      statusSelected(action[1])
      break;
    case "unselected":
      statusSelectables();
      break;
    default:
      window.container.textContent = `Unknown sprite action ${action[0]}`
  }
}

/**
 * @function module:monsters.update()
 */
export function update() {
  // slow default requestAnimationFrame loop speed of 60 ticks a second to about 12/frames a second by advancing frame every fifth tick
  secondHand += 1;
  if (secondHand === 5) {  
    secondHand = 0;
    nextFrame();
  }
  if (nexts.length > 0 && !gm.board.animationBusy) {
    const next = nexts.shift();
    if (JSON.stringify(currentState) !== JSON.stringify(next)) {
      currentState = next;
      state2monsters();
    }
  }
  if (mq.length > 0 && !gm.board.animationBusy) {
    messageHandler(mq.shift());
  }
  updateMoves();
  // remove dead monsters
  monsterArr = monsterArr.filter((monster) => monster.live);
}

export function resize() {
  monsterArr.forEach(function(monster) {
    monster.x1 *= gm.board.scale;
    monster.y1 *= gm.board.scale;
    monster.x2 *= gm.board.scale;
    monster.y2 *= gm.board.scale;
    monster.width *= gm.board.scale;
    monster.height *= gm.board.scale;
    monster.velocity *= gm.board.scale;
    monster.deltaX *= Math.cos(monster.angle1) * monster.velocity;
    monster.deltaY *= Math.sin(monster.angle1) * monster.velocity;
  });
}

/**
 * needed to guard against undefined monster.frame for some reason when selected clicked on bees
 * monsters[monster.name].frames[monster.action][monster.frame] stops garbled sprites, but causes sprite to vanish
 */
function draw(monster) {
  if (monsters[monster.name].loaded[monster.action] && monsters[monster.name].frames[monster.action][monster.frame] !== undefined) {
    try {
      window.ctx.save();
      window.ctx.translate(monster.x1, monster.y1);
      window.ctx.rotate(monster.angle1);
      window.ctx.drawImage(
        monsters[monster.name].image[monster.action], 
        monsters[monster.name].frames[monster.action][monster.frame][0],
        monsters[monster.name].frames[monster.action][monster.frame][1],
        monsters[monster.name].width, 
        monsters[monster.name].height, 
        -monster.width/2, 
        -monster.height/2, 
        monster.width, 
        monster.height
      );
      window.ctx.restore();
    } catch (error) {
      window.container.textContent = error;
    }
  }
}

/**
 * @function module:monsters.render()
 */
export function render() {
  monsterArr.forEach((monster) => draw(monster));
}