diff --git a/src/Area.js b/src/Area.js index cb1e7568..6cf4fa4e 100644 --- a/src/Area.js +++ b/src/Area.js @@ -23,7 +23,7 @@ const AreaFloor = require('./AreaFloor'); */ class Area extends GameEntity { constructor(bundle, name, manifest) { - super(); + super(manifest); this.bundle = bundle; this.name = name; this.title = manifest.title; diff --git a/src/AreaOfEffectDamage.js b/src/AreaOfEffectDamage.js new file mode 100644 index 00000000..a9f8f9ef --- /dev/null +++ b/src/AreaOfEffectDamage.js @@ -0,0 +1,54 @@ +'use strict'; + +const Damage = require('./Damage'); +const Room = require('./Room'); +const Character = require('./Character'); + +/** + * Damage class used for applying damage to multiple entities in a room. By + * default it will target all npcs in the room. To customize this behavior you + * can extend this class and override the `getValidTargets` method + */ +class AreaOfEffectDamage extends Damage +{ + /** + * @param {Room|Character} target + * @throws RangeError + * @fires Room#areaDamage + */ + commit(room) { + if (!(room instanceof Room)) { + if (!(room instanceof Character)) { + throw new RangeError('AreaOfEffectDamage commit target must be an instance of Room or Character'); + } + + super.commit(room); + return; + } + + const targets = this.getValidTargets(room); + for (const target of targets) { + super.commit(target); + } + + /** + * @event Room#areaDamage + * @param {Damage} damage + * @param {Array} targets + */ + room.emit('areaDamage', this, targets); + } + + /** + * Override this method to customize valid targets such as + * only targeting hostile npcs, or only targeting players, etc. + * @param {Room} room + * @return {Array} + */ + getValidTargets(room) { + const targets = [...room.npcs]; + return targets.filter(t => t.hasAttribute(this.attribute)); + } +} + +module.exports = AreaOfEffectDamage; diff --git a/src/AreaOfEffectHeal.js b/src/AreaOfEffectHeal.js new file mode 100644 index 00000000..9e752680 --- /dev/null +++ b/src/AreaOfEffectHeal.js @@ -0,0 +1,55 @@ +'use strict'; + +const Heal = require('./Heal'); +const Room = require('./Room'); +const Character = require('./Character'); + +/** + * Heal class used for applying healing to multiple entities in a room. By + * default it will target all players in the room. To customize this behavior you + * can extend this class and override the `getValidTargets` method + */ +class AreaOfEffectHeal extends Heal +{ + /** + * @param {Room|Character} target + * @throws RangeError + * @fires Room#areaHeal + */ + commit(room) { + if (!(room instanceof Room)) { + if (!(room instanceof Character)) { + throw new RangeError('AreaOfEffectHeal commit target must be an instance of Room or Character'); + } + + super.commit(room); + return; + } + + const targets = this.getValidTargets(room); + for (const target of targets) { + super.commit(target); + } + + + /** + * @event Room#areaHeal + * @param {Heal} heal + * @param {Array} targets + */ + room.emit('areaHeal', this, targets); + } + + /** + * Override this method to customize valid targets such as + * only targeting hostile npcs, or only targeting players, etc. + * @param {Room} room + * @return {Array} + */ + getValidTargets(room) { + const targets = [...room.players]; + return targets.filter(t => t.hasAttribute(this.attribute)); + } +} + +module.exports = AreaOfEffectHeal; diff --git a/src/Character.js b/src/Character.js index 8b63883c..a97bf6d1 100644 --- a/src/Character.js +++ b/src/Character.js @@ -1,8 +1,7 @@ 'use strict'; -const Attributes = require('./Attributes'); const Config = require('./Config'); -const EffectList = require('./EffectList'); +const EffectableEntity = require('./EffectableEntity'); const { EquipSlotTakenError, EquipAlreadyEquippedError } = require('./EquipErrors'); const EventEmitter = require('events'); const Heal = require('./Heal'); @@ -17,16 +16,15 @@ const { Inventory, InventoryFullError } = require('./Inventory'); * @property {Inventory} inventory * @property {Set} combatants Enemies this character is currently in combat with * @property {number} level - * @property {Attributes} attributes * @property {EffectList} effects List of current effects applied to the character * @property {Room} room Room the character is currently in * - * @extends EventEmitter + * @extends EffectableEntity * @mixes Metadatable */ -class Character extends Metadatable(EventEmitter) { +class Character extends Metadatable(EffectableEntity) { constructor(data) { - super(); + super(data); this.name = data.name; this.inventory = new Inventory(data.inventory || {}); @@ -35,194 +33,16 @@ class Character extends Metadatable(EventEmitter) { this.combatData = {}; this.level = data.level || 1; this.room = data.room || null; - this.attributes = data.attributes || new Attributes(); this.followers = new Set(); this.following = null; this.party = null; - this.effects = new EffectList(this, data.effects); - // Arbitrary data bundles are free to shove whatever they want in // WARNING: values must be JSON.stringify-able this.metadata = data.metadata || {}; } - /** - * Proxy all events on the player to effects - * @param {string} event - * @param {...*} args - */ - emit(event, ...args) { - super.emit(event, ...args); - - this.effects.emit(event, ...args); - } - - /** - * @param {string} attr Attribute name - * @return {boolean} - */ - hasAttribute(attr) { - return this.attributes.has(attr); - } - - /** - * Get current maximum value of attribute (as modified by effects.) - * @param {string} attr - * @return {number} - */ - getMaxAttribute(attr) { - if (!this.hasAttribute(attr)) { - throw new RangeError(`Character does not have attribute [${attr}]`); - } - - const attribute = this.attributes.get(attr); - const currentVal = this.effects.evaluateAttribute(attribute); - - if (!attribute.formula) { - return currentVal; - } - - const { formula } = attribute; - - const requiredValues = formula.requires.map( - reqAttr => this.getMaxAttribute(reqAttr) - ); - - return formula.evaluate.apply(formula, [attribute, this, currentVal, ...requiredValues]); - } - - /** - * @see {@link Attributes#add} - */ - addAttribute(attribute) { - this.attributes.add(attribute); - } - - /** - * Get the current value of an attribute (base modified by delta) - * @param {string} attr - * @return {number} - */ - getAttribute(attr) { - if (!this.hasAttribute(attr)) { - throw new RangeError(`Character does not have attribute [${attr}]`); - } - - return this.getMaxAttribute(attr) + this.attributes.get(attr).delta; - } - - /** - * Get the base value for a given attribute - * @param {string} attr Attribute name - * @return {number} - */ - getBaseAttribute(attr) { - var attr = this.attributes.get(attr); - return attr && attr.base; - } - - /** - * Fired when a Character's attribute is set, raised, or lowered - * @event Character#attributeUpdate - * @param {string} attributeName - * @param {Attribute} attribute - */ - - /** - * Clears any changes to the attribute, setting it to its base value. - * @param {string} attr - * @fires Character#attributeUpdate - */ - setAttributeToMax(attr) { - if (!this.hasAttribute(attr)) { - throw new Error(`Invalid attribute ${attr}`); - } - - this.attributes.get(attr).setDelta(0); - this.emit('attributeUpdate', attr, this.getAttribute(attr)); - } - - /** - * Raise an attribute by name - * @param {string} attr - * @param {number} amount - * @see {@link Attributes#raise} - * @fires Character#attributeUpdate - */ - raiseAttribute(attr, amount) { - if (!this.hasAttribute(attr)) { - throw new Error(`Invalid attribute ${attr}`); - } - - this.attributes.get(attr).raise(amount); - this.emit('attributeUpdate', attr, this.getAttribute(attr)); - } - - /** - * Lower an attribute by name - * @param {string} attr - * @param {number} amount - * @see {@link Attributes#lower} - * @fires Character#attributeUpdate - */ - lowerAttribute(attr, amount) { - if (!this.hasAttribute(attr)) { - throw new Error(`Invalid attribute ${attr}`); - } - - this.attributes.get(attr).lower(amount); - this.emit('attributeUpdate', attr, this.getAttribute(attr)); - } - - /** - * Update an attribute's base value. - * - * NOTE: You _probably_ don't want to use this the way you think you do. You should not use this - * for any temporary modifications to an attribute, instead you should use an Effect modifier. - * - * This will _permanently_ update the base value for an attribute to be used for things like a - * player purchasing a permanent upgrade or increasing a stat on level up - * - * @param {string} attr Attribute name - * @param {number} newBase New base value - * @fires Character#attributeUpdate - */ - setAttributeBase(attr, newBase) { - if (!this.hasAttribute(attr)) { - throw new Error(`Invalid attribute ${attr}`); - } - - this.attributes.get(attr).setBase(newBase); - this.emit('attributeUpdate', attr, this.getAttribute(attr)); - } - - /** - * @param {string} type - * @return {boolean} - * @see {@link Effect} - */ - hasEffectType(type) { - return this.effects.hasEffectType(type); - } - - /** - * @param {Effect} effect - * @return {boolean} - */ - addEffect(effect) { - return this.effects.add(effect); - } - - /** - * @param {Effect} effect - * @see {@link Effect#remove} - */ - removeEffect(effect) { - this.effects.remove(effect); - } - /** * Start combat with a given target. * @param {Character} target @@ -324,26 +144,6 @@ class Character extends Metadatable(EventEmitter) { } } - /** - * @see EffectList.evaluateIncomingDamage - * @param {Damage} damage - * @return {number} - */ - evaluateIncomingDamage(damage, currentAmount) { - let amount = this.effects.evaluateIncomingDamage(damage, currentAmount); - return Math.floor(amount); - } - - /** - * @see EffectList.evaluateOutgoingDamage - * @param {Damage} damage - * @param {number} currentAmount - * @return {number} - */ - evaluateOutgoingDamage(damage, currentAmount) { - return this.effects.evaluateOutgoingDamage(damage, currentAmount); - } - /** * @param {Item} item * @param {string} slot Slot to equip the item in @@ -553,57 +353,16 @@ class Character extends Metadatable(EventEmitter) { return this.followers.has(target); } - /** - * Initialize the character from storage - * @param {GameState} state - */ - hydrate(state) { - if (this.__hydrated) { - Logger.warn('Attempted to hydrate already hydrated character.'); - return false; - } - - if (!(this.attributes instanceof Attributes)) { - const attributes = this.attributes; - this.attributes = new Attributes(); - - for (const attr in attributes) { - let attrConfig = attributes[attr]; - if (typeof attrConfig === 'number') { - attrConfig = { base: attrConfig }; - } - - if (typeof attrConfig !== 'object' || !('base' in attrConfig)) { - throw new Error('Invalid base value given to attributes.\n' + JSON.stringify(attributes, null, 2)); - } - - if (!state.AttributeFactory.has(attr)) { - throw new Error(`Entity trying to hydrate with invalid attribute ${attr}`); - } - - this.addAttribute(state.AttributeFactory.create(attr, attrConfig.base, attrConfig.delta || 0)); - } - } - - this.effects.hydrate(state); - - // inventory is hydrated in the subclasses because npc and players hydrate their inventories differently - - this.__hydrated = true; - } - /** * Gather data to be persisted * @return {Object} */ serialize() { - return { - attributes: this.attributes.serialize(), + return Object.assign(super.serialize(), { level: this.level, name: this.name, room: this.room.entityReference, - effects: this.effects.serialize(), - }; + }); } /** diff --git a/src/Effect.js b/src/Effect.js index 4c7d2547..67cad18c 100644 --- a/src/Effect.js +++ b/src/Effect.js @@ -56,6 +56,7 @@ class Effect extends EventEmitter { this.paused = 0; this.modifiers = Object.assign({ attributes: {}, + properties: {}, incomingDamage: (damage, current) => current, outgoingDamage: (damage, current) => current, }, def.modifiers); @@ -196,9 +197,11 @@ class Effect extends EventEmitter { } /** + * Apply effect attribute modifiers to a given value + * * @param {string} attrName * @param {number} currentValue - * @return {number} attribute modified by effect + * @return {number} attribute value modified by effect */ modifyAttribute(attrName, currentValue) { let modifier = _ => _; @@ -212,6 +215,25 @@ class Effect extends EventEmitter { return modifier.bind(this)(currentValue); } + /** + * Apply effect property modifiers to a given value + * + * @param {string} propertyName + * @param {*} currentValue + * @return {*} property value modified by effect + */ + modifyProperty(propertyName, currentValue) { + let modifier = _ => _; + if (typeof this.modifiers.properties === 'function') { + modifier = (current) => { + return this.modifiers.properties.bind(this)(propertyName, current); + }; + } else if (propertyName in this.modifiers.properties) { + modifier = this.modifiers.properties[propertyName]; + } + return modifier.bind(this)(currentValue); + } + /** * @param {Damage} damage * @param {number} currentAmount @@ -262,17 +284,19 @@ class Effect extends EventEmitter { * @param {Object} data */ hydrate(state, data) { - data.config.duration = data.config.duration === 'inf' ? Infinity : data.config.duration; - this.config = data.config; + if (data.config) { + data.config.duration = data.config.duration === 'inf' ? Infinity : data.config.duration; + this.config = data.config; + } if (!isNaN(data.elapsed)) { this.startedAt = Date.now() - data.elapsed; } - if (!isNaN(data.state.lastTick)) { + if (data.state && !isNaN(data.state.lastTick)) { data.state.lastTick = Date.now() - data.state.lastTick; + this.state = data.state; } - this.state = data.state; if (data.skill) { this.skill = state.SkillManager.get(data.skill) || state.SpellManager.get(data.skill); diff --git a/src/EffectList.js b/src/EffectList.js index fc6920fa..12d806f8 100644 --- a/src/EffectList.js +++ b/src/EffectList.js @@ -196,6 +196,24 @@ class EffectList { return attrValue; } + /** + * Gets the effective value of property doing all effect modifications. + * @param {string} propertyName + * @return {number} + */ + evaluateProperty(propertyName, propertyValue) { + this.validateEffects(); + + for (const effect of this.effects) { + if (effect.paused) { + continue; + } + propertyValue = effect.modifyProperty(propertyName, propertyValue); + } + + return propertyValue; + } + /** * @param {Damage} damage * @param {number} currentAmount diff --git a/src/EffectableEntity.js b/src/EffectableEntity.js new file mode 100644 index 00000000..e127e220 --- /dev/null +++ b/src/EffectableEntity.js @@ -0,0 +1,290 @@ +'use strict'; + +const EventEmitter = require('events'); +const EffectList = require('./EffectList'); +const Attributes = require('./Attributes'); + +/** + * @ignore + * @exports MetadatableFn + * @param {*} parentClass + * @return {module:MetadatableFn~Metadatable} + */ +class EffectableEntity extends EventEmitter +{ + constructor(data) { + super(); + + this.attributes = data.attributes || new Attributes(); + this.effects = new EffectList(this, data.effects); + } + + /** + * Proxy all events on the entity to effects + * @param {string} event + * @param {...*} args + */ + emit(event, ...args) { + super.emit(event, ...args); + + this.effects.emit(event, ...args); + } + + /** + * @param {string} attr Attribute name + * @return {boolean} + */ + hasAttribute(attr) { + return this.attributes.has(attr); + } + + /** + * Get current maximum value of attribute (as modified by effects.) + * @param {string} attr + * @return {number} + */ + getMaxAttribute(attr) { + if (!this.hasAttribute(attr)) { + throw new RangeError(`Entity does not have attribute [${attr}]`); + } + + const attribute = this.attributes.get(attr); + const currentVal = this.effects.evaluateAttribute(attribute); + + if (!attribute.formula) { + return currentVal; + } + + const { formula } = attribute; + + const requiredValues = formula.requires.map( + reqAttr => this.getMaxAttribute(reqAttr) + ); + + return formula.evaluate.apply(formula, [attribute, this, currentVal, ...requiredValues]); + } + + /** + * @see {@link Attributes#add} + */ + addAttribute(attribute) { + this.attributes.add(attribute); + } + + /** + * Get the current value of an attribute (base modified by delta) + * @param {string} attr + * @return {number} + */ + getAttribute(attr) { + if (!this.hasAttribute(attr)) { + throw new RangeError(`Entity does not have attribute [${attr}]`); + } + + return this.getMaxAttribute(attr) + this.attributes.get(attr).delta; + } + + /** + * Get the effected value of a given property + * @param {string} propertyName + * @return {*} + */ + getProperty(propertyName) { + if (!(propertyName in this)) { + throw new RangeError(`Cannot evaluate uninitialized property [${propertyName}]`); + } + + let propertyValue = this[propertyName]; + + // deep copy non-scalar property values to prevent modifiers from actually + // changing the original value + if (typeof propertyValue === 'function' || typeof propertyValue === 'object') { + propertyValue = JSON.parse(JSON.stringify(propertyValue)); + } + + return this.effects.evaluateProperty(propertyName, propertyValue); + } + + /** + * Get the base value for a given attribute + * @param {string} attr Attribute name + * @return {number} + */ + getBaseAttribute(attr) { + var attr = this.attributes.get(attr); + return attr && attr.base; + } + + /** + * Fired when an Entity's attribute is set, raised, or lowered + * @event EffectableEntity#attributeUpdate + * @param {string} attributeName + * @param {Attribute} attribute + */ + + /** + * Clears any changes to the attribute, setting it to its base value. + * @param {string} attr + * @fires EffectableEntity#attributeUpdate + */ + setAttributeToMax(attr) { + if (!this.hasAttribute(attr)) { + throw new Error(`Invalid attribute ${attr}`); + } + + this.attributes.get(attr).setDelta(0); + this.emit('attributeUpdate', attr, this.getAttribute(attr)); + } + + /** + * Raise an attribute by name + * @param {string} attr + * @param {number} amount + * @see {@link Attributes#raise} + * @fires EffectableEntity#attributeUpdate + */ + raiseAttribute(attr, amount) { + if (!this.hasAttribute(attr)) { + throw new Error(`Invalid attribute ${attr}`); + } + + this.attributes.get(attr).raise(amount); + this.emit('attributeUpdate', attr, this.getAttribute(attr)); + } + + /** + * Lower an attribute by name + * @param {string} attr + * @param {number} amount + * @see {@link Attributes#lower} + * @fires EffectableEntity#attributeUpdate + */ + lowerAttribute(attr, amount) { + if (!this.hasAttribute(attr)) { + throw new Error(`Invalid attribute ${attr}`); + } + + this.attributes.get(attr).lower(amount); + this.emit('attributeUpdate', attr, this.getAttribute(attr)); + } + + /** + * Update an attribute's base value. + * + * NOTE: You _probably_ don't want to use this the way you think you do. You should not use this + * for any temporary modifications to an attribute, instead you should use an Effect modifier. + * + * This will _permanently_ update the base value for an attribute to be used for things like a + * player purchasing a permanent upgrade or increasing a stat on level up + * + * @param {string} attr Attribute name + * @param {number} newBase New base value + * @fires EffectableEntity#attributeUpdate + */ + setAttributeBase(attr, newBase) { + if (!this.hasAttribute(attr)) { + throw new Error(`Invalid attribute ${attr}`); + } + + this.attributes.get(attr).setBase(newBase); + this.emit('attributeUpdate', attr, this.getAttribute(attr)); + } + + /** + * @param {string} type + * @return {boolean} + * @see {@link Effect} + */ + hasEffectType(type) { + return this.effects.hasEffectType(type); + } + + /** + * @param {Effect} effect + * @return {boolean} + */ + addEffect(effect) { + return this.effects.add(effect); + } + + /** + * @param {Effect} effect + * @see {@link Effect#remove} + */ + removeEffect(effect) { + this.effects.remove(effect); + } + + /** + * @see EffectList.evaluateIncomingDamage + * @param {Damage} damage + * @return {number} + */ + evaluateIncomingDamage(damage, currentAmount) { + let amount = this.effects.evaluateIncomingDamage(damage, currentAmount); + return Math.floor(amount); + } + + /** + * @see EffectList.evaluateOutgoingDamage + * @param {Damage} damage + * @param {number} currentAmount + * @return {number} + */ + evaluateOutgoingDamage(damage, currentAmount) { + return this.effects.evaluateOutgoingDamage(damage, currentAmount); + } + + /** + * Initialize the entity from storage + * @param {GameState} state + */ + hydrate(state, serialized = {}) { + if (this.__hydrated) { + Logger.warn('Attempted to hydrate already hydrated entity.'); + return false; + } + + if (!(this.attributes instanceof Attributes)) { + const attributes = this.attributes; + this.attributes = new Attributes(); + + for (const attr in attributes) { + let attrConfig = attributes[attr]; + if (typeof attrConfig === 'number') { + attrConfig = { base: attrConfig }; + } + + if (typeof attrConfig !== 'object' || !('base' in attrConfig)) { + throw new Error('Invalid base value given to attributes.\n' + JSON.stringify(attributes, null, 2)); + } + + if (!state.AttributeFactory.has(attr)) { + throw new Error(`Entity trying to hydrate with invalid attribute ${attr}`); + } + + this.addAttribute(state.AttributeFactory.create(attr, attrConfig.base, attrConfig.delta || 0)); + } + } + + this.effects.hydrate(state); + + // inventory is hydrated in the subclasses because npc and players hydrate their inventories differently + + this.__hydrated = true; + } + + /** + * Gather data to be persisted + * @return {Object} + */ + serialize() { + return { + attributes: this.attributes.serialize(), + effects: this.effects.serialize(), + }; + } +} + +module.exports = EffectableEntity; + diff --git a/src/GameEntity.js b/src/GameEntity.js index d655da20..72ed6507 100644 --- a/src/GameEntity.js +++ b/src/GameEntity.js @@ -1,6 +1,6 @@ 'use strict'; -const EventEmitter = require('events'); +const EffectableEntity = require('./EffectableEntity'); const Metadatable = require('./Metadatable'); const Scriptable = require('./Scriptable'); @@ -9,6 +9,6 @@ const Scriptable = require('./Scriptable'); * @mixes Metadatable * @mixes Scriptable */ -class GameEntity extends Scriptable(Metadatable(EventEmitter)) {} +class GameEntity extends Scriptable(Metadatable(EffectableEntity)) {} module.exports = GameEntity; diff --git a/src/Item.js b/src/Item.js index df5f3fc0..9dcaec24 100644 --- a/src/Item.js +++ b/src/Item.js @@ -35,7 +35,7 @@ const { Inventory, InventoryFullError } = require('./Inventory'); */ class Item extends GameEntity { constructor (area, item) { - super(); + super(item); const validate = ['keywords', 'name', 'id']; for (const prop of validate) { @@ -204,10 +204,7 @@ class Item extends GameEntity { } hydrate(state, serialized = {}) { - if (this.__hydrated) { - Logger.warn('Attempted to hydrate already hydrated item.'); - return false; - } + super.hydrate(state); // perform deep copy if behaviors is set to prevent sharing of the object between // item instances @@ -243,17 +240,16 @@ class Item extends GameEntity { this.addItem(newItem); }); } - - this.__hydrated = true; } serialize() { + const data = super.serialize(); let behaviors = {}; for (const [key, val] of this.behaviors) { behaviors[key] = val; } - return { + return Object.assign(data, { entityReference: this.entityReference, inventory: this.inventory && this.inventory.serialize(), @@ -272,7 +268,7 @@ class Item extends GameEntity { // behaviors are serialized in case their config was modified during gameplay // and that state needs to persist (charges of a scroll remaining, etc) behaviors, - }; + }); } } diff --git a/src/Room.js b/src/Room.js index cb4433c4..450aef8c 100644 --- a/src/Room.js +++ b/src/Room.js @@ -22,7 +22,7 @@ const Logger = require('./Logger'); */ class Room extends GameEntity { constructor(area, def) { - super(); + super(def); const required = ['title', 'description', 'id']; for (const prop of required) { if (!(prop in def)) { @@ -341,6 +341,8 @@ class Room extends GameEntity { } hydrate(state) { + super.hydrate(state); + this.setupBehaviors(state.RoomBehaviorManager); this.items = new Set(); diff --git a/src/RoomFactory.js b/src/RoomFactory.js index a35fbac3..ecc21f23 100644 --- a/src/RoomFactory.js +++ b/src/RoomFactory.js @@ -4,7 +4,7 @@ const Room = require('./Room'); const EntityFactory = require('./EntityFactory'); /** - * Stores definitions of npcs to allow for easy creation/cloning + * Stores definitions of rooms to allow for easy creation/cloning * @extends EntityFactory */ class RoomFactory extends EntityFactory { @@ -16,9 +16,9 @@ class RoomFactory extends EntityFactory { * @return {Room} */ create(area, entityRef) { - const npc = this.createByType(area, entityRef, Room); - npc.area = area; - return npc; + const room = this.createByType(area, entityRef, Room); + room.area = area; + return room; } }