remove fireflies

This commit is contained in:
DramaticShape
2026-08-08 14:21:41 -04:00
parent b8b2e0cd33
commit 6482325e26
10 changed files with 162 additions and 524 deletions
+11 -51
View File
@@ -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
+2 -14
View File
@@ -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;
+21 -183
View File
@@ -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
+16
View File
@@ -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
+65 -5
View File
@@ -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)
+2 -4
View File
@@ -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
+18 -2
View File
@@ -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
-78
View File
@@ -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
-187
View File
@@ -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
+27
View File
@@ -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