UNPKG

hobgoblin

Version:

A framework for writing roguelike games in JavaScript, implementing ROT.js

768 lines (751 loc) 28.9 kB
// 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); } } });