From e92b6d844f2982e62340d5b547ff95beb5402e9b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:13:32 +0200 Subject: [PATCH] feat: add Hazards.at and Hazards.spawn --- src/hazards.lua | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/hazards.lua diff --git a/src/hazards.lua b/src/hazards.lua new file mode 100644 index 0000000..b7a9c7b --- /dev/null +++ b/src/hazards.lua @@ -0,0 +1,114 @@ +-- Timed area hazards that live on the map itself rather than on an entity: +-- fire and caustic gas. Both linger on their cell, hurt and afflict whoever +-- ends a turn standing there, and can spread into open neighbouring floor +-- before burning or blowing themselves out. Fire is doused outright by water. +-- Hazards are plain data on level.hazards so they serialize like floor items +-- and traps do, with no special save-format handling. + +local Tiles = require("tiles") +local Status = require("status") +local Resistances = require("resistances") +local Conductivity = require("conductivity") + +local Hazards = {} + +Hazards.FIRE = "fire" +Hazards.GAS = "gas" + +local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- First hazard occupying (x, y), optionally filtered to a specific kind so +-- fire and gas can coexist on the same cell without merging into each other. +function Hazards.at(level, x, y, kind) + for _, hz in ipairs(level.hazards) do + if hz.x == x and hz.y == y and (not kind or hz.kind == kind) then return hz end + end + return nil +end + +-- Drops a hazard on (x, y), or refreshes one already there of the same kind +-- to whichever of the two durations and potencies is stronger. Walls never +-- catch fire or hold gas. +function Hazards.spawn(level, x, y, kind, duration, potency) + if not level.map:inBounds(x, y) then return nil end + if level.map:get(x, y) == Tiles.WALL then return nil end + local existing = Hazards.at(level, x, y, kind) + if existing then + existing.duration = math.max(existing.duration, duration) + existing.potency = math.max(existing.potency, potency) + return existing + end + local hz = { x = x, y = y, kind = kind, duration = duration, potency = potency } + level.hazards[#level.hazards + 1] = hz + return hz +end + +function Hazards.ignite(level, x, y, duration, potency) + return Hazards.spawn(level, x, y, Hazards.FIRE, duration or 3, potency or 2) +end + +function Hazards.spawnGas(level, x, y, duration, potency) + return Hazards.spawn(level, x, y, Hazards.GAS, duration or 4, potency or 2) +end + +-- Applies whichever hazard occupies entity's current cell, mirroring how +-- Status.tick afflicts the same actor once per turn. Fire and gas both deal +-- their potency in direct damage and lay down a fading status effect on top, +-- so stepping out of the hazard still leaves a lingering burn or sickness. +function Hazards.afflict(level, entity) + local hz = Hazards.at(level, entity.x, entity.y) + if not hz then return nil end + if hz.kind == Hazards.FIRE then + local dealt = Conductivity.applyFire(entity, hz.potency) + entity.hp = entity.hp - dealt + Status.apply(entity, Status.BURNING, 2, math.max(1, hz.potency // 2)) + if Conductivity.isWet(entity) then + return string.format("flames hiss against %s's soaked skin for %d", entity.name, dealt) + end + return string.format("flames engulf %s for %d", entity.name, dealt) + end + local dealt = Resistances.apply(entity, hz.potency, Resistances.POISON) + entity.hp = entity.hp - dealt + Status.apply(entity, Status.POISON, 2, math.max(1, hz.potency // 2)) + return string.format("choking fumes sicken %s for %d", entity.name, dealt) +end + +local function canSpreadInto(level, x, y) + if not level.map:inBounds(x, y) then return false end + local tile = level.map:get(x, y) + return Tiles.isWalkable(tile) and tile ~= Tiles.WATER +end + +-- Advances every hazard one environmental step: each has an even chance to +-- bloom into an open neighbouring cell that isn't already burning or gassed +-- the same way, water douses fire outright, and every hazard's clock counts +-- down until it burns or blows itself out. Called once per world round (not +-- per actor) so a seeded run always spreads identically regardless of how +-- many actors acted that round. +function Hazards.step(level, rng) + local spawns = {} + for _, hz in ipairs(level.hazards) do + for _, dir in ipairs(DIRS) do + local nx, ny = hz.x + dir[1], hz.y + dir[2] + if canSpreadInto(level, nx, ny) and not Hazards.at(level, nx, ny, hz.kind) + and rng:chance(1, 2) then + spawns[#spawns + 1] = { nx, ny, hz.kind, hz.duration, hz.potency } + end + end + end + for _, s in ipairs(spawns) do + Hazards.spawn(level, s[1], s[2], s[3], s[4], s[5]) + end + + local kept = {} + for _, hz in ipairs(level.hazards) do + local doused = hz.kind == Hazards.FIRE and level.map:get(hz.x, hz.y) == Tiles.WATER + hz.duration = hz.duration - 1 + if hz.duration > 0 and not doused then + kept[#kept + 1] = hz + end + end + level.hazards = kept +end + +return Hazards