diff --git a/src/streaks.lua b/src/streaks.lua new file mode 100644 index 0000000..42f3660 --- /dev/null +++ b/src/streaks.lua @@ -0,0 +1,57 @@ +-- Win/loss streak tracking over meta.lua's own run history: not "how many +-- wins in total" (Meta.recordRun's own totalRuns/wins tally already answers +-- that) but how many of the *most recent* runs in a row ended the same +-- way - the number that actually swings the instant a lucky or unlucky run +-- lands, unlike a lifetime win rate that a hundred prior runs already numbs. + +local Streaks = {} + +Streaks.HISTORY_CAP = 50 + +function Streaks.new() + return {} +end + +-- Appends `won` (a boolean) as the newest entry, trimming the oldest once +-- history grows past Streaks.HISTORY_CAP - a streak only ever needs to look +-- back so far, and an unbounded history would grow meta.lua's save file +-- forever for no further benefit. +function Streaks.record(history, won) + history[#history + 1] = won + while #history > Streaks.HISTORY_CAP do + table.remove(history, 1) + end +end + +-- Length of the run of identical outcomes ending at the most recent entry, +-- and which outcome that is - (0, nil) for an empty history. A single win +-- right after a loss reads as a streak of exactly 1, not 0: the run just +-- recorded is itself part of its own streak. +function Streaks.current(history) + if #history == 0 then return 0, nil end + local outcome = history[#history] + local length = 0 + for i = #history, 1, -1 do + if history[i] ~= outcome then break end + length = length + 1 + end + return length, outcome +end + +-- The longest run of consecutive wins anywhere in history, not just the one +-- still active - a player's all-time best winning streak survives even +-- after it eventually breaks. +function Streaks.bestWinStreak(history) + local best, running = 0, 0 + for _, won in ipairs(history) do + if won then + running = running + 1 + if running > best then best = running end + else + running = 0 + end + end + return best +end + +return Streaks diff --git a/src/terrain.lua b/src/terrain.lua new file mode 100644 index 0000000..1c16dea --- /dev/null +++ b/src/terrain.lua @@ -0,0 +1,49 @@ +-- Permanent map terrain that punishes careless movement, as opposed to +-- hazards.lua's fire and gas which are timed and spawned mid-game. Lava +-- scorches whoever stands on it every time they step there, a chasm drops +-- the player to the floor below (or swallows a monster outright), and ice +-- keeps sliding a traveller in the direction they started moving until +-- something stops them. None of it spreads, fades, or needs saving beyond +-- the glyph already baked into the map by the builder. + +local Tiles = require("tiles") +local Status = require("status") +local Resistances = require("resistances") + +local Terrain = {} + +Terrain.LAVA_DAMAGE = 8 + +-- Slides entity through consecutive ice tiles in direction (dx, dy), from +-- wherever it just came to rest. Stops the instant the current tile isn't +-- ice, the next cell is unwalkable, or another actor already occupies it - +-- so ice never carries a traveller through a wall or on top of someone else. +function Terrain.slide(level, entity, dx, dy) + local map = level.map + while map:get(entity.x, entity.y) == Tiles.ICE do + local nx, ny = entity.x + dx, entity.y + dy + if not map:isWalkable(nx, ny) then break end + if level:entityAt(nx, ny) then break end + entity.x, entity.y = nx, ny + end +end + +-- Applies lava's burn to whoever is currently standing on it. Returns a log +-- line, or nil when the tile underfoot isn't lava. +function Terrain.afflictLava(level, entity) + if level.map:get(entity.x, entity.y) ~= Tiles.LAVA then return nil end + local dealt = Resistances.apply(entity, Terrain.LAVA_DAMAGE, Resistances.FIRE) + entity.hp = entity.hp - dealt + if dealt <= 0 then + return entity.kind == "player" and "you wade through the lava unharmed" + or string.format("%s wades through the lava unharmed", entity.name) + end + Status.apply(entity, Status.BURNING, 2, math.max(1, dealt // 3)) + return string.format("molten rock sears %s for %d", entity.name, dealt) +end + +function Terrain.isChasm(level, entity) + return level.map:get(entity.x, entity.y) == Tiles.CHASM +end + +return Terrain diff --git a/src/throwing.lua b/src/throwing.lua new file mode 100644 index 0000000..93eb7ca --- /dev/null +++ b/src/throwing.lua @@ -0,0 +1,85 @@ +-- Player ranged option that costs no mana: any spare weapon or potion sitting +-- in the inventory (not the one currently wielded) can be hurled at a monster +-- in range and line of sight. A thrown weapon hits harder than a melee swing +-- and lands where it struck, ready to be picked back up; a thrown potion +-- shatters and visits its effect on the target instead of the thrower, which +-- only makes sense for potions with a hostile reading. + +local Los = require("los") +local Status = require("status") +local Items = require("items") +local Hazards = require("hazards") +local Resistances = require("resistances") + +local Throwing = {} + +Throwing.RANGE = 6 +local POWER_MULT = 2 + +-- Hostile reading of each potion effect. Potions with no meaningful effect on +-- an enemy (healing, strength) just shatter harmlessly rather than helping +-- whoever they were thrown at. +local hostileEffects = { + poison = function(game, target) + Status.apply(target, Status.POISON, 6, 3) + Hazards.spawnGas(game.level, target.x, target.y, 4, 2) + return string.format("the potion shatters, poisoning %s in a cloud of fumes", target.name) + end, + haste = function(_, target) + Status.apply(target, Status.HASTE, 10, 1) + return string.format("the potion shatters, and %s bolts away", target.name) + end, + confuse = function(_, target) + Status.apply(target, Status.CONFUSE, 6, 1) + return string.format("the potion shatters, and %s reels in confusion", target.name) + end, + blind = function(_, target) + Status.apply(target, Status.BLIND, 6, 1) + return string.format("the potion shatters in a blinding flash, and %s is struck blind", target.name) + end, + -- Same hostile reading as haste above: lifting a dangerous target off the + -- ground lets it drift clear of whatever lava, chasm, or trap the thrower + -- was counting on to block or punish it (see status.lua's Status.LEVITATE), + -- so handing it to an enemy is a liability, not a gift. + levitate = function(_, target) + Status.apply(target, Status.LEVITATE, 10, 1) + return string.format("the potion shatters, and %s rises off the ground", target.name) + end, +} + +function Throwing.canThrow(item) + return item.kind == Items.WEAPON or item.kind == Items.POTION +end + +function Throwing.canReach(game, x1, y1, x2, y2) + local dist = math.max(math.abs(x1 - x2), math.abs(y1 - y2)) + return dist <= Throwing.RANGE and Los.clear(game.level.map, x1, y1, x2, y2) +end + +-- Resolves a thrown item against a target. Returns (true, lines) if the item +-- left the thrower's hand - whether or not it did anything useful on arrival +-- - or (false, lines) if the throw never happened at all. +function Throwing.resolve(game, thrower, item, target) + if not Throwing.canThrow(item) then + return false, { "that can't be thrown" } + end + if not target or not target:isAlive() then + return false, { "no target" } + end + if not Throwing.canReach(game, thrower.x, thrower.y, target.x, target.y) then + return false, { "you have no clear throw" } + end + if item.kind == Items.WEAPON then + local dmg = Resistances.apply(target, item.power * POWER_MULT, item.damageType or Resistances.PHYSICAL) + target.hp = target.hp - dmg + local lines = { string.format("your thrown %s hits %s for %d", item.name, target.name, dmg) } + game.level.floorItems[#game.level.floorItems + 1] = { x = target.x, y = target.y, item = item } + if not target:isAlive() then game:awardKill(target) end + return true, lines + end + local handler = hostileEffects[item.effect] + local line = handler and handler(game, target) or "the potion shatters harmlessly" + return true, { line } +end + +return Throwing