hobgoblin
Version:
A framework for writing roguelike games in JavaScript, implementing ROT.js
768 lines (751 loc) • 28.9 kB
JavaScript
// From http://www.codingcookies.com/2013/04/20/building-a-roguelike-in-javascript-part-4/
Game.EntityMixins = {};
// TODO: Implement functions for scanning for friends/enemies and getting the nearest of them
Game.EntityMixins.AIActor = {
name: 'AIActor',
groupName: 'Actor',
init: function(template) {
// Load tasks
this._ai = template['ai'] || ['wander'];
this._behavior = template['behavior'] || 'aggressive';
this._target = template['target'] || null;
this._path = template['path'] || [];
this._friends = template['friends'] || [this.getName(), this.getType()];
this._enemies = template['enemies'] || ['player'];
},
act: function() {
// Iterate through all our behaviors
for (var i = 0; i < this._ai.length; i++) {
var success = Game.AI[this._ai[i]](this); // Do the AI behavior, passing in the entity
// If this was a success, then break out of the loop
if(success)
return true;
}
},
getBehavior: function() {
return this._behavior;
},
getTarget: function() {
return this._target;
},
getPath: function() {
return this._path;
},
getNextStep: function() {
return this._path.shift();
},
getFriends: function() {
return this._friends;
},
scanForFriends: function() {
if(this.hasMixin('Sight')) {
var friends = [];
var radius = this.getSightRadius();
var entities = this.getMap().getEntitiesWithinRadius(this.getX(), this.getY(), this.getZ(), radius);
for (var i = 0; i < entities.length; i++) {
if((this._friends.indexOf(entities[i].getName()) > -1 || this._friends.indexOf(entities[i].getType()) > -1) && this.canSee(entities[i]))
friends.push(entities[i])
}
return friends;
}
},
getEnemies: function() {
return this._enemies;
},
scanForEnemies: function() {
if(this.hasMixin('Sight')) {
var enemies = [];
var radius = this.getSightRadius();
var entities = this.getMap().getEntitiesWithinRadius(this.getX(), this.getY(), this.getZ(), radius);
for (var i = 0; i < entities.length; i++) {
if((this._enemies.indexOf(entities[i].getName()) > -1 || this._enemies.indexOf(entities[i].getType()) > -1) && this.canSee(entities[i]))
enemies.push(entities[i]);
}
return enemies;
}
},
setTarget: function(target) {
this._target = target;
},
setPath: function(path) {
this._path = path;
},
};
Game.EntityMixins.Attacker = {
name: 'Attacker',
groupName: 'Attacker',
init: function(template) {
this._attackValue = template['attackValue'] || 1;
},
getAttackValue: function() {
var modifier = 0;
// If we can equip items, then have to take into
// consideration weapon and armor
if (this.hasMixin(Game.EntityMixins.Equipper)) {
if (this.getWeapon()) {
modifier += this.getWeapon().getAttackValue();
}
if (this.getArmor()) {
modifier += this.getArmor().getAttackValue();
}
}
return this._attackValue + modifier;
},
attack: function(target) {
// Only remove the entity if they were attackable
if (target.hasMixin('Destructible')) {
var attack = this.getAttackValue();
var defense = target.getDefenseValue();
var max = Math.max(0, attack - defense);
var damage = 1 + Math.floor(Math.random() * max);
Game.sendMessage(this, 'You strike the %s for %s damage!', [target.getName(), damage]);
Game.sendMessage(target, 'The %s strikes you for %s damage!', [this.getName(), damage]);
target.takeDamage(this, damage);
}
},
increaseAttackValue: function(value) {
// If no value was passed, default to 2.
value = value || 2;
// Add to the attack value.
this._attackValue += value;
Game.sendMessage(this, "You look stronger!");
},
listeners: {
details: function() {
return [{key: 'attack', value: this.getAttackValue()}];
}
}
};
Game.EntityMixins.CorpseDropper = {
name: 'CorpseDropper',
init: function(template) {
// Chance of dropping a corpse (out of 100).
this._corpseDropRate = template['corpseDropRate'] || 100;
},
listeners: {
onDeath: function(attacker) {
// Check if we should drop a corpse.
if (Math.round(Math.random() * 100) <= this._corpseDropRate) {
// Create a new corpse item and drop it.
this._map.addItem(this.getX(), this.getY(), this.getZ(),
Game.ItemRepository.create('corpse', {
name: this._name + ' corpse',
foreground: this._foreground
}));
}
}
}
};
Game.EntityMixins.Destructible = {
name: 'Destructible',
init: function(template) {
this._maxHp = template['maxHp'] || 10;
this._hp = template['hp'] || this._maxHp;
this._defenseValue = template['defenseValue'] || 0;
},
getDefenseValue: function() {
var modifier = 0;
// If we can equip items, then have to take into
// consideration weapon and armor
if (this.hasMixin(Game.EntityMixins.Equipper)) {
if (this.getWeapon()) {
modifier += this.getWeapon().getDefenseValue();
}
if (this.getArmor()) {
modifier += this.getArmor().getDefenseValue();
}
}
return this._defenseValue + modifier;
},
getHp: function() {
return this._hp;
},
getMaxHp: function() {
return this._maxHp;
},
takeDamage: function(attacker, damage) {
this._hp -= damage;
if(this._hp <= 0) {
Game.sendMessage(attacker, 'You kill the %s!', [this.getName()]);
// Raise events
this.raiseEvent('onDeath', attacker);
attacker.raiseEvent('onKill', this);
this.kill();
}
},
setHp: function(hp) {
this._hp = hp;
},
increaseDefenseValue: function(value) {
// If no value was passed, default to 2.
value = value || 2;
// Add to the defense value.
this._defenseValue += value;
Game.sendMessage(this, "You look tougher!");
},
increaseMaxHp: function(value) {
// If no value was passed, default to 10.
value = value || 10;
// Add to both max HP and HP.
this._maxHp += value;
this._hp += value;
Game.sendMessage(this, "You look healthier!");
},
listeners: {
onGainLevel: function() {
// Heal the entity.
this.setHp(this.getMaxHp());
},
details: function() {
return [
{key: 'defense', value: this.getDefenseValue()},
{key: 'hp', value: this.getHp()}
];
}
}
};
Game.EntityMixins.Equipper = {
name: 'Equipper',
init: function(template) {
this._weapon = null;
this._armor = null;
},
wield: function(item) {
this._weapon = item;
item.wield();
},
unwield: function() {
if(this._weapon)
this._weapon.unwield();
this._weapon = null;
},
wear: function(item) {
this._armor = item;
item.wear();
},
takeOff: function() {
if(this._armor)
this._armor.takeOff();
this._armor = null;
},
getWeapon: function() {
return this._weapon;
},
getArmor: function() {
return this._armor;
},
unequip: function(item) {
// Helper function to be called before getting rid of an item.
if (this._weapon === item) {
this.unwield();
}
if (this._armor === item) {
this.takeOff();
}
}
};
Game.EntityMixins.ExperienceGainer = {
name: 'ExperienceGainer',
init: function(template) {
this._level = template['level'] || 1;
this._experience = template['experience'] || 0;
this._statPointsPerLevel = template['statPointsPerLevel'] || 1;
this._statPoints = 0;
// Determine what stats can be levelled up.
this._statOptions = [];
if (this.hasMixin('Attacker')) {
this._statOptions.push(['Increase attack value', this.increaseAttackValue]);
}
if (this.hasMixin('Destructible')) {
this._statOptions.push(['Increase defense value', this.increaseDefenseValue]);
this._statOptions.push(['Increase max health', this.increaseMaxHp]);
}
if (this.hasMixin('Sight')) {
this._statOptions.push(['Increase sight range', this.increaseSightRadius]);
}
if (this.hasMixin('Thrower')) {
this._statOptions.push(['Increase throwing skill', this.increaseThrowingSkill]);
}
},
getLevel: function() {
return this._level;
},
getExperience: function() {
return this._experience;
},
getNextLevelExperience: function() {
return (this._level * this._level) * 10;
},
getStatPoints: function() {
return this._statPoints;
},
setStatPoints: function(statPoints) {
this._statPoints = statPoints;
},
getStatOptions: function() {
return this._statOptions;
},
giveExperience: function(points) {
var statPointsGained = 0;
var levelsGained = 0;
// Loop until we've allocated all points.
while (points > 0) {
// Check if adding in the points will surpass the level threshold.
if (this._experience + points >= this.getNextLevelExperience()) {
// Fill our experience till the next threshold.
var usedPoints = this.getNextLevelExperience() - this._experience;
points -= usedPoints;
this._experience += usedPoints;
// Level up our entity!
this._level++;
levelsGained++;
this._statPoints += this._statPointsPerLevel;
statPointsGained += this._statPointsPerLevel;
} else {
// Simple case - just give the experience.
this._experience += points;
points = 0;
}
}
// Check if we gained at least one level.
if (levelsGained > 0) {
Game.sendMessage(this, "You advance to level %s.", [this._level]);
this.raiseEvent('onGainLevel');
}
},
listeners: {
onKill: function(victim) {
var exp = victim.getMaxHp() + victim.getDefenseValue();
if (victim.hasMixin('Attacker')) {
exp += victim.getAttackValue();
}
// Account for level differences
if (victim.hasMixin('ExperienceGainer')) {
exp -= (this.getLevel() - victim.getLevel()) * 3;
}
// Only give experience if more than 0.
if (exp > 0) {
this.giveExperience(exp);
}
},
details: function() {
return [{key: 'level', value: this.getLevel()}];
}
}
};
Game.EntityMixins.FoodConsumer = {
name: 'FoodConsumer',
init: function(template) {
this._maxFullness = template['maxFullness'] || 1000;
// Start halfway to max fullness if no default value
this._fullness = template['fullness'] || (this._maxFullness / 2);
// Number of points to decrease fullness by every turn.
this._fullnessDepletionRate = template['fullnessDepletionRate'] || 1;
},
addTurnHunger: function() {
// Remove the standard depletion points
this.modifyFullnessBy(-this._fullnessDepletionRate);
},
modifyFullnessBy: function(points) {
this._fullness = this._fullness + points;
if (this._fullness <= 0) {
this.kill("You have died of starvation!");
} else if (this._fullness > this._maxFullness) {
this.kill("You choke and die!");
}
},
getHungerState: function() {
// Fullness points per percent of max fullness
var perPercent = this._maxFullness / 100;
// 5% of max fullness or less = starving
if(this._fullness <= perPercent * 5) {
return 'Starving';
// 25% of max fullness or less = hungry
} else if (this._fullness <= perPercent * 25) {
return 'Hungry';
// 95% of max fullness or more = oversatiated
} else if (this._fullness >= perPercent * 95) {
return 'Oversatiated';
// 75% of max fullness or more = full
} else if (this._fullness >= perPercent * 75) {
return 'Full';
// Anything else = not hungry
} else {
return 'Not Hungry';
}
}
};
Game.EntityMixins.FungusActor = {
name: 'FungusActor',
groupName: 'Actor',
init: function() {
this._growthsRemaining = 5;
},
act: function() {
if(this._growthsRemaining > 0) {
if(Math.random() <= 0.02) {
// Generate the coordinates of a random adjacent square by
// generating an offset between [-1, 0, 1] for both the x and
// y directions. To do this, we generate a number from 0-2 and then
// subtract 1.
var xOffset = Math.floor(Math.random() * 3) - 1;
var yOffset = Math.floor(Math.random() * 3) - 1;
// Make sure we aren't trying to spawn on the same tile as us
if (xOffset != 0 || yOffset != 0) {
// Check if we can actually spawn at that location, and if so
// then we grow!
if (this.getMap().isEmptyFloor(this.getX() + xOffset, this.getY() + yOffset, this.getZ())) {
var entity = Game.EntityRepository.create('fungus');
entity.setPosition(this.getX() + xOffset, this.getY() + yOffset, this.getZ());
this.getMap().addEntity(entity);
this._growthsRemaining--;
// Send a message nearby!
Game.sendMessageNearby(this.getMap(), entity.getX(), entity.getY(), entity.getZ(), 'The fungus is spreading!');
}
}
}
}
}
};
Game.EntityMixins.InventoryHolder = {
name: 'InventoryHolder',
init: function(template) {
// Default to 10 inventory slots.
var inventorySlots = template['inventorySlots'] || 10;
// Set up an empty inventory.
this._items = new Array(inventorySlots);
// If the template specifies items, put them in the entity's inventory
if(template['items']) {
for (var i = 0; i < template['items'].length; i++) {
this._items[i] = Game.ItemRepository.create(template['items'][i]);
}
}
},
getItems: function() {
return this._items;
},
getItem: function(i) {
return this._items[i];
},
addItem: function(item) {
// Try to find a slot, returning true only if we could add the item.
// Check to see if we can stack the item unless we find an open slot first
if(item.hasMixin('Stackable')) {
for (var i = 0; i < this._items.length; i++) {
if (!this._items[i]) {
this._items[i] = item;
return true;
} else if(this._items[i].describe() == item.describe()) {
this._items[i].addToStack();
return true;
}
}
} else {
for (var i = 0; i < this._items.length; i++) {
if (!this._items[i]) {
this._items[i] = item;
return true;
}
}
}
return false;
},
removeItem: function(i, amount) {
// If we can equip items, then make sure we unequip the item we are removing.
if (this._items[i] && this.hasMixin(Game.EntityMixins.Equipper)) {
this.unequip(this._items[i]);
}
// If the item is in a stack, decrement the stack amount
if(this._items[i].hasMixin('Stackable') && this._items[i].amount() > 1) {
this._items[i].removeFromStack(amount);
} else {
// Simply clear the inventory slot.
this._items[i] = null;
}
},
canAddItem: function() {
// Check if we have an empty slot.
for (var i = 0; i < this._items.length; i++) {
if (!this._items[i]) {
return true;
}
}
return false;
},
pickupItems: function(indices) {
// Allows the user to pick up items from the map, where indices is
// the indices for the array returned by map.getItemsAt
var mapItems = this._map.getItemsAt(this.getX(), this.getY(), this.getZ());
var added = 0;
// Iterate through all indices.
for (var i = 0; i < indices.length; i++) {
// Try to add the item. If our inventory is not full, then splice the
// item out of the list of items. In order to fetch the right item, we
// have to offset the number of items already added.
if (this.addItem(mapItems[indices[i] - added])) {
mapItems.splice(indices[i] - added, 1);
added++;
} else {
// Inventory is full
break;
}
}
// Update the map items
this._map.setItemsAt(this.getX(), this.getY(), this.getZ(), mapItems);
// Return true only if we added all items
return added === indices.length;
},
dropItem: function(i) {
// Drops an item to the current map tile
if (this._items[i]) {
var amount = 0;
if(this._items[i].hasMixin('Stackable')) {
amount = this._item[i].amount();
}
if (this._map) {
this._map.addItem(this.getX(), this.getY(), this.getZ(), this._items[i]);
}
this.removeItem(i, amount);
}
}
};
Game.EntityMixins.MessageRecipient = {
name: 'MessageRecipient',
init: function(template) {
this._messages = [];
},
receiveMessage: function(message) {
this._messages.push(message);
},
getMessages: function() {
return this._messages;
},
clearMessage: function(i) {
this._messages.splice(i, 1);
},
clearMessages: function() {
this._messages = [];
}
};
Game.EntityMixins.PlayerActor = {
name: 'PlayerActor',
groupName: 'Actor',
act: function() {
if (this._acting) {
return;
}
this._acting = true;
this.addTurnHunger();
// Detect if the game is over
if(!this.isAlive()) {
Game.Screen.playScreen.setGameEnded(true);
// Send a last message to the player
Game.sendMessage(this, 'Press [Enter] to continue!');
}
// Re-render the screen
Game.refresh();
// Lock the engine and wait asynchronously
// for the player to press a key.
this.getMap().getEngine().lock();
this.clearMessages();
this._acting = false;
}
};
Game.EntityMixins.PlayerStatGainer = {
name: 'PlayerStatGainer',
groupName: 'StatGainer',
listeners: {
onGainLevel: function() {
// Setup the gain stat screen and show it.
Game.Screen.gainStatScreen.enter(this);
Game.Screen.playScreen.setSubScreen(Game.Screen.gainStatScreen);
}
}
};
Game.EntityMixins.RandomStatGainer = {
name: 'RandomStatGainer',
groupName: 'StatGainer',
listeners: {
onGainLevel: function() {
var statOptions = this.getStatOptions();
// Randomly select a stat option and execute the callback for each stat point.
while (this.getStatPoints() > 0) {
// Call the stat increasing function with this as the context.
statOptions.random()[1].call(this);
this.setStatPoints(this.getStatPoints() - 1);
}
}
}
};
Game.EntityMixins.Sight = {
name: 'Sight',
groupName: 'Sight',
init: function(template) {
this._sightRadius = template['sightRadius'] || 5;
},
getSightRadius: function() {
return this._sightRadius;
},
canSee: function(entity) {
// If not on the same map or on different floors, then exit early
if (!entity || this._map !== entity.getMap() || this._z !== entity.getZ()) {
return false;
}
var otherX = entity.getX();
var otherY = entity.getY();
// If we're not in a square field of view, then we won't be in a real
// field of view either.
if ((otherX - this._x) * (otherX - this._x) +
(otherY - this._y) * (otherY - this._y) >
this._sightRadius * this._sightRadius) {
return false;
}
// Compute the FOV and check if the coordinates are in there.
// TODO: This should use the existing FOV isntead of re-computing
var found = false;
this.getMap().getFov(this.getZ()).compute(
this.getX(), this.getY(),
this.getSightRadius(),
function(x, y, radius, visibility) {
if (x === otherX && y === otherY) {
found = true;
}
});
return found;
},
increaseSightRadius: function(value) {
// If no value was passed, default to 1.
value = value || 1;
// Add to sight radius.
this._sightRadius += value;
Game.sendMessage(this, "You are more aware of your surroundings!");
},
};
Game.EntityMixins.Thrower = {
name: 'Thrower',
init: function(template) {
this._throwing = template['throwing'] || null;
this._throwingSkill = template['throwingSkill'] || 1;
},
getThrowing: function() {
return this._throwing;
},
getThrowingSkill: function() {
return this._throwingSkill;
},
setThrowing: function(i) {
this._throwing = i;
},
increaseThrowingSkill: function(value) {
var value = value || 2;
this._throwingSkill += 2;
Game.sendMessage(this, "You feel better at throwing things!");
},
_getTarget: function(targetX, targetY) {
var linePoints = Game.Geometry.getLine(this.getX(), this.getY(), targetX, targetY);
var z = this.getZ();
// Check to see if any walls or other creatures other than the thrower in the path
var end;
var lastPoint;
for (var i = 1; i < linePoints.length; i++) {
if(!this.getMap().getTile(linePoints[i].x, linePoints[i].y, z).isWalkable()) {
end = lastPoint;
break;
} else if(this.getMap().getEntityAt(linePoints[i].x, linePoints[i].y, z)) {
end = linePoints[i];
break;
} else {
lastPoint = linePoints[i];
}
};
// If nothing is in the way, the end point is targetX and targetY
if(!end) {
end = {x: targetX, y: targetY}
}
return {x: end.x, y: end.y, distance: linePoints.length};
},
throwItem: function(i, targetX, targetY) {
// Select the item to be thrown by its index
var item = this._items[i];
if(item.isThrowable()) {
// Check to see if there is a destructible entity at targetX and targetY
var target = this._getTarget(targetX, targetY);
var entity = this.getMap().getEntityAt(target.x, target.y, this.getZ());
if(entity && entity.hasMixin('Destructible')) {
// Entity has been found, calculate damage!
var attack = this.getThrowingSkill() + item.getAttackValue();
var defense = entity.getDefenseValue();
// The distance penalty will decrease as the skill increases
var distancePenalty = Math.floor(target.distance / this.getThrowingSkill());
var max = Math.max(0, attack - defense - distancePenalty);
var damage = 1 + Math.floor(Math.random() * max);
Game.sendMessage(this, "You throw %s at %s for %s damage!", [item.describeA(), entity.describeThe(), damage]);
Game.sendMessage(entity, "%s throws %s at you!", [this.describeThe(), item.describeA()]);
entity.takeDamage(this, damage);
} else {
Game.sendMessage(this, "You throw %s!", [item.describeA()]);
}
if(item.hasMixin('Stackable')) {
// It's actually easer to just create a new object at the location because of weird pass-by-reference stuff that javascript does.
var newItem = Game.ItemRepository.create(item.describe());
this.getMap().addItem(target.x, target.y, this.getZ(), newItem);
} else {
this.getMap().addItem(target.x, target.y, this.getZ(), item);
}
this.removeItem(i);
}
}
}
// For some reason, Game.extend has to be called after Game.EntityMixins.TaskActor is defined, since that's the thing it's trying to extend.
Game.EntityMixins.GiantZombieActor = Game.extend(Game.EntityMixins.TaskActor, {
init: function(template) {
// Call the task actor init with the right tasks.
Game.EntityMixins.TaskActor.init.call(this, Game.extend(template, {
'tasks' : ['growArm', 'spawnSlime', 'hunt', 'wander']
}));
// We only want to grow the arm once.
this._hasGrownArm = false;
},
canDoTask: function(task) {
// If we haven't already grown arm and HP <= 20, then we can grow.
if (task === 'growArm') {
return this.getHp() <= 20 && !this._hasGrownArm;
// Spawn a slime only a 10% of turns.
} else if (task === 'spawnSlime') {
return Math.round(Math.random() * 100) <= 10;
// Call parent canDoTask
} else {
return Game.EntityMixins.TaskActor.canDoTask.call(this, task);
}
},
growArm: function() {
this._hasGrownArm = true;
this.increaseAttackValue(5);
// Send a message saying the zombie grew an arm.
Game.sendMessageNearby(this.getMap(),
this.getX(), this.getY(), this.getZ(),
'An extra arm appears on the giant zombie!');
},
spawnSlime: function() {
// Generate a random position nearby.
var xOffset = Math.floor(Math.random() * 3) - 1;
var yOffset = Math.floor(Math.random() * 3) - 1;
// Check if we can spawn an entity at that position.
if (!this.getMap().isEmptyFloor(this.getX() + xOffset, this.getY() + yOffset, this.getZ())) {
// If we cant, do nothing
return;
}
// Create the entity
var slime = Game.EntityRepository.create('slime');
slime.setX(this.getX() + xOffset);
slime.setY(this.getY() + yOffset)
slime.setZ(this.getZ());
this.getMap().addEntity(slime);
},
listeners: {
onDeath: function(attacker) {
// Switch to win screen when killed!
Game.switchScreen(Game.Screen.winScreen);
}
}
});