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