diff --git a/src/mimics.lua b/src/mimics.lua new file mode 100644 index 0000000..32a208e --- /dev/null +++ b/src/mimics.lua @@ -0,0 +1,60 @@ +-- A mimic disguises itself as ordinary loot until a hand reaches for it. +-- Builder.build drops one as a plain floorItem tagged `mimic = true`, +-- wearing a random disguise's name and glyph (Mimics.DISGUISES) - utterly +-- indistinguishable at rest from a real item, since render.lua and +-- Game:pickupHere both just read an ordinary floorItem's own `.item.glyph` +-- and `.item.name`, with no idea a given tile might be a trap. The instant +-- the player steps onto it (Game:triggerMimic, called from pickupHere), +-- Mimics.reveal builds the real hostile monster underneath, scaled by +-- depth the same way Monsters.create scales a wild species's hp, and gets +-- one free ambush swing in (via combat.lua, so it still respects the +-- player's defense and resistances) before it settles into an ordinary +-- chase like any other monster on its own turns from then on. + +local Entity = require("entity") +local Factions = require("factions") + +local Mimics = {} + +Mimics.FACTION = Factions.BEAST + +Mimics.DISGUISES = { + { name = "a weathered chest", glyph = "=" }, + { name = "a glinting pile of gold", glyph = "$" }, + { name = "an ornate tome", glyph = "&" }, + { name = "a discarded shield", glyph = "[" }, +} + +-- The inert floorItem a builder drops: it looks exactly like whichever +-- disguise it rolled until Game:pickupHere unmasks it. +function Mimics.floorItem(rng) + local disguise = Mimics.DISGUISES[rng:range(1, #Mimics.DISGUISES)] + return { kind = "mimic", mimic = true, name = disguise.name, glyph = disguise.glyph } +end + +-- The hostile monster a disguised item becomes the instant it is +-- disturbed, its hp scaled by depth exactly the way Monsters.create scales +-- a wild species's own base hp. +function Mimics.reveal(id, x, y, depth) + local bonusHp = depth - 1 + local entity = Entity.new({ + id = id, x = x, y = y, + glyph = "M", name = "mimic", kind = "monster", + species = "mimic", ai = "chase", + hp = 12 + bonusHp, maxHp = 12 + bonusHp, + attack = 5, defense = 1, speed = 100, + }) + entity.cooldown = 0 + entity.faction = Mimics.FACTION + entity.mimic = true + return entity +end + +-- Flat xp reward for felling a revealed mimic - kept separate from +-- Monsters.xpFor since a mimic is never a member of the wild bestiary +-- Monsters.forDepth draws its ordinary per-floor population from. +function Mimics.xpFor() + return 10 +end + +return Mimics diff --git a/src/momentum.lua b/src/momentum.lua new file mode 100644 index 0000000..72cc551 --- /dev/null +++ b/src/momentum.lua @@ -0,0 +1,39 @@ +-- A player-only combo meter that rewards sustained melee aggression: every +-- landed melee swing raises the stack by one, up to a hard cap, and each +-- stack adds a flat attack bonus that reads live through +-- Entity:effectiveAttack - the very next swing already hits harder, not +-- just some future one. Taking a hit from a monster's claw, breath or +-- ambush snaps the whole stack back to zero outright - unlike a status +-- effect's timed decay, there is no partial forgiveness, so holding a combo +-- against several foes at once is a far harder rhythm than a lone duel. An +-- evaded attack (see skills.lua) never reaches Combat.attack at all, so it +-- never breaks the stack either - only a blow that actually lands does. +-- Ranged shots, thrown items and environmental damage (traps, poison, +-- lava) neither build nor break it: momentum is specifically a melee-vs- +-- monster reward, not a universal combat buff. + +local Momentum = {} + +Momentum.MAX_STACKS = 5 +Momentum.BONUS_PER_STACK = 1 + +function Momentum.stacks(entity) + return entity.combo or 0 +end + +-- Raises `entity`'s stack by one landed melee hit, capped at MAX_STACKS. +function Momentum.onMeleeHit(entity) + entity.combo = math.min(Momentum.MAX_STACKS, Momentum.stacks(entity) + 1) +end + +-- Snaps the stack back to zero the instant `entity` takes a hit. +function Momentum.onDamaged(entity) + entity.combo = 0 +end + +-- The flat attack bonus `entity`'s current stack grants. +function Momentum.attackBonus(entity) + return Momentum.stacks(entity) * Momentum.BONUS_PER_STACK +end + +return Momentum diff --git a/src/monsters.lua b/src/monsters.lua index b6c9a7f..976359e 100644 --- a/src/monsters.lua +++ b/src/monsters.lua @@ -4,20 +4,41 @@ -- depth so lower floors bite harder. local Entity = require("entity") +local Factions = require("factions") +local Disease = require("disease") local Monsters = {} Monsters.species = { - { key = "rat", glyph = "r", name = "rat", hp = 4, attack = 2, defense = 0, speed = 110, ai = "chase", minDepth = 1 }, - { key = "bat", glyph = "v", name = "cave bat", hp = 3, attack = 2, defense = 0, speed = 150, ai = "erratic", minDepth = 1 }, - { key = "kobold", glyph = "k", name = "kobold", hp = 6, attack = 3, defense = 1, speed = 100, ai = "pack", minDepth = 1 }, - { key = "goblin", glyph = "g", name = "goblin", hp = 5, attack = 3, defense = 0, speed = 110, ai = "pack", minDepth = 2 }, - { key = "archer", glyph = "a", name = "skeleton archer", hp = 6, attack = 3, defense = 1, speed = 100, ai = "ranged", minDepth = 2, range = 5 }, - { key = "orc", glyph = "o", name = "orc", hp = 10, attack = 4, defense = 1, speed = 100, ai = "chase", minDepth = 3 }, - { key = "wretch", glyph = "w", name = "cave wretch", hp = 8, attack = 2, defense = 0, speed = 120, ai = "flee", minDepth = 3 }, - { key = "troll", glyph = "T", name = "troll", hp = 16, attack = 6, defense = 2, speed = 80, ai = "chase", minDepth = 4 }, - { key = "mage", glyph = "m", name = "dark mage", hp = 8, attack = 2, defense = 0, speed = 100, ai = "ranged", minDepth = 5, range = 6 }, - { key = "dragon", glyph = "D", name = "young dragon", hp = 30, attack = 8, defense = 3, speed = 90, ai = "ranged", minDepth = 6, range = 4 }, + { key = "rat", glyph = "r", name = "rat", hp = 4, attack = 2, defense = 0, speed = 110, ai = "chase", minDepth = 1, xp = 3, faction = Factions.VERMIN, disease = Disease.PARASITES }, + { key = "bat", glyph = "v", name = "cave bat", hp = 3, attack = 2, defense = 0, speed = 150, ai = "erratic", minDepth = 1, xp = 3, faction = Factions.VERMIN }, + { key = "kobold", glyph = "k", name = "kobold", hp = 6, attack = 3, defense = 1, speed = 100, ai = "pack", minDepth = 1, xp = 5, faction = Factions.GOBLINOID, disease = Disease.PLAGUE }, + { key = "goblin", glyph = "g", name = "goblin", hp = 5, attack = 3, defense = 0, speed = 110, ai = "pack", minDepth = 2, xp = 5, faction = Factions.GOBLINOID }, + { key = "archer", glyph = "a", name = "skeleton archer", hp = 6, attack = 3, defense = 1, speed = 100, ai = "ranged", minDepth = 2, range = 5, xp = 6, faction = Factions.UNDEAD }, + { key = "orc", glyph = "o", name = "orc", hp = 10, attack = 4, defense = 1, speed = 100, ai = "chase", minDepth = 3, xp = 9, faction = Factions.ORCISH }, + { key = "wretch", glyph = "w", name = "cave wretch", hp = 8, attack = 2, defense = 0, speed = 120, ai = "flee", minDepth = 3, xp = 6, onDeath = "spores", faction = Factions.BEAST }, + { key = "troll", glyph = "T", name = "troll", hp = 16, attack = 6, defense = 2, speed = 80, ai = "chase", minDepth = 4, xp = 15, regen = 3, resist = { fire = "weak" }, faction = Factions.BEAST }, + { key = "mage", glyph = "m", name = "dark mage", hp = 8, attack = 2, defense = 0, speed = 100, ai = "ranged", minDepth = 5, range = 6, xp = 12, faction = Factions.CULT }, + { key = "dragon", glyph = "D", name = "young dragon", hp = 30, attack = 8, defense = 3, speed = 90, ai = "ranged", minDepth = 6, range = 4, xp = 25, breath = "fire", resist = { fire = "immune", cold = "weak" }, faction = Factions.DRAKE }, + + -- Ability bearers: each carries an `ability` tag resolved by abilities.lua's + -- tryAct dispatch, which runs before the plain ai.lua behaviour listed here + -- and only falls through to it when the ability is on cooldown or has no + -- valid target. minDepth is kept at 4+ so these never appear on the shallow + -- floors exercised by the fixed-maxDepth win-condition tests. + { key = "zombie", glyph = "z", name = "shambling zombie", hp = 7, attack = 3, defense = 1, speed = 90, ai = "chase", minDepth = 4, xp = 5, faction = Factions.UNDEAD, disease = Disease.ROT }, + { key = "slime", glyph = "j", name = "gelatinous slime", hp = 10, attack = 2, defense = 0, speed = 90, ai = "chase", minDepth = 4, xp = 5, ability = "split", faction = Factions.BEAST }, + { key = "footpad", glyph = "f", name = "footpad", hp = 6, attack = 2, defense = 0, speed = 120, ai = "chase", minDepth = 4, xp = 6, ability = "steal", faction = Factions.BANDIT }, + { key = "shaman", glyph = "s", name = "goblin shaman", hp = 7, attack = 2, defense = 0, speed = 100, ai = "flee", minDepth = 4, xp = 9, ability = "curse", faction = Factions.GOBLINOID }, + { key = "necromancer", glyph = "n", name = "necromancer", hp = 9, attack = 2, defense = 0, speed = 95, ai = "flee", minDepth = 4, xp = 10, ability = "summon", summonSpecies = "zombie", faction = Factions.CULT }, + { key = "cleric", glyph = "c", name = "dark cleric", hp = 8, attack = 2, defense = 1, speed = 100, ai = "pack", minDepth = 5, xp = 9, ability = "heal", faction = Factions.CULT }, + { key = "chieftain", glyph = "C", name = "orc chieftain", hp = 14, attack = 5, defense = 2, speed = 100, ai = "pack", minDepth = 6, xp = 14, ability = "rally", faction = Factions.GOBLINOID }, + { key = "banshee", glyph = "b", name = "banshee", hp = 7, attack = 2, defense = 0, speed = 100, ai = "flee", minDepth = 5, xp = 11, ability = "shriek", faction = Factions.UNDEAD }, + + -- Shopkeeper: a passive vendor guarding priced wares (see shop.lua). Its + -- "vend" behaviour idles until game.lua flags it hostile after a theft, at + -- which point it falls back to a plain chase like any other monster. + { key = "shopkeeper", glyph = "h", name = "shopkeeper", hp = 8, attack = 3, defense = 1, speed = 100, ai = "vend", minDepth = 4, xp = 7, faction = Factions.MERCHANT }, } Monsters.byKey = {} @@ -36,9 +57,28 @@ function Monsters.forDepth(depth) return eligible end +-- Xp reward for felling an entity, looked up by the species key stamped onto +-- it at creation. Falls back to a flat amount for anything without one. +function Monsters.xpFor(entity) + local spec = Monsters.byKey[entity.species] + return spec and spec.xp or 4 +end + +-- A fresh copy of a species' innate resist table, never the catalog's own +-- object: elites.lua's "warded" affix mutates entity.resist in place once +-- rolled, and a shared reference there would leak that bump onto every +-- other spawn of the same species for the rest of the process, not just +-- the one entity actually promoted. +local function copyResist(resist) + if not resist then return nil end + local copy = {} + for damageType, tier in pairs(resist) do copy[damageType] = tier end + return copy +end + function Monsters.create(spec, id, x, y, depth) local bonusHp = depth - 1 - return Entity.new({ + local entity = Entity.new({ id = id, x = x, y = y, glyph = spec.glyph, name = spec.name, kind = "monster", @@ -47,6 +87,16 @@ function Monsters.create(spec, id, x, y, depth) attack = spec.attack, defense = spec.defense, speed = spec.speed, range = spec.range or 0, }) + entity.regen = spec.regen + entity.onDeath = spec.onDeath + entity.breath = spec.breath + entity.ability = spec.ability + entity.summonSpecies = spec.summonSpecies + entity.cooldown = 0 + entity.resist = copyResist(spec.resist) + entity.faction = spec.faction + entity.biteDisease = spec.disease + return entity end return Monsters