feat: add rollLoot and Chests.new
This commit is contained in:
1 parent
0e8655e871
commit
a8b7261a84
1 file changed
+182
+182
@@ -0,0 +1,182 @@
|
||||
-- Lockable loot containers scattered through the dungeon: an unlocked chest
|
||||
-- yields its hoard the instant it is opened, a locked one demands either a
|
||||
-- spent key (Items.KEY, the same key a locked door already consumes) or a
|
||||
-- lockpick gamble (Items.LOCKPICK) that risks springing the chest's own trap
|
||||
-- on a failed pick - a second, riskier route into the same treasure rather
|
||||
-- than a strictly worse one, since a lockpick is commoner floor loot than a
|
||||
-- key. Only a locked chest can be trapped at all: there is no lock to punish
|
||||
-- fumbling with on one that was never going to resist in the first place.
|
||||
-- A chest never moves and never decays; once opened it stays on the map as
|
||||
-- an emptied husk exactly like a corpse past its last butchering, so a
|
||||
-- second visit finds nothing left to take. Chests are plain data on
|
||||
-- level.chests, exactly like level.corpses and level.nodes, so they
|
||||
-- serialize with no special save-format handling.
|
||||
|
||||
local Items = require("items")
|
||||
local Affixes = require("affixes")
|
||||
local Status = require("status")
|
||||
|
||||
local Chests = {}
|
||||
|
||||
Chests.LOCK_CHANCE_NUMERATOR = 1
|
||||
Chests.LOCK_CHANCE_DENOMINATOR = 2
|
||||
|
||||
Chests.TRAP_CHANCE_NUMERATOR = 1
|
||||
Chests.TRAP_CHANCE_DENOMINATOR = 3
|
||||
|
||||
Chests.PICK_BASE_NUMERATOR = 1
|
||||
Chests.PICK_BASE_DENOMINATOR = 2
|
||||
Chests.PICK_LEVEL_BONUS_DIVISOR = 5
|
||||
|
||||
Chests.TRAP_DART_MIN = 2
|
||||
Chests.TRAP_DART_MAX = 5
|
||||
|
||||
Chests.GOLD_MIN_BONUS = 0
|
||||
Chests.GOLD_MAX_BONUS = 10
|
||||
Chests.GOLD_BASE = 10
|
||||
Chests.GOLD_PER_DEPTH = 6
|
||||
|
||||
local chestWeapons = {
|
||||
{ name = "ornate rapier", power = 4 },
|
||||
{ name = "gilded axe", power = 5 },
|
||||
}
|
||||
local chestArmors = {
|
||||
{ name = "banded mail", power = 3 },
|
||||
}
|
||||
|
||||
-- One chest's hoard: always some gold plus a coin flip between a lightly
|
||||
-- affixed weapon or armor piece (see affixes.lua) and, half the time on top
|
||||
-- of that, a spare healing potion. Richer on average than a single floor
|
||||
-- drop (builder.lua's rollItem), since reaching it costs a key or a
|
||||
-- lockpick gamble rather than a free step onto its tile.
|
||||
local function rollLoot(rng, depth)
|
||||
local loot = { Items.gold(Chests.GOLD_BASE + depth * Chests.GOLD_PER_DEPTH
|
||||
+ rng:range(Chests.GOLD_MIN_BONUS, Chests.GOLD_MAX_BONUS)) }
|
||||
if rng:chance(1, 2) then
|
||||
local base = chestWeapons[rng:range(1, #chestWeapons)]
|
||||
loot[#loot + 1] = Affixes.decorate(Items.weapon(base.name, base.power + depth // 4), Affixes.rollWeapon(rng))
|
||||
else
|
||||
local base = chestArmors[rng:range(1, #chestArmors)]
|
||||
loot[#loot + 1] = Affixes.decorate(Items.armor(base.name, base.power + depth // 4), Affixes.rollArmor(rng))
|
||||
end
|
||||
if rng:chance(1, 2) then
|
||||
loot[#loot + 1] = Items.potionOf("heal")
|
||||
end
|
||||
return loot
|
||||
end
|
||||
|
||||
-- A freshly rolled chest at (unassigned) x/y, exactly like Nodes.new -
|
||||
-- builder.lua stamps x/y directly onto the returned table once it finds a
|
||||
-- free cell for it. Trapped is only ever rolled for a locked chest (see
|
||||
-- above), so the rng stream is never touched by the trap roll at all on an
|
||||
-- unlocked one.
|
||||
function Chests.new(rng, depth)
|
||||
local locked = rng:chance(Chests.LOCK_CHANCE_NUMERATOR, Chests.LOCK_CHANCE_DENOMINATOR)
|
||||
local trapped = locked and rng:chance(Chests.TRAP_CHANCE_NUMERATOR, Chests.TRAP_CHANCE_DENOMINATOR)
|
||||
return {
|
||||
locked = locked,
|
||||
trapped = trapped,
|
||||
opened = false,
|
||||
loot = rollLoot(rng, depth),
|
||||
}
|
||||
end
|
||||
|
||||
-- A chest still worth forcing: locked and not already emptied. An opened
|
||||
-- chest reports unlocked regardless of its own `locked` flag, since nothing
|
||||
-- is left inside to gate.
|
||||
function Chests.isLocked(chest)
|
||||
return chest.locked and not chest.opened
|
||||
end
|
||||
|
||||
-- Odds of a lockpick attempt succeeding: a flat coin flip that improves
|
||||
-- slightly with player level, capped a point shy of the denominator so a
|
||||
-- pick attempt is never a sure thing no matter how experienced the player
|
||||
-- gets - mirrors disarm.lua's Disarm.oddsFor.
|
||||
function Chests.oddsFor(player)
|
||||
local numerator = Chests.PICK_BASE_NUMERATOR + player.level // Chests.PICK_LEVEL_BONUS_DIVISOR
|
||||
if numerator > Chests.PICK_BASE_DENOMINATOR - 1 then
|
||||
numerator = Chests.PICK_BASE_DENOMINATOR - 1
|
||||
end
|
||||
return numerator, Chests.PICK_BASE_DENOMINATOR
|
||||
end
|
||||
|
||||
-- Pours every item in `chest`'s hoard into `player`'s inventory and marks
|
||||
-- the chest spent for good.
|
||||
local function grant(player, chest)
|
||||
for _, item in ipairs(chest.loot) do
|
||||
player.inventory[#player.inventory + 1] = item
|
||||
end
|
||||
chest.opened = true
|
||||
chest.loot = {}
|
||||
end
|
||||
|
||||
-- Opens an already-unlocked (or never-locked) chest outright. Refuses with
|
||||
-- (false, line) and no state change against one still locked or already
|
||||
-- emptied - the caller (Game:openChest) routes a locked chest through
|
||||
-- Chests.unlockWithKey or Chests.pick instead.
|
||||
function Chests.open(player, chest)
|
||||
if chest.opened then return false, "this chest is already empty" end
|
||||
if Chests.isLocked(chest) then return false, "the chest is locked" end
|
||||
grant(player, chest)
|
||||
return true, "you open the chest and take its hoard"
|
||||
end
|
||||
|
||||
-- Spends a key from `player`'s inventory to force the lock outright, no
|
||||
-- roll involved - the same certain trade a locked door already makes for
|
||||
-- one of Items.KEY. Returns (false, line) with nothing spent if the player
|
||||
-- is not carrying one, or against a chest that is not actually locked.
|
||||
function Chests.unlockWithKey(player, chest)
|
||||
if not Chests.isLocked(chest) then return false, "this chest has no lock to open" end
|
||||
for i, item in ipairs(player.inventory) do
|
||||
if item.kind == Items.KEY then
|
||||
table.remove(player.inventory, i)
|
||||
chest.locked = false
|
||||
grant(player, chest)
|
||||
return true, "you unlock the chest with your key"
|
||||
end
|
||||
end
|
||||
return false, "you have no key"
|
||||
end
|
||||
|
||||
-- A trapped chest's punishment for a failed pick: a dart to the ribs or a
|
||||
-- dose of poison, the same two effects a floor trap can spring (see
|
||||
-- game.lua's Game:springTrap) but resolved locally since a chest is not a
|
||||
-- level.traps entry. Spends the trap the instant it springs - a chest only
|
||||
-- ever bites back once.
|
||||
function Chests.springTrap(game, chest)
|
||||
chest.trapped = false
|
||||
local player = game.player
|
||||
if game.rng:chance(1, 2) then
|
||||
local dealt = game.rng:range(Chests.TRAP_DART_MIN, Chests.TRAP_DART_MAX)
|
||||
player.hp = player.hp - dealt
|
||||
return string.format("a needle trap jabs you for %d", dealt)
|
||||
end
|
||||
Status.apply(player, Status.POISON, 5, 2)
|
||||
return "a cloud of poison bursts from the lock"
|
||||
end
|
||||
|
||||
-- Gambles a lockpick against `chest`'s lock. Always spends the pick, win or
|
||||
-- lose, once one is found. On success the chest opens exactly like
|
||||
-- Chests.unlockWithKey; on failure a trapped chest springs on the player
|
||||
-- (Chests.springTrap) while an untrapped one merely wastes the pick, the
|
||||
-- lock still standing for a later key or another pick.
|
||||
function Chests.pick(game, chest)
|
||||
local player = game.player
|
||||
if not Chests.isLocked(chest) then return false, "this chest has no lock to pick" end
|
||||
local pickIndex
|
||||
for i, item in ipairs(player.inventory) do
|
||||
if item.kind == Items.LOCKPICK then pickIndex = i; break end
|
||||
end
|
||||
if not pickIndex then return false, "you have no lockpicks" end
|
||||
table.remove(player.inventory, pickIndex)
|
||||
local numerator, denominator = Chests.oddsFor(player)
|
||||
if not game.rng:chance(numerator, denominator) then
|
||||
if chest.trapped then return false, Chests.springTrap(game, chest) end
|
||||
return false, "your lockpick slips and the lock holds"
|
||||
end
|
||||
chest.locked = false
|
||||
grant(player, chest)
|
||||
return true, "your lockpick clicks the lock open"
|
||||
end
|
||||
|
||||
return Chests
|
||||
Reference in new issue
Block a user