feat: extend game with Game:monsterDamageMultiplier

This commit is contained in:
dualfroz committed 2026-09-06 13:10:10 +02:00
1 parent 2d1cf09afa
commit d04add4a7b
1 file changed
+148 -6
+148 -6
View File
@@ -241,9 +241,34 @@ function Game:currentEntities()
end end
function Game:effectiveRadius() function Game:effectiveRadius()
if not self.level.dark then return FOV_RADIUS end local radius = FOV_RADIUS
if self.player.light.fuel > 0 then return self.player.light.radius end if self.level.dark then
return 2 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 end
function Game:computeVisible() function Game:computeVisible()
@@ -252,6 +277,19 @@ function Game:computeVisible()
for idx in pairs(self.visible) do for idx in pairs(self.visible) do
self.level:markExplored(idx) self.level:markExplored(idx)
end 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 end
function Game:cleanupDead() function Game:cleanupDead()
@@ -265,6 +303,13 @@ function Game:hasKey()
return false return false
end 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() function Game:randomFloorCell()
local map = self.level.map local map = self.level.map
local cells = {} 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 return math.abs(monster.x - self.player.x) + math.abs(monster.y - self.player.y) == 1
end 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) 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)) 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) return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y)
end end
@@ -305,20 +380,87 @@ function Game:hasClearShot(monster)
end end
function Game:monsterMelee(monster) 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)) 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 end
function Game:monsterShoot(monster) 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 self.player.hp = self.player.hp - dealt
Momentum.onDamaged(self.player)
self.log:add(string.format("%s shoots you for %d", monster.name, dealt)) 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 end
local function blockedForMonster(game, self, goalX, goalY) local function blockedForMonster(game, self, goalX, goalY)
return function(x, y) return function(x, y)
if x == goalX and y == goalY then return false end if x == goalX and y == goalY then return false end
if not game.level.map:isWalkable(x, y) then return true 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 if x == game.player.x and y == game.player.y then return true end
local occupant = game.level:entityAt(x, y) local occupant = game.level:entityAt(x, y)
return occupant ~= nil and occupant ~= self return occupant ~= nil and occupant ~= self