feat: add chebyshev and Sanity.current
This commit is contained in:
1 parent
cf286f9da3
commit
35b97b6c04
1 file changed
+201
+201
@@ -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
|
||||||
Reference in new issue
Block a user