From d04add4a7b9c2bf2490509a330e4921d56b86b67 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 13:10:10 +0200 Subject: [PATCH] feat: extend game with Game:monsterDamageMultiplier --- src/game.lua | 154 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 6 deletions(-) diff --git a/src/game.lua b/src/game.lua index 4a06ff6..a78b980 100644 --- a/src/game.lua +++ b/src/game.lua @@ -241,9 +241,34 @@ function Game:currentEntities() end function Game:effectiveRadius() - if not self.level.dark then return FOV_RADIUS end - if self.player.light.fuel > 0 then return self.player.light.radius end - return 2 + local radius = FOV_RADIUS + if self.level.dark then + radius = self.player.light.fuel > 0 and self.player.light.radius or 2 + end + radius = radius - Atmosphere.sightPenalty(self.level.weather) - Events.sightPenalty(self.level.event) + - Ascension.sightPenalty(self.ascension) + if radius < 1 then radius = 1 end + -- Blindness overrides every other light/weather/mutator term rather than + -- stacking with them: a blinded player run through with a full lantern in + -- clear weather still can't see past their own hand. + if Status.has(self.player, Status.BLIND) then radius = 1 end + return radius +end + +-- Folds events.lua's level-scoped monster damage swing together with +-- whatever ascension.lua mutators the player opted into for this run, read +-- live at the point a monster actually lands a hit (Game:monsterMelee, +-- Game:monsterShoot, Game:triggerMimic and Game:triggerGrave's ambush +-- swings) rather than baked into any monster's own stats. +function Game:monsterDamageMultiplier() + return Events.monsterDamageMultiplier(self.level.event) * Ascension.damageMultiplier(self.ascension) +end + +-- Folds events.lua's rich-veins swing together with ascension.lua's own +-- essence mutators, read live wherever essence is actually credited +-- (Game:awardKill, Nodes.harvest). +function Game:essenceMultiplier() + return Events.essenceMultiplier(self.level.event) * Ascension.essenceMultiplier(self.ascension) end function Game:computeVisible() @@ -252,6 +277,19 @@ function Game:computeVisible() for idx in pairs(self.visible) do self.level:markExplored(idx) end + self:noteSightings() +end + +-- Records every currently-visible living monster's species into the +-- compendium (see compendium.lua) - silent bookkeeping, never touches the +-- message log, so it cannot perturb anything that reads recent log lines. +function Game:noteSightings() + local map = self.level.map + for _, entity in ipairs(self.level.entities) do + if entity.kind == "monster" and entity:isAlive() and self.visible[map:index(entity.x, entity.y)] then + Compendium.noteSighting(self.compendium, entity.species) + end + end end function Game:cleanupDead() @@ -265,6 +303,13 @@ function Game:hasKey() return false end +function Game:hasAmulet() + for _, item in ipairs(self.player.inventory) do + if item.kind == Items.AMULET then return true end + end + return false +end + function Game:randomFloorCell() local map = self.level.map local cells = {} @@ -294,9 +339,39 @@ function Game:playerAdjacent(monster) return math.abs(monster.x - self.player.x) + math.abs(monster.y - self.player.y) == 1 end +-- The player-aligned companion, hireling, or thrall standing right next to +-- `monster` (Manhattan-1, the same cardinal-only adjacency Game:playerAdjacent +-- itself uses), or nil if none is that close. A hostile monster whose only +-- route toward the player runs through a one-wide corridor an ally is +-- standing in cannot path around it (Game:stepMonsterToward's own +-- blockedForMonster treats any occupied non-goal cell as a wall), so without +-- this check every ai.lua pursuit behaviour just holds position forever once +-- it reaches that ally's doorstep - the ally becomes an unkillable, un- +-- fightable roadblock rather than the combatant Game:onActorDeath and the +-- companion/mercenary/thrall noise-emission work already treat it as. Ties +-- break toward the lower entity id, matching Game:nearestAlly's own rule. +function Game:adjacentAlly(monster) + local best + for _, other in ipairs(self.level.entities) do + if other ~= monster and other:isAlive() + and (other.kind == "companion" or other.kind == "hireling" or other.kind == "thrall") + and math.abs(other.x - monster.x) + math.abs(other.y - monster.y) == 1 + and (not best or other.id < best.id) then + best = other + end + end + return best +end + function Game:canSeePlayer(monster, range) + local penalty = Skills.detectionPenalty(self.player, self.level.dark) + local effectiveRange = math.max(range - penalty, 0) + -- A blinded monster can still feel the player out at melee range but loses + -- every longer-ranged aggro/pack/flee/ranged check in ai.lua - the same + -- clamp Game:effectiveRadius applies to a blinded player's own sight. + if Status.has(monster, Status.BLIND) then effectiveRange = math.min(effectiveRange, 1) end local cheb = math.max(math.abs(monster.x - self.player.x), math.abs(monster.y - self.player.y)) - if cheb > range then return false end + if cheb > effectiveRange then return false end return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y) end @@ -305,20 +380,87 @@ function Game:hasClearShot(monster) end function Game:monsterMelee(monster) - local outcome = Combat.attack(monster, self.player) + if Skills.evades(self, self.player) then + self.log:add(string.format("you evade %s's attack", monster.name)) + return + end + local outcome = Combat.attack(monster, self.player, self:monsterDamageMultiplier()) + Momentum.onDamaged(self.player) self.log:add(string.format("%s hits you for %d", monster.name, outcome.damage)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end + self:applyEliteOnHit(monster, outcome.damage, self.player) + local thorns = Artifacts.thornsAmount(self.player, outcome.damage) + if thorns > 0 then + -- Aegis of Embers "sears" its attacker by name and by log line, so this + -- reflect is fire damage, not a bare stat subtraction - a fire-immune + -- dragon (bestiary-real, reachable from depth 6) shrugs it off entirely, + -- exactly like every other fire source in the engine already does. + local dealt = Resistances.apply(monster, thorns, Resistances.FIRE) + if dealt > 0 then + monster.hp = monster.hp - dealt + self.log:add(string.format("your armor sears %s for %d", monster.name, dealt)) + if not monster:isAlive() then self:awardKill(monster) end + else + self.log:add(string.format("your armor's embers wash harmlessly over %s", monster.name)) + end + end + if monster.biteDisease and self.player:isAlive() and self.rng:chance(1, 4) then + if Disease.infect(self.player, monster.biteDisease) then + self.log:add(string.format("%s's bite leaves you feeling ill", monster.name)) + end + end + self:emitNoise(monster.x, monster.y, Noise.MELEE) +end + +-- A vampiric elite heals a portion of the damage it just dealt; a chilling +-- one leaves its status rider on the victim. Shared by melee and a ranged +-- shot so either delivery route triggers the same elite hooks - and, since +-- Infighting.strike resolves an elite fighting a rival monster or the +-- player's own companion/hireling/thrall through this same method, the log +-- line names the actual victim instead of always assuming it is the player. +function Game:applyEliteOnHit(attacker, dealt, victim) + local lifesteal = Elites.lifestealFraction(attacker) + if lifesteal > 0 then + local healed = math.floor(dealt * lifesteal) + if healed > 0 and attacker:isAlive() then + attacker.hp = math.min(attacker.maxHp, attacker.hp + healed) + local from = victim == self.player and "you" or victim.name + self.log:add(string.format("%s drains %d hp from %s", attacker.name, healed, from)) + end + end + local rider = Elites.onHitRider(attacker) + if rider and victim:isAlive() then + Status.apply(victim, rider.status, rider.duration, rider.potency) + local who = victim == self.player and "you" or victim.name + self.log:add(string.format("%s's touch leaves %s %s", attacker.name, who, rider.status)) + end end function Game:monsterShoot(monster) - local dealt = Combat.damage(monster, self.player) + local damageType = monster.breath == "fire" and Resistances.FIRE or Resistances.PHYSICAL + local dealt = Combat.damage(monster, self.player, self:monsterDamageMultiplier(), damageType) self.player.hp = self.player.hp - dealt + Momentum.onDamaged(self.player) self.log:add(string.format("%s shoots you for %d", monster.name, dealt)) + local broke = Durability.wearArmor(self.player) + if broke then self.log:add(broke) end + self:applyEliteOnHit(monster, dealt, self.player) + if monster.breath == "fire" then + if Resistances.tierOf(self.player, Resistances.FIRE) == Resistances.IMMUNE then + self.log:add("the flames wash over you harmlessly") + else + Status.apply(self.player, Status.BURNING, 3, 2) + self.log:add("the flames catch you alight") + end + end end local function blockedForMonster(game, self, goalX, goalY) return function(x, y) if x == goalX and y == goalY then return false end if not game.level.map:isWalkable(x, y) then return true end + if game.level:blockAt(x, y) then return true end if x == game.player.x and y == game.player.y then return true end local occupant = game.level:entityAt(x, y) return occupant ~= nil and occupant ~= self