/* global ml */
/** @module PlayerCommon */
import Monster from "./monster.mjs";
const KEYS = {
upArrow: 38,
w: 87,
rightArrow: 39,
d: 68,
downArrow: 40,
s: 83,
leftArrow: 37,
a: 65,
space: 32,
i: 73,
j: 74,
k: 75,
l: 76,
e: 69
};
// map w to up, d to right, etc
const MAPPINGS = {
[KEYS.w]: KEYS.upArrow,
[KEYS.d]: KEYS.rightArrow,
[KEYS.s]: KEYS.downArrow,
[KEYS.a]: KEYS.leftArrow,
[KEYS.e]: KEYS.e
};
export const BASE_STATS = {
hp: 500,
hpMax: 500,
damage: 10,
speed: 400,
range: 100,
defence: 0,
};
/**
* The player class.
*/
export default class PlayerCommon {
/**
* @param {string} name - The name of the player. Should be the same as user.username
* @param {int} hp - The player's hitpoints
* @param {string} spriteName - The name of the sprite for this player.
* @param {floor} floor - The floor this player is on.
*/
constructor(name, hp, spriteName, floor) {
this.name = name;
this.alive = true;
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.spriteName = spriteName;
this.floor = floor;
this.input = new Set();
this._nextId = 0;
this._lastFrame = Date.now();
this._lastAttack = Date.now();
this._frames = [];
this.size = 1; // used with monster collision checking, acts as a size multiplier
this.attackAngle = Math.PI / 4;
this.attackType = "rectangle";
/* Stats */
this.hp = BASE_STATS.hp;
this.hpMax = BASE_STATS.hpMax;
this._setStatsToBase();
this.wearing = {
'hat': null,
'chest': null,
'glove': null,
'shortWep': null, // will need to change to weapon, or offhand?
'shield': null
};
}
/**
* Get the position of the player.
* @return {object}
*/
getPosition() {
return { x: this.x, y: this.y };
}
/**
* Get the name of the player
* @return {String} Name of this player.
*/
getName() {
return this.name;
}
/**
* Get the hp of this player
* @return {int} This player's hp.
*/
getHp() {
return this.hp;
}
/**
* Get the velocity of the player
* @return {object} { vx: int, vy: int }
*/
getVelocity() {
return { vx: this.vx, vy: this.vy };
}
/**
* Update the player's velocity from key input.
* @param {string} type - Either down or up
* @param {event} e - User's keyboard input,
*/
handleKeyPress(type, e) { // eslint-disable-line complexity
let code = MAPPINGS[e.keyCode] || e.keyCode;
if(type === "down") {
this.input.add(code);
} else {
this.input.delete(code);
}
// change in x and y
this.vx = 0;
this.vy = 0;
if(this.input.has(KEYS.upArrow)) {
this.vy -= 1;
}
if(this.input.has(KEYS.downArrow)) {
this.vy += 1;
}
if(this.input.has(KEYS.rightArrow)) {
this.vx += 1;
}
if(this.input.has(KEYS.leftArrow)) {
this.vx -= 1;
}
// attack direction x and y
this.vxAttack = 0;
this.vyAttack = 0;
if(this.input.has(KEYS.i)) {
this.vyAttack -= 1;
}
if(this.input.has(KEYS.k)) {
this.vyAttack += 1;
}
if(this.input.has(KEYS.l)) {
this.vxAttack += 1;
}
if(this.input.has(KEYS.j)) {
this.vxAttack -= 1;
}
if(this.vxAttack || this.vyAttack) {
this._attacking = true;
this._mouseAttack = false;
}
}
/**
* Handle mouse positions
*/
handleMouse(clicking, x, y) {
if(clicking) {
this._attacking = true;
this._mouseAttack = true;
}
this._targetX = x - (PlayerCommon.SPRITE_SIZE / 2);
this._targetY = y - (PlayerCommon.SPRITE_SIZE / 2);
}
/**
* Send an input frame to the server
*/
/* eslint-disable complexity */
sendFrame() {
let now = Date.now();
let frame = {
id: ++this._nextId,
start: this._lastFrameSent,
end: now,
vx: this.vx,
vy: this.vy,
attacking: this._attacking,
targetX: this._mouseAttack ? this._targetX : this.x + this.vxAttack,
targetY: this._mouseAttack ? this._targetY : this.y + this.vyAttack
};
if(this.animateAttack && this._isAttacking()) {
this.animateAttack(Math.atan2(frame.targetY - this.y, frame.targetX - this.x));
}
this._lastFrameSent = now;
if(this.vx || this.vy || this._isAttacking()) {
this._frames.push(frame);
this.floor.sock.emit("player-frame", frame);
if(this._isAttacking()) {
this._lastAttack = now;
}
}
this._attacking = false;
}
/* eslint-enable complexity */
_isAttacking() {
// eslint-disable-next-line
return this._attacking && ((this._targetX && this._targetY) || !this._mouseAttack) &&
Date.now() - this._lastAttack > PlayerCommon.ATTACK_TIME;
}
/**
* Update the player's position based off the player's velocity
*/
move() {
this.x = this._confirmedX;
this.y = this._confirmedY;
/* eslint-disable complexity */
this._frames.forEach((frame) => {
// Ensure vx and vy are -1, 0, or 1
if(frame.vx !== 0) {
frame.vx = frame.vx < 0 ? -1 : 1;
}
if(frame.vy !== 0) {
frame.vy = frame.vy < 0 ? -1 : 1;
}
// move the player
let duration = frame.end - frame.start;
if(duration < 0) {
duration = 0;
}
duration *= this.speed / 1000;
let prev = this.getPosition();
this.x += frame.vx * duration;
this.y += frame.vy * duration;
if(!this.spriteIsOnMap() || this.collisionEntities(this.floor.monsters, Monster.SPRITE_SIZE) !== -1) {
this.x = prev.x;
this.y = prev.y;
}
ml.logger.debug(`Player ${this.name} moving from (${this._confirmedX}, ${this._confirmedY}) to (${this.x}, ${this.y})`, ml.tags.player);
if(typeof window === "undefined") {
this.processAttack(frame);
if(Object.values(this.wearing).includes(null)) {
this._pickupNearbyItems();
}
}
});
/* eslint-enable complexity */
}
/* eslint-disable complexity, no-mixed-operators */
/**
* Checks to see if there's a monster colliding with this monster.
* Compares corners of each sprite to do so.
* @returns {boolean}
*/
collisionEntities(entities, spriteSize) {
let x = -1;
let y = -1;
for(let entity of entities) {
if(this !== entity) {
for(let j = 0; j < 4; j++) { // four corners to check for each sprite
if(j === 0) { // upper left corner
x = entity.x;
y = entity.y;
} else if(j === 1) { // upper right corner
x = entity.x + spriteSize * entity.size;
y = entity.y;
} else if(j === 2) { // lower right corner
x = entity.x + spriteSize * entity.size;
y = entity.y + spriteSize * entity.size;
} else if(j === 3) { // lower left corner
x = entity.x;
y = entity.y + spriteSize * entity.size;
}
if(x >= this.x && x <= this.x + spriteSize * entity.size) { // within x bounds
if(y >= this.y && y <= this.y + spriteSize * entity.size) { // and within y bounds
let index = entities.indexOf(entity);
ml.logger.debug(`Player ${this.id} at (${this.x}, ${this.y}) collided with entity ${index} at (${entity.x}, ${entity.y})`, ml.tags.monster);
return index;
}
}
}
}
}
return -1; // indicate no collision
}
/* eslint-disable complexity, no-mixed-operators */
/**
* @private
* Sets the player's stats to base
*/
_setStatsToBase() {
this.speed = BASE_STATS.speed;
this.damage = BASE_STATS.damage;
this.defence = BASE_STATS.defence;
this.range = BASE_STATS.range;
}
/**
* Process an attack frame
*/
/* eslint-disable complexity */
processAttack(frame) {
if(!frame.attacking) {
return;
}
if(frame.start - this._lastAttack < PlayerCommon.ATTACK_TIME || frame.start < this._lastAttack) {
ml.logger.debug(`Reject attack request ${frame.start} ${this._lastAttack}`, ml.tags.player);
return;
}
this._lastAttack = frame.start;
let attackAngle = Math.atan2(frame.targetY - this.y, frame.targetX - this.x);
ml.logger.debug(`Player ${this.name} attacking at angle ${attackAngle}`, ml.tags.player);
for(let monster of this.floor.monsters) {
if(this.attackType === "rectangle") {
if(this._isHittingRect(attackAngle, monster)) {
this.attack(monster);
}
} else {
// eslint-disable-next-line
let monsterDist = Math.sqrt((monster.x - this.x) ** 2 + (monster.y - this.y) ** 2);
let monsterAngle = Math.atan2(monster.y - this.y, monster.x - this.x);
// check if the monster is in range
if(monsterDist <= this.range && Math.abs(attackAngle - monsterAngle) <= this.attackAngle / 2) {
this.attack(monster);
}
}
}
}
/**
* Check if the attack line is hitting a monster
*/
_isHittingRect(attackAngle, monster) {
let targetStart = {
x: this.x + (PlayerCommon.SPRITE_SIZE / 2),
y: this.y + (PlayerCommon.SPRITE_SIZE / 2)
};
let targetEnd = {
x: targetStart.x + (this.range * Math.cos(attackAngle)),
y: targetStart.y + (this.range * Math.sin(attackAngle))
};
// corrners of the monster hit box
let monsterPoints = [
{ x: monster.x, y: monster.y },
{ x: monster.x + Monster.SPRITE_SIZE * monster.size, y: monster.y },
{ x: monster.x, y: monster.y + Monster.SPRITE_SIZE * monster.size },
{ x: monster.x + Monster.SPRITE_SIZE * monster.size, y: monster.y + Monster.SPRITE_SIZE * monster.size }
];
// check if the attack line intersects with the monster's hit box
if(this._intersects(targetStart, targetEnd, monsterPoints[0], monsterPoints[1])) {
return true;
}
if(this._intersects(targetStart, targetEnd, monsterPoints[0], monsterPoints[2])) {
return true;
}
if(this._intersects(targetStart, targetEnd, monsterPoints[3], monsterPoints[1])) {
return true;
}
if(this._intersects(targetStart, targetEnd, monsterPoints[3], monsterPoints[2])) {
return true;
}
return false;
}
///////////////////////////////////////////////////////////////////////////////
// The following code is from Geeks for Geeks //
// https://www.geeksforgeeks.org/check-if-two-given-line-segments-intersect/ //
///////////////////////////////////////////////////////////////////////////////
/**
* Check if iPoint is on the line formed by point1 and point2
* @param point1
* @param point2
* @param iPoint
*/
_isOnLine(point1, point2, iPoint) {
return Math.min(point1.x, point2.x) <= iPoint.x && iPoint.x <= Math.max(point1.x, point2.x) &&
Math.min(point1.y, point2.y) <= iPoint.y && iPoint.y <= Math.max(point1.y, point2.y);
}
/**
* Determine the orientation of three points
* @param p
* @param q
* @param r
*/
_getOrientation(p, q, r) {
// Algorithm from https://www.geeksforgeeks.org/orientation-3-ordered-points/
// eslint-disable-next-line
let val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
if(val === 0) {
return "colinear";
}
return val > 0 ? "clockwise" : "counterclockwise";
}
/**
* Check if two points intersect
* @param line1p1
* @param line1p2
* @param line2p1
* @param line2p2
*/
_intersects(line1p1, line1p2, line2p1, line2p2) {
let o1 = this._getOrientation(line1p1, line1p2, line2p1);
let o2 = this._getOrientation(line1p1, line1p2, line2p2);
let o3 = this._getOrientation(line2p1, line2p2, line1p1);
let o4 = this._getOrientation(line2p1, line2p2, line1p2);
// General case (intersecting non-parallel)
if(o1 !== o2 && o3 !== o4) {
return true;
}
// Special Cases (colinear aka all parallel)
// line1p1, line1p2q1 and line2p1 are colinear and line2p1 lies on segment line1p1 line1p2
if(o1 === "colinear" && this._isOnLine(line1p1, line1p2, line2p1)) {
return true;
}
// line1p1, line1p2 and line2p2 are colinear and line2p2 lies on segment line1p1 line1p2
if(o2 === "colinear" && this._isOnLine(line1p1, line1p2, line2p2)) {
return true;
}
// line2p1, line2p2 and line1p1 are colinear and line1p1 lies on segment line1p line2p2
if(o3 === "colinear" && this._isOnLine(line2p1, line2p2, line1p1)) {
return true;
}
// linep2, line2p2 and line1p2 are colinear and line1p2 lies on segment linep2 line2p2
if(o4 === "colinear" && this._isOnLine(line2p1, line1p2, line1p2)) {
return true;
}
return false; // Doesn't fall in any of the above cases
}
/* eslint-enable complexity */
///////////////////////////////////////////////////////////////////////////////
// End of code from Geeks for Geeks //
///////////////////////////////////////////////////////////////////////////////
/**
* Drop all confirmed frames
*/
dropConfirmed() {
while(this._frames.length && this._frames[0].id <= this._confirmedId) {
this._frames.shift();
}
}
/**
* Checks to see if whole sprite is on the map. (Same as monster class)
* @returns {boolean}
*/
spriteIsOnMap() {
return this.floor.map.isOnMap(this.x, this.y) &&
this.floor.map.isOnMap(this.x + PlayerCommon.SPRITE_SIZE, this.y) &&
this.floor.map.isOnMap(this.x, this.y + PlayerCommon.SPRITE_SIZE) &&
this.floor.map.isOnMap(this.x + PlayerCommon.SPRITE_SIZE, this.y + PlayerCommon.SPRITE_SIZE);
}
/**
* Set coordinates for this player
* @param {int} x - new x-coordinate,
* @param {int} y - new y-coordinate
*/
setCoordinates(x, y) {
this.x = x;
this.y = y;
}
/**
* Monster attacks player
* @param {*} hp health points that the player's health decrements by
*/
beAttacked(hp) {
let damageTaken = 0;
if(this.defence !== BASE_STATS.hp) {
damageTaken = hp * 0.3;
this.hp -= damageTaken; // use dice here instead
} else {
damageTaken = hp;
this.hp -= damageTaken;
}
if(this.hp <= 0) {
this.die();
}
ml.logger.verbose(`Player ${this.name} was attacked with ${damageTaken} damage (hp: ${this.hp})`, ml.tags.player);
}
/**
* Player attacks monster
* @param {*} monster The monster to attack
*/
attack(monster) {
monster.beAttacked(this.damage);
}
}
PlayerCommon.SPRITE_SIZE = 48;
// the minimum time between attacks
PlayerCommon.ATTACK_TIME = 187;