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