feat: add chebyshev and Sanity.current

This commit is contained in:
dualfroz committed 2026-09-07 20:24:53 +02:00
1 parent cf286f9da3
commit 35b97b6c04
1 file changed
+201
+201
View File
@@ -0,0 +1,201 @@
-- The player's grip on their own mind, tracked as a slow-moving meter
-- alongside hp and mana rather than a temporary status.lua effect: nothing
-- brews or casts sanity away in a single stroke, it only bleeds out from
-- sustained exposure to dread (standing in the dark with a spent torch,
-- corpses piling up nearby, undead in plain sight, a curse whose bite is
-- already known, an infested floor's own creeping wrongness) and claws
-- back just as slowly, one calm turn - or one potion of clarity - at a
-- time. Falling tiers cost real attack and defense, exactly like
-- encumbrance.lua's overloaded penalty, and the worst tier risks losing a
-- turn outright to panic or giving the player's own position away with an
-- involuntary scream, so the meter is never just flavour text on the
-- status panel.
local Factions = require("factions")
local Curses = require("curses")
local Atmosphere = require("atmosphere")
local Stealth = require("stealth")
local Sanity = {}
Sanity.MAX = 100
Sanity.COMPOSED = "composed"
Sanity.SHAKEN = "shaken"
Sanity.TERRIFIED = "terrified"
Sanity.BROKEN = "broken"
-- Tier ceilings: a value at or below TERRIFIED_MAX but above 0 reads as
-- TERRIFIED, and so on upward. 0 itself is always BROKEN regardless of
-- where these ceilings sit.
Sanity.TERRIFIED_MAX = 30
Sanity.SHAKEN_MAX = 60
Sanity.DARKNESS_DREAD = 2
Sanity.CORPSE_DREAD_PER = 1
Sanity.CORPSE_DREAD_CAP = 4
Sanity.UNDEAD_DREAD_PER = 2
Sanity.UNDEAD_DREAD_CAP = 6
Sanity.CURSE_DREAD = 2
Sanity.WEATHER_DREAD = 2
-- Passive drift back toward composure on any turn that accrued no dread
-- at all - never enough on its own to outrun a floor still actively
-- haunting the player, but reliable once they step clear of it.
Sanity.CALM_RECOVERY = 1
Sanity.PANIC_NUMERATOR = 1
Sanity.PANIC_DENOMINATOR = 3
Sanity.SCREAM_NUMERATOR = 1
Sanity.SCREAM_DENOMINATOR = 4
Sanity.SCREAM_RADIUS = 6
local ATTACK_PENALTY = {
[Sanity.COMPOSED] = 0, [Sanity.SHAKEN] = -1, [Sanity.TERRIFIED] = -3, [Sanity.BROKEN] = -5,
}
local DEFENSE_PENALTY = {
[Sanity.COMPOSED] = 0, [Sanity.SHAKEN] = -1, [Sanity.TERRIFIED] = -2, [Sanity.BROKEN] = -4,
}
local function chebyshev(ax, ay, bx, by)
return math.max(math.abs(ax - bx), math.abs(ay - by))
end
-- A fresh entity - or a monster, which never accrues any dread of its own
-- since nothing ever calls Sanity.tick for one - reads as fully composed.
function Sanity.current(entity)
return entity.sanity or Sanity.MAX
end
function Sanity.tierOf(entity)
local value = Sanity.current(entity)
if value <= 0 then return Sanity.BROKEN end
if value <= Sanity.TERRIFIED_MAX then return Sanity.TERRIFIED end
if value <= Sanity.SHAKEN_MAX then return Sanity.SHAKEN end
return Sanity.COMPOSED
end
function Sanity.adjust(entity, delta)
local updated = Sanity.current(entity) + delta
if updated > Sanity.MAX then updated = Sanity.MAX end
if updated < 0 then updated = 0 end
entity.sanity = updated
return updated
end
function Sanity.attackPenalty(entity)
return ATTACK_PENALTY[Sanity.tierOf(entity)]
end
function Sanity.defensePenalty(entity)
return DEFENSE_PENALTY[Sanity.tierOf(entity)]
end
-- Each dread source below mirrors the vocabulary an existing module already
-- uses (Atmosphere's weather keys, Factions' undead tag, Curses' reveal
-- flag) rather than inventing a parallel one, and every source caps its own
-- contribution so no single cause can spike the meter to zero in one turn.
function Sanity.darknessDread(game)
if not game.level.dark then return 0 end
if game.player.light.fuel > 0 then return 0 end
return Sanity.DARKNESS_DREAD
end
function Sanity.corpseDread(game)
local count = 0
for _, corpse in ipairs(game.level.corpses) do
if chebyshev(corpse.x, corpse.y, game.player.x, game.player.y) <= 1 then
count = count + 1
end
end
return math.min(count * Sanity.CORPSE_DREAD_PER, Sanity.CORPSE_DREAD_CAP)
end
-- Only the undead currently visible weigh on the mind - one lurking
-- somewhere unseen on the floor cannot, unlike a nearby corpse the player
-- can smell without looking.
function Sanity.undeadDread(game)
local count = 0
local map = game.level.map
for _, monster in ipairs(game.level.entities) do
if monster:isAlive() and monster.faction == Factions.UNDEAD
and game.visible[map:index(monster.x, monster.y)] then
count = count + 1
end
end
return math.min(count * Sanity.UNDEAD_DREAD_PER, Sanity.UNDEAD_DREAD_CAP)
end
-- A curse still hiding behind an ordinary appearance has not yet given its
-- wearer anything to dread - only a revealed one gnaws at the mind.
function Sanity.curseDread(entity)
for _, slot in ipairs(Curses.SLOTS) do
local item = entity[slot]
if Curses.isCursed(item) and Curses.isRevealed(item) then return Sanity.CURSE_DREAD end
end
return 0
end
function Sanity.weatherDread(weather)
if weather == Atmosphere.INFESTED then return Sanity.WEATHER_DREAD end
return 0
end
function Sanity.totalDread(game)
return Sanity.darknessDread(game)
+ Sanity.corpseDread(game)
+ Sanity.undeadDread(game)
+ Sanity.curseDread(game.player)
+ Sanity.weatherDread(game.level.weather)
end
-- Advances the player's sanity by one turn: every dread source at once, or
-- - on a turn none of them fired at all - a small step back toward
-- composure instead. Returns a log line the instant the player's tier
-- actually changes, in either direction, or nil while it holds.
function Sanity.tick(game)
local before = Sanity.tierOf(game.player)
local dread = Sanity.totalDread(game)
local worsening = dread > 0
if worsening then
Sanity.adjust(game.player, -dread)
else
Sanity.adjust(game.player, Sanity.CALM_RECOVERY)
end
local after = Sanity.tierOf(game.player)
if after == before then return nil end
if worsening then
if after == Sanity.BROKEN then return "your mind gives way to raw terror" end
if after == Sanity.TERRIFIED then return "dread claws at the edges of your sanity" end
return "your nerves begin to fray"
end
if after == Sanity.COMPOSED then return "your mind is your own again" end
return "your nerves start to steady"
end
-- BROKEN alone risks losing the turn outright to a fit of terror; every
-- other tier always lets the player act. Rolls against `rng` so a seeded
-- run always panics on the identical turns.
function Sanity.rollPanic(rng, entity)
if Sanity.tierOf(entity) ~= Sanity.BROKEN then return false end
return rng:chance(Sanity.PANIC_NUMERATOR, Sanity.PANIC_DENOMINATOR)
end
-- TERRIFIED or worse risks an involuntary scream that alerts every living
-- monster within earshot (reuses stealth.lua's own wake path) - the fear
-- itself becomes a hazard to whoever else is on the floor.
function Sanity.rollScream(rng, game)
local tier = Sanity.tierOf(game.player)
if tier ~= Sanity.TERRIFIED and tier ~= Sanity.BROKEN then return false end
if not rng:chance(Sanity.SCREAM_NUMERATOR, Sanity.SCREAM_DENOMINATOR) then return false end
for _, monster in ipairs(game.level.entities) do
if monster:isAlive() and chebyshev(monster.x, monster.y, game.player.x, game.player.y) <= Sanity.SCREAM_RADIUS then
Stealth.alert(monster, game.player.x, game.player.y)
end
end
return true
end
return Sanity