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