shiny capture
DramaticShape · 2026-08-08 ·
parent bee22507b2
shiny capture
Changed files
CHANGELOG.md | 101 ++++++++++++++++++++++++++
data/map_atmosphere.lua | 16 ++++-
lib/ForestAtmos.lua | 204 ++++++++++++++++++++++++++++++++++++++++++++++------
lib/SettingsHelp.lua | 178 +++++++++++++++++++++++++++++++++++++++++++++
lib/SettingsMenu.lua | 88 ++++++++++++++++++-----
lib/VoxelScene.lua | 6 +-
main.lua | 121 ++++++-------------------------
tests/dramatic_shape_test.lua | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
tests/grass_firefly_shots.lua | 187 +++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1000 insertions(+), 144 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2de78e2..39b0256 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,57 @@
### 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
@@ -153,6 +204,40 @@
nor takes it away, on the same reasoning as AA -- what the look costs is
the player's question, not a preset's.
+### Added
+
+- **SELECT on any row of the mod's menus explains what it does.** Every
+ setting here has carried a paragraph of help since it was written -- it is
+ handed to the mod manager with the rest of the schema -- and nothing in the
+ engine has ever drawn one. It could not: a row is a label and a value, and
+ no options row anywhere has room for a third thing. So a row says what it
+ IS on one line and what it is SET TO on the next, and SELECT says what that
+ MEANS, which is the question RENDER DIST or 2D-3D B cannot answer in
+ eighteen characters however the label is worded.
+
+ It opens the game's own dialogue box -- drawn with the ROM's own border
+ glyphs, anchored to the bottom of the screen where this game has always put
+ text, and only as tall as the sentence it holds, so the row being asked
+ about is still visible above it. A, B, START and SELECT all close it, SELECT
+ included: it is the button somebody who just pressed it will reach for. The
+ bottom line of every one of the mod's menus now reads `B BACK SEL HELP`,
+ because a binding nobody knows about is worth nothing.
+
+ Every description is ONE SENTENCE, and the whole of it is on screen at once.
+ The long paragraphs these grew from were written for a reader that never
+ existed, and they read as documentation rather than as an answer; a box you
+ have to scroll is a worse reply to "what does this do" than a shorter
+ sentence is. Both properties are tested rather than trusted -- a description
+ that gains a second sentence, or that outgrows its box, fails the suite.
+ VOXEL, T-SHIFT and STADIUM ROM got sentences of their own to go with the
+ thirteen settings: the first two are the engine's row descriptors with
+ nowhere to keep one, and the third is an action rather than a setting.
+
+ The suite also checks every character of every description against the ROM's
+ real charmap, because Font.encode answers a glyph it does not have with a
+ SPACE and a one-time console warning -- so a curly quote pasted in from
+ somewhere would blank a word on screen and say nothing about it.
+
### Changed
- **The settings live on menus of their own now, behind one red row at the
@@ -259,6 +344,22 @@
input.step seam, and taken rather than peeked so a press the capture used
does not also page the message it just queued.
+- **No more grass smeared across the top of a LET'S GO throw.** The capture
+ seat handed BattleScene its pitch as the DEPRESSION below level, and the
+ one thing that reads it -- the camera-ward pull the grass and flowers are
+ drawn with -- measures angles off STRAIGHT DOWN, the complement. So a seat
+ looking nearly level read as the top-down end of the ladder, where the pull
+ is longest: 46 world pixels of bias, handed to a camera standing 46 world
+ pixels behind the player. The pull is a shove along each vertex's own eye
+ ray -- a pure depth bias while it is shorter than the range, and past that
+ it carries geometry THROUGH the lens, where the projection turns inside out
+ and a single tuft at the eye lands smeared across the frame. That was the
+ greenery hanging over the top of a capture shot on any route or street with
+ grass rows beside it. The seat now speaks the same convention the battle's
+ own rig does, and the vertex stage clamps the pull to half the range to the
+ eye besides -- so no camera standing this close can be smeared by a bias
+ again, in a capture, a fight, or first person.
+
- **The grass moves during a staged battle.** The wind is switched on around
the free-roam pass's grass draws and off again after them, and the battle
pass -- which draws the same tufts, on the same map, from its own camera --
diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua
index 5d6c321..b9ff808 100644
--- a/data/map_atmosphere.lua
+++ b/data/map_atmosphere.lua
@@ -13,7 +13,9 @@
-- 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.
+-- 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).
--
-- 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
@@ -36,8 +38,18 @@
-- 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
+-- fireflies count of the night shift, dealt over the map's whole volume
-- 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 884f671..cd22560 100644
--- a/lib/ForestAtmos.lua
+++ b/lib/ForestAtmos.lua
@@ -137,6 +137,7 @@ 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
@@ -156,9 +157,10 @@ end
function ForestAtmos.invalidate(mapId)
if mapId then
layoutCache[mapId] = nil
+ grassCache[mapId] = nil
meshCache[mapId] = nil
else
- layoutCache, meshCache = {}, {}
+ layoutCache, grassCache, meshCache = {}, {}, {}
shaders = {}
leafTex = nil
rayMesh = nil
@@ -194,6 +196,18 @@ 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.
@@ -203,7 +217,8 @@ 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, flies = 0, 0, 0, 0
+ local alpha, dens, motes = 0, 0, 0
+ local flies = ForestAtmos.fireflyLevel(t)
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
@@ -211,7 +226,6 @@ 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
@@ -374,6 +388,111 @@ 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)
@@ -637,6 +756,9 @@ 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;
@@ -644,7 +766,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 + vec3(
+ vec3 base = vertex_position.xyz + origin + 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);
@@ -733,12 +855,19 @@ local function buildPartMesh(points)
return mesh
end
-local function meshesFor(map, L)
+-- 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 hit = meshCache[map.id]
if hit then return hit end
+ local L = layoutFor(map)
local M = {
- motes = buildPartMesh(L.motes),
- flies = buildPartMesh(L.flies),
+ motes = L and buildPartMesh(L.motes) or nil,
+ flies = L and buildPartMesh(L.flies) or nil,
+ grass = buildPartMesh(grassFliesFor(map) or {}),
}
meshCache[map.id] = M
return M
@@ -746,6 +875,7 @@ 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
@@ -774,19 +904,29 @@ end
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
-- refused shader, no readable depth, no shadow map -- subtracts only
-- itself.
-function ForestAtmos.draw(map)
+--
+-- `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)
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")
- if f.rayAlpha > 0.01 then
- if not Voxel3D.depthReadable() then
- say("depth", "no readable depth this frame -- beams off, fog stays")
- return
- end
+ -- 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
-- 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()
@@ -843,11 +983,13 @@ function ForestAtmos.draw(map)
end
if rung == "full" then
- local L = layoutFor(map)
- local M = L and meshesFor(map, L)
+ 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 psh = partShader()
local axisR, axisU = billboardAxes(Voxel3D)
- if M and psh and axisR then
+ if (M.motes or M.flies or M.grass) and psh and axisR then
Voxel3D.blend("add")
if Voxel3D.beginEffect(psh) then
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
@@ -860,7 +1002,8 @@ function ForestAtmos.draw(map)
pcall(psh.send, psh, "axisR", axisR)
pcall(psh.send, psh, "axisU", axisU)
pcall(psh.send, psh, "time", ForestAtmos.time)
- if M.motes and f.moteLevel > 0.02 then
+ pcall(psh.send, psh, "origin", HOME)
+ if M.motes and f 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)
@@ -868,13 +1011,32 @@ function ForestAtmos.draw(map)
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
pcall(love.graphics.draw, M.motes)
end
- if M.flies and f.fireflyLevel > 0.02 then
+ -- 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
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", f.fireflyLevel * 0.85)
- pcall(love.graphics.draw, M.flies)
+ 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
end
Voxel3D.endEffect()
end
diff --git a/lib/SettingsHelp.lua b/lib/SettingsHelp.lua
new file mode 100644
index 0000000..59fa617
--- /dev/null
+++ b/lib/SettingsHelp.lua
@@ -0,0 +1,178 @@
+-- SELECT on a row explains what it does.
+--
+-- ------- why this exists at all
+--
+-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
+-- into the schema handed to the mod manager (ModSetting:schema takes it), it
+-- has been written and kept up to date beside every row in main.lua's
+-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
+-- menu, whose row is a label and a value and has no room for a third thing;
+-- not the mod manager's own page, which renders the same two lines. It was
+-- authored, structured, accurate prose sitting in a field with no reader.
+--
+-- So it gets one. A row on this mod's menus says what it IS on one line and
+-- what it is SET TO on the next, and SELECT says what that means -- which is
+-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
+-- characters however the label is worded.
+--
+-- SELECT rather than a button that already does something: A steps a setting,
+-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
+-- SELECT hotkey is installed on OverworldController:handleInput, which only
+-- runs while the overworld is the top state, so a menu can have the button
+-- without taking anything from the map.
+--
+-- ------- the shape of it
+--
+-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
+-- ROM's own glyphs and a mod-supplied font theme retextures this along with
+-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
+-- with the menu still visible above it, which is where this game has put
+-- every line of text anybody has ever read in it.
+--
+-- Sized to what it holds rather than to the screen. Each description is one
+-- sentence, so most of these are five or six tiles tall and the row being
+-- asked about is still on screen over the top of the box. A sentence long
+-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
+-- time on the d-pad -- which is a fallback, not the design: the answer to a
+-- description that needs scrolling is a shorter description.
+
+-- the mod namespace (see main.lua)
+local V = ...
+
+local Font = require("src.render.Font")
+local Theme = require("src.ui.Theme")
+local PaletteFX = require("src.render.PaletteFX")
+
+local SettingsHelp = {}
+SettingsHelp.__index = SettingsHelp
+
+-- NOT opaque: the menu stays drawn underneath, so the row being asked about
+-- is still on screen above the box. That is most of why the box is only as
+-- tall as it needs to be.
+SettingsHelp.isOpaque = false
+
+-- The box spans the screen's twenty tiles and its border owns the outer ring,
+-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
+-- is kept clear for the more-arrow, which would otherwise land on top of the
+-- last character of any line that filled the width.
+local COLS = 17
+local PEN_X = 8
+local SCREEN_ROWS = 18
+-- title, plus the body, plus the two border rows
+local CHROME_ROWS = 3
+-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
+-- characters, which is a long sentence and a box two thirds up the screen.
+local MAX_LINES = 8
+
+-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
+-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
+-- show, and this one is the whole point of the screen.
+local function wrapped(str, cols)
+ cols = cols or COLS
+ local lines, line = {}, nil
+ for word in tostring(str or ""):gmatch("%S+") do
+ local try = line and (line .. " " .. word) or word
+ if #try <= cols then
+ line = try
+ else
+ if line then lines[#lines + 1] = line end
+ -- a word longer than the line is broken across lines rather than cut;
+ -- nothing in the help text is that long today, but losing the end of a
+ -- sentence silently is not a failure mode worth leaving open
+ while #word > cols do
+ lines[#lines + 1] = word:sub(1, cols)
+ word = word:sub(cols + 1)
+ end
+ line = word
+ end
+ end
+ if line then lines[#lines + 1] = line end
+ return lines
+end
+
+SettingsHelp.wrapped = wrapped
+
+function SettingsHelp.new(game, title, body)
+ return setmetatable({
+ game = game,
+ title = tostring(title or ""):gsub("%.%.$", ""),
+ lines = wrapped(body),
+ top = 0,
+ }, SettingsHelp)
+end
+
+-- How many body lines this box shows: all of them, unless there are more than
+-- a box is allowed to be tall.
+function SettingsHelp:bodyRows()
+ return math.min(#self.lines, MAX_LINES)
+end
+
+function SettingsHelp:maxTop()
+ return math.max(0, #self.lines - self:bodyRows())
+end
+
+-- Every button that could mean "done" closes it, including SELECT itself --
+-- the press that opened the box is the one a player is most likely to reach
+-- for to get rid of it. A is in there too: it steps a setting everywhere else
+-- on these menus, and stepping one you cannot see would be worse than an
+-- extra way out.
+local DISMISS = { "a", "b", "start", "select" }
+
+function SettingsHelp:update()
+ local input = self.game and self.game.input
+ if not input then return end
+ local maxTop = self:maxTop()
+ -- the d-pad only does anything when there is something below the fold; a
+ -- box showing its whole sentence has nowhere to go and says so by not
+ -- moving
+ if input:wasPressed("down") then
+ self.top = math.min(maxTop, self.top + 1)
+ return
+ elseif input:wasPressed("up") then
+ self.top = math.max(0, self.top - 1)
+ return
+ end
+ for _, btn in ipairs(DISMISS) do
+ if input:wasPressed(btn) then
+ local stack = self.game.stack
+ if self.game.data then
+ require("src.core.Sound").play(self.game.data, "Press_AB")
+ end
+ if stack and stack:top() == self then stack:pop() end
+ return
+ end
+ end
+end
+
+function SettingsHelp:draw()
+ local body = self:bodyRows()
+ local th = body + CHROME_ROWS
+ local ty = SCREEN_ROWS - th -- anchored to the bottom of the screen
+ Font.drawBox(0, ty, 20, th)
+ love.graphics.setColor(0, 0, 0, 1)
+ -- the row's own name, so the box says what it is about even where it covers
+ -- the row that was asked
+ Font.draw(self.title, PEN_X, (ty + 1) * 8)
+ for i = 1, body do
+ local line = self.lines[self.top + i]
+ if not line then break end
+ Font.draw(line, PEN_X, (ty + 1 + i) * 8)
+ end
+ -- the same marker the options list uses for "there is more below this", so
+ -- it means here what it means there
+ if self.top < self:maxTop() then
+ Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
+ end
+ love.graphics.setColor(1, 1, 1, 1)
+end
+
+-- Game:draw stops at the first state that HAS this method, so without one the
+-- box would inherit whatever is underneath -- which is a menu of ours, whose
+-- answer happens to be right. Stated anyway: the reason that answer is right
+-- is not a property of this screen, and a future menu that paints something
+-- of its own would silently repaint this box with it.
+function SettingsHelp:sgbPalettes(game)
+ return PaletteFX.wholeNamed(game.data, "MEWMON")
+end
+
+return SettingsHelp
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
index 9984da8..0c66c1c 100644
--- a/lib/SettingsMenu.lua
+++ b/lib/SettingsMenu.lua
@@ -70,12 +70,36 @@ end
-- opening it. Where no single row speaks for the rest, it counts them, which
-- is honest rather than arbitrary.
SettingsMenu.CATEGORIES = {
- { id = "world", label = "3D WORLD.." },
+ { id = "world", label = "3D WORLD..",
+ help = "The diorama itself: how far the world bends, how much of it is "
+ .. "drawn, what the water does and what hour it is outdoors." },
{ id = "battles", label = "BATTLES..",
- summary = function() return V.require("OverworldBattle").setting:valueLabel() end },
- { id = "perf", label = "PERFORMANCE.." },
+ summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
+ help = "What a fight is drawn over, how it is framed, and how a ball is "
+ .. "thrown." },
+ { id = "perf", label = "PERFORMANCE..",
+ help = "What the look costs -- the three most expensive things in the "
+ .. "frame after the geometry itself." },
{ id = "vr", label = "VR..",
- summary = function() return V.require("VR").setting:valueLabel() end },
+ summary = function() return V.require("VR").setting:valueLabel() end,
+ help = "PCVR through OpenXR, and the one comfort setting that belongs to "
+ .. "the headset alone." },
+}
+
+-- ------- help for the rows that are not settings
+--
+-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
+-- next to the row it explains. What is left is the two pipeline rows -- whose
+-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
+-- and the ROM import, which is an action rather than a setting and has no
+-- SETTINGS entry to live in.
+local ROW_HELP = {
+ ["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
+ .. "by a 3D camera, with the numbered rungs its angle in degrees.",
+ ["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
+ .. "look, sharp across the middle and softening above and below it.",
+ ["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
+ .. "cartridge that 3D-BTL's STADIUM rungs need.",
}
-- ------- what the menus are built from
@@ -91,6 +115,25 @@ function SettingsMenu.define(list)
settings = list or {}
end
+-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
+-- the category's out of CATEGORIES, or one of the three above for the rows
+-- that have nowhere else to keep it.
+--
+-- Looked up BY ID rather than hung on the row as a field, because two of
+-- these rows are the engine's own tables reused verbatim -- and annotating
+-- somebody else's table is how a mod ends up owning a field it never meant
+-- to. nil for a row with nothing to say, which SELECT reads as "no box".
+function SettingsMenu.helpFor(id)
+ if ROW_HELP[id] then return ROW_HELP[id] end
+ for _, cat in ipairs(SettingsMenu.CATEGORIES) do
+ if SettingsMenu.id(cat.id) == id then return cat.help end
+ end
+ for _, entry in ipairs(settings) do
+ if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
+ end
+ return nil
+end
+
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
-- .rows), captured by the options hook on its way past and shown here instead
-- of at the top level. Reused verbatim, tables and all: they persist in
@@ -273,19 +316,19 @@ function SettingsMenu.signature(rows)
return table.concat(ids, "\1")
end
--- OptionRows has no room for a title: the four boxes fill the screen down to
--- the bottom line. So the bottom line carries the name as well as the way
--- out, which is the one place left to say where the player is. "BACK" alone
--- at the root, where "BACK: DRAMATIC SHAPE" would run past the 18 characters
--- the line has.
+-- The bottom line is the only place on this screen to say anything that is
+-- not a row: OptionRows' four boxes fill everything above it and there is no
+-- header slot. It spends that line on the two buttons that are not obvious.
+--
+-- It used to carry the category's NAME instead, for orientation. The hint
+-- won: a binding nobody knows about is worth nothing, and where the player is
+-- was just answered by the row they pressed A on. Sixteen characters of the
+-- eighteen the line has, which is also why the name could not stay -- "BACK:
+-- PERFORMANCE" is seventeen on its own.
+SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
+
function SettingsMenu:backLabel()
- if self.cat == SettingsMenu.ROOT then return "BACK" end
- for _, cat in ipairs(SettingsMenu.CATEGORIES) do
- if cat.id == self.cat then
- return "BACK: " .. (cat.label:gsub("%.%.$", ""))
- end
- end
- return "BACK"
+ return SettingsMenu.BACK_LABEL
end
-- A category's contents can change while the player is looking at them: 3D-BTL
@@ -349,6 +392,19 @@ function SettingsMenu:update()
pop(self)
return
end
+ elseif input:wasPressed("select") then
+ -- SELECT explains the row the cursor is on. Every row on these menus has
+ -- something to say -- the settings have carried a paragraph each since
+ -- they were written, and nothing has ever drawn it (see SettingsHelp) --
+ -- but a row that does not is simply left alone rather than opening an
+ -- empty box.
+ local row = rows[self.index]
+ local help = row and SettingsMenu.helpFor(row.id)
+ if help and self.game.stack then
+ self.game.stack:push(
+ V.require("SettingsHelp").new(self.game, row.label, help))
+ end
+ return
elseif input:wasPressed("b") or input:wasPressed("start") then
-- B and START both, like every other menu -- and one level only: this
-- pops US, leaving the OPTIONS menu underneath exactly as the player
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 6239deb..179fc8f 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -1246,8 +1246,10 @@ 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.
- ForestAtmos.draw(state.map)
+ -- 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 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 39baf40..055e63e 100644
--- a/main.lua
+++ b/main.lua
@@ -475,40 +475,20 @@ local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
cat = "world" },
{ WorldCurve.setting,
- "Bend the world down over the horizon, Animal Crossing style. 1 is a "
- .. "hint of roll at the frame edges and 2 is the classic read; 3 is as "
- .. "far as it goes before the horizon closes over ground you can still "
- .. "walk into. 4 and 5 are past that on purpose and they are for a "
- .. "headset's DIORAMA, where the world is a model being looked at "
- .. "rather than walked around in -- 5 curls it into a half sphere, a "
- .. "town on top of its own little planet.",
+ "Bends the world down over the horizon, until a town sits on top of its "
+ .. "own little planet.",
cat = "world" },
{ ViewBox.setting,
- "How much of the map the camera bothers to draw. FIT is exactly the "
- .. "ground on screen and no more -- the shape a tilted camera really "
- .. "frames, which reaches well north of you and flares wide out there, "
- .. "not the square the flat game shows. So a connected map that falls "
- .. "entirely outside it is skipped before it is drawn, terrain, water, "
- .. "grass and shadows together, which is most of the frame's geometry "
- .. "at the high rungs. Below about 63 degrees that is all the row does "
- .. "and the picture is untouched. At 75 the camera can see all the way "
- .. "to the horizon, so something has to name a distance: FIT is the "
- .. "closest, WIDE through WIDEST push the world's edge further out, "
- .. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are "
- .. "standing in the world there -- and the box opens out and away as "
- .. "the camera dives in.",
+ "How far out the camera bothers to draw, which only changes the picture "
+ .. "above about 63 degrees where the horizon comes into view.",
cat = "world" },
{ Water.setting,
- "Reflections on water. FULL adds screen-space reflections of the "
- .. "shoreline, the trees and the buildings behind it; SKY is the sky, "
- .. "the sun and the moon alone, which is most of the look for a "
- .. "fraction of the cost.",
+ "Reflections on water: SKY is the sun, moon and sky alone, and FULL "
+ .. "adds the shoreline and trees behind it.",
cat = "world" },
{ DayNight.setting,
- "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
- .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
- .. "shadows, the sky and the light following -- or SYNC it to the "
- .. "clock on the wall, so Kanto's evening falls when yours does.",
+ "What time it is outdoors -- pinned to an hour, running on a ten-minute "
+ .. "cycle, or synced to the clock on your wall.",
cat = "world" },
-- ------- BATTLES -- what a fight is drawn over, and how it is played
@@ -520,45 +500,24 @@ local SETTINGS = {
-- and forbids back sprites (backPinned answers false), so both rows
-- decide nothing there and a dead switch on the menu reads as broken.
{ OverworldBattle.setting,
- "Fight in three dimensions, shot over the shoulder with a slow parallax "
- .. "drift. 2D-3D stands the game's own battle pics up as cards; STADIUM "
- .. "replaces them with the Pokemon Stadium battle models, animated, "
- .. "playing the animation the move being used actually calls for. A "
- .. "stages the fight on the MAP -- the nearest clear ground, in that "
- .. "place's own weather and light; B stands it on two discs against the "
- .. "sky instead, which works everywhere, including the caves and shop "
- .. "floors that have nowhere to stage a fight. The STADIUM rungs only "
- .. "appear once the models have been built, and building them needs a "
- .. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
- .. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
- .. "other version works: the reader is keyed to that one cartridge.",
+ "Fights staged in 3D over your shoulder, on the map or on discs against "
+ .. "the sky, as cards or Stadium's animated models.",
cat = "battles",
when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
-- and a row that no longer decides anything is worse than no row.
{ OverworldBattle.backSetting,
- "Keep your own Pokemon on the battle menu, seen from behind in its "
- .. "original slot, instead of standing it on the map facing the foe. "
- .. "The foe is still out there on its own tile.",
+ "Keeps your own Pokemon on the battle menu, seen from behind, instead "
+ .. "of standing it on the map facing the foe.",
cat = "battles",
when = function() return stagedBattles() and not VR.enabled() end,
full = true },
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
-- the diorama, so the FULL preset neither sets it nor takes it away.
{ LetsGo.setting,
- "Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, "
- .. "a finger or the right stick to throw the ball at the wild Pokemon "
- .. "-- spin it first for a curve -- and land inside the shrinking "
- .. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. "
- .. "CATCH ONLY changes nothing else: picking a ball in battle simply "
- .. "plays the throw. FULL is the whole Let's Go treatment: wild "
- .. "encounters open straight in throwing mode (B backs out to the "
- .. "classic menu), Poke/Great/Ultra Balls are half price, and a catch "
- .. "pays the whole party experience -- scaled by throw quality, first "
- .. "throws, new species and your running catch combo. Needs 3D-BTL "
- .. "on; anywhere the staged fight cannot stand, balls quietly throw "
- .. "the classic way.",
+ "Pokemon GO-style catching -- flick to throw the ball, with FULL adding "
+ .. "half-price balls and party experience (needs 3D-BTL).",
cat = "battles", full = true },
-- ------- PERFORMANCE -- what the look COSTS, which is a different question
@@ -570,39 +529,21 @@ 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,
- "The air of the deep woods (Viridian Forest): a ground haze, and "
- .. "volumetric light let down through the unseen canopy overhead -- "
- .. "gold spears of sun by day, silver moon rays at night, pollen "
- .. "drifting through the beams and fireflies once they cool. LOW "
- .. "keeps the haze, halves the beam march and stands the particles "
- .. "down. On a phone the row offers LOW alone: the beams need a "
- .. "depth texture the pass can read back, and no mobile driver here "
- .. "grants one.",
+ "Haze and light shafts in the deep woods, with pollen in the beams by "
+ .. "day and fireflies over tall grass 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
-- a question about the machine rather than a knob on the diorama, and it
-- has to stay reachable from inside FULL -- which never sets it either.
{ Shadows.setting,
- "Real cast shadows: the scene rendered a second time from the sun, so "
- .. "buildings, trees, ledges and people throw shadows that climb walls, "
- .. "drape over roofs and slide across each other, following the hour on "
- .. "the DAYTIME row. It is the most expensive pass in the mode after the "
- .. "geometry itself -- a whole extra draw of the world every time the "
- .. "view or anybody in it moves -- so OFF is the first thing to try on a "
- .. "phone or an old machine. OFF is no shadow at all, the flat drop "
- .. "shadows under characters included, and the forest's light shafts go "
- .. "with it: the beams are lit by the sun's own map.",
+ "Real cast shadows from the sun, and the first thing to switch off on a "
+ .. "phone or an old machine.",
cat = "perf", full = true },
-- Marked `full` for the opposite reason the battle rows are: this is not a
-- knob on the look at all, it is what the look COSTS.
{ AntiAlias.setting,
- "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
- .. "lips, a tree against the sky -- by rendering the diorama larger than "
- .. "the window and folding it back down. Every edge in the picture "
- .. "softens with them, the tileset's own texels included, so the diorama "
- .. "reads smoother rather than sharper. 2X costs half again as many "
- .. "pixels in each direction and 4X twice, which makes this the most "
+ "Smooths the stair-stepped edges of the 3D world, and the most "
.. "expensive row in the mod.",
cat = "perf", full = true },
@@ -611,21 +552,8 @@ local SETTINGS = {
-- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk.
{ VR.setting,
- "PCVR through OpenXR (SteamVR, Oculus, WMR). STANDARD follows the VOXEL "
- .. "ladder: the orbit rungs become a tabletop model your head moves "
- .. "around, and the 1ST rung stands you inside the world at life size, "
- .. "looking where the headset looks. DIORAMA is one presentation "
- .. "instead -- the world always a model, cut to a square viewport you "
- .. "grab with the grips to carry, turn and open out, with a "
- .. "fight arriving as a floating disc of the map. There is no 2D and "
- .. "no first person in it, and the left stick's click throws V-CURVE "
- .. "to its top rung and back -- which turns the square cut into a ball "
- .. "with a dissolved rim, because a bent world has no straight sides. "
- .. "DIORAMA-MR is the same with the background keyed green, for a "
- .. "mixed-reality capture. "
- .. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
- .. "and the mod running from a real folder; without them the row stays "
- .. "and the game stays flat, with the reason on the console.",
+ "PCVR through OpenXR on Windows, either following the VOXEL ladder or "
+ .. "as a DIORAMA you carry and turn with the grips.",
cat = "vr",
-- on Windows the row stays even when a runtime is missing (the console
-- says why); off Windows -- mobile above all -- there is no VR to have
@@ -635,11 +563,8 @@ local SETTINGS = {
-- device that is not plugged in decides nothing, and this one is read
-- exclusively by the headset's right stick.
{ VR.smoothTurn,
- "Turn smoothly with the right stick instead of snapping 45 degrees a "
- .. "flick. OFF by default, and deliberately: a software turn moves the "
- .. "world past a head that did not move, which is the most reliable way "
- .. "to make somebody ill in a headset. Turn it on if you have your sea "
- .. "legs and want the continuity.",
+ "Turns smoothly with the right stick instead of snapping 45 degrees, "
+ .. "if you have your sea legs for it.",
cat = "vr",
-- and only under STANDARD: the stick turns a HEAD, and neither diorama
-- mode has the player standing in the world to be turned
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 7a5a3f0..c93b9f3 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -567,7 +567,10 @@ local root = menuGame.stack:top()
T.check(root ~= nil, "pressing A pushed a screen")
T.eq(#root.rows, #Menus.lib.rows(Menus.lib.ROOT, menuGame),
"carrying the root menu's rows")
-T.eq(root:backLabel(), "BACK", "and a way out named for what it does")
+T.eq(root:backLabel(), "B BACK SEL HELP",
+ "and a bottom line naming the two buttons that are not obvious")
+T.check(#root:backLabel() <= 18,
+ "which fits the eighteen characters the line has")
-- the second line of a category row
T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(),
@@ -582,10 +585,9 @@ root.index = rowIndex(root, Menus.catId("world"))
pressed = { a = true }
root:update(0)
pressed = {}
-T.eq(menuGame.stack:top():backLabel(), "BACK: 3D WORLD",
- "A on a category opens it, and the bottom line says which one -- "
- .. "OptionRows has no header, so the way out carries the name")
-T.check(menuGame.stack:top() ~= root, "which is a screen of its own")
+T.check(menuGame.stack:top() ~= root,
+ "A on a category opens it, as a screen of its own")
+T.eq(menuGame.stack:top().cat, "world", "the one the cursor was on")
pressed = { b = true }
menuGame.stack:top():update(0)
@@ -597,6 +599,159 @@ pressed = {}
T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does")
end
+-- ------- SELECT explains the row the cursor is on
+--
+-- Every setting has carried a paragraph of help since it was written -- it
+-- goes into the schema the mod manager is handed -- and nothing in the engine
+-- has ever drawn one. A row says what it IS and what it is SET TO, and no
+-- engine row anywhere has room for a third thing.
+do
+menuGame.stack.states = {}
+Pipelines.setLevel("voxel", 2)
+local Help = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsHelp")
+local menu2 = Menus.lib.new(menuGame, "world")
+
+-- every row on every menu has something to say, the two engine-owned pipeline
+-- rows and the ROM import included -- those three keep theirs in SettingsMenu,
+-- having no SETTINGS entry of their own to keep it in
+for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+ for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+ T.check(Menus.lib.helpFor(row.id),
+ ("%s has help behind SELECT"):format(row.id))
+ end
+end
+T.check(not Menus.lib.helpFor("nothing:here"),
+ "and a row with nothing to say gets nil rather than an empty box")
+
+-- every character of every one of them has a glyph in the ROM's font.
+--
+-- Read from the REAL charmap rather than the fixture's, which is a stub with
+-- seven entries in it. This is the one place in the mod where prose reaches
+-- the screen verbatim, and Font.encode answers a character it does not know
+-- with a SPACE and a one-time console warning that nobody is reading -- so a
+-- curly quote or a `%` pasted in from somewhere would blank a word and say
+-- nothing about it.
+do
+ local ok, font = pcall(dofile, "data/generated/font.lua")
+ if ok and font and font.charmap then
+ local glyph = { [" "] = true }
+ for _, e in ipairs(font.charmap) do
+ if #e.seq == 1 then glyph[e.seq] = true end
+ end
+ local missing = {}
+ for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+ for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+ for ch in (Menus.lib.helpFor(row.id) or ""):gmatch(".") do
+ if not glyph[ch] then missing[ch] = true end
+ end
+ end
+ end
+ local names = {}
+ for ch in pairs(missing) do names[#names + 1] = ("%q"):format(ch) end
+ table.sort(names)
+ T.eq(#names, 0, "every character of every help string has a glyph in the "
+ .. "ROM font -- these do not: " .. table.concat(names, " "))
+ end
+end
+
+menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
+pressed = { select = true }
+menu2:update(0)
+pressed = {}
+local box = menuGame.stack:top()
+T.check(box ~= nil and box ~= menu2, "SELECT opens a box over the menu")
+T.eq(box.title, "RENDER DIST", "titled with the row it explains")
+T.check(#box.lines > 1, "carrying the row's own description, wrapped")
+for _, line in ipairs(box.lines) do
+ T.check(#line <= 17,
+ "every line fits between the border and the more-arrow's column")
+end
+
+-- ------- every description is ONE SENTENCE, and the whole of it is on screen
+--
+-- Both halves matter and the second follows from the first. The box is sized
+-- to what it holds and anchored to the bottom, so a description that fits is
+-- read at a glance with the row it describes still visible above it -- and one
+-- that does not turns a glance into a scroll, which is a worse answer to
+-- "what does this do" than a shorter sentence would have been.
+--
+-- One sentence is: ends with a full stop, and has none inside it. The version
+-- numbers that would break that naive test ("US 1.0") have no space after the
+-- point, which is why the check is for ". " rather than for ".".
+do
+ local long, many = {}, {}
+ for _, cat in ipairs({ Menus.lib.ROOT, "world", "battles", "perf", "vr" }) do
+ for _, row in ipairs(Menus.lib.rows(cat, menuGame)) do
+ local help = Menus.lib.helpFor(row.id)
+ if help then
+ if help:find(". ", 1, true) or not help:find("%.$") then
+ many[#many + 1] = row.id
+ end
+ local probe = Help.new(menuGame, row.label, help)
+ if probe:maxTop() > 0 then
+ long[#long + 1] = ("%s (%d lines)"):format(row.id, #probe.lines)
+ end
+ end
+ end
+ end
+ T.eq(#many, 0,
+ "every description is one sentence -- these are not: "
+ .. table.concat(many, ", "))
+ T.eq(#long, 0,
+ "and every one of them fits its box without scrolling: "
+ .. table.concat(long, ", "))
+end
+
+-- the scroll is still there, for a description that outgrows the box despite
+-- the check above -- a mod-supplied font with a wider glyph, or a sentence
+-- somebody lengthens later. It stops at both ends rather than wrapping.
+do
+ local tall = Help.new(menuGame, "TALL", ("word "):rep(80))
+ T.check(tall:maxTop() > 0, "a description too long for the box scrolls")
+ T.eq(tall.top, 0, "opening at the top")
+ pressed = { up = true }
+ tall:update()
+ pressed = {}
+ T.eq(tall.top, 0, "up at the top stays there")
+ pressed = { down = true }
+ tall:update()
+ pressed = {}
+ T.eq(tall.top, 1, "down moves one line")
+ for _ = 1, 200 do
+ pressed = { down = true }
+ tall:update()
+ pressed = {}
+ end
+ T.eq(tall.top, tall:maxTop(), "and the end of the text ends the scroll")
+ T.eq(tall:maxTop(), #tall.lines - tall:bodyRows(),
+ "with the last boxful still full, not one line stranded at the bottom")
+end
+
+-- and every button that could mean "done" closes it -- SELECT included, which
+-- is the one a player who just pressed it will reach for
+for _, btn in ipairs({ "a", "b", "start", "select" }) do
+ menuGame.stack.states = {} -- the scrolled box above is still open
+ menu2.index = rowIndex(menu2, "DRAMATIC_SHAPE:viewbox")
+ pressed = { select = true }
+ menu2:update(0)
+ pressed = {}
+ T.check(menuGame.stack:top() ~= nil, "the box is open")
+ pressed = { [btn] = true }
+ menuGame.stack:top():update()
+ pressed = {}
+ T.eq(menuGame.stack:top(), nil, ("%s closes it"):format(btn:upper()))
+end
+
+-- a title strips the "..'" a category row wears to say it opens something:
+-- inside the box it has already opened, the dots are a promise about a press
+-- that has been made
+T.eq(Help.new(menuGame, "3D WORLD..", "x").title, "3D WORLD",
+ "a category's box is titled without the dots that meant `opens something'")
+-- and the wrap never strands a word it cannot fit
+T.eq(#Help.wrapped(("y"):rep(40))[1], 17,
+ "a word longer than the line is broken across lines rather than cut")
+end
+
-- ------- the mod's row is RED, which is a palette zone and not a color
--
-- setColor cannot do it here twice over: the glyph atlas is black ink and LOVE
@@ -4531,6 +4686,84 @@ 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
new file mode 100644
index 0000000..211baec
--- /dev/null
+++ b/tests/grass_firefly_shots.lua
@@ -0,0 +1,187 @@
+-- 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