feat: add Stealth.isUnaware and Stealth.sneakMultiplier
This commit is contained in:
1 parent
249d2d047b
commit
530042d48b
1 file changed
+154
+154
@@ -0,0 +1,154 @@
|
||||
-- Monster alertness state machine layered on top of ai.lua's chase/pack/etc
|
||||
-- behaviours. A wild monster the builder places starts dormant (asleep) and
|
||||
-- takes no action at all until something wakes it: on each of its own turns
|
||||
-- a still-dormant monster rolls a wake chance driven by distance, line of
|
||||
-- sight and the player's detection-penalty skills (see skills.lua's
|
||||
-- camouflage), guaranteed once the player stands right next to it. Landing
|
||||
-- any hit on it - melee, spell or ranged - wakes it outright regardless of
|
||||
-- the roll.
|
||||
--
|
||||
-- Once alert a monster hunts exactly as ai.lua always has; losing line of
|
||||
-- sight for several turns in a row drops it to searching, where it makes
|
||||
-- for wherever it last saw the player instead of running its usual
|
||||
-- behaviour. Giving up the search with nothing found puts it back to sleep.
|
||||
--
|
||||
-- A blow struck against a monster that is still dormant is a sneak attack:
|
||||
-- it never saw the swing coming, so it lands for several times the normal
|
||||
-- damage (see Stealth.sneakMultiplier, read by Game:playerAttack).
|
||||
--
|
||||
-- Bosses, the shopkeeper and anything summoned or split mid-fight (see
|
||||
-- game.lua's spawnMinion and bosses.lua's create) start "hunting" outright
|
||||
-- rather than dormant, so none of this changes behaviour that was already
|
||||
-- always-alert. A monster built directly without ever passing through
|
||||
-- builder.lua's placement loop - as most of tests/run.lua's fixtures do -
|
||||
-- also defaults to "hunting" whenever its awareness field is simply absent,
|
||||
-- which keeps every existing hand-built scenario acting exactly as it did
|
||||
-- before this module existed.
|
||||
|
||||
local Los = require("los")
|
||||
local Skills = require("skills")
|
||||
|
||||
local Stealth = {}
|
||||
|
||||
Stealth.DORMANT = "dormant"
|
||||
Stealth.HUNTING = "hunting"
|
||||
Stealth.SEARCHING = "searching"
|
||||
|
||||
Stealth.WAKE_RADIUS = 6
|
||||
Stealth.TRACK_RADIUS = 20
|
||||
Stealth.LOST_TURNS_LIMIT = 4
|
||||
Stealth.SEARCH_TURNS_LIMIT = 4
|
||||
Stealth.SNEAK_MULTIPLIER = 3
|
||||
|
||||
function Stealth.isUnaware(monster)
|
||||
return monster.awareness == Stealth.DORMANT
|
||||
end
|
||||
|
||||
-- Read by Game:playerAttack before Stealth.alert (below) fires on the same
|
||||
-- swing, so the bonus is computed against the state the monster was
|
||||
-- actually in the instant the blow landed.
|
||||
function Stealth.sneakMultiplier(monster)
|
||||
if Stealth.isUnaware(monster) then return Stealth.SNEAK_MULTIPLIER end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Called the instant a monster is provoked by any offensive player action -
|
||||
-- whatever it was doing before, it now knows exactly where the player is.
|
||||
function Stealth.alert(monster, px, py)
|
||||
monster.awareness = Stealth.HUNTING
|
||||
monster.lastKnownX, monster.lastKnownY = px, py
|
||||
monster.lostTurns = 0
|
||||
end
|
||||
|
||||
-- Called when noise.lua's flood reaches a still-dormant monster (see
|
||||
-- game.lua's Game:emitNoise). Unlike alert() above - a direct provocation,
|
||||
-- which snaps a monster straight to hunting with the player's exact
|
||||
-- position - a monster that only heard something does not know it was the
|
||||
-- player specifically, or where they actually are now: it starts searching
|
||||
-- the noise's own origin instead, exactly like a hunter that just lost
|
||||
-- track (see Stealth.tick's searching branch below), so it may well arrive
|
||||
-- to find nothing there and give up. A monster that is not currently
|
||||
-- dormant is already tracking something better than a noise and ignores
|
||||
-- this outright.
|
||||
function Stealth.hear(monster, x, y)
|
||||
if monster.awareness ~= Stealth.DORMANT then return end
|
||||
monster.awareness = Stealth.SEARCHING
|
||||
monster.lastKnownX, monster.lastKnownY = x, y
|
||||
monster.searchTurns = 0
|
||||
end
|
||||
|
||||
local function chebyshev(ax, ay, bx, by)
|
||||
return math.max(math.abs(ax - bx), math.abs(ay - by))
|
||||
end
|
||||
|
||||
local function canSpot(game, monster, radius)
|
||||
local penalty = Skills.detectionPenalty(game.player, game.level.dark)
|
||||
local effective = radius - penalty
|
||||
if effective < 1 then effective = 1 end
|
||||
if chebyshev(monster.x, monster.y, game.player.x, game.player.y) > effective then return false end
|
||||
return Los.clear(game.level.map, monster.x, monster.y, game.player.x, game.player.y)
|
||||
end
|
||||
|
||||
-- Whether a dormant monster notices the player this turn: guaranteed once
|
||||
-- adjacent, otherwise a chance that improves the closer the player stands,
|
||||
-- scaled against the monster's own (penalty-adjusted) waking radius.
|
||||
local function rollWake(game, monster)
|
||||
if not canSpot(game, monster, Stealth.WAKE_RADIUS) then return false end
|
||||
local dist = chebyshev(monster.x, monster.y, game.player.x, game.player.y)
|
||||
if dist <= 1 then return true end
|
||||
local penalty = Skills.detectionPenalty(game.player, game.level.dark)
|
||||
local radius = math.max(Stealth.WAKE_RADIUS - penalty, 1)
|
||||
local roll = game.rng:range(1, radius)
|
||||
return roll <= (radius - dist + 1)
|
||||
end
|
||||
|
||||
-- Advances one monster's alertness ahead of its upcoming turn and reports
|
||||
-- whether game.lua should still run its normal ability/ai dispatch this
|
||||
-- turn. A monster that stays dormant, or one that spends the turn marching
|
||||
-- back toward wherever it last saw the player, returns false - stealth.lua
|
||||
-- has already fully resolved its turn (for dormant, that means doing
|
||||
-- nothing at all).
|
||||
function Stealth.tick(game, monster)
|
||||
local awareness = monster.awareness or Stealth.HUNTING
|
||||
if awareness == Stealth.DORMANT then
|
||||
if rollWake(game, monster) then
|
||||
Stealth.alert(monster, game.player.x, game.player.y)
|
||||
game.log:add(string.format("%s stirs awake", monster.name))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
if awareness == Stealth.SEARCHING then
|
||||
if canSpot(game, monster, Stealth.TRACK_RADIUS) then
|
||||
Stealth.alert(monster, game.player.x, game.player.y)
|
||||
return true
|
||||
end
|
||||
monster.searchTurns = (monster.searchTurns or 0) + 1
|
||||
if monster.lastKnownX and (monster.x ~= monster.lastKnownX or monster.y ~= monster.lastKnownY) then
|
||||
game:stepMonsterToward(monster, monster.lastKnownX, monster.lastKnownY)
|
||||
end
|
||||
if monster.searchTurns >= Stealth.SEARCH_TURNS_LIMIT then
|
||||
monster.awareness = Stealth.DORMANT
|
||||
monster.lastKnownX, monster.lastKnownY = nil, nil
|
||||
monster.lostTurns, monster.searchTurns = nil, nil
|
||||
game.log:add(string.format("%s gives up the search and settles back down", monster.name))
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- hunting
|
||||
if canSpot(game, monster, Stealth.TRACK_RADIUS) then
|
||||
monster.lastKnownX, monster.lastKnownY = game.player.x, game.player.y
|
||||
monster.lostTurns = 0
|
||||
return true
|
||||
end
|
||||
monster.lostTurns = (monster.lostTurns or 0) + 1
|
||||
if monster.lostTurns >= Stealth.LOST_TURNS_LIMIT and monster.lastKnownX then
|
||||
monster.awareness = Stealth.SEARCHING
|
||||
monster.searchTurns = 0
|
||||
game.log:add(string.format("%s loses track of you", monster.name))
|
||||
return Stealth.tick(game, monster)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Stealth
|
||||
Reference in new issue
Block a user