diff --git a/engine/core/level.lua b/engine/core/level.lua index 211536c6..4bde8ae5 100644 --- a/engine/core/level.lua +++ b/engine/core/level.lua @@ -513,9 +513,12 @@ function Level:updateOpacityCache(x, y) break end + local currentValue = self.opacityCache:get(x, y) opaque = opaque or self.map.opacityCache:get(x, y) - self.opacityCache:set(x, y, opaque) - self.systemManager:afterOpacityChanged(self, x, y) + if currentValue ~= opaque then + self.opacityCache:set(x, y, opaque) + self.systemManager:afterOpacityChanged(self, x, y) + end end --- Finds a path between two positions. @@ -608,4 +611,11 @@ function Level:__finalize() end end +--- Returns the width and height of the level. +--- @return integer width +--- @return integer height +function Level:getSize() + return self.map.w, self.map.h +end + return Level diff --git a/engine/math/color.lua b/engine/math/color.lua index ae4216d0..d04e2d4c 100644 --- a/engine/math/color.lua +++ b/engine/math/color.lua @@ -1,26 +1,31 @@ local bit = require("bit") + +--- A color with red, green, blue, and alpha components. --- @class Color4 : Object ---- @field r number The red component (0-1). ---- @field g number The green component (0-1). ---- @field b number The blue component (0-1). ---- @field a number The alpha component (0-1). +--- @field r number The red component (0–1). +--- @field g number The green component (0–1). +--- @field b number The blue component (0–1). +--- @field a number The alpha component (0–1). --- @overload fun(r?: number, g?: number, b?: number, a?: number): Color4 local Color4 = prism.Object:extend("Color4") ---- Constructor for Color4 accepts red, green, blue, and alpha values. All default to 0, alpha to 1. ---- @param r number The red component (0-1). ---- @param g number The green component (0-1). ---- @param b number The blue component (0-1). ---- @param a number The alpha component (0-1). +--- Creates a new Color4. +--- @param r number? Red component (0–1) +--- @param g number? Green component (0–1) +--- @param b number? Blue component (0–1) +--- @param a number? Alpha component (0–1), defaults to 1 function Color4:__new(r, g, b, a) self.r = r or 0 self.g = g or 0 self.b = b or 0 - self.a = a or 1 -- Default alpha to 1 (fully opaque) + self.a = a or 1 end ---- Constructor for Color4 that accepts a hexadecimal number. ---- @param hex number A hex number representing a color, e.g. 0xFFFFFF. Alpha is optional and defaults to 1. +--- Creates a Color4 from a hexadecimal color value. +--- Accepts RGB (0xRRGGBB) or RGBA (0xRRGGBBAA). +--- Allocates a new Color4. +--- @param hex number Hexadecimal color value +--- @return Color4 function Color4.fromHex(hex) local hasAlpha = #string.format("%x", hex) > 6 @@ -32,109 +37,228 @@ function Color4.fromHex(hex) return Color4(r, g, b, hasAlpha and a or 1) end ---- Returns a copy of the color. ---- @param out Color4? ---- @return Color4 out A copy of the color. -function Color4:copy(out) - local out = out or Color4() - out:compose(self.r, self.g, self.b, self.a) +--- Copies a color. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 Source color +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.copy(a, out) + out = out or Color4() + out.r, out.g, out.b, out.a = a.r, a.g, a.b, a.a + return out +end + +--- Writes components directly into an existing color. +--- Does not allocate. +--- @param out Color4 Destination color +--- @param r number +--- @param g number +--- @param b number +--- @param a number +--- @return Color4 out +function Color4.compose(out, r, g, b, a) + out.r, out.g, out.b, out.a = r, g, b, a + return out +end + +--- Adds two colors component-wise. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param b Color4 +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.add(a, b, out) + out = out or Color4() + out.r = a.r + b.r + out.g = a.g + b.g + out.b = a.b + b.b + out.a = a.a + b.a + return out +end + +--- Subtracts one color from another component-wise. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param b Color4 +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.sub(a, b, out) + out = out or Color4() + out.r = a.r - b.r + out.g = a.g - b.g + out.b = a.b - b.b + out.a = a.a - b.a + return out +end + +--- Multiplies a color by a scalar. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param s number Scalar value +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.mul(a, s, out) + out = out or Color4() + out.r = a.r * s + out.g = a.g * s + out.b = a.b * s + out.a = a.a * s + return out +end + +--- Divides a color by a scalar. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param s number Scalar divisor +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.div(a, s, out) + out = out or Color4() + out.r = a.r / s + out.g = a.g / s + out.b = a.b / s + out.a = a.a / s + return out +end + +--- Negates all components of a color. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.neg(a, out) + out = out or Color4() + out.r = -a.r + out.g = -a.g + out.b = -a.b + out.a = -a.a return out end --- Linearly interpolates between two colors. ---- @param target Color4 The target color. ---- @param t number A value between 0 and 1, where 0 is this color and 1 is the target color. ---- @return Color4 The interpolated color. -function Color4:lerp(target, t) - return Color4( - self.r + (target.r - self.r) * t, - self.g + (target.g - self.g) * t, - self.b + (target.b - self.b) * t, - self.a + (target.a - self.a) * t - ) -end - ---- Multiplies the color's components by a scalar. ---- @param scalar number The scalar value. ---- @return Color4 The scaled color. -function Color4.__mul(self, scalar) - return Color4(self.r * scalar, self.g * scalar, self.b * scalar, self.a * scalar) -end - ---- Adds two colors together. ---- @param a Color4 The first color. ---- @param b Color4 The second color. ---- @return Color4 The sum of the two colors. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 Start color +--- @param b Color4 End color +--- @param t number Interpolation factor (0–1) +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.lerp(a, b, t, out) + out = out or Color4() + out.r = a.r + (b.r - a.r) * t + out.g = a.g + (b.g - a.g) * t + out.b = a.b + (b.b - a.b) * t + out.a = a.a + (b.a - a.a) * t + return out +end + +--- Clamps all components of a color to the range [0, 1]. +--- Allocates a new Color4 if `out` is nil. +--- @param a Color4 +--- @param out Color4? Optional output color +--- @return Color4 out +function Color4.clamp(a, out) + out = out or Color4() + out.r = math.min(1, math.max(0, a.r)) + out.g = math.min(1, math.max(0, a.g)) + out.b = math.min(1, math.max(0, a.b)) + out.a = math.min(1, math.max(0, a.a)) + return out +end + +--- Adds two colors. +--- Always allocates a new Color4. +--- @param a Color4 +--- @param b Color4 +--- @return Color4 function Color4.__add(a, b) - return Color4(a.r + b.r, a.g + b.g, a.b + b.b, a.a + b.a) + return Color4.add(a, b) end ---- Subtracts one color from another. ---- @param a Color4 The first color. ---- @param b Color4 The second color. ---- @return Color4 The difference of the two colors. +--- Subtracts two colors. +--- Always allocates a new Color4. +--- @param a Color4 +--- @param b Color4 +--- @return Color4 function Color4.__sub(a, b) - return Color4(a.r - b.r, a.g - b.g, a.b - b.b, a.a - b.a) + return Color4.sub(a, b) +end + +--- Multiplies a color by a scalar. +--- Always allocates a new Color4. +--- @param a Color4 +--- @param s number +--- @return Color4 +function Color4.__mul(a, s) + return Color4.mul(a, s) end ---- Negates the color's components. ---- @return Color4 The negated color. -function Color4.__unm(self) - return Color4(-self.r, -self.g, -self.b, -self.a) +--- Divides a color by a scalar. +--- Always allocates a new Color4. +--- @param a Color4 +--- @param s number +--- @return Color4 +function Color4.__div(a, s) + return Color4.div(a, s) end ---- Checks equality between two colors. ---- @param a Color4 The first color. ---- @param b Color4 The second color. ---- @return boolean True if the colors are equal, false otherwise. +--- Negates a color. +--- Always allocates a new Color4. +--- @param a Color4 +--- @return Color4 +function Color4.__unm(a) + return Color4.neg(a) +end + +--- Checks component-wise equality. +--- @param a Color4 +--- @param b Color4 +--- @return boolean function Color4.__eq(a, b) return a.r == b.r and a.g == b.g and a.b == b.b and a.a == b.a end ---- Creates a string representation of the color. ---- @return string The string representation. -function Color4:__tostring() - return string.format("r: %.2f, g: %.2f, b: %.2f, a: %.2f", self.r, self.g, self.b, self.a) +--- Returns the average of the RGB components. +--- Does not allocate. +--- @return number +function Color4:average() + return (self.r + self.g + self.b) / 3 end ---- Returns the components of the color as numbers. ---- @return number r, number g, number b, number a The components of the color. +--- Returns the components as separate values. +--- Does not allocate. +--- @return number r +--- @return number g +--- @return number b +--- @return number a function Color4:decompose() return self.r, self.g, self.b, self.a end -function Color4:compose(r, g, b, a) - self.r, self.g, self.b, self.a = r, g, b, a -end - ---- Clamps the components of the color between 0 and 1. ---- @return Color4 The clamped color. -function Color4:clamp() - return Color4( - math.min(1, math.max(0, self.r)), - math.min(1, math.max(0, self.g)), - math.min(1, math.max(0, self.b)), - math.min(1, math.max(0, self.a)) - ) -end - ---- PICO-8 palette -Color4.BLACK = Color4(0, 0, 0, 1) -Color4.WHITE = Color4.fromHex(0xFFF1E8) -Color4.RED = Color4.fromHex(0xFF004D) -Color4.GREEN = Color4.fromHex(0x008751) -Color4.LIME = Color4.fromHex(0x00E436) -Color4.BLUE = Color4.fromHex(0x29ADFF) -Color4.NAVY = Color4.fromHex(0x1D2B53) -Color4.PURPLE = Color4.fromHex(0x7E2553) -Color4.BROWN = Color4.fromHex(0xAB5236) -Color4.DARKGREY = Color4.fromHex(0x5F574F) -Color4.GREY = Color4.fromHex(0xC2C3C7) -Color4.YELLOW = Color4.fromHex(0xFFEC27) -Color4.ORANGE = Color4.fromHex(0xFFA300) -Color4.PINK = Color4.fromHex(0xFF77A8) -Color4.LAVENDER = Color4.fromHex(0x83769C) -Color4.PEACH = Color4.fromHex(0xFFCCAA) +--- Returns a human-readable string representation. +--- Does not allocate a Color4. +--- @return string +function Color4:__tostring() + return string.format("r: %.2f, g: %.2f, b: %.2f, a: %.2f", self.r, self.g, self.b, self.a) +end + +-- stylua: ignore start +Color4.BLACK = Color4(0, 0, 0, 1) +Color4.WHITE = Color4.fromHex(0xFFF1E8) +Color4.RED = Color4.fromHex(0xFF004D) +Color4.GREEN = Color4.fromHex(0x008751) +Color4.LIME = Color4.fromHex(0x00E436) +Color4.BLUE = Color4.fromHex(0x29ADFF) +Color4.NAVY = Color4.fromHex(0x1D2B53) +Color4.PURPLE = Color4.fromHex(0x7E2553) +Color4.BROWN = Color4.fromHex(0xAB5236) +Color4.DARKGREY = Color4.fromHex(0x5F574F) +Color4.GREY = Color4.fromHex(0xC2C3C7) +Color4.YELLOW = Color4.fromHex(0xFFEC27) +Color4.ORANGE = Color4.fromHex(0xFFA300) +Color4.PINK = Color4.fromHex(0xFF77A8) +Color4.LAVENDER = Color4.fromHex(0x83769C) +Color4.PEACH = Color4.fromHex(0xFFCCAA) Color4.TRANSPARENT = Color4(0, 0, 0, 0) +-- stylua: ignore end return Color4 diff --git a/engine/structures/grid.lua b/engine/structures/grid.lua index 6c373b1c..40e371bf 100644 --- a/engine/structures/grid.lua +++ b/engine/structures/grid.lua @@ -3,7 +3,7 @@ --- @field w integer The width of the grid. --- @field h integer The height of the grid. --- @field data any[] The data stored in the grid. ---- @overload fun(w: integer, h: integer, initialValue: any?): Grid +--- @overload fun(w: integer, h: integer, initialValue: any?): Grid local Grid = prism.Object:extend("Grid") --- The constructor for the 'Grid' class. @@ -86,6 +86,10 @@ function Grid:fill(value) end end +function Grid:clear() + self.data = {} +end + --- Iterates over each cell in the grid, yielding x, y, and the value. --- @generic T --- @return fun(): number, number, T -- An iterator returning x, y, and value for each cell. diff --git a/extra/lighting/components/light.lua b/extra/lighting/components/light.lua new file mode 100644 index 00000000..fc0a3c0d --- /dev/null +++ b/extra/lighting/components/light.lua @@ -0,0 +1,22 @@ +--- A light to emit from an entity. +--- @class Light : Component +--- @field private color Color4 +--- @overload fun(color: Color4, radius: integer, effect?: LightEffect): Light +local Light = prism.Component:extend "Light" + +function Light:__new(color, radius, effect) + self.color = color + self.radius = radius + self.lightEffect = effect +end + +function Light:attenuate(distance) + local atten = math.max(0, (self.radius - distance) / self.radius) + return atten * atten +end + +function Light:getColor() + return self.color +end + +return Light diff --git a/extra/lighting/components/lightsight.lua b/extra/lighting/components/lightsight.lua new file mode 100644 index 00000000..03085d3e --- /dev/null +++ b/extra/lighting/components/lightsight.lua @@ -0,0 +1,14 @@ +--- @class LightSightOptions : SightOptions +--- @field darkvision number The light level an entity can see in. + +--- An extension of sight to represent an actor sensing via light. +--- @class LightSight : Sight +local LightSight = prism.components.Sight:extend("LightSight") + +--- @param options LightSightOptions +function LightSight:__new(options) + self.super.__new(self, options) + self.darkvision = options.darkvision +end + +return LightSight diff --git a/extra/lighting/lightbuffer.lua b/extra/lighting/lightbuffer.lua new file mode 100644 index 00000000..ac1db53a --- /dev/null +++ b/extra/lighting/lightbuffer.lua @@ -0,0 +1,64 @@ +--- @class LightBuffer +--- @field effect? LightEffect +--- @field private grid SparseGrid +--- @field private minX integer? +--- @field private minY integer? +--- @field private maxX integer? +--- @field private maxY integer? +local LightBuffer = prism.Object:extend "LightBuffer" + +function LightBuffer:__new(color, effect) + self.grid = prism.SparseGrid() + + self.color = color + self.effect = effect + + -- bounding box (nil until first write) + self.minX = nil + self.minY = nil + self.maxX = nil + self.maxY = nil +end + +--- @param x integer +--- @param y integer +--- @return integer +function LightBuffer:get(x, y) + return self.grid:get(x, y) +end + +--- @param x integer +--- @param y integer +--- @param luminance integer +function LightBuffer:set(x, y, luminance) + self.grid:set(x, y, luminance) + + if not self.minX then + -- first write initializes bounds + self.minX = x + self.maxX = x + self.minY = y + self.maxY = y + return + end + + if x < self.minX then self.minX = x end + if x > self.maxX then self.maxX = x end + if y < self.minY then self.minY = y end + if y > self.maxY then self.maxY = y end +end + +--- @return integer?, integer?, integer?, integer? +function LightBuffer:getBounds() + return self.minX, self.minY, self.maxX, self.maxY +end + +function LightBuffer:clear() + self.grid:clear() + self.minX = nil + self.minY = nil + self.maxX = nil + self.maxY = nil +end + +return LightBuffer diff --git a/extra/lighting/lighteffect.lua b/extra/lighting/lighteffect.lua new file mode 100644 index 00000000..7b18c643 --- /dev/null +++ b/extra/lighting/lighteffect.lua @@ -0,0 +1,11 @@ +--- @class LightEffect: Object +local LightEffect = prism.Object:extend "LightEffect" + +--- @param time number +--- @param color Color4 +--- @return Color4 color +function LightEffect:effect(time, color) + return color -- OVERRIDE ME! +end + +return LightEffect diff --git a/extra/lighting/lighteffects/flicker.lua b/extra/lighting/lighteffects/flicker.lua new file mode 100644 index 00000000..0e858455 --- /dev/null +++ b/extra/lighting/lighteffects/flicker.lua @@ -0,0 +1,40 @@ +--- @class FlickerEffectOptions +--- @field baseIntensity? number +--- @field speed? number +--- @field flickerAmplitude? number +--- @field colorShift? number + +--- An effect that makes the light flicker, reminiscent of a torch. +--- @class FlickerEffect : LightEffect +--- @overload fun(options?: FlickerEffectOptions): FlickerEffect +local FlickerEffect = prism.lighting.LightEffect:extend "FlickerEffect" + +--- @param opts FlickerEffectOptions +function FlickerEffect:__new(opts) + opts = opts or {} + + self.flickerAmplitude = opts.flickerAmplitude or 0.3 + self.baseIntensity = 1 - self.flickerAmplitude + self.colorShift = opts.colorShift or 0.2 + self.speed = opts.speed or 3 +end + +--- @param time number +--- @param color Color4 +--- @return Color4 +function FlickerEffect:effect(time, color) + local flicker = love.math.noise(time * self.speed) + + local intensity = self.baseIntensity + flicker * self.flickerAmplitude + + local warm = flicker * self.colorShift + + return prism.Color4( + math.max(0, color.r * intensity + warm), + math.max(0, color.g * intensity), + math.max(0, color.b * intensity - warm), + color.a + ) +end + +return FlickerEffect diff --git a/extra/lighting/lighteffects/heartbeat.lua b/extra/lighting/lighteffects/heartbeat.lua new file mode 100644 index 00000000..6e2ec33d --- /dev/null +++ b/extra/lighting/lighteffects/heartbeat.lua @@ -0,0 +1,59 @@ +--- @class HeartbeatEffectOptions +--- @field bpm? number +--- @field amplitude? number +--- @field bias? number +--- @field sharpness? number + +--- A light effect that pulses the light like a heartbeat. +--- @class HeartbeatEffect : LightEffect +--- @overload fun(options?: HeartbeatEffectOptions): HeartbeatEffect +local HeartbeatEffect = prism.lighting.LightEffect:extend "HeartbeatEffect" + +--- @param opts HeartbeatEffectOptions +function HeartbeatEffect:__new(opts) + opts = opts or {} + + -- Beats per minute + self.bpm = opts.bpm or 43 + + -- Intensity of the pulse + self.amplitude = opts.amplitude or 0.17 + + -- Baseline multiplier + self.bias = opts.bias or 1.0 + + -- Controls how sharp the pulse is (higher = snappier) + self.sharpness = opts.sharpness or 6.0 +end + +--- @param time number +--- @param color Color4 +--- @param x integer +--- @param y integer +--- @return Color4 +function HeartbeatEffect:effect(time, color, x, y) + -- Convert BPM to seconds per beat + local period = 60 / self.bpm + + -- Phase in [0, 1) + local t = (time % period) / period + + -- Primary beat (sharp spike) + local beat1 = math.exp(-self.sharpness * (t / 0.15) ^ 2) + + -- Secondary beat (weaker, delayed) + local dt = t - 0.25 + local beat2 = math.exp(-self.sharpness * (dt / 0.12) ^ 2) * 0.5 + + local pulse = beat1 + beat2 + local scale = self.bias + pulse * self.amplitude + + return prism.Color4( + math.max(0, color.r * scale), + math.max(0, color.g * scale), + math.max(0, color.b * scale), + color.a + ) +end + +return HeartbeatEffect diff --git a/extra/lighting/lighteffects/sin.lua b/extra/lighting/lighteffects/sin.lua new file mode 100644 index 00000000..4fe38627 --- /dev/null +++ b/extra/lighting/lighteffects/sin.lua @@ -0,0 +1,52 @@ +--- @class SineEffectOptions +--- @field amplitude? number +--- @field speed? number +--- @field spatialScale? number +--- @field noiseScale? number + +--- An effect that makes the light wax and wane in a smooth wave. +--- @class SineEffect : LightEffect +--- @overload fun(options?: SineEffectOptions) +local SineEffect = prism.lighting.LightEffect:extend "SineEffect" + +--- @param opts SineEffectOptions +function SineEffect:__new(opts) + opts = opts or {} + + self.amplitude = opts.amplitude or 0.2 + self.speed = opts.speed or 2.0 + + -- Frequency of spatial sampling + self.spatialScale = opts.spatialScale or 0.15 + + -- Strength of phase distortion + self.noiseScale = opts.noiseScale or math.pi +end + +--- @param time number +--- @param color Color4 +--- @param x integer +--- @param y integer +--- @return Color4 +function SineEffect:effect(time, color, x, y) + -- Sample smooth spatial noise + local n = love.math.noise(x * self.spatialScale, y * self.spatialScale) + + -- Map noise to phase offset + local phase = n * self.noiseScale + + local s = math.sin(time * self.speed + phase) + + -- Base intensity derived from amplitude + local baseIntensity = 1.0 - self.amplitude + local scale = baseIntensity + s * self.amplitude + + return prism.Color4( + math.max(0, color.r * scale), + math.max(0, color.g * scale), + math.max(0, color.b * scale), + color.a + ) +end + +return SineEffect diff --git a/extra/lighting/lightsample.lua b/extra/lighting/lightsample.lua new file mode 100644 index 00000000..8c304e90 --- /dev/null +++ b/extra/lighting/lightsample.lua @@ -0,0 +1,24 @@ +--- @class LightSample +--- @field x integer +--- @field y integer +--- @field color Color4 +local LightSample = prism.Object:extend "LightSample" + +--- @param x integer +---@param y integer +---@param depth integer +function LightSample:__new(x, y, depth) + assert(x and y and depth) + self.x = x + self.y = y + self.depth = depth +end + +function LightSample:set(x, y, depth) + self.x = x + self.y = y + self.depth = depth + return self +end + +return LightSample diff --git a/extra/lighting/lightsamplepool.lua b/extra/lighting/lightsamplepool.lua new file mode 100644 index 00000000..f0b9393b --- /dev/null +++ b/extra/lighting/lightsamplepool.lua @@ -0,0 +1,23 @@ +--- @class LightSamplePool +--- @field pool LightSample[] +local LightSamplePool = prism.Object:extend "LightSamplePool" + +function LightSamplePool:__new() + self.pool = {} +end + +--- Get a LightSample from the pool +--- @return LightSample +function LightSamplePool:acquire(x, y, depth) + local obj = table.remove(self.pool) + if obj then return obj:set(x, y, depth) end + + return prism.lighting.LightSample(x, y, depth) +end + +--- Return a LightSample to the pool +function LightSamplePool:release(obj) + table.insert(self.pool, obj) +end + +return LightSamplePool diff --git a/extra/lighting/module.lua b/extra/lighting/module.lua new file mode 100644 index 00000000..b4cae9d1 --- /dev/null +++ b/extra/lighting/module.lua @@ -0,0 +1,15 @@ +local path = ... +local basePath = path:match("^(.*)%.") or "" + +prism.lighting = {} + +--- @module "extra.lighting.lightsample" +prism.lighting.LightSample = require(basePath .. ".lightsample") +--- @module "extra.lighting.lightsamplepool" +prism.lighting.LightSamplePool = require(basePath .. ".lightsamplepool") +--- @module "extra.lighting.lightbuffer" +prism.lighting.LightBuffer = require(basePath .. ".lightbuffer") +--- @module "extra.lighting.lighteffect" +prism.lighting.LightEffect = require(basePath .. ".lighteffect") + +prism.registerRegistry("lighteffects", prism.lighting.LightEffect) diff --git a/extra/lighting/passes/sightlightpass.lua b/extra/lighting/passes/sightlightpass.lua new file mode 100644 index 00000000..f5f9161d --- /dev/null +++ b/extra/lighting/passes/sightlightpass.lua @@ -0,0 +1,67 @@ +--- A display pass that modifies the colour of every cell/actor based on the perspective of a player actor. +--- @class SightLightPass : DisplayPass +--- @overload fun(lightSystem: LightSystem): SightLightPass +local SightLightPass = spectrum.DisplayPass:extend "SightLightPass" + +--- @param lightSystem LightSystem +function SightLightPass:__new(lightSystem) + self.lightSystem = lightSystem +end + +--- @param player Actor +function SightLightPass:setPlayer(player) + self.player = player +end + +local dummy = prism.Color4() +function SightLightPass:run(entity, x, y, drawable) + local sight = self.player:get(prism.components.Sight) + local darkvision = sight and sight.darkvision or 0 + + local light = self.lightSystem:getRTValuePerspective(x, y, self.player) + light = light or dummy + + -- Preserve original color + local base = drawable.color:copy() + local baseBackground = drawable.background:copy() + + -- Apply lighting normally + if prism.Actor:is(entity) then + local value = math.min(light:average(), 1) + drawable.color = drawable.color * value + drawable.background = drawable.background * value + else + drawable.color.r = drawable.color.r * light.r + drawable.color.g = drawable.color.g * light.g + drawable.color.b = drawable.color.b * light.b + + drawable.background.r = drawable.background.r * light.r + drawable.background.g = drawable.background.g * light.g + drawable.background.b = drawable.background.b * light.b + end + + -- Linear darkness (no perceptual luminance) + local brightness = drawable.color:average() + local darkness = math.min(math.max(1 - brightness, 0), 1) + darkness = math.max(darkness - darkvision, 0) + + -- Knee at 0.25: everything below stays bright + if darkness <= 0.6 then + darkness = 0 + else + -- Remap [0.25 .. 1] → [0 .. 1] + darkness = (darkness - 0.6) / 0.4 + end + + -- Shape the curve (optional but recommended) + local restore = math.pow(darkness, 1.5) + local alphaLoss = darkness * 0.70 + + -- Lerp back toward base color + drawable.color = drawable.color:lerp(base, restore) + drawable.background = drawable.background:lerp(baseBackground, restore) + -- Fade opacity as darkness increases + drawable.color.a = base.a * (1 - alphaLoss) +end + +return SightLightPass diff --git a/extra/lighting/relations/lights.lua b/extra/lighting/relations/lights.lua new file mode 100644 index 00000000..6f95a497 --- /dev/null +++ b/extra/lighting/relations/lights.lua @@ -0,0 +1,10 @@ +--- Represents an entity lighting another entity. +--- @class LightsRelation : Relation +--- @overload fun(): LightsRelation +local LightsRelation = prism.Relation:extend "LightsRelation" + +function LightsRelation:generateInverse() + return prism.relations.LitByRelation +end + +return LightsRelation diff --git a/extra/lighting/relations/litby.lua b/extra/lighting/relations/litby.lua new file mode 100644 index 00000000..7861688b --- /dev/null +++ b/extra/lighting/relations/litby.lua @@ -0,0 +1,10 @@ +--- Represents an entity being lit by another entity. +--- @class LitByRelation : Relation +--- @overload fun(): LitByRelation +local LitByRelation = prism.Relation:extend "LitByRelation" + +function LitByRelation:generateInverse() + return prism.relations.LightsRelation +end + +return LitByRelation diff --git a/extra/lighting/systems/lightsightsystem.lua b/extra/lighting/systems/lightsightsystem.lua new file mode 100644 index 00000000..033893c5 --- /dev/null +++ b/extra/lighting/systems/lightsightsystem.lua @@ -0,0 +1,58 @@ +local LightSightSystem = prism.systems.SightSystem:extend "LightSightSystem" + +LightSightSystem.DEFAULT_DARKVISION = 2 / 16 + +-- These functions update the fov and visibility of actors on the level. +---@param level Level +---@param actor Actor +function LightSightSystem:onSenses(level, actor) + -- check if actor has a sight component and if not return + local sensesComponent = actor:get(prism.components.Senses) + if not sensesComponent then return end + + local sightComponent = actor:get(prism.components.Sight) + if not sightComponent then return end + + local actorPos = actor:getPosition() + if not actorPos then return end + + local sightLimit = sightComponent.range + -- we check if the sight component has a fov and if so we clear it + if sightComponent.fov then + self.computeFOV(level, sensesComponent, actorPos, sightLimit) + else + -- we have a sight component but no fov which essentially means the actor has blind sight and can see + -- all cells within a certain radius generally only simple actors have this vision type + for x = actorPos.x - sightLimit, actorPos.x + sightLimit do + for y = actorPos.y - sightLimit, actorPos.y + sightLimit do + sensesComponent.cells:set(x, y, level:getCell(x, y)) + end + end + end + + local lightSystem = level:getSystem(prism.systems.LightSystem) + --- @cast lightSystem LightSystem + + local darkvision = sightComponent.darkvision or self.DEFAULT_DARKVISION + local removed = {} + local actorPosition = actor:expectPosition() + local vec = prism.Vector2() + for x, y, cell in sensesComponent.cells:each() do + local value = lightSystem:getValuePerspective(x, y, actor) + local luminance = value and value:average() or 0 + + vec:compose(x, y) + if luminance < darkvision and actorPosition:distanceChebyshev(vec) > 1 then + table.insert(removed, prism.Vector2(x, y)) + end + end + + for _, vec in pairs(removed) do + sensesComponent.cells:set(vec.x, vec.y, nil) + end + + self:updateSeenActors(level, actor) +end + +return LightSightSystem + diff --git a/extra/lighting/systems/lightsystem.lua b/extra/lighting/systems/lightsystem.lua new file mode 100644 index 00000000..5782a81f --- /dev/null +++ b/extra/lighting/systems/lightsystem.lua @@ -0,0 +1,195 @@ +--- @type LightSamplePool +local samplePool = prism.lighting.LightSamplePool() + +--- Handles lighting calculations for the level. LightSystem:update must be called every frame. +--- @class LightSystem : System +--- @field lightBuffers table +--- @field buffer Grid +--- @field rtBuffer Grid +--- @field tileInfluence Grid> +local LightSystem = prism.System:extend "LightSystem" +LightSystem.MINIMUM_LUMINANCE = 1 / 16 + +--- @param level Level +function LightSystem:initialize(level) + self.lightBuffers = {} + self.needsRebuild = false + self.buffer = prism.Grid(level:getSize()) + self.rtBuffer = prism.Grid(level:getSize()) + self.tileInfluence = prism.Grid(level:getSize()) +end + +--- @param actor Actor +function LightSystem:setDirty(actor) + if self.lightBuffers[actor] or actor:has(prism.components.Light) then + self.needsRebuild = true + self.lightBuffers[actor] = nil + end + + for litby, _ in pairs(actor:getRelations(prism.relations.LitByRelation)) do + self:setDirty(litby) + end +end + +function LightSystem:onComponentAdded(_, actor, _) + self:setDirty(actor) +end +function LightSystem:onComponentRemoved(_, actor, _) + self:setDirty(actor) +end +function LightSystem:onActorAdded(_, actor) + self:setDirty(actor) +end +function LightSystem:onActorRemoved(_, actor) + self:setDirty(actor) +end +function LightSystem:beforeMove(_, actor, _, _) + self:setDirty(actor) +end + +function LightSystem:afterOpacityChanged(level, x, y) + for actor, buffer in pairs(self.lightBuffers) do + if buffer:get(x, y) then self:setDirty(actor) end + end +end + +local dummy = prism.Color4() +function LightSystem:rebuild() + for actor, light in self.owner:query(prism.components.Light):iter() do + --- @cast light Light + + if not self.lightBuffers[actor] then + local x, y + if actor:getPosition() then + x, y = actor:expectPosition():decompose() + else + local related = actor:getRelation(prism.relations.LightsRelation) + x, y = related:expectPosition():decompose() + end + + if x and y then self.lightBuffers[actor] = self:cast(x, y, light) end + end + end + + self.buffer:clear() + for _, buffer in pairs(self.lightBuffers) do + for x, y, luminance in buffer.grid:each() do + local c = buffer.color + local cur = self.buffer:get(x, y) or dummy + self.buffer:set(x, y, cur + c * luminance) + end + end + + self.needsRebuild = false +end + +function LightSystem:update() + self.time = love.timer.getTime() + + -- Ensure static lighting is valid + if self.needsRebuild then self:rebuild() end + + self.rtBuffer:clear() + + for _, buffer in pairs(self.lightBuffers) do + for x, y, luminance in buffer.grid:each() do + local c = buffer.color + if buffer.effect then c = buffer.effect:effect(self.time, c, x, y) end + local cur = self.rtBuffer:get(x, y) or dummy + self.rtBuffer:set(x, y, cur + c * luminance) + end + end +end + +--- @return Grid +--- @param x integer +--- @param y integer +--- @param lightComponent Light +function LightSystem:cast(x, y, lightComponent) + local out = prism.lighting.LightBuffer(lightComponent:getColor(), lightComponent.lightEffect) + local frontier = prism.Queue() + + frontier:push(samplePool:acquire(x, y, 0)) + out:set(x, y, 1) + + while not frontier:empty() do + --- @type LightSample + local current = frontier:pop() + + for _, neighborDir in ipairs(prism.neighborhood) do + local nx, ny = current.x + neighborDir.x, current.y + neighborDir.y + if nx >= 1 and nx <= self.owner.map.w and ny >= 1 and ny <= self.owner.map.h then + if not out:get(nx, ny) and not self.owner:getOpacityCache():get(nx, ny) then + local luminance = lightComponent:attenuate(current.depth + 1) + out:set(nx, ny, luminance) + if luminance >= self.MINIMUM_LUMINANCE then + frontier:push(samplePool:acquire(nx, ny, current.depth + 1)) + end + end + end + end + + samplePool:release(current) + end + + return out +end + +--- @param self LightSystem +--- @param getValue fun(self: LightSystem, x: integer, y: integer): Color4? +--- @param x integer +--- @param y integer +--- @param actor Actor +--- @return Color4? +local function getValuePerspectiveImpl(self, getValue, x, y, actor) + local senses = actor:get(prism.components.Senses) + if not senses then return nil end + + -- Actor cannot perceive this cell at all + if not senses.cells:get(x, y) then return nil end + + if getValue(self, x, y) then return getValue(self, x, y) end + + local accum = prism.Color4(0, 0, 0, 1) + local count = 0 + + for _, dir in ipairs(prism.neighborhood) do + local nx, ny = x + dir.x, y + dir.y + + if senses.cells:get(nx, ny) and not self.owner:getCellOpaque(nx, ny) then + local c = getValue(self, nx, ny) + if c then + accum = accum + c + count = count + 1 + end + end + end + + if count > 0 then return accum / count end + + return prism.Color4(0, 0, 0, 1) +end + +function LightSystem:getValue(x, y) + if self.needsRebuild then self:rebuild() end + + return self.buffer:get(x, y) +end + +--- @param x integer +--- @param y integer +--- @param actor Actor +--- @return Color4? +function LightSystem:getValuePerspective(x, y, actor) + return getValuePerspectiveImpl(self, LightSystem.getValue, x, y, actor) +end + +function LightSystem:getRTValue(x, y) + return self.rtBuffer:get(x, y) +end + +function LightSystem:getRTValuePerspective(x, y, actor) + return getValuePerspectiveImpl(self, LightSystem.getRTValue, x, y, actor) +end + +return LightSystem diff --git a/geometer/elements/selectiongrid.lua b/geometer/elements/selectiongrid.lua index 06d38980..908167a9 100644 --- a/geometer/elements/selectiongrid.lua +++ b/geometer/elements/selectiongrid.lua @@ -33,6 +33,8 @@ local function Tile(self, scene) return function(_, x, y, w, h) local drawable = self.props.placeable.entity:get(prism.components.Drawable) + if not drawable then return end + local quad = self.props.display:getQuad(drawable.index) love.graphics.push("all") diff --git a/spectrum/display.lua b/spectrum/display.lua index 26ffce8b..022cfe59 100644 --- a/spectrum/display.lua +++ b/spectrum/display.lua @@ -25,6 +25,8 @@ --- @field pushed boolean Whether to draw with the camera offset applied or not. --- @field overridenActors table A set of actors that are being manually drawn to the display. --- @field animations AnimationMessage[] +--- @field fgCallback fun(fg, bg): fg, bg +--- @field passes DisplayPass[] --- @overload fun(width: integer, heigh: integer, spriteAtlas: SpriteAtlas, cellSize: Vector2): Display local Display = prism.Object:extend("Display") @@ -39,9 +41,11 @@ function Display:__new(width, height, spriteAtlas, cellSize) self.width = width self.height = height self.camera = prism.Vector2() + self.lighting = nil self.pushed = false self.overridenActors = {} self.animations = {} + self.passes = {} self.cells = { {} } @@ -241,6 +245,45 @@ end local tempColor = prism.Color4() +local tmpFG = prism.Color4() +local tmpBG = prism.Color4() +local tmpDrawable + +local function copytemp(drawable) + if not tmpDrawable then tmpDrawable = prism.components.Drawable {} end + + for k, v in pairs(tmpDrawable) do + tmpDrawable[k] = nil + end + + for k, v in pairs(drawable) do + tmpDrawable[k] = v + end + + tmpDrawable.color = drawable.color:copy(tmpFG) + tmpDrawable.background = drawable.background:copy(tmpBG) + + return tmpDrawable +end + +--- Applies stack of display passes on the given entity. +--- @param entity Entity +--- @param x integer +--- @param y integer +--- @param drawable Drawable +--- @param alpha? number +function Display:applyPasses(entity, x, y, drawable, alpha) + drawable = copytemp(drawable) + + for _, pass in ipairs(self.passes) do + pass:run(entity, x, y, drawable) + end + + tempColor = drawable.color:copy(tempColor) + tempColor.a = tempColor.a * (alpha or 0) + if self.fgCallback then self.fgCallback("cell", tempColor) end +end + --- Draws cells from a given cell map onto the display, handling depth and transparency. --- @private --- @param drawnCells SparseGrid A sparse grid to keep track of already drawn cells to prevent overdrawing. @@ -253,8 +296,7 @@ function Display:_drawCells(drawnCells, cellMap, alpha) --- @cast cell Cell local drawable = cell:expect(prism.components.Drawable) - tempColor = drawable.color:copy(tempColor) - tempColor.a = tempColor.a * alpha + self:applyPasses(cell, cx, cy, drawable, alpha) self:putDrawable(cx, cy, drawable, tempColor) end end @@ -273,16 +315,27 @@ function Display:_drawActors(drawnActors, senses, level, alpha) --- @cast drawable Drawable if not drawnActors[actor] and not self.overridenActors[actor] then drawnActors[actor] = true - tempColor = drawable.color:copy(tempColor) - tempColor.a = tempColor.a * alpha --- @cast position Position local ax, ay = position:getVector():decompose() + self:applyPasses(actor, ax, ay, drawable, alpha) + self:putDrawable(ax, ay, drawable, tempColor) end end end +--- Pushes a pass onto display pass stack. +--- @param pass DisplayPass +function Display:pushPass(pass) + table.insert(self.passes, pass) +end + +--- Pops a display pass off of the stack. +function Display:popPass() + table.remove(self.passes, #self.passes) +end + --- @param drawnActors table ---@param grid SparseGrid ---@param alpha number diff --git a/spectrum/displaypass.lua b/spectrum/displaypass.lua new file mode 100644 index 00000000..d5f27136 --- /dev/null +++ b/spectrum/displaypass.lua @@ -0,0 +1,17 @@ +--- A modifier that runs on every cell/actor during Display rendering. +--- @class DisplayPass : Object +--- @overload fun(self: DisplayPass, entity: Entity, x: integer, y: integer, drawable: Drawable): DisplayPass +local DisplayPass = prism.Object:extend "DisplayPass" + +--- @param run fun(self: DisplayPass, entity: Entity, x: integer, y: integer, drawable: Drawable) +function DisplayPass:__new(run) + self.run = run +end + +--- @param entity Entity +--- @param x integer +--- @param y integer +--- @param drawable Drawable +function DisplayPass:run(entity, x, y, drawable) end + +return DisplayPass diff --git a/spectrum/init.lua b/spectrum/init.lua index d9e2a1ff..418f62e5 100644 --- a/spectrum/init.lua +++ b/spectrum/init.lua @@ -29,5 +29,9 @@ spectrum.GameState = spectrum.require "gamestate" --- @module "spectrum.statemanager" spectrum.StateManager = spectrum.require "statemanager" +--- @module "spectrum.displaypass" +spectrum.DisplayPass = spectrum.require "displaypass" + prism.registerRegistry("animations", spectrum.Animation, true, "spectrum") prism.registerRegistry("gamestates", spectrum.GameState, false, "spectrum") +prism.registerRegistry("passes", spectrum.DisplayPass, false, "spectrum") diff --git a/test/tests/grid.lua b/test/tests/grid.lua index ff0e4e5e..f4634eca 100644 --- a/test/tests/grid.lua +++ b/test/tests/grid.lua @@ -23,7 +23,7 @@ describe("Grid", function() it("fromData initializes correctly", function() local g = Grid(1, 1) - local data = {1, 2, 3, 4} + local data = { 1, 2, 3, 4 } g:fromData(2, 2, data) expect.equal(g.w, 2) expect.equal(g.h, 2) @@ -34,7 +34,7 @@ describe("Grid", function() it("fromData asserts on wrong length", function() local g = Grid(1, 1) local ok, err = pcall(function() - g:fromData(2, 2, {1, 2, 3}) + g:fromData(2, 2, { 1, 2, 3 }) end) expect.falsy(ok) end) @@ -54,7 +54,7 @@ describe("Grid", function() it("set and get within bounds", function() local g = Grid(2, 2) - g.data = {nil, nil, nil, nil} + g.data = { nil, nil, nil, nil } g:set(1, 1, "a") g:set(2, 1, "b") @@ -69,10 +69,18 @@ describe("Grid", function() it("set throws on out of bounds", function() local g = Grid(2, 2) - local ok1 = pcall(function() g:set(0, 1, "x") end) - local ok2 = pcall(function() g:set(3, 1, "x") end) - local ok3 = pcall(function() g:set(1, 0, "x") end) - local ok4 = pcall(function() g:set(1, 3, "x") end) + local ok1 = pcall(function() + g:set(0, 1, "x") + end) + local ok2 = pcall(function() + g:set(3, 1, "x") + end) + local ok3 = pcall(function() + g:set(1, 0, "x") + end) + local ok4 = pcall(function() + g:set(1, 3, "x") + end) expect.falsy(ok1) expect.falsy(ok2) @@ -90,7 +98,7 @@ describe("Grid", function() it("fill sets all cells to a value", function() local g = Grid(3, 2) - g.data = {1, 2, 3, 4, 5, 6} + g.data = { 1, 2, 3, 4, 5, 6 } g:fill(9) expect.equal(#g.data, 6) for i = 1, #g.data do diff --git a/test/tests/priorityqueue.lua b/test/tests/priorityqueue.lua index 8df0875d..99b145e8 100644 --- a/test/tests/priorityqueue.lua +++ b/test/tests/priorityqueue.lua @@ -6,7 +6,6 @@ local describe, it, expect = lester.describe, lester.it, lester.expect local PriorityQueue = prism.PriorityQueue describe("PriorityQueue", function() - it("starts empty", function() local pq = PriorityQueue() expect.truthy(pq:isEmpty()) @@ -97,5 +96,4 @@ describe("PriorityQueue", function() expect.equal(pq:pop(), nil) expect.equal(pq:size(), 0) end) - end) diff --git a/test/tests/queue.lua b/test/tests/queue.lua index dc04946b..68ede822 100644 --- a/test/tests/queue.lua +++ b/test/tests/queue.lua @@ -6,7 +6,6 @@ local describe, it, expect = lester.describe, lester.it, lester.expect local Queue = prism.Queue describe("Queue", function() - it("starts empty", function() local q = Queue() expect.truthy(q:empty()) @@ -122,5 +121,4 @@ describe("Queue", function() expect.falsy(q:remove(3)) expect.equal(q:size(), 2) end) - end) diff --git a/test/tests/sparsearray.lua b/test/tests/sparsearray.lua index e9465441..ec050de2 100644 --- a/test/tests/sparsearray.lua +++ b/test/tests/sparsearray.lua @@ -153,4 +153,3 @@ describe("SparseArray", function() expect.equal(values[2], "c") end) end) - diff --git a/test/tests/vector2.lua b/test/tests/vector2.lua index b58c1b37..fb78352b 100644 --- a/test/tests/vector2.lua +++ b/test/tests/vector2.lua @@ -4,7 +4,6 @@ local describe, it, expect = lester.describe, lester.it, lester.expect local Vector2 = prism.Vector2 describe("Vector2", function() - it("constructs with defaults", function() local v = Vector2() expect.equal(v.x, 0) @@ -114,10 +113,10 @@ describe("Vector2", function() it("hash and unhash are reversible", function() for _, coords in ipairs({ - {0, 0}, - {10, -10}, - {-20, 50}, - {-100000, 999999}, + { 0, 0 }, + { 10, -10 }, + { -20, 50 }, + { -100000, 999999 }, }) do local x, y = coords[1], coords[2] local h = Vector2._hash(x, y)