remove fireflies
DramaticShape · 2026-08-08 ·
parent b8b2e0cd33
remove fireflies
Changed files
CHANGELOG.md | 62 ++++---------------
data/map_atmosphere.lua | 16 +----
lib/ForestAtmos.lua | 204 +++++++-------------------------------------------------------
lib/SettingsMenu.lua | 16 +++++
lib/Shiny.lua | 70 +++++++++++++++++++--
lib/VoxelScene.lua | 6 +-
main.lua | 20 +++++-
tests/dramatic_shape_test.lua | 78 ------------------------
tests/grass_firefly_shots.lua | 187 --------------------------------------------------------
tests/shiny_test.lua | 27 +++++++++
10 files changed, 162 insertions(+), 524 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39b0256..fc3e552 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,57 +4,6 @@
### Added
-- **Fireflies over tall grass, on every outdoor map.** Viridian Forest has
- had them since 1.6.2 -- blinking green motes low in the air, dealt over
- the forest's whole volume as part of its atmosphere. They now come out
- over TALL GRASS anywhere the hour reaches: a route at midnight has
- lights loose over its grass and none over the road beside it.
-
- The same particle, moved rather than rebuilt. Same mesh format, same
- blinking shader, same one ramp off the day/night clock -- so they come
- on through dusk and reach full contrast at deepest night exactly when
- the forest's do, because it is the same function answering (pulled out
- of `ForestAtmos.frame` as `fireflyLevel` so the two cannot drift
- apart). What changed is where they are dealt.
-
- **Grass is the entry.** `data/map_atmosphere.lua` is still the opt-in
- for fog and god rays, and still names one map; the grass fireflies need
- no line in it. Each map is scanned once for the cells the engine's own
- `isGrassCell` calls tall grass -- the collision tile, the rule that
- decides where a wild battle starts, and the same test `Structures`
- passes before it sprouts a tuft. Going by the grass GRAPHIC instead
- would hang lights over town plazas, where the same art turns up as
- decorative filler inside ordinary ground blocks. Roughly four fireflies
- to five grass cells, capped at 200 a map; an atmosphere entry may tune
- both through a `grassFlies` row. A map with no grass on it deals none
- and costs one scan.
-
- They are dealt INTO a cell rather than at its middle, and the shader's
- existing wander carries each about a cell's width from where it was
- dealt, in the tufts' own height band -- so a patch reads as a patch
- with fireflies loose over it rather than as a grid of lights. The deal
- is seeded off the map id, so a route's swarm is the same arrangement on
- every visit and no two routes get the same one.
-
- **The connected neighbours get theirs too.** A neighbour map is drawn
- in full -- ground, trees, grass and the firefly cards standing in it --
- so a swarm that stopped at the map seam would draw a line across the
- picture where the lights ran out. The particle shader takes a map
- origin now and each neighbour's own cached mesh is drawn where its
- terrain is. Nothing else in the atmosphere crosses a seam, and nothing
- else needed to: fog and beams belong to the map you are standing in.
-
- This is the FULL rung of FOREST FX, like every particle in that pass.
- The per-tuft firefly cards in the grass mesh are the layer underneath
- and are unchanged -- static geometry the scene shader already carries,
- so grass still has something alight after dark on LOW and on Android,
- where this pass is not drawn at all.
-
- One fix falls out of it: a machine that cannot hand back a readable
- depth buffer used to lose the whole atmosphere pass, particles
- included, rather than just the beams it actually blocks. The refusal
- now takes the beams alone.
-
- **SHINY POKEMON.** On by default, with no row to switch it off:
shininess is a property of a Pokemon, not a display mode, and one that
differed between two players' saves would be a setting rather than a
@@ -127,6 +76,17 @@
pixel rather than floating over the finished frame. Page 1 only: page 2
clears that block itself.
+ **SHINY ODDS**, on the DRAMATIC SHAPE menu itself rather than in one of
+ its four categories -- those are the diorama, the fights, what the look
+ costs and the headset, and an encounter rate is none of them. The row
+ reads `1:8192` and halves down to `1:1`, so every rung is exactly twice
+ as often as the one above it, and `1:8192` is both the default and the
+ fallback for an unreadable options file: the mod's default is the games'
+ own rate, not a buff. The number is the truth rather than an
+ approximation of it, because a missed roll also clears a mon that landed
+ on the pattern by luck -- without that, every setting would be itself
+ and 1/8192 in parallel, and no setting could ever be rarer than 8192.
+
## 1.8.0
### Added
diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua
index b9ff808..5d6c321 100644
--- a/data/map_atmosphere.lua
+++ b/data/map_atmosphere.lua
@@ -13,9 +13,7 @@
-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
-- rays after dark, pollen adrift in the day's beams and fireflies once
--- they cool. The fireflies are the one part of this that reaches past the
--- table: they also come out over TALL GRASS on every outdoor map, entry
--- or no entry (see grassFlies below).
+-- they cool.
--
-- A map with no entry here has no atmosphere at all: no fog uniform is
-- raised, no march runs, nothing is spent. That is the contract a new
@@ -38,18 +36,8 @@
-- rays strength overall in-scatter gain on the march
-- reach how far out the march walks, in world px
-- motes count of pollen/dust flecks adrift in the daylight beams
--- fireflies count of the night shift, dealt over the map's whole volume
+-- fireflies count of the night shift
-- seed the xorshift seed the particle deal runs on
--- grassFlies per fireflies dealt per TALL GRASS cell (default 0.8)
--- cap and the most one map may have of them (default 200)
---
--- That last row is the odd one out, and the only knob here a map does not
--- need an entry to get. The GRASS fireflies are dealt over a map's tall
--- grass rather than over its air, and every outdoor map has them -- grass
--- is the entry, so a route gets fireflies after dark without a line here
--- and a map with no grass on it deals none. What an entry buys is TUNING:
--- a `grassFlies` row moves the two numbers off their defaults, and that
--- is the whole of it. See ForestAtmos, under "the grass fireflies".
--
-- Two caveats for maps opting in later: the water pass has no fog term,
-- so a lake under heavy haze stays clear-day sharp in its reflections;
diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua
index cd22560..884f671 100644
--- a/lib/ForestAtmos.lua
+++ b/lib/ForestAtmos.lua
@@ -137,7 +137,6 @@ ForestAtmos.configFor = configFor
-- shader and texture sentinels, which is what a lost GL context needs.
local layoutCache = {}
-local grassCache = {} -- the grass deal, keyed by map; false = no grass
local meshCache = {}
local shaders = {} -- keyed by variant; nil untried, false refused
local leafTex = nil -- the tiling leaf-dapple field
@@ -157,10 +156,9 @@ end
function ForestAtmos.invalidate(mapId)
if mapId then
layoutCache[mapId] = nil
- grassCache[mapId] = nil
meshCache[mapId] = nil
else
- layoutCache, grassCache, meshCache = {}, {}, {}
+ layoutCache, meshCache = {}, {}
shaders = {}
leafTex = nil
rayMesh = nil
@@ -196,18 +194,6 @@ ForestAtmos.RAMP = {
alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
}
--- How far out the night shift is, 0..1, at clock `t`. Pulled out of the
--- frame's own loop because the GRASS fireflies (below) need it on maps
--- that have no atmosphere at all -- and pulled out rather than copied so
--- the two can never disagree about when a firefly comes on.
-function ForestAtmos.fireflyLevel(t)
- local flies = 0
- for name, w in pairs(DayNight.mix(t or DayNight.time())) do
- flies = flies + (ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day).flies * w
- end
- return flies
-end
-
-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
-- and Voxel3D.fog should be left nil.
@@ -217,8 +203,7 @@ function ForestAtmos.frame(map, t)
if not cfg then return nil end
local mix = DayNight.mix(t or DayNight.time())
local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
- local alpha, dens, motes = 0, 0, 0
- local flies = ForestAtmos.fireflyLevel(t)
+ local alpha, dens, motes, flies = 0, 0, 0, 0
for name, w in pairs(mix) do
local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
@@ -226,6 +211,7 @@ function ForestAtmos.frame(map, t)
alpha = alpha + p.alpha * w
dens = dens + p.density * w
motes = motes + p.motes * w
+ flies = flies + p.flies * w
end
-- the haze takes on a little of the light standing in it
local LEAN = 0.15
@@ -388,111 +374,6 @@ function ForestAtmos.layout(cfg, w, h)
return { motes = motes, flies = flies }
end
--- ------- the grass fireflies
---
--- The same particle -- same mesh format, same blinking shader, same hour's
--- ramp -- dealt over a map's TALL GRASS instead of over its whole volume,
--- and on any outdoor map rather than only the ones with an atmosphere
--- entry. Grass IS the entry: a route with tall grass on it gets fireflies
--- after dark without anybody authoring a line, and a map with no grass
--- cell on it deals nothing and costs one scan.
---
--- Placement asks the engine's own question, `map:isGrassCell` -- the
--- cell's collision tile, the rule that decides where a wild battle can
--- start -- which is the same test Structures runs before it sprouts a
--- tuft. The grass GRAPHIC also turns up as decorative filler inside plain
--- ground blocks, and going by the tile would hang fireflies over town
--- plazas (the trap that note in Structures.buildGrass records).
---
--- They are dealt into the cell rather than at its middle, and the shader's
--- own wander (sway 10, 4) carries each one about a cell's width from where
--- it was dealt -- so a patch reads as a patch with fireflies loose over it,
--- not as a grid of lights. The height band is the tufts' own: 3..15, which
--- is blade height and a little air (Structures stands its blades to y = 16).
---
--- This is the FULL rung, like every other particle here. The per-tuft
--- firefly cards in Structures.buildGrass are the layer underneath -- they
--- are static geometry the scene shader already carries, so grass still has
--- something alight after dark on LOW and on Android, where this pass is
--- not drawn at all.
-local GRASS_PER_CELL = 0.8 -- fireflies per grass cell...
-local GRASS_CAP = 200 -- ...up to this many on one map
-local CELL = 16 -- world px, the grid isGrassCell speaks
-
--- A map id deals its own swarm: stable across visits and saves (nothing
--- here rides the clock), different between maps, so two routes with the
--- same amount of grass do not get the same arrangement of lights.
-local function seedOf(id)
- local s = 0x51D
- for i = 1, #id do s = (s * 31 + id:byte(i)) % 0x100000000 end
- return s
-end
-
--- `cells` is a flat list of { cx, cy } pairs; count is worked out from how
--- many there are. Deterministic, and separated from the map so a test can
--- hand it a meadow.
-function ForestAtmos.grassLayout(cells, seed, per, cap)
- local n = #cells
- if n == 0 then return {} end
- local want = min(floor(n * (per or GRASS_PER_CELL) + 0.5), cap or GRASS_CAP)
- local rng = newRng(seed or 0x51D)
- local flies = {}
- for _ = 1, want do
- local c = cells[1 + min(floor(rng:unit() * n), n - 1)]
- flies[#flies + 1] = {
- x = c[1] * CELL + rng:unit() * CELL,
- y = 3 + rng:unit() * 12,
- z = c[2] * CELL + rng:unit() * CELL,
- phase = rng:unit() * 6.2832,
- rate = 0.5 + rng:unit(),
- }
- end
- return flies
-end
-
--- Only where the clock reaches: an outdoor map, or a CANOPY one (Viridian
--- Forest is not outdoor -- no sky, no sun -- but night still falls in it,
--- and it is the map these fireflies were drawn for). A cave stays a cave.
-local function litByTheHour(map)
- if DayNight.isCanopy(map) then return true end
- local ok, Map = pcall(require, "src.world.Map")
- if not (ok and Map and Map.isOutdoor) then return false end
- local got, outdoor = pcall(Map.isOutdoor, map.def or {})
- return got and outdoor or false
-end
-
--- One scan of the map's cells, cached with everything else that goes stale
--- with it. `false` records "scanned, no grass" so a grassless map is never
--- walked twice.
-local function grassFliesFor(map)
- local hit = grassCache[map.id]
- if hit ~= nil then return hit or nil end
- if not (map.isGrassCell and litByTheHour(map)) then
- grassCache[map.id] = false
- return nil
- end
- local cells = {}
- local w = map.widthCells or ((map.def and map.def.width or 0) * 2)
- local h = map.heightCells or ((map.def and map.def.height or 0) * 2)
- -- one pcall around the whole scan, not one per cell: a route is a couple
- -- of thousand cells and this runs on the frame that first draws the map
- pcall(function()
- for cy = 0, h - 1 do
- for cx = 0, w - 1 do
- if map:isGrassCell(cx, cy) then cells[#cells + 1] = { cx, cy } end
- end
- end
- end)
- local cfg = configFor(map.id) or {}
- local knob = cfg.grassFlies or {}
- local flies = ForestAtmos.grassLayout(cells, seedOf(map.id),
- knob.per, knob.cap)
- grassCache[map.id] = (#flies > 0) and flies or false
- return grassCache[map.id] or nil
-end
-
-ForestAtmos.grassFliesFor = grassFliesFor
-
-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
-- for world pixels (the same arithmetic Structures runs in tiles).
local function layoutFor(map)
@@ -756,9 +637,6 @@ local PART_SHADER = [[
uniform float size;
uniform vec2 sway; // wander amplitude: horizontal, vertical
uniform float blinky; // 0 = steady motes, 1 = blinking fireflies
- uniform vec3 origin; // the map's corner: zero for the one being
- // stood on, the connection offset for a
- // neighbour's swarm (one mesh, drawn per map)
attribute vec4 AtmosData; // corner x, corner y, phase, rate
vec4 position(mat4 transform_projection, vec4 vertex_position) {
float ph = AtmosData.z;
@@ -766,7 +644,7 @@ local PART_SHADER = [[
float t = time * (0.5 + rt);
// bounded wander only -- three incommensurate sines, so nothing ever
// walks off the map or needs a CPU tick to bring it home
- vec3 base = vertex_position.xyz + origin + vec3(
+ vec3 base = vertex_position.xyz + vec3(
sin(t * 0.23 + ph) * sway.x,
sin(t * 0.17 + ph * 2.7) * sway.y,
cos(t * 0.19 + ph * 1.3) * sway.x);
@@ -855,19 +733,12 @@ local function buildPartMesh(points)
return mesh
end
--- The map's three swarms in one cache entry: the atmosphere's pollen and
--- fireflies, which exist only for a map with an entry, and the grass
--- fireflies, which exist for any outdoor map with tall grass on it. Each
--- is nil on its own -- a route builds one mesh, Viridian Forest builds
--- three, and a cave builds none.
-local function meshesFor(map)
+local function meshesFor(map, L)
local hit = meshCache[map.id]
if hit then return hit end
- local L = layoutFor(map)
local M = {
- motes = L and buildPartMesh(L.motes) or nil,
- flies = L and buildPartMesh(L.flies) or nil,
- grass = buildPartMesh(grassFliesFor(map) or {}),
+ motes = buildPartMesh(L.motes),
+ flies = buildPartMesh(L.flies),
}
meshCache[map.id] = M
return M
@@ -875,7 +746,6 @@ end
local MOTE_COLOR = { 1.0, 0.96, 0.78 }
local FLY_COLOR = { 0.72, 1.0, 0.45 }
-local HOME = { 0, 0, 0 } -- the origin of the map being stood on
-- The billboard frame: the camera's own right and up, from the same
-- fields every pass sets (per VR eye too -- drawScene runs per eye and
@@ -904,29 +774,19 @@ end
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
-- refused shader, no readable depth, no shadow map -- subtracts only
-- itself.
---
--- `f` is nil for every map without an atmosphere entry, and the pass does
--- NOT stop there any more: the grass fireflies belong to the grass, not to
--- an authored line, so the fog and the beams sit that map out and the
--- particle block below still runs. Every read of `f` past here is guarded.
-function ForestAtmos.draw(map, neighbors)
+function ForestAtmos.draw(map)
local rung = ForestAtmos.setting:get()
if rung == "off" then return end
- if not (map and map.id) then return end
local f = ForestAtmos.frame(map)
+ if not f then return end
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
- -- A refusal here takes the BEAMS, not the frame: this one used to
- -- `return` out of the function, which took the particles with it. They
- -- fall through now, so a machine that cannot hand back a readable depth
- -- buffer still gets fireflies over its grass.
- local beams = f and f.rayAlpha > 0.01 or false
- if beams and not Voxel3D.depthReadable() then
- say("depth", "no readable depth this frame -- beams off, fog stays")
- beams = false
- end
- if beams then
+ if f.rayAlpha > 0.01 then
+ if not Voxel3D.depthReadable() then
+ say("depth", "no readable depth this frame -- beams off, fog stays")
+ return
+ end
-- no beams without the sun's own pass: uvVP is only the world -> map
-- transform while a shadow map is actually standing
local sunTex = ShadowMap.active() and ShadowMap.texture()
@@ -983,13 +843,11 @@ function ForestAtmos.draw(map, neighbors)
end
if rung == "full" then
- local M = meshesFor(map)
- -- the night shift, for the grass swarm: the atmosphere's own answer
- -- where there is one, and the bare ramp where there is not
- local flyLevel = f and f.fireflyLevel or ForestAtmos.fireflyLevel()
+ local L = layoutFor(map)
+ local M = L and meshesFor(map, L)
local psh = partShader()
local axisR, axisU = billboardAxes(Voxel3D)
- if (M.motes or M.flies or M.grass) and psh and axisR then
+ if M and psh and axisR then
Voxel3D.blend("add")
if Voxel3D.beginEffect(psh) then
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
@@ -1002,8 +860,7 @@ function ForestAtmos.draw(map, neighbors)
pcall(psh.send, psh, "axisR", axisR)
pcall(psh.send, psh, "axisU", axisU)
pcall(psh.send, psh, "time", ForestAtmos.time)
- pcall(psh.send, psh, "origin", HOME)
- if M.motes and f and f.moteLevel > 0.02 then
+ if M.motes and f.moteLevel > 0.02 then
pcall(psh.send, psh, "size", 1.4)
pcall(psh.send, psh, "sway", { 5, 2.5 })
pcall(psh.send, psh, "blinky", 0)
@@ -1011,32 +868,13 @@ function ForestAtmos.draw(map, neighbors)
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
pcall(love.graphics.draw, M.motes)
end
- -- every swarm of fireflies shares every uniform but the mesh and
- -- the map corner it stands on: the grass ones ARE the forest's,
- -- moved onto the grass.
- --
- -- The NEIGHBOURS matter here in a way they never did for the fog
- -- or the beams. A connected route is drawn in full -- its ground,
- -- its trees, its grass and the firefly cards standing in it -- so
- -- a swarm that stopped at the seam would draw a line across the
- -- map where the lights ran out. Each neighbour's own deal is a
- -- mesh already cached against its id; it costs a uniform and a
- -- draw call to put it where the terrain under it is.
- if flyLevel > 0.02 then
+ if M.flies and f.fireflyLevel > 0.02 then
pcall(psh.send, psh, "size", 1.6)
pcall(psh.send, psh, "sway", { 10, 4 })
pcall(psh.send, psh, "blinky", 1)
pcall(psh.send, psh, "dotColor", FLY_COLOR)
- pcall(psh.send, psh, "level", flyLevel * 0.85)
- if M.flies then pcall(love.graphics.draw, M.flies) end
- if M.grass then pcall(love.graphics.draw, M.grass) end
- for _, nb in ipairs(neighbors or {}) do
- local NM = nb.map and nb.map.id and meshesFor(nb.map)
- if NM and NM.grass then
- pcall(psh.send, psh, "origin", { nb.ox or 0, 0, nb.oy or 0 })
- pcall(love.graphics.draw, NM.grass)
- end
- end
+ pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
+ pcall(love.graphics.draw, M.flies)
end
Voxel3D.endEffect()
end
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
index 0c66c1c..e5ad7fd 100644
--- a/lib/SettingsMenu.lua
+++ b/lib/SettingsMenu.lua
@@ -192,6 +192,22 @@ function SettingsMenu.rows(catId, game)
out[#out + 1] = row
end
end
+ -- ------- settings that belong to no category
+ --
+ -- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
+ -- menu next to the pipeline rows. For a setting that is about the GAME
+ -- rather than about one of the four things the categories are for --
+ -- SHINY ODDS is the first -- burying it under a heading it does not
+ -- belong to is worse than the flat list this menu was built to end.
+ --
+ -- Above the categories, because these are rows you CHANGE and those are
+ -- rows you OPEN: everything with a value on it stays together at the top
+ -- of the screen, and the "..." rows read as the way further in.
+ for _, entry in ipairs(settings) do
+ if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
+ out[#out + 1] = entry[1]:row()
+ end
+ end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
local kids = SettingsMenu.rows(cat.id, game)
-- An EMPTY category is not offered. This is the whole of what makes
diff --git a/lib/Shiny.lua b/lib/Shiny.lua
index 3a191d9..bcf32c9 100644
--- a/lib/Shiny.lua
+++ b/lib/Shiny.lua
@@ -42,6 +42,8 @@ local V = ...
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
local Stats = require("src.pokemon.Stats")
+local ModSetting = V.require("ModSetting")
+
local Shiny = {}
-- ------- the odds
@@ -59,17 +61,75 @@ local Shiny = {}
-- the number says.
Shiny.ODDS_DENOM = 8192
--- Set the denominator. Guards the degenerate values because a 0 or a
--- negative here would divide-by-zero or make every encounter shiny by
--- accident rather than by choice; 1 (always shiny) stays reachable because
--- it is genuinely useful for walking the whole model set.
+-- ------- the row the player cycles
+--
+-- A ladder that HALVES, so every step is exactly "twice as often as the one
+-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
+-- rate is what the number says, not an approximation of it, because the
+-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
+-- 1:2 really is every other encounter.
+--
+-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
+-- and the fallback for an unreadable or unrecognised stored value, so the
+-- canonical rate is what a player who never opens the menu gets and what a
+-- corrupted options.lua comes back to.
+--
+-- No rung RARER than 8192. The mod's promise is that its default is not a
+-- change to the game; making the game harder than it ships is a different
+-- promise and nobody asked for it.
+local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
+
+local ODDS_LABELS = {}
+for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
+
+Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
+
+-- ------- the setting is PULLED, not pushed
+--
+-- decide() asks this every roll rather than the menu telling us when it
+-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
+-- settings page -- and only the first has a change hook to hang on; the
+-- manager writes through mod.options and calls ModSetting:sync, which
+-- notifies nothing. Pulling is the only way both are seen, and the cost is
+-- a table read on an event that happens once per encounter.
+--
+-- ODDS_DENOM stays the live value and is written through on every ask, so
+-- anything already reading that field keeps reading the truth.
+local pinned = false
+
+function Shiny.odds()
+ if not pinned then
+ local ok, value = pcall(Shiny.setting.get, Shiny.setting)
+ local n = ok and tonumber(value)
+ if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
+ end
+ return Shiny.ODDS_DENOM
+end
+
+-- Set the denominator BY HAND, which also PINS it: a driver or a test that
+-- has asked for 1:1 means it, and must not have the next roll quietly put
+-- back to whatever the player left on the menu. Nothing in the game calls
+-- this -- the row is how a player changes the rate.
+--
+-- Guards the degenerate values because a 0 or a negative here would
+-- divide-by-zero or make every encounter shiny by accident rather than by
+-- choice; 1 (always shiny) stays reachable because it is genuinely useful
+-- for walking the whole model set.
function Shiny.setOdds(denom)
denom = tonumber(denom)
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
Shiny.ODDS_DENOM = math.floor(denom)
+ pinned = true
return Shiny.ODDS_DENOM
end
+-- Hand the row back control, for a test that pinned the odds and wants the
+-- setting to mean something again afterwards.
+function Shiny.unpinOdds()
+ pinned = false
+ return Shiny.odds()
+end
+
-- ------- reading it
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
@@ -205,7 +265,7 @@ function Shiny.decide(mon, rng)
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
-- stub and the call below reads identically either way
rng = rng or function(_lo, hi) return roll(hi) end
- local hit = rng(1, Shiny.ODDS_DENOM) == 1
+ local hit = rng(1, Shiny.odds()) == 1
if hit then
forceShiny(mon.dvs)
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 179fc8f..6239deb 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -1246,10 +1246,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- finished depth buffer, so the trees occlude the light and the light
-- writes nothing; here in the prop slot, after everything the beams
-- should fall across and inside drawScene so VR gets them per eye. On
- -- the one map that has any, today -- but the FIREFLIES over tall grass
- -- need no entry and reach every outdoor map, which is why the connected
- -- neighbours go too: their grass is drawn, so their lights are.
- ForestAtmos.draw(state.map, state.neighbors)
+ -- the one map that has any, today.
+ ForestAtmos.draw(state.map)
-- The VR pokedex in the player's left hand, last of all: a prop over
-- the world drawn with real depth, so leaning it into a wall still
diff --git a/main.lua b/main.lua
index 055e63e..33d1842 100644
--- a/main.lua
+++ b/main.lua
@@ -88,6 +88,7 @@ local WorldCurve = V.require("WorldCurve")
local ViewBox = V.require("ViewBox")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
+local Shiny = V.require("Shiny")
local ShinyBattle = V.require("ShinyBattle")
local ShinyUI = V.require("ShinyUI")
local DayNight = V.require("DayNight")
@@ -471,6 +472,21 @@ end
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
-- category empties out under FULL and takes itself off the menu.
local SETTINGS = {
+ -- ------- the top-level menu -- settings that are about the GAME
+ --
+ -- SettingsMenu.ROOT as a `cat` puts a row on the DRAMATIC SHAPE screen
+ -- itself rather than inside one of the four categories, which is right
+ -- here: the categories are the diorama, the fights, what the look costs
+ -- and the headset, and how often a shiny appears is none of those.
+ --
+ -- `full` for the battle rows' reason: FULL is a preset for the LOOK, and
+ -- an encounter rate is a rule of the game. A player inside FULL must be
+ -- able to reach it, and FULL must never set it.
+ { Shiny.setting,
+ "How often a wild Pokemon turns up shiny. 1:8192 is the games' own "
+ .. "rate, and every rung below it is twice as often as the one above.",
+ cat = SettingsMenu.ROOT, full = true },
+
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
cat = "world" },
@@ -529,8 +545,8 @@ local SETTINGS = {
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
- "Haze and light shafts in the deep woods, with pollen in the beams by "
- .. "day and fireflies over tall grass at night.",
+ "Haze and volumetric light shafts in the deep woods, with pollen in the "
+ .. "beams by day and fireflies at night.",
cat = "perf", full = true },
-- `full` on AA's reasoning below, and for the same reason: the sun's pass
-- is the most expensive thing in the frame after the geometry, so this is
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index c93b9f3..e121062 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -4686,84 +4686,6 @@ ForestAtmos.setting:sync("off")
T.check(ForestAtmos.frame(fmap, DayNight.T.day) == nil,
"OFF answers no frame at all: no fog uniform, no draw, no spend")
ForestAtmos.setting:sync("full")
-
--- ------- and the fireflies get out of the forest
---
--- The same particle -- same mesh, same blinking shader, same hour's ramp --
--- dealt over a map's TALL GRASS rather than over its whole volume, and on
--- every outdoor map rather than the one with an entry. Grass is the entry.
-
--- the hour reads the same whether a map has an atmosphere or not, which is
--- the point of pulling it out of frame(): the two cannot drift apart
-T.check(ForestAtmos.fireflyLevel(DayNight.T.night) > 0.5,
- "the night shift is out at midnight on a map with no entry either")
-T.check(ForestAtmos.fireflyLevel(DayNight.T.day) < 0.05,
- "and in by day")
-T.eq(ForestAtmos.fireflyLevel(DayNight.T.night),
- ForestAtmos.frame(fmap, DayNight.T.night).fireflyLevel,
- "the forest's own answer IS that answer -- one ramp, read twice")
-
--- the deal: over the cells handed in, one seed, one arrangement
-local meadow = {}
-for cx = 4, 9 do
- for cy = 4, 9 do meadow[#meadow + 1] = { cx, cy } end
-end
-local g1 = ForestAtmos.grassLayout(meadow, 4242)
-local g2 = ForestAtmos.grassLayout(meadow, 4242)
-T.eq(#g1, 29, "a 36-cell patch musters fireflies at the default density")
-local steady = true
-for i = 1, #g1 do
- steady = steady and g1[i].x == g2[i].x and g1[i].z == g2[i].z
-end
-T.check(steady, "and deals them the same way on every visit")
-T.check(#ForestAtmos.grassLayout(meadow, 99) > 0
- and ForestAtmos.grassLayout(meadow, 99)[1].x ~= g1[1].x,
- "a different map's seed deals a different swarm")
-
--- every one of them stands OVER the grass it was dealt for, in the tufts'
--- own height band -- Structures stands a blade to y = 16
-local overGrass, lowDown = true, true
-for _, fly in ipairs(g1) do
- local cx, cy = math.floor(fly.x / 16), math.floor(fly.z / 16)
- if cx < 4 or cx > 9 or cy < 4 or cy > 9 then overGrass = false end
- if fly.y < 0 or fly.y > 16 then lowDown = false end
-end
-T.check(overGrass, "every firefly is dealt into a cell that HAS grass in it")
-T.check(lowDown, "and low over the blades rather than up in the air")
-
--- the knobs, and the cap that keeps a big route from dealing thousands
-T.eq(#ForestAtmos.grassLayout(meadow, 1, 2.0), 72,
- "the density knob musters more of them per cell")
-T.eq(#ForestAtmos.grassLayout(meadow, 1, 2.0, 10), 10,
- "and the cap is the ceiling over it")
-T.eq(#ForestAtmos.grassLayout({}, 1), 0,
- "a map with no grass on it deals nothing at all")
-
--- and the map-level gate: outdoors or under a canopy, never in a cave.
--- A 4x4-block map is 8x8 cells; grass down the middle two columns.
-local function meadowMap(id, def)
- return {
- id = id, def = def, widthCells = 8, heightCells = 8,
- isGrassCell = function(_, cx, cy)
- return cx >= 3 and cx <= 4 and cy >= 1 and cy <= 6
- end,
- }
-end
-local route = meadowMap("ROUTE_1", { width = 4, height = 4,
- tileset = "OVERWORLD" })
-T.check(#ForestAtmos.grassFliesFor(route) > 0,
- "a route with tall grass on it has fireflies without an entry anywhere")
-local cave = meadowMap("MT_MOON_1F", { width = 4, height = 4,
- tileset = "CAVERN" })
-T.check(ForestAtmos.grassFliesFor(cave) == nil,
- "and a cave has none, whatever its collision tiles say -- no hour "
- .. "reaches it, so no night ever falls in it")
-local wood = meadowMap("VIRIDIAN_FOREST", { width = 4, height = 4,
- tileset = "FOREST" })
-T.check(#ForestAtmos.grassFliesFor(wood) > 0,
- "the canopy map is not outdoor and gets them anyway: night still FALLS "
- .. "in a forest, and it is the map they were drawn for")
-ForestAtmos.invalidate()
end
-- ------- a shadow keeps hold of the feet that throw it
diff --git a/tests/grass_firefly_shots.lua b/tests/grass_firefly_shots.lua
deleted file mode 100644
index 211baec..0000000
--- a/tests/grass_firefly_shots.lua
+++ /dev/null
@@ -1,187 +0,0 @@
--- Driver: screenshot the fireflies over tall grass.
---
--- The forest's own particle, dealt over a map's grass instead of over its
--- air, on every outdoor map rather than the one with an atmosphere entry
--- (see ForestAtmos, "the grass fireflies"). What this has to show:
---
--- * a route with tall grass on it, at night, with lights over the grass
--- and none over the road beside it -- the whole point of placing them
--- by isGrassCell rather than by the grass GRAPHIC;
--- * the same vantage by DAY, which must have none at all;
--- * a MAP SEAM at night: the swarm has to carry onto the connected
--- neighbour, whose grass is drawn in full;
--- * Viridian Forest, where the grass swarm stands alongside the
--- authored map-wide one and neither should read as double;
--- * and the row OFF, which must be pixel-identical to a run from before
--- the feature existed.
---
--- Deterministic on the same recipe forest_fog_shots runs: encounters
--- killed, tile animation frozen, the atmosphere's clock pinned -- so an
--- AB_TAG=before/after pair diffs clean. The grass deal itself is seeded
--- off the map id and never moves.
---
--- SHOT_DIR=.scratchpad/grassfly AB_TAG=after \
--- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/grass_firefly_shots.lua \
--- lovec.exe .
-return function(game)
- local U = dofile("tests/drivers/util.lua")
- local Pipelines = require("src.render.Pipelines")
-
- local ROOT = (os.getenv("SHOT_DIR") or ".scratchpad/grassfly")
- .. "/" .. (os.getenv("AB_TAG") or "after")
- -- U.shot's own mkdir is Unix-flavoured and fails silently on Windows
- pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
- pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
-
- local exports = game.mods and game.mods.exports
- local handle = exports and exports.DRAMATIC_SHAPE
- if not (handle and handle.lib) then
- U.log("DRAMATIC_SHAPE is not loaded -- check the ds_fp_ceiling conflict")
- return
- end
- local lib = handle.lib
- local DayNight = lib.require("DayNight")
- local ChunkMesher = lib.require("ChunkMesher")
- local Voxel = lib.require("VoxelState")
- local ForestAtmos = lib.require("ForestAtmos")
-
- -- Prove the running game is reading THIS copy of the file before any
- -- shot is trusted: an install in the save dir shadows the repo, and a
- -- stale one makes an unchanged AFTER look like a broken feature.
- if not ForestAtmos.grassFliesFor then
- U.log("the loaded ForestAtmos has no grassFliesFor -- a STALE copy is "
- .. "being read, not this repo. Shots would be meaningless.")
- return
- end
- U.log("grass fireflies present in the loaded mod -- shooting")
-
- -- ------- the determinism recipe (see forest_fog_shots)
- require("src.world.OverworldController").rollEncounter =
- function() return nil end
- local TileRenderer = require("src.render.TileRenderer")
- TileRenderer.tick = function() end
- TileRenderer.animFrame = function() return 0 end
- local Zoom = require("src.render.Zoom")
- pcall(function()
- game.save.options.zoom = 1
- Zoom.applyOptions(game.save.options)
- end)
- ForestAtmos.frozen = true
- ForestAtmos.time = 5
-
- local function setTime(value)
- DayNight.setting:sync(value)
- DayNight.update(0)
- end
-
- local function settle()
- for _ = 1, 900 do
- if ChunkMesher.pending() == 0 then break end
- U.wait(1)
- end
- for _ = 1, 300 do
- if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
- break
- end
- U.wait(1)
- end
- U.wait(40)
- end
-
- local function go(mapId, x, y, face, rung)
- U.teleport(game, mapId, x, y, face or "up")
- Pipelines.setLevel("voxel", rung or 5)
- Pipelines.setLevel("tiltshift", 0) -- judge the lights, not the blur
- settle()
- end
-
- ForestAtmos.setting:sync("full")
-
- -- ------- find a route the deal actually put fireflies on
- --
- -- Rather than guessing a cell with grass in it, ask the layout: it is
- -- the same list the mesh is built from, so a vantage picked out of it
- -- is guaranteed to have lights in frame.
- local ROUTES = { "ROUTE_1", "ROUTE_2", "ROUTE_22", "ROUTE_3" }
- local routeId, spotX, spotY
- for _, id in ipairs(ROUTES) do
- if game.data.maps and game.data.maps[id] then
- go(id, 5, 5)
- local map = game.stack:top() and game.stack:top().map
- local flies = map and ForestAtmos.grassFliesFor(map)
- if flies and #flies > 0 then
- -- the middle of the swarm, so the frame holds many rather than one
- local sx, sy, n = 0, 0, 0
- for _, fly in ipairs(flies) do
- sx, sy, n = sx + fly.x, sy + fly.z, n + 1
- end
- routeId = id
- spotX = math.floor(sx / n / 16)
- spotY = math.floor(sy / n / 16)
- U.log(("%s: %d fireflies over its grass, centre cell %d,%d")
- :format(id, #flies, spotX, spotY))
- break
- end
- U.log(id .. ": no grass fireflies dealt")
- end
- end
-
- if not routeId then
- U.log("no route in this dataset got a grass swarm -- nothing to shoot")
- return
- end
-
- -- ------- the route: night, then the same frame by day
- setTime("night")
- go(routeId, spotX, spotY + 3, "up")
- U.shot(game, ROOT .. "/10_route_night.png")
- go(routeId, spotX, spotY + 3, "up", 6) -- 1ST: eye level in the grass
- U.shot(game, ROOT .. "/11_route_night_fp.png")
- setTime("day")
- go(routeId, spotX, spotY + 3, "up")
- U.shot(game, ROOT .. "/12_route_day.png") -- must have none at all
- setTime("dusk")
- go(routeId, spotX, spotY + 3, "up")
- U.shot(game, ROOT .. "/13_route_dusk.png") -- coming on, not yet full
-
- -- ------- the seam: the swarm has to carry onto the connected neighbour
- --
- -- Stand at the map's own edge looking out of it. The neighbour's ground,
- -- trees and grass are all drawn; if its fireflies were not, the picture
- -- would have a line across it where the lights stopped.
- setTime("night")
- do
- local map = game.stack:top() and game.stack:top().map
- local h = map and map.heightCells or 20
- go(routeId, spotX, math.max(h - 2, 0), "down")
- U.shot(game, ROOT .. "/20_seam_south_night.png")
- go(routeId, spotX, 1, "up")
- U.shot(game, ROOT .. "/21_seam_north_night.png")
- end
-
- -- ------- Viridian Forest: the authored swarm and the grass one together
- if game.data.maps and game.data.maps.VIRIDIAN_FOREST then
- setTime("night")
- go("VIRIDIAN_FOREST", 17, 20)
- U.shot(game, ROOT .. "/30_forest_night.png")
- go("VIRIDIAN_FOREST", 16, 24)
- U.shot(game, ROOT .. "/31_forest_night_b.png")
- setTime("day")
- go("VIRIDIAN_FOREST", 17, 20)
- U.shot(game, ROOT .. "/32_forest_day.png") -- pollen, no fireflies
- end
-
- -- ------- the controls
- setTime("night")
- ForestAtmos.setting:sync("low")
- go(routeId, spotX, spotY + 3, "up")
- U.shot(game, ROOT .. "/40_route_night_low.png") -- the tuft cards alone
- ForestAtmos.setting:sync("off")
- go(routeId, spotX, spotY + 3, "up")
- U.shot(game, ROOT .. "/41_route_night_off.png") -- a pre-feature run
-
- ForestAtmos.setting:sync("full")
- ForestAtmos.frozen = false
- setTime("day")
- U.log("done -- " .. ROOT)
-end
diff --git a/tests/shiny_test.lua b/tests/shiny_test.lua
index c2b97bf..32d670a 100644
--- a/tests/shiny_test.lua
+++ b/tests/shiny_test.lua
@@ -133,6 +133,33 @@ do
eq(Shiny.ODDS_DENOM, saved, "and a sane one is accepted")
end
+do
+ -- ------- the row on the menu
+ --
+ -- The ladder's first rung is both the default and the fallback, so the
+ -- canonical 1:8192 has to be it: a player who never opens the menu, and a
+ -- corrupted options.lua, must both land on the games' own rate.
+ local s = Shiny.setting
+ eq(s.values[1], 8192, "the ladder starts at the canonical rate")
+ eq(s.labels[1], "1:8192", "and says so in the 1:# the row shows")
+ eq(s.labels[#s.labels], "1:1", "the last rung is every encounter")
+ eq(#s.values, #s.labels, "every rung has a label")
+ for i = 2, #s.values do
+ eq(s.values[i] * 2, s.values[i - 1],
+ ("rung %d is twice as often as the one above"):format(i))
+ end
+
+ -- and the roll READS it. Pulled rather than pushed, so a value written by
+ -- the mod manager's page -- which notifies nobody -- is seen too.
+ local before = s:read()
+ s:setValue(512)
+ Shiny.unpinOdds()
+ eq(Shiny.odds(), 512, "the roll follows the row without being told")
+ eq(Shiny.ODDS_DENOM, 512, "and the live field is written through")
+ s:setIndex(before)
+ Shiny.setOdds(8192) -- and pinned again, for the blocks below
+end
+
do
-- stats must follow the DVs, and a full-health mon must stay full: a wild
-- mon that appears at less than full HP is visible in the first frame