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