From dde0879527663c5af6cb31aa2533e617f1a6306c Mon Sep 17 00:00:00 2001 From: Code-Grub <34581585+Code-Grub@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:45:56 -0400 Subject: [PATCH 1/3] keep a sloped roof's surface off its own outline cap The hip ends of the gabled house and Oak's lab come out as black teeth marching down the slope instead of battens running with it. The depth->row map is authored for columns the drawing paints edge to edge. A tapered column starts further down the band, and the surface was clamped into its first DRAWN row to stay inside the silhouette -- but a column's first drawn row is its silhouette CAP, and the cap is outline black. On a tapered column the map spends most of the roof's depth above that cap (seven of the gabled house's fifty depth voxels land on it outright), so the clamp painted one outline pixel the length of the slope and roofCycle beat against it: black punctuated by the real batten colour every 4 rows on the house, every 8 on the lab. Lift to the column's first PAINTED row instead -- the same refusal to let outline black stand as a face that measure() already makes for the side faces, which the roof band was never given. Geometry is untouched: voxel, shell and recess counts are identical for all 50 templates. verify_roof now asserts no roof surface samples its column's cap, and tests/roof_cap_shots.lua shoots the two Pallet Town drawings A/B. The version bump and CHANGELOG entry are left out deliberately, so this does not collide with the release flow. --- lib/Buildings.lua | 33 +++++++++++++--- tests/roof_cap_shots.lua | 83 ++++++++++++++++++++++++++++++++++++++++ tools/building_voxels.py | 72 ++++++++++++++++++++++++++-------- 3 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 tests/roof_cap_shots.lua diff --git a/lib/Buildings.lua b/lib/Buildings.lua index def6c81..c466965 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -264,6 +264,27 @@ local function measure(sp, t) top[x] = r end + -- The row a column's roof SURFACE may sink to. `top[x]` is the + -- silhouette cap -- the black the drawing closes its shape with -- and + -- the depth map spends most of a tapered column's depth above it, so + -- clamping onto `top[x]` paints that one outline pixel the length of + -- the slope and the courses beat against it. The surface belongs on the + -- first PAINTED row instead: the same refusal to let the outline stand + -- as a face that the side faces already make below. + local surfaceTop = {} + for x = 0, W - 1 do + local y = top[x] + while y < roofRows and sp.inside[y * W + x] + and sp.col[y * W + x] == BLACK do + y = y + 1 + end + if y < roofRows and sp.inside[y * W + x] then + surfaceTop[x] = y + else + surfaceTop[x] = top[x] + end + end + -- The drawing's own ground line: the row after the last drawn one. A -- building ends on the black threshold row it stands on (ground == H), -- but furniture is drawn standing on open floor -- the lab table's @@ -386,7 +407,7 @@ local function measure(sp, t) -- building. `depthPx` names it in voxels, for an object whose real -- depth is not a whole tile row -- the Bike Shop toolbox is a box -- standing in the middle of its own cell, not a thing that fills a plot. - return { top = top, ytop = ytop, + return { top = top, surfaceTop = surfaceTop, ytop = ytop, D = t.depthPx or ((t.depth or #t.tiles) * 8), ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } @@ -882,6 +903,7 @@ local function model(sp, pr, t) local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows local top, ytop, ground = pr.top, pr.ytop, pr.ground + local surfaceTop = pr.surfaceTop -- The roof's drawn span. A sprite inset from its box (B03) leaves outer -- columns undrawn in the roof band; they carry no roof at all, and the @@ -931,11 +953,12 @@ local function model(sp, pr, t) if top[x] < roofRows and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then - -- the surface itself. Clamping the row into the column's first - -- drawn row keeps the flank battens running down the slope - -- instead of falling off the silhouette. + -- the surface itself. Lifting the row into the column's first + -- PAINTED row keeps the flank battens running down the slope + -- instead of falling off the silhouette -- and off its cap, which + -- is outline black and belongs to the rim, not to the surface. local sy = roofSy[z] - if sy < top[x] then sy = top[x] end + if sy < surfaceTop[x] then sy = surfaceTop[x] end return sy * W + x end -- The rim reproduces the eave the drawing itself paints under the diff --git a/tests/roof_cap_shots.lua b/tests/roof_cap_shots.lua new file mode 100644 index 0000000..854f040 --- /dev/null +++ b/tests/roof_cap_shots.lua @@ -0,0 +1,83 @@ +-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit +-- rungs, to see what the roof surface wears down a TAPERED flank. +-- +-- A tapered column's first drawn row is its silhouette cap, and the cap is +-- outline black. The depth map spends most of the roof's depth above that +-- cap, so a surface clamped onto it paints the outline the length of the +-- slope and the course cycle beats against it -- black teeth marching down +-- the hip. The surface belongs on the column's first PAINTED row instead. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \ +-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before 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 "shots/roofcap") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cap] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + 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 + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + 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(30) + end + + -- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the + -- two tapered roof bands standing on one map -- 16 drawn rows and 32, + -- the two groups every other sloped building borrows its band table + -- from. Standing south of each puts the hip end across the frame. + local SCENES = { + { map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" }, + { map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" }, + { map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[cap] capture missed: " .. path) end + end + end + print(("[cap] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index c8718c0..bc25643 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -1246,6 +1246,22 @@ def profile(sp, t): wall_h = ground - t["roof_rows"] ytop = wall_h - 1 + t["slab"] + # The row a column's roof SURFACE may sink to. `top[x]` is the + # silhouette cap -- the black the drawing closes its shape with -- and + # the depth map spends most of a tapered column's depth above it, so a + # clamp onto `top[x]` paints that one outline pixel across the slope + # and the courses beat against it. The surface belongs on the first + # PAINTED row instead, which is the same thing the side faces already + # do when the drawing's own pixel is the outline. + surface_top = [] + for x in range(W): + y = top[x] + while (y < t["roof_rows"] and inside(x, y) + and sp["col"][y][x] == BLACK): + y += 1 + surface_top.append(y if y < t["roof_rows"] and inside(x, y) + else top[x]) + # Recesses: the panes the art seals behind a black frame. Non-black # pixels of the facade split into components across the black outline; # a component small enough to be a window or a doorway sinks one voxel. @@ -1287,7 +1303,8 @@ def profile(sp, t): # Depth is the PLOT. For a whole-drawing building that is the grid # itself; `depth` (in tile rows) names it when the grid runs past the # plot onto ground the drawing merely stands its legs on. - return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, + return dict(top=top, surface_top=surface_top, + wall_h=wall_h, ytop=ytop, recess=recess, inside=inside, # `depth` names the plot in TILE ROWS, which is the right # grain for a building. `depth_px` names it in voxels, for @@ -1697,6 +1714,29 @@ def build_desk_set(sp, pr, t): return vox +def roof_surface_row(pr, t): + """Which drawn row the roof surface wears at column x, depth z. + + The drawing looks at the roof from the north, so its rows ARE depth: the + rims map one row per voxel and the middle cycles a run whose period is + the course rhythm. A tapered column's band starts lower down the + drawing, and the row it wears is lifted to stay inside it.""" + z0, z1 = 0, pr["D"] - 1 + t["front_eave"] + back, front = t["roof_back"], t["roof_front"] + c0, c1 = t["roof_cycle"] + rows, floor = t["roof_rows"], pr["surface_top"] + + def depth_row(z): + df, db = z - z0, z1 - z # from the north / the south edge + if df < back: + return df # north rim: the drawing's top rows + if db < front: + return rows - 1 - db # south rim: fascia and eave course + return c0 + (df - c0) % (c1 - c0 + 1) + + return (lambda x, z: max(depth_row(z), floor[x])), z0, z1 + + def build(sp, pr, t): """The voxel model. Order is load-bearing: walls, ledge, recesses, then the roof solid overwrites what it intersects and the walls are trimmed @@ -1775,17 +1815,7 @@ def build(sp, pr, t): vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None) # ---- roof: flat top over the plateau, stepped diagonal ends - z0, z1 = 0, D - 1 + t["front_eave"] - back, front = t["roof_back"], t["roof_front"] - c0, c1 = t["roof_cycle"] - - def roof_sy(z): - df, db = z - z0, z1 - z # from the north / the south edge - if df < back: - return df # north rim: the drawing's top rows - if db < front: - return t["roof_rows"] - 1 - db # south rim: fascia and eave course - return c0 + (df - c0) % (c1 - c0 + 1) + surface_row, z0, z1 = roof_surface_row(pr, t) shade_px = {} for sy in range(H): @@ -1797,10 +1827,7 @@ def build(sp, pr, t): tt = T(x) for z in range(z0, z1 + 1): outer = x == x0d or x == x1d or z == z0 or z == z1 - # the slope's texture is the drawing's own: clamping into the - # column's first drawn row keeps flank battens running down the - # slope instead of falling off the silhouette - sy = max(roof_sy(z), pr["top"][x]) + sy = surface_row(x, z) for y in range(tt - slab + 1, tt + 1): if y == tt and not outer: put(x, y, z, x, sy) @@ -2043,6 +2070,19 @@ def verify_roof(vox, pr, t): # drawing's own corner rounding assert all(ytop - v <= 1 for v in prof), "the flat roof is not level" + # The surface is surface, not outline. `top[x]` is the column's own + # silhouette cap -- the black the drawing closes the shape with, the + # same black measure() already refuses to let stand as a wall's side + # face -- so a surface that samples it promotes a boundary decoration + # into texture. A tapered column's cap sits well down the band, where + # the depth map spends most of the roof's depth above it. + surface_row, sz0, sz1 = roof_surface_row(pr, t) + for x in roofed[1:-1]: + for z in range(sz0 + 1, sz1): + assert surface_row(x, z) != top[x], \ + f"roof surface wears the silhouette cap at {x},{z} " \ + f"(row {top[x]})" + # every wall column carries roof over it -- and a column the roof never # reaches carries nothing at all, rather than being silently trimmed away over = set(roofed) From 6240b50cec304b3c1d0fd4134fddc06152592947 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Wed, 5 Aug 2026 20:36:46 -0400 Subject: [PATCH 2/3] update all battle locations --- CHANGELOG.md | 2 +- data/battle_arenas.lua | 152 ++-- lib/BattleArena.lua | 80 +- lib/BattleCam.lua | 23 +- lib/BattleScene.lua | 4 + tests/arena_config.lua | 197 ++++ tests/arena_editor.lua | 1617 +++++++++++++++++++++++++++++++++ tests/dramatic_shape_test.lua | 103 +++ tests/stadium_all_shots.lua | 157 ++++ 9 files changed, 2260 insertions(+), 75 deletions(-) create mode 100644 tests/arena_config.lua create mode 100644 tests/arena_editor.lua create mode 100644 tests/stadium_all_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e35b6..b6d4873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.6.1 ### Fixed diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua index 52714a1..33f7164 100644 --- a/data/battle_arenas.lua +++ b/data/battle_arenas.lua @@ -23,6 +23,20 @@ -- -- SHOT_DIR=.scratchpad/arenas \ -- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love . +-- +-- To change ONE map's spot, or to go over these by eye, run the editor +-- instead -- it is the same choice made in front of the map rather than in a +-- batch. It slides the arena around on a plan of the map with the fit, the +-- clearance and the camera's own sightlines answering live, stages a real +-- battle on the spot when asked, and writes this file back a map at a time, +-- replacing only the lines that changed and leaving every comment here alone: +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec . +-- +-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over; +-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it +-- moved describes where that spot USED to be, and it says so by name at +-- export time -- those are the lines to re-word by hand. -- `cam = "wide"` on an entry swaps the long default lens for the 44-degree -- one (BattleCam.RIGS). Both frame the same composition -- the two mons land @@ -49,14 +63,14 @@ return { -- height along the sightline and a hedge in the apron row is not terrain. -- So the choice is where in the clearing, and this is its west end: tree -- line square behind the pair, nothing crossing either of them. - ["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" }, + ["ROUTE_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 }, ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" }, - ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" }, - ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" }, - ["ROUTE_5"] = { x = 13, y = 24, shape = "wide" }, - ["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" }, - ["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" }, - ["ROUTE_8"] = { x = 25, y = 7, shape = "wide" }, + ["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 }, + ["ROUTE_4"] = { x = 46, y = 4, shape = "wide" }, + ["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 }, + ["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" }, + ["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" }, + ["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 }, -- the whole route admits six bare wide arenas, all in the west cliff -- corridor; this is the best of them. A flower cluster crosses the far -- mon's hind legs, which the brief allows -- every alternative put a @@ -64,13 +78,13 @@ return { ["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" }, ["ROUTE_10"] = { x = 7, y = 40, shape = "wide" }, ["ROUTE_11"] = { x = 9, y = 6, shape = "wide" }, - ["ROUTE_12"] = { x = 0, y = 73, shape = "wide" }, - ["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" }, - ["ROUTE_14"] = { x = 11, y = 25, shape = "wide" }, - ["ROUTE_15"] = { x = 9, y = 10, shape = "wide" }, - ["ROUTE_16"] = { x = 6, y = 10, shape = "wide" }, + ["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 }, + ["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" }, + ["ROUTE_14"] = { x = 11, y = 24, shape = "wide" }, + ["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 }, + ["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 }, ["ROUTE_17"] = { x = 14, y = 70, shape = "wide" }, - ["ROUTE_18"] = { x = 11, y = 4, shape = "wide" }, + ["ROUTE_18"] = { x = 11, y = 3, shape = "wide" }, -- ------- buildings and caves -- @@ -78,10 +92,10 @@ return { -- or gravestones in it rarely holds a 3x6 clearing whose whole width is -- also SEEN, and giving up the apron is usually the difference between a -- fight in the open and one behind a console. - ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" }, - ["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" }, - ["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" }, - ["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" }, + ["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" }, + ["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" }, + ["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" }, + ["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 }, ["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" }, ["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" }, -- every one of 4F's thirty candidate spots puts a gravestone through a @@ -89,73 +103,79 @@ return { -- is shot there ["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6, shape = "wide" }, - ["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" }, - ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" }, + ["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" }, + ["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" }, ["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" }, ["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" }, ["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" }, ["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" }, - ["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" }, - ["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" }, - ["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" }, - ["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" }, + ["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" }, + ["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" }, + ["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15, + shape = "narrow", turn = 90, cam = "wide" }, + ["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" }, ["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" }, - ["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" }, - ["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" }, - ["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" }, + ["SAFARI_ZONE_EAST"] = { x = 16, y = 8, + shape = "wide", turn = 90, cam = "wide" }, + ["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 }, + ["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" }, ["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" }, - ["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" }, - ["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" }, - ["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" }, - ["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" }, + ["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8, + shape = "wide", turn = 90, cam = "wide" }, + ["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 }, + ["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 }, + ["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" }, -- Silph Co is office floors partitioned into small rooms, so the long lens -- often lands outside the walls it is meant to be looking between; the -- floors that could not be framed any other way ask for the wide one. - ["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" }, - ["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" }, + ["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 }, + ["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 }, ["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" }, - ["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" }, + ["SILPH_CO_6F"] = { x = 19, y = 2, + shape = "narrow", turn = 90, cam = "wide" }, ["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" }, ["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" }, ["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" }, - ["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" }, + ["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" }, -- the ship is all two-cell corridors, so the wide arena shape fits nowhere -- aboard and the long lens always lands outside the hull ["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" }, -- these two decks are byte-identical geometry, so they take the same spot - ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, - ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" }, + ["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" }, + ["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" }, ["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" }, - ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" }, - ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" }, + ["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" }, + ["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" }, -- ------- towns and the last interiors ["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" }, - ["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" }, + ["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" }, -- the lab is ten cells by twelve, so the long lens is always off-map, and -- on it a desk clipped one mon and a pillar the other - ["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" }, + ["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" }, -- Saffron's gym is a grid of small walled cells: no wide shape exists -- anywhere in it, and the long lens sits inside a divider ["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" }, ["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" }, - ["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" }, - ["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" }, + ["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" }, + ["SILPH_CO_11F"] = { x = 10, y = 6, + shape = "wide", turn = 180, cam = "wide" }, -- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the -- gym's barrier, so arenas there silently fail the fit test - ["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" }, - ["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" }, + ["VERMILION_GYM"] = { x = 3, y = 2, + shape = "narrow", turn = 90, cam = "wide" }, + ["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" }, ["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" }, -- ------- the remaining routes - ["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" }, + ["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" }, -- the two surf routes fight AFLOAT, in the middle of their own sea rather -- than on the rim of beach the land search would otherwise find ["ROUTE_20"] = { x = 23, y = 7, shape = "wide" }, ["ROUTE_21"] = { x = 8, y = 46, shape = "wide" }, - ["ROUTE_22"] = { x = 35, y = 7, shape = "wide" }, - ["ROUTE_23"] = { x = 4, y = 36, shape = "wide" }, - ["ROUTE_24"] = { x = 13, y = 15, shape = "wide" }, + ["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" }, + ["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" }, + ["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" }, ["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" }, -- ------- caves, gyms and the Elite Four @@ -163,25 +183,29 @@ return { -- None of these tilesets has a grass tile at all, so the no-grass rule -- constrained nothing here; what constrains them is furniture, rock -- pillars and how small the rooms are. - ["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" }, - ["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" }, - ["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" }, - ["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" }, + ["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" }, + ["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 }, + ["CELADON_GYM"] = { x = 3, y = 4, + shape = "narrow", turn = 90, cam = "wide" }, + ["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" }, -- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots -- hide a mon, so it borrows the floor below -- the same cave ["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0, shape = "wide" }, - ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" }, - ["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" }, + ["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" }, + ["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" }, ["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" }, - ["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" }, - ["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" }, - ["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" }, - ["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" }, - ["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" }, - ["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" }, - ["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" }, - ["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" }, + ["CINNABAR_GYM"] = { x = 9, y = 16, + shape = "narrow", turn = 90, cam = "wide" }, + ["DIGLETTS_CAVE"] = { x = 14, y = 26, + shape = "wide", turn = 90, cam = "wide" }, + ["FIGHTING_DOJO"] = { x = 3, y = 5, + shape = "narrow", turn = 90, cam = "wide" }, + ["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" }, + ["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" }, + ["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" }, + ["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" }, + ["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" }, -- The three gyms the default rig cannot stand back from. Five blocks is -- further than these rooms are wide, so the eye landed outside the map and @@ -190,7 +214,7 @@ return { -- BattleCam), which fits inside the room; the mons come out smaller and all -- three became stageable. It is asked for HERE, per map, so every area that -- does not ask keeps the long lens it was framed for. - ["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" }, - ["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" }, + ["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" }, + ["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" }, ["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" }, } diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index 025fe8f..ccc124b 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -109,6 +109,57 @@ BattleArena.SHAPES = { { id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } }, } +-- ------- which way round the fight stands +-- +-- Both shapes above are drawn north-south, with the foe at the top and the +-- player below it, and the camera is solved for that: it sits off the +-- player's shoulder, low and back down the arena's own axis. `turn` swings +-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and +-- the camera with them -- so the composition on screen is identical and only +-- the ground under it is different. +-- +-- It buys two things. +-- +-- A footprint that FITS. The wide shape is three cells by six; an east-west +-- corridor two cells deep has no room for it standing up and all the room in +-- the world for it lying down. Half the maps in Kanto run the other way from +-- the one shape this mode was drawn in. +-- +-- And a BACKDROP. A quarter turn moves the camera to a different side of the +-- same patch of ground, so the wall behind the pair becomes the window +-- behind them, or the cliff becomes the valley. Nothing about the shot's +-- geometry changes -- the mons land on the same two screen anchors at the +-- same size -- so this is purely a choice about what is behind them, made +-- per map by somebody looking at it. +-- +-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is +-- what it is; handled as quarter turns everywhere below. +local function quarters(turn) + local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5) + return ((q % 4) + 4) % 4 +end + +BattleArena.quarters = quarters + +-- The footprint a shape covers once turned: a quarter or three of a turn +-- swaps how far it reaches in each direction, which is the whole reason a +-- corridor takes one and not the other. +function BattleArena.extent(shape, turn) + if quarters(turn) % 2 == 1 then return shape.h, shape.w end + return shape.w, shape.h +end + +-- Where a cell offset inside the shape ends up under the same turn, measured +-- from the turned footprint's own north-west corner -- so the corner an entry +-- names stays the corner, whichever way the fight faces from it. +local function spin(shape, turn, ox, oy) + local q = quarters(turn) + if q == 1 then return shape.h - 1 - oy, ox end + if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end + if q == 3 then return oy, shape.w - 1 - ox end + return ox, oy +end + -- Whether a cell is open ground for the purpose above. -- -- "Open" is the walk test the player themselves answer to, so an arena can @@ -174,12 +225,19 @@ end -- Build the record the renderer reads: the two mons' cells and, in world -- pixels, the centre of each and of the pair. -local function place(shape, x, y) - local ex, ey = x + shape.enemy[1], y + shape.enemy[2] - local px, py = x + shape.player[1], y + shape.player[2] +local function place(shape, x, y, turn) + local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2]) + local pox, poy = spin(shape, turn, shape.player[1], shape.player[2]) + local ex, ey = x + eox, y + eoy + local px, py = x + pox, y + poy + local w, h = BattleArena.extent(shape, turn) local arena = { shape = shape.id, - x = x, y = y, w = shape.w, h = shape.h, + -- carried in degrees, so everything downstream that reasons about the + -- shot -- the camera's base yaw above all -- reads the same number the + -- data file was written with + turn = quarters(turn) * 90, + x = x, y = y, w = w, h = h, enemyCell = { ex, ey }, playerCell = { px, py }, -- world-pixel centres of the two cells a mon stands on @@ -302,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing) -- ocean rather than on a scrap of beach at the edge of the map. Land -- entries are unaffected: land passes the test either way. local grid, gw = openGrid(host, true) - if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then - local arena = place(shape, pick.x, pick.y) + -- measured against the TURNED footprint: an entry that lies the arena + -- down an east-west corridor covers different ground from the one that + -- stands it up, and the fit test is the thing that has to know + local fw, fh = BattleArena.extent(shape, pick.turn) + if fits(grid, gw, pick.x, pick.y, fw, fh) then + local arena = place(shape, pick.x, pick.y, pick.turn) arena.map = host -- which camera rig this spot is framed for; nil is the default long -- lens, "close" the short one small rooms need (see BattleCam) @@ -321,9 +383,11 @@ end -- The arena at a given north-west corner, whatever the map says about it. -- The authoring tool's manual override: a spot chosen by eye rather than by -- the search, so it can be photographed and judged before it is written down. -function BattleArena.at(x, y, shapeId) +function BattleArena.at(x, y, shapeId, turn) for _, shape in ipairs(BattleArena.SHAPES) do - if shape.id == (shapeId or "wide") then return place(shape, x, y) end + if shape.id == (shapeId or "wide") then + return place(shape, x, y, turn) + end end return nil end diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 3bd3170..160f4fd 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -431,7 +431,22 @@ function BattleCam.rig(arena, groundY, canonical) -- arena's own axis, and the room the player has is all on the far side of -- that -- out toward square-on. (orbitRange measures exactly that room.) local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0 - local yaw = steer + (fixed and 0 + -- ------- and the quarter turn the arena itself is standing at + -- + -- An arena may be laid down any of the four ways (BattleArena's `turn`), + -- and the rig is solved for ONE of them: eye off the player's shoulder, + -- back down an axis that runs north-south. So the whole offset is turned + -- with the ground under it, which leaves the camera in exactly the same + -- place RELATIVE to the two mons -- same distance, same height, same + -- angle -- and therefore lands them on the same two screen anchors at the + -- same size. A turn is a fact about the map, never about the shot. + -- + -- It goes in with the drift and the steer rather than beside them because + -- it is the same rotation about the same point; the player's own orbit is + -- then measured from wherever the arena starts, so both stops travel with + -- it and side-on stays side-on. + local base = math.rad(arena.turn or 0) + local yaw = base + steer + (fixed and 0 or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)) local c, s = math.cos(yaw), math.sin(yaw) -- the breath scales the whole offset, height included, so the eye moves @@ -443,7 +458,11 @@ function BattleCam.rig(arena, groundY, canonical) local dz = (R.side * s + R.back * c) * k local eye = { mx + dx, groundY + R.height * k, mz + dz } - local focus = { mx + R.lookX, groundY + R.lookY, mz } + -- the aim's own offset turns with the arena too, and with the BASE alone -- + -- the drift and the steer swing the eye about the focus, so a focus that + -- followed them would take the thing being orbited around with it + local bc, bs = math.cos(base), math.sin(base) + local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs } -- and the climb: the eye swung UP about the focus, at a constant radius. -- About the focus so the aim stays nailed to the two mons and only the diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 008d84b..52ad143 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -303,7 +303,11 @@ end -- first drawn in. local function shadowSignature(state, arena, terrain, nbMesh, token) local host = arena.map or state.map + -- `turn` is in the signature with the corner and the shape: the same corner + -- turned a quarter is a different footprint standing on different ground, + -- and a cast kept from the other one freezes the shadows across it local parts = { "battle", host.id, arena.x, arena.y, arena.shape, + tostring(arena.turn or 0), tostring(terrain), tostring(token or 0), -- the cycle keeps running through a fight, and an arena lit -- from somewhere new must be re-cast from there diff --git a/tests/arena_config.lua b/tests/arena_config.lua new file mode 100644 index 0000000..c72a258 --- /dev/null +++ b/tests/arena_config.lua @@ -0,0 +1,197 @@ +-- data/battle_arenas.lua, edited in place by line. +-- +-- The arena editor (tests/arena_editor.lua) writes this file back every time +-- a map is committed, and most of what is in it is PROSE: which alternatives +-- were rejected for each area and what was wrong with them. That argument is +-- the part a tool cannot reconstruct, so nothing here regenerates the file -- +-- an entry that changed has its own lines replaced, a new one is appended, +-- and every other byte comes through exactly as it was. +-- +-- Kept out of the driver so it can be tested without a game: a bug in here +-- writes a config file, and the failure mode of a bad one is silent -- every +-- map loses its authored spot and battles quietly start happening somewhere +-- else. `apply` therefore never decides on its own that its output is good; +-- it hands back the text and the caller parses it back and checks it says +-- what was asked for before anything is overwritten. +-- +-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua") +-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14, +-- shape = "narrow" } }) +-- +-- An edit's value is an entry table, `false` for an authored refusal, or +-- Config.REMOVE to take the entry out altogether. +local Config = {} + +Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end }) + +-- The heading new maps are collected under, so a file worked over several +-- sessions grows one list rather than one heading per session. +Config.SECTION = " -- ------- added by tests/arena_editor" + +-- Lossless both ways: a trailing newline becomes a trailing empty line, and +-- concat puts it back. +function Config.splitLines(text) + local out, pos = {}, 1 + while true do + local a, b = text:find("\n", pos, true) + if not a then + out[#out + 1] = text:sub(pos) + break + end + out[#out + 1] = text:sub(pos, a - 1) + pos = b + 1 + end + return out +end + +-- One entry, laid out the way the file already lays them out: on one line +-- where it fits inside the file's own margin, and wrapped under the brace +-- where it does not (which is how the cross-floor entries are written). +function Config.entryLines(id, entry) + if entry == false then + return { (" [%q] = false,"):format(id) } + end + local head = (" [%q] = { "):format(id) + local parts = {} + if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end + parts[#parts + 1] = ("x = %d"):format(entry.x) + parts[#parts + 1] = ("y = %d"):format(entry.y) + parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide") + if (entry.turn or 0) ~= 0 then + parts[#parts + 1] = ("turn = %d"):format(entry.turn) + end + if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end + local one = head .. table.concat(parts, ", ") .. " }," + if #one <= 79 then return { one } end + local first = entry.map and 3 or 2 + return { + head .. table.concat(parts, ", ", 1, first) .. ",", + (" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },", + } +end + +-- Where an id's entry starts and ends, plus how many comment lines sit +-- directly above it. The end is the first line that closes the table, so a +-- wrapped entry is found whole rather than half-replaced. +function Config.findEntry(lines, id) + local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*=' + for i = 1, #lines do + if lines[i]:find(pat) then + local note = 0 + while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do + note = note + 1 + end + if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end + for j = i, #lines do + if lines[j]:find("}%s*,?%s*$") then return i, j, note end + end + return i, i, note + end + end + return nil +end + +-- The `}` that closes the returned table: new entries go in above it. +function Config.closingLine(lines) + for i = #lines, 1, -1 do + if lines[i]:find("^}") then return i end + end + return #lines + 1 +end + +-- Apply `edits` (map id -> entry | false | REMOVE) to `text`. +-- +-- Returns the new text and a report: `changed`, `added`, `removed` and +-- `stale` as lists of map ids. `stale` is the one worth acting on -- those +-- entries had comment lines directly above them, and a comment above an +-- entry that has just moved describes where it USED to be. +function Config.apply(text, edits) + local lines = Config.splitLines(text) + local report = { changed = {}, added = {}, removed = {}, stale = {} } + + local ordered = {} + for id in pairs(edits) do ordered[#ordered + 1] = id end + table.sort(ordered) + + local appended = {} + for _, id in ipairs(ordered) do + local entry = edits[id] + -- found again per edit rather than indexed once up front: each + -- replacement changes the line count under every index after it + local from, to, note = Config.findEntry(lines, id) + if entry == Config.REMOVE then + if from then + for _ = from, to do table.remove(lines, from) end + report.removed[#report.removed + 1] = id + if note > 0 then report.stale[#report.stale + 1] = id end + end + elseif from then + local new = Config.entryLines(id, entry) + for _ = from, to do table.remove(lines, from) end + for k = #new, 1, -1 do table.insert(lines, from, new[k]) end + report.changed[#report.changed + 1] = id + if note > 0 then report.stale[#report.stale + 1] = id end + else + appended[#appended + 1] = id + report.added[#report.added + 1] = id + end + end + + if #appended > 0 then + local at, block = nil, {} + for i = 1, #lines do + if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then + at = i + 1 + end + end + if not at then + at = Config.closingLine(lines) + block[#block + 1] = "" + block[#block + 1] = Config.SECTION + end + for _, id in ipairs(appended) do + for _, l in ipairs(Config.entryLines(id, edits[id])) do + block[#block + 1] = l + end + end + for k = #block, 1, -1 do table.insert(lines, at, block[k]) end + end + + return table.concat(lines, "\n"), report +end + +-- Whether two entries say the same thing. `false` and nil are values here -- +-- a refusal is not the absence of one -- so this is an equality test and not +-- a truthiness one. +function Config.same(a, b) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + return a.x == b.x and a.y == b.y + and (a.shape or "wide") == (b.shape or "wide") + and (a.turn or 0) == (b.turn or 0) + and a.cam == b.cam and a.map == b.map +end + +-- Parse a config back and check it says what was committed. This is what +-- stands between a bug in the surgery above and a data file that no longer +-- loads, so it is run on the TEXT before that text replaces anything. +-- Returns the parsed table, or nil and why not. +function Config.verify(text, edits, chunkName) + local chunk, err = load(text, "@" .. (chunkName or "battle_arenas")) + if not chunk then return nil, "would not compile: " .. tostring(err) end + local ok, list = pcall(chunk) + if not (ok and type(list) == "table") then + return nil, "did not return a table: " .. tostring(list) + end + for id, want in pairs(edits) do + local got = list[id] + if want == Config.REMOVE then + if got ~= nil then return nil, id .. " is still in the file" end + elseif not Config.same(want, got) then + return nil, id .. " did not read back as it was committed" + end + end + return list +end + +return Config diff --git a/tests/arena_editor.lua b/tests/arena_editor.lua new file mode 100644 index 0000000..b540b11 --- /dev/null +++ b/tests/arena_editor.lua @@ -0,0 +1,1617 @@ +-- Driver: the interactive battle-arena editor. +-- +-- data/battle_arenas.lua holds one authored spot per area -- where that map's +-- 3D battles are staged. tests/arena_pick.lua AUTHORS that file in a batch: +-- it takes the search's answer for every map, photographs each one, and +-- leaves a pile of PNGs and PICK lines to be read afterwards. This is the +-- other way round. It is one window you sit in front of: pick a map, slide +-- the arena around on a plan of it, watch the fit and the sightlines answer +-- as you move, stage a REAL battle on the spot to see what the shot actually +-- looks like, and commit. Committing rewrites the config. +-- +-- The plan is the MAP'S OWN ART -- the tiles extracted from the cartridge, +-- drawn from the game's own atlas through the game's own palette -- with the +-- walk grid washed over it, because "where may an arena stand" and "what is +-- it standing in" are two different questions and a spot has to answer both. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so +-- the console is attached: every commit prints its line there too, and the +-- console transcript alone is enough to redo a session by hand. +-- +-- ------- the keys +-- +-- arrows move the arena's north-west corner (hold shift for 5 cells) +-- [ ] previous / next map +-- s cycle the SHAPE -- wide (3x6, with the apron) or narrow (1x4) +-- r TURN the whole staging a quarter (shift: the other way) -- +-- footprint, both mons and the camera together, so the picture +-- is unchanged and only the ground under it moves. What it is +-- for: a long arena down a corridor that runs east-west, and +-- swapping what stands behind the pair +-- c cycle the LENS -- the long default, or cam = "wide" +-- h cycle the HOST map: stage this map's fight on a sibling floor +-- w water counts as ground for the search (the surf routes) +-- g what the plan shows: the map's own tiles, the walk grid over +-- them, or the walk grid alone +-- f scan this map for every arena that fits, clear ones first +-- n p step through that scan +-- a jump to the automatic pick (what arena_pick would have said) +-- u back to whatever the config file holds for this map +-- t TEST: stage a real battle here and look at it +-- y screenshot +-- enter COMMIT this map (shift+enter: commit and go to the next map) +-- backspace commit a REFUSAL: nowhere on this map a fight can be seen +-- delete commit a REMOVAL: no entry, so battle time falls back to the +-- nearest-clear search +-- e export now (a commit exports on its own; this is for after a +-- revert, or just to be sure) +-- F1 the key legend on screen +-- escape quit (in TEST, back to the editor) +-- +-- In TEST every key except t / escape / y goes to the GAME, so the fight is +-- driven with the game's own buttons and the camera steered with the mod's +-- own Q/E and mouse -- what you are judging is the shot a player gets. +-- +-- ------- what it writes +-- +-- The export is data/battle_arenas.lua REWRITTEN LINE BY LINE rather than +-- regenerated: an entry that changed has its lines replaced in place, a new +-- one is appended, and every other line of that file -- which is mostly the +-- prose explaining why each spot is the spot -- comes through untouched. A +-- comment sitting directly above a replaced entry describes the OLD spot, so +-- each of those is reported by id at export time and is yours to re-word. +-- +-- It lands in .scratchpad/arena_editor/battle_arenas.lua by default, so a +-- session is a file to diff and copy over rather than an edit to the mod +-- under a running game. ARENA_WRITE=1 writes to data/battle_arenas.lua +-- itself, keeping a .bak of what was there. Either way the written text is +-- parsed back and checked against what was committed BEFORE it replaces +-- anything -- a tool that writes a config file must never be the reason one +-- stops loading. +-- +-- Re-run and the session resumes: the export is a complete config, so it is +-- read back as the base when it exists (ARENA_FRESH=1 starts from the mod's +-- own file instead). +-- +-- ------- environment +-- +-- ARENA_MAPS=ID,ID work this list instead of every map a fight happens on +-- ARENA_OUT=path where the config is written +-- ARENA_WRITE=1 write to data/battle_arenas.lua (keeps a .bak) +-- ARENA_FRESH=1 ignore an existing export and start from the mod's file +-- ARENA_MON=SPECIES what to fight in TEST (default NIDORINO) +-- ARENA_LEVEL=N its level (default 20) +-- ARENA_RUNG=N the voxel angle rung to sit on (default 3) +-- ARENA_MARGIN=N keep the scan this far off the map's edge (default: 2 +-- outdoors, where the outermost cells are the connection +-- border, and 0 indoors) +-- SHOT_DIR=path screenshots and the default export directory +return function(game) + local U = dofile("tests/drivers/util.lua") + -- the config's line surgery, kept in its own file so it can be tested + -- without a game (see the header there) + local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua") + + local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end + + local DIR = os.getenv("SHOT_DIR") + if not DIR or DIR == "" then DIR = ".scratchpad/arena_editor" end + local DATA = "mods/DramaticShapeVoxelMod/data/battle_arenas.lua" + local IN_PLACE = truthy(os.getenv("ARENA_WRITE")) + local OUT = os.getenv("ARENA_OUT") + if not OUT or OUT == "" then + OUT = IN_PLACE and DATA or (DIR .. "/battle_arenas.lua") + end + local FRESH = truthy(os.getenv("ARENA_FRESH")) + local SELFTEST = truthy(os.getenv("ARENA_SELFTEST")) + if SELFTEST then + -- never the mod's own file, whatever the environment says: the check runs + -- unattended and its whole point is that it can be thrown away + IN_PLACE = false + OUT = DIR .. "/selftest_battle_arenas.lua" + FRESH = true -- and always from the mod's own file, so a rerun + end -- checks the same surgery on the same text + local SPECIES = os.getenv("ARENA_MON") + if not SPECIES or SPECIES == "" then SPECIES = "NIDORINO" end + local LEVEL = tonumber(os.getenv("ARENA_LEVEL") or "") or 20 + local RUNG = tonumber(os.getenv("ARENA_RUNG") or "") or 3 + local MARGIN = tonumber(os.getenv("ARENA_MARGIN") or "") + + -- SHOT_DIR is created with both spellings: U.shot's own mkdir is + -- Unix-flavoured and silently does nothing on Windows, which is where this + -- tool is driven from, and a screenshot that never reaches disk is the one + -- failure a screenshot tool must not have. + -- U.shot's own mkdir is Unix-flavoured, and this tool is driven from + -- Windows, where `2>/dev/null` is cmd.exe being asked to redirect into a + -- directory that does not exist -- which prints "the system cannot find the + -- path specified" for every call and creates nothing. So the shell is asked + -- in its own language. + local WINDOWS = love.system and love.system.getOS() == "Windows" + + local function mkdirp(dir) + if not dir or dir == "" then return end + if WINDOWS then + os.execute('if not exist "' .. dir:gsub("/", "\\") .. '" mkdir "' + .. dir:gsub("/", "\\") .. '"') + else + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + end + end + mkdirp(DIR) + + local function readFile(path) + local f = io.open(path, "rb") + if not f then return nil end + local text = f:read("*a") + f:close() + return text + end + + local function writeFile(path, text) + mkdirp(path:match("^(.*)[/\\][^/\\]+$")) + local f = io.open(path, "wb") + if not f then return false end + f:write(text) + f:close() + return true + end + + -- ------- the mod, and the modes this tool needs it in + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local Arena = lib.require("BattleArena") + local Battles = lib.require("OverworldBattle") + local BattleCam = lib.require("BattleCam") + local DayNight = lib.require("DayNight") + local ChunkMesher = lib.require("ChunkMesher") + local Voxel3D = lib.require("Voxel3D") + local Pipelines = require("src.render.Pipelines") + local PaletteFX = require("src.render.PaletteFX") + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + local MapLoader = require("src.world.MapLoader") + local Map = require("src.world.Map") + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } + game.save.player.name = "RED" + + -- Every mod setting is set WITHOUT a game argument, deliberately: passing + -- one persists the value into the player's own options file, and a tool + -- that is borrowing their install for an hour should not come back out + -- having changed what they play with. ModSetting caches the index it is + -- given, which is what every reader asks, so the modes are live for this + -- process and stored nowhere. + Battles.setting:setValue(true) -- 2D-3D A: the fight on the map + Battles.backSetting:setValue(false) -- both mons out there on it + DayNight.setting:setValue("day") -- one light to judge them all in + -- the rung IS persisted by the engine's own registry, so it is noted here + -- and put back on the way out + local wasRung = Pipelines.level("voxel") + Pipelines.setLevel("voxel", RUNG) + if not Voxel3D.available() then + U.log("no 3D pass available here -- the editor still picks and exports, " + .. "but TEST will draw the flat battle screen") + end + + -- ------- which maps get a spot + -- + -- The same filter arena_pick uses, and for the same reason: authoring a + -- spot for a shop floor is authoring a spot for a fight that never happens + -- there. The encounter tables are their own registry keyed by map id, NOT a + -- field on the map record -- reading def.encounters answers nil for every + -- map in the game and silently narrows this to trainer maps alone. + local function fights(id, def) + local enc = game.data.encounters and game.data.encounters[id] + if enc then + for _, kind in ipairs({ "grass", "water" }) do + local t = enc[kind] + if t and (t.rate or 0) > 0 and t.slots and t.slots[1] then + return true + end + end + end + for _, obj in ipairs(def.objects or {}) do + if obj.trainer or obj.trainerClass or obj.species then return true end + end + return false + end + + local ids = {} + local explicit = os.getenv("ARENA_MAPS") + if explicit and explicit ~= "" then + for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end + else + for id, def in pairs(game.data.maps) do + if type(id) == "string" and type(def) == "table" and fights(id, def) then + ids[#ids + 1] = id + end + end + table.sort(ids) + end + if #ids == 0 then + U.log("no maps to work on") + return + end + + -- ------- the config this session starts from + -- + -- The export is a complete config file, so when one is already there it is + -- what we read back: a session that ran out of evening resumes exactly + -- where it stopped, and the file being rewritten is the same one being + -- read, which keeps the prose in it accumulating rather than reverting. + local baseFrom = OUT + local baseText = (not FRESH) and readFile(OUT) or nil + if not baseText then + baseFrom = DATA + baseText = readFile(DATA) + end + local base = {} + if baseText then + local chunk = load(baseText, "@" .. baseFrom) + local ok, list = pcall(chunk) + if ok and type(list) == "table" then + base = list + else + U.log(("could not read %s (%s) -- starting from an empty config") + :format(baseFrom, tostring(list))) + baseText = "return {\n}\n" + baseFrom = "(empty)" + end + else + U.log("no config file found -- starting from an empty one") + baseText = "return {\n}\n" + baseFrom = "(empty)" + end + U.log(("base config: %s (%d maps)"):format(baseFrom, (function() + local n = 0 + for _ in pairs(base) do n = n + 1 end + return n + end)())) + + -- What this session has decided, per map id: an entry table, `false` for a + -- refusal, or REMOVE for "take the entry out and let the search answer". + -- Kept apart from `base` so the export knows which lines it may touch. + local REMOVE = Config.REMOVE + local edits = {} + local sameEntry = Config.same + + local backedUp = false + + -- Apply every committed edit to the base text and write it out. Returns + -- ok, message. + local function export() + local n = 0 + for _ in pairs(edits) do n = n + 1 end + local text, report = Config.apply(baseText, edits) + + -- Parsed back and checked against what was committed BEFORE it replaces + -- anything. A config file this tool has made unloadable would take the + -- authored spot away from every map in the game, and the failure would + -- turn up as battles quietly happening somewhere else. + local list, why = Config.verify(text, edits, OUT) + if not list then return false, why end + + if IN_PLACE and not backedUp then + if writeFile(DATA .. ".bak", baseText) then + U.log("kept the original at " .. DATA .. ".bak") + end + backedUp = true + end + if not writeFile(OUT, text) then + return false, "could not open " .. OUT .. " for writing" + end + + -- the written file becomes the base, so the next export edits the text it + -- just produced and every edit is applied exactly once + baseText, base, edits = text, list, {} + + if #report.added > 0 then + U.log("ADDED " .. table.concat(report.added, ", ")) + end + if #report.removed > 0 then + U.log("REMOVED " .. table.concat(report.removed, ", ")) + end + if #report.stale > 0 then + U.log("NOTE these entries had comments above them that describe the " + .. "spot they USED to be: " .. table.concat(report.stale, ", ")) + end + return true, ("wrote %s (%d %s)"):format(OUT, n, n == 1 and "map" or "maps") + end + + -- ------- the editor's state + + local S = { + i = 1, -- which map, into ids + map = nil, -- the live Map for it + pick = nil, -- the working spot: a table, false, or nil + fits = false, -- does its footprint lie on open ground + clear = false, -- would both mons be seen from the battle camera + arena = nil, -- the arena record the two above were judged on + host = nil, -- the map that arena stands on (a sibling floor) + cands = nil, -- the last scan + ci = 0, + surf = false, -- water counts as ground for the search + mode = "edit", + msg = "", + legend = true, + planMode = 1, -- the map's own tiles, with the walk grid over + tiles = nil, -- that map, baked from its own tileset + shooting = false, -- the overlay stands aside for a screenshot + quit = false, + done = {}, -- ids committed at some point this session + doneN = 0, + overrideOn = nil, -- the map whose entry we have forced, if any + } + + local queue = {} + local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end + local function say(fmt, ...) + S.msg = select("#", ...) > 0 and fmt:format(...) or fmt + end + + local function mapId() return ids[S.i] end + + local hosts = {} + local function hostMap(id) + if id == nil or (S.map and id == S.map.id) then return S.map end + if hosts[id] ~= nil then return hosts[id] or nil end + local ok, m = pcall(MapLoader.load, game.data, id) + hosts[id] = (ok and m) or false + return hosts[id] or nil + end + + local function shapeFor(id) + for _, s in ipairs(Arena.SHAPES) do + if s.id == (id or "wide") then return s end + end + return Arena.SHAPES[1] + end + + -- Does this spot's whole footprint lie on ground an arena may be laid on? + -- + -- Water counts, whatever the player is doing, because that is what + -- BattleArena.find does for an AUTHORED entry: the surfing test exists to + -- stop the automatic search staging a walker's fight out at sea, and an + -- entry chosen by a person who is looking at it is not that. + -- the footprint a shape covers at this turn: a quarter turn swaps how far + -- it reaches each way, which is the whole reason a corridor takes one + -- orientation and refuses the other + local function extentOf(shapeId, turn) + return Arena.extent(shapeFor(shapeId), turn) + end + + local function footprintFits(host, x, y, shapeId, turn) + local w, h = extentOf(shapeId, turn) + for cy = y, y + h - 1 do + for cx = x, x + w - 1 do + if not Arena.openCell(host, cx, cy, true) then return false end + end + end + return true + end + + -- Re-judge the working pick and hand the same answer to the mod, so a TEST + -- stages exactly what is on screen rather than whatever the search would + -- have found from the player's cell. + local function evaluate() + S.fits, S.clear, S.arena, S.host = false, false, nil, nil + -- The working spot is handed to the mod as this map's authored entry, so + -- a TEST stages what is on screen. Any map we forced earlier is let go + -- first: an override outlives the map it was set on, and one left behind + -- would quietly stage some other area's fight on a spot that was never + -- committed. + if S.overrideOn and S.overrideOn ~= mapId() then + Arena.setOverride(S.overrideOn, nil) + end + S.overrideOn = mapId() + Arena.setOverride(mapId(), S.pick) + local p = S.pick + if type(p) ~= "table" or not S.map then return end + local host = hostMap(p.map) + if not host then + say("host map %s could not be loaded", tostring(p.map)) + return + end + S.host = host + if not footprintFits(host, p.x, p.y, p.shape, p.turn) then return end + S.fits = true + local arena = Arena.at(p.x, p.y, p.shape, p.turn) + if not arena then return end + arena.map, arena.cam = host, p.cam + S.arena = arena + local ok, clear = pcall(Arena.clearance, host, arena) + S.clear = (ok and clear) and true or false + end + + local function setPick(entry, why) + S.pick = entry + evaluate() + if why then say(why) end + end + + -- Put the player on the near mon's cell, so the free-roam camera is looking + -- at the spot being edited and the meshes around it are the ones built. + -- Written the way OverworldController's own placement writes it -- cell, + -- pixel and the half-finished step alike -- because setting the cell alone + -- leaves the body mid-stride toward wherever it was walking. + local function standOnPick() + local p, ow = S.pick, game.overworld + if type(p) ~= "table" or not (ow and ow.player) then return end + if S.host and S.map and S.host.id ~= S.map.id then return end + -- through the placed record rather than the shape's own offsets, so the + -- near mon's cell is the turned one + local arena = Arena.at(p.x, p.y, p.shape, p.turn) + if not arena then return end + local cx, cy = arena.playerCell[1], arena.playerCell[2] + if not S.map:inBounds(cx, cy) then return end + local player = ow.player + player.cellX, player.cellY = cx, cy + player.px, player.py = cx * 16, cy * 16 + player.moving = false + player.targetX, player.targetY = nil, nil + player.facing = "up" + end + + -- ------- what the map looks like from above + -- + -- THE MAP'S OWN ART, first: the plan is the surface a spot is chosen on, + -- and a grid of coloured squares is a map of the walk grid rather than of + -- the place. What tells you the fight will be staged in the clearing north + -- of the ledge, or on the jetty rather than the sand, is the tiles -- the + -- game's own, extracted from the cartridge, drawn from the same atlas and + -- through the same shade-remap shader the flat 2D world uses. + -- + -- Baked ONCE per map into a canvas rather than drawn per frame: the tile + -- layer is a static SpriteBatch over the whole body, and asking the engine + -- for it sixty times a second would refill that batch against a window the + -- game is also using. Baked during UPDATE, never inside a draw, because it + -- binds a canvas of its own -- the same reason the battle's scene pass + -- lives on the update hook. + local PLAN_MAX = 2048 -- the largest bake, in pixels on the long side + + -- The four colours this map's ground is drawn in: its own SGB palette, + -- looked up exactly as the overworld looks it up, so a cave is a cave's + -- grey and Viridian Forest is its own green. Falls back to the boot ROM's + -- background palette, which is what the engine falls back to. + local function paletteFor(m) + local ow = game.overworld + if ow and ow.paletteNameFor then + local okName, name = pcall(ow.paletteNameFor, ow, m) + if okName and name then + local okPal, colours = pcall(PaletteFX.pal, game.data, name) + if okPal and colours then return colours end + end + end + local okOg, og = pcall(PaletteFX.ogBg) + return okOg and og or nil + end + + local function bakeTiles() + if S.tiles then pcall(S.tiles.release, S.tiles) end + S.tiles = nil + local m = S.map + if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then + return + end + local w, h = m.widthCells * 16, m.heightCells * 16 + local scale = math.min(1, PLAN_MAX / math.max(w, h)) + local okNew, canvas = pcall(love.graphics.newCanvas, + math.max(1, math.floor(w * scale)), + math.max(1, math.floor(h * scale))) + if not (okNew and canvas) then return end + -- linear, deliberately, and the one place in this mod where that is + -- right: the plan is this art MINIFIED to a panel, where nearest + -- sampling throws away most of the tiles and keeps a moire of whichever + -- texels the ratio happens to land on + pcall(canvas.setFilter, canvas, "linear", "linear") + + local g = love.graphics + local prev = g.getCanvas() + -- the true-colour marks a RED++ atlas registers are for the frame the + -- engine is composing, not for a canvas baked outside one + local wasTrue = m.renderer.trueColor + m.renderer.trueColor = false + local ok, err = pcall(function() + g.push("all") + g.origin() + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + g.scale(scale, scale) + -- the atlas is the Game Boy's four greys; this is the pass that turns + -- them into the map's colours, and it is the engine's own + local shader = PaletteFX.shader() + local colours = paletteFor(m) + if shader and colours then + g.setShader(shader) + PaletteFX.sendColors(shader, colours) + end + m.renderer:drawMapOnly(0, 0, w, h) + g.pop() + end) + m.renderer.trueColor = wasTrue + if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end + if not ok then + pcall(canvas.release, canvas) + U.log("could not bake the map's tiles: " .. tostring(err)) + return + end + S.tiles = canvas + end + + -- And the walk grid over it, one class per cell, built once with it. The + -- art says what the place looks like; this says where an arena may legally + -- go, which is not visible in the art -- a ledge lip and the path beside it + -- are the same three pixels of grass. + -- + -- How much of it is laid over the art is the `g` key. Declared up here + -- rather than beside the drawing that reads it: a local declared further + -- down the chunk would leave the key handler above reading a GLOBAL of the + -- same name, which is nil forever and takes the key silently with it. + local PLAN_MODES = { "both", "tiles", "grid" } + + local CELL_COLOUR = { + open = { 0.78, 0.74, 0.63 }, + grass = { 0.33, 0.60, 0.30 }, + water = { 0.24, 0.44, 0.78 }, + warp = { 0.85, 0.68, 0.20 }, + solid = { 0.17, 0.17, 0.20 }, + } + + local function buildPlan() + local m = S.map + if not m then S.plan = nil return end + local w, h = m.widthCells, m.heightCells + local plan = { w = w, h = h } + for cy = 0, h - 1 do + for cx = 0, w - 1 do + local kind + if not m:isWalkableCell(cx, cy) then + kind = m:isWaterCell(cx, cy) and "water" or "solid" + elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then + kind = "warp" + elseif m.isGrassCell and m:isGrassCell(cx, cy) then + kind = "grass" + else + kind = "open" + end + plan[cy * w + cx] = kind + end + end + S.plan = plan + end + + -- ------- moving between maps + + local function autoPick(quiet) + if not S.map then return nil end + local def = game.data.maps[mapId()] + -- def.width/height are in BLOCKS and a block is two cells, so the map's + -- middle in cells is the width itself + local ox, oy = math.floor(def.width), math.floor(def.height) + if S.surf then + -- A surf route's land is a rim of beach around the edge of the map, so + -- a search from the middle always lands on the rim and the fight is + -- staged on sand at the corner of a sea. Starting from the middle of + -- the BIGGEST body of water puts it out in the open instead -- biggest, + -- not all of it at once, because a map with a lake and an ocean has a + -- centroid between them that is on neither. + local m = S.map + local w, h = m.widthCells, m.heightCells + local seen, best = {}, nil + for y0 = 0, h - 1 do + for x0 = 0, w - 1 do + if not seen[y0 * w + x0] and m:isWaterCell(x0, y0) then + local stack, n, sx, sy = { { x0, y0 } }, 0, 0, 0 + seen[y0 * w + x0] = true + while #stack > 0 do + local cell = table.remove(stack) + local cx, cy = cell[1], cell[2] + n, sx, sy = n + 1, sx + cx, sy + cy + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = cx + d[1], cy + d[2] + local key = ny * w + nx + if nx >= 0 and ny >= 0 and nx < w and ny < h and not seen[key] + and m:isWaterCell(nx, ny) then + seen[key] = true + stack[#stack + 1] = { nx, ny } + end + end + end + if not best or n > best.n then + best = { n = n, x = sx / n, y = sy / n } + end + end + end + end + if best then ox, oy = best.x, best.y end + end + local found = Arena.search(S.map, ox, oy, S.surf, true) + local why = "clear" + if not found then + found = Arena.search(S.map, ox, oy, S.surf) + -- The shipped search asks about the DEFAULT lens only, which is what + -- makes it say this about rooms it cannot stand back from. `f` asks + -- about both, so it is the thing to reach for next rather than a + -- conclusion that the map has nowhere to fight. + why = "OBSTRUCTED on the long lens -- try f, which also tries the wide one" + end + if not found then + if not quiet then say("no arena of either shape fits on this map") end + return nil + end + if not quiet then + say("automatic pick, %s", why) + end + return { x = found.x, y = found.y, shape = found.shape } + end + + local function enterMap(index) + S.i = ((index - 1) % #ids) + 1 + local id = mapId() + S.cands, S.ci = nil, 0 + local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end) + if not ok then + S.map, S.plan = nil, nil + say("could not enter %s (%s)", id, tostring(err)) + U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err))) + return + end + S.map = game.overworld.map + buildPlan() + bakeTiles() + -- the entry this map already has wins; a map with none gets the search's + -- answer as a starting point, uncommitted + local entry = edits[id] + if entry == REMOVE then entry = nil end + if entry == nil then entry = base[id] end + if entry == nil then + setPick(autoPick(true), nil) + say("%s: no entry yet -- this is the automatic pick", id) + else + if entry == false then + setPick(false, nil) + say("%s: the config refuses this map", id) + else + setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide", + turn = entry.turn, cam = entry.cam, map = entry.map }, nil) + say("%s: from the config", id) + end + end + standOnPick() + end + + -- ------- the frame + -- + -- The driver harness turns the frame cap off (main.lua: a scripted run is + -- not paced), so each step is held to a 60th of a second by hand. Without + -- it the game's logic runs at the refresh rate of whatever monitor this is + -- on, and a battle intro on a 165Hz panel is over before it can be read. + local lastT = love.timer.getTime() + local function step(n) + for _ = 1, (n or 1) do + local rest = (1 / 60) - (love.timer.getTime() - lastT) + if rest > 0 then love.timer.sleep(rest) end + lastT = love.timer.getTime() + U.wait(1) + end + end + + -- `withHud` keeps the overlay in the shot, which is how a spot is filed + -- with its own plan and verdict beside it; the plain shot is the picture a + -- player would get and is the one to judge by. + local function shoot(name, withHud) + local path = ("%s/%s.png"):format(DIR, name) + S.shooting = not withHud + game.capturePath = path + for _ = 1, 120 do + if not game.capturePath then break end + step(1) + end + step(2) + S.shooting = false + local f = io.open(path, "rb") + if f then + f:close() + -- the panel gets the file name and the console the whole path: one is + -- read at a glance and the other is pasted into something + say("shot %s.png", name) + U.log("SHOT " .. path) + else + say("screenshot did not reach disk: %s", path) + end + end + + -- The spot IS the filename, so a directory of these is a contact sheet of + -- decisions rather than a pile of numbered frames -- and the mode is in it + -- too, or the shot of the staged fight lands on top of the shot of the + -- ground it was staged on. + local function shotName(kind) + local p = S.pick + local where = mapId():lower() + if type(p) == "table" then + where = ("%s_x%d_y%d_%s"):format(where, p.x, p.y, p.shape or "wide") + -- the turn is part of the spot's identity, or the same corner shot two + -- ways round writes one file twice and the comparison is impossible + if (p.turn or 0) ~= 0 then where = ("%s_t%d"):format(where, p.turn) end + end + return where .. "_" .. (kind or (S.mode == "test" and "battle" or "plan")) + end + + -- ------- TEST: a real fight on the spot + -- + -- Staged through the mod's own path -- the override is already installed by + -- evaluate(), the overworld's own pushBattle is what starts it -- so what + -- appears is a battle, not a mock of one. Judging a spot against anything + -- less is how a spot that reads on a plan and hides a Pokemon in the game + -- gets committed. + local function beginTest() + if type(S.pick) ~= "table" then + say("nothing to stage here") + return + end + if not S.fits then + say("that footprint is not on open ground -- nothing would stage") + return + end + standOnPick() + say("staging...") + -- let the terrain around the arena finish building before the shot needs + -- it, so the first battle frame is not the flat fallback + for _ = 1, 120 do + if (ChunkMesher.pending and ChunkMesher.pending() or 0) == 0 then break end + step(1) + end + step(20) + local battle = BattleState.newWild(game, SPECIES, LEVEL) + -- the fight is a fixture, so it must not run the real one's aftermath + battle.onFinish = function() end + game.overworld:pushBattle(battle) + S.mode = "test" + -- Drive it to the MENU. Anything earlier is the intro, where the player's + -- side legitimately shows the trainer's back and there is no Pokemon on + -- the field at all -- which looks exactly like a broken arena and is not. + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + step(6) + end + local staged = Battles.arena() + if staged then + say("TEST at %d,%d %s -- t or escape to come back", staged.x, staged.y, + staged.shape) + else + say("TEST: nothing staged, this is the flat battle screen") + end + U.log(("TEST %s phase=%s staged=%s"):format(mapId(), + tostring(battle.phase), staged and "yes" or "no")) + end + + local function endTest() + U.log("TEST left") + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + step(8) + S.mode = "edit" + standOnPick() + say("back in the editor") + end + + -- ------- the scan + -- + -- Every arena that fits anywhere on this map, clear ones first. The + -- clearance test is what makes the list worth stepping through: an + -- obstructed spot is not a candidate however good the ground under it + -- looks, and half the ground on a route is obstructed from a camera this + -- low. + local function scan() + local m = S.map + if not m then return end + local margin = MARGIN + if not margin then + -- outdoors the outermost cells are the CONNECTION BORDER, the strip the + -- neighbouring map is drawn into: ground there passes every test and is + -- still the wrong answer, because a fight staged on it happens at the + -- edge of the world. Indoors there is no such strip and the walls are + -- already off the table, so nothing is kept back. + margin = Map.isOutdoor(m.def) and 2 or 0 + end + local list = {} + local mx = (m.widthCells - 1) / 2 + local my = (m.heightCells - 1) / 2 + -- Both footprints, because a quarter turn is what lets the 3x6 shape into + -- an east-west corridor at all: turns 0 and 2 cover the same ground and + -- so do 1 and 3, so the GROUND is walked twice and the four ways round + -- are asked about each patch that takes one. + for _, shape in ipairs(Arena.SHAPES) do + for _, lie in ipairs({ 0, 90 }) do + local fw, fh = Arena.extent(shape, lie) + -- the square footprint the narrow shape never has, and the wide one + -- never has either -- so nothing is scanned twice + local twice = (fw == shape.w and fh == shape.h and lie ~= 0) + for y = margin, m.heightCells - fh - margin do + for x = margin, m.widthCells - fw - margin do + local fits = not twice + if fits then + for cy = y, y + fh - 1 do + for cx = x, x + fw - 1 do + if not Arena.openCell(m, cx, cy, S.surf) then + fits = false + break + end + end + if not fits then break end + end + end + if fits then + -- Judged on both TURNS that share this footprint and on both + -- LENSES, because which way round a fight stands and what it is + -- shot on are part of the answer, not separate questions asked + -- afterwards. + -- + -- The lens matters most indoors: the long default cannot stand + -- back from a small room -- its eye lands outside the walls, and + -- on an indoor map that is inside the border ring this mode + -- extrudes into a cliff -- so a gym where nothing is clear on + -- the tele lens can be entirely clear on the 44-degree one. That + -- is what `cam = "wide"` is for on the three gyms in the shipped + -- file, and an editor that only asked about the default lens + -- would report those maps as having nowhere to fight. + -- + -- The turn matters most against a wall: the two ends of the same + -- axis put the camera on opposite sides of the pair, and one of + -- them is often looking into the rock the other has behind it. + -- + -- First combination that reads wins, in preference order, so the + -- common case -- clear, as it lies, on the default lens -- costs + -- exactly one clearance test. + local clear, cam, turn = false, nil, lie + for _, t in ipairs({ lie, lie + 180 }) do + for _, lens in ipairs({ false, "wide" }) do + local a = Arena.at(x, y, shape.id, t) + a.cam = lens or nil + local ok, got = pcall(Arena.clearance, m, a) + if ok and got then + clear, cam, turn = true, lens or nil, t + break + end + end + if clear then break end + end + local dx = x + (fw - 1) / 2 - mx + local dy = y + (fh - 1) / 2 - my + list[#list + 1] = { x = x, y = y, shape = shape.id, turn = turn, + clear = clear, cam = cam, + d = dx * dx + dy * dy } + end + end + say("scanning %s%s... %d found", shape.id, + lie ~= 0 and " (turned)" or "", #list) + step(1) + end + end + end + -- Clear before obstructed; among the clear, the default lens before the + -- one that had to be widened to get there; then the wide arena shape + -- before the narrow one, since that is the shot this mode is framed for; + -- and within all that, nearest to the middle of the map -- which is the + -- order arena_pick's own answer comes first in, so `f` then `n` walks + -- outward from the spot the batch tool would have chosen. + table.sort(list, function(a, b) + if a.clear ~= b.clear then return a.clear end + if (a.cam ~= nil) ~= (b.cam ~= nil) then return a.cam == nil end + if a.shape ~= b.shape then return a.shape == "wide" end + -- and the way round the mode was drawn for before the three that had + -- to be turned to get there + if (a.turn or 0) ~= (b.turn or 0) then + return (a.turn or 0) < (b.turn or 0) + end + return a.d < b.d + end) + local clearN, wideN = 0, 0 + for _, c in ipairs(list) do + if c.clear then + clearN = clearN + 1 + if c.cam then wideN = wideN + 1 end + end + end + S.cands, S.ci = list, 0 + S.candClear, S.candWide = clearN, wideN + if #list == 0 then + say("nothing fits anywhere on this map") + else + say("%d spots fit, %d of them clear%s -- n and p step through them", + #list, clearN, + wideN > 0 and (" (%d only on the wide lens)"):format(wideN) or "") + end + end + + local function stepCandidate(dir) + if not S.cands or #S.cands == 0 then + say("scan the map first (f)") + return + end + S.ci = ((S.ci + dir - 1) % #S.cands + #S.cands) % #S.cands + 1 + local c = S.cands[S.ci] + local keep = type(S.pick) == "table" and S.pick or {} + -- the turn and the lens come WITH the candidate: the scan reached its + -- verdict about one particular way round on one particular lens, and + -- landing on the spot with either of the others selected would show a + -- verdict that was never about this shot + setPick({ x = c.x, y = c.y, shape = c.shape, turn = c.turn, + cam = c.clear and c.cam or keep.cam, map = keep.map }) + standOnPick() + say("candidate %d/%d %s%s%s", S.ci, #S.cands, + c.clear and "clear" or "OBSTRUCTED", + (c.turn or 0) ~= 0 and (" turned %d"):format(c.turn) or "", + c.cam and " on the wide lens" or "") + end + + -- ------- committing + + local function commit(entry, advance) + local id = mapId() + local was = base[id] + if entry == nil then + -- there is no spot to commit. Saying so is the honest answer; a map + -- with nowhere a fight can be seen is committed with backspace, which + -- writes a refusal the mod reads rather than silence it has to guess at + say("no spot here to commit -- backspace commits a refusal instead") + return + end + if not S.done[id] then + S.done[id] = true + S.doneN = S.doneN + 1 + end + if entry == REMOVE then + if was == nil then + edits[id] = nil + say("%s has no entry to remove", id) + return + end + edits[id] = REMOVE + U.log(("REMOVE %s -- battle time falls back to the search"):format(id)) + elseif entry == false then + edits[id] = false + U.log(("PICK [%q] = false,"):format(id)) + else + if sameEntry(entry, was) then + edits[id] = nil + say("%s already says exactly that", id) + if advance then post("map", 1) end + return + end + edits[id] = { x = entry.x, y = entry.y, shape = entry.shape or "wide", + turn = ((entry.turn or 0) ~= 0) and entry.turn or nil, + cam = entry.cam, map = entry.map } + U.log(("PICK [%q] = { %sx = %d, y = %d, shape = %q%s%s },%s") + :format(id, entry.map and ("map = %q, "):format(entry.map) or "", + entry.x, entry.y, entry.shape or "wide", + ((entry.turn or 0) ~= 0) + and (", turn = %d"):format(entry.turn) or "", + entry.cam and (", cam = %q"):format(entry.cam) or "", + S.clear and "" or " -- OBSTRUCTED")) + end + local ok, msg = export() + if ok then + say("committed %s -- %s", id, msg) + else + say("COMMITTED BUT NOT WRITTEN: %s", msg) + U.log("EXPORT FAILED: " .. msg) + end + if advance then post("map", 1) end + end + + -- ------- the keys + -- + -- Wrapped around love.keypressed rather than polled, so a press is taken + -- INSTEAD of reaching the game rather than as well as -- an arrow key that + -- moved the arena and walked the player would fight itself. Only the keys + -- below are claimed; everything else falls through, which is what leaves + -- the mod's own hotkeys (the rung, the water, the wireframe) working while + -- the editor is up, and the whole keyboard working inside a TEST. + local function shifted() + return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") + end + + -- Keep the corner on the map, measured against the footprint as it + -- currently lies -- a shape that has just been turned or swapped reaches a + -- different distance each way from the same corner. + local function clampPick() + local p = S.pick + if type(p) ~= "table" then return end + local fw, fh = extentOf(p.shape, p.turn) + local w = S.host and S.host.widthCells or (S.map and S.map.widthCells) or 0 + local h = S.host and S.host.heightCells or (S.map and S.map.heightCells) or 0 + p.x = math.max(0, math.min(w - fw, p.x)) + p.y = math.max(0, math.min(h - fh, p.y)) + end + + local function nudge(dx, dy) + local p = S.pick + if type(p) ~= "table" then + setPick(autoPick(true), "no spot here yet -- took the automatic one") + return + end + local n = shifted() and 5 or 1 + p.x, p.y = p.x + dx * n, p.y + dy * n + clampPick() + evaluate() + standOnPick() + say("%d,%d %s", p.x, p.y, p.shape or "wide") + end + + -- A quarter turn of the whole staging: the footprint, both mons and the + -- camera together (see BattleArena's `turn`). The picture does not change -- + -- the pair land on the same screen anchors at the same size -- so what this + -- is for is the GROUND: fitting a long arena down a corridor that runs the + -- other way, and swapping what is standing behind the pair. + local function rotate(dir) + local p = S.pick + if type(p) ~= "table" then return end + p.turn = (((p.turn or 0) + dir * 90) % 360 + 360) % 360 + clampPick() + evaluate() + standOnPick() + local fw, fh = extentOf(p.shape, p.turn) + say("turned %d degrees -- %dx%d cells, foe to the %s", p.turn, fw, fh, + ({ [0] = "north", [90] = "east", [180] = "south", + [270] = "west" })[p.turn]) + end + + local function cycleShape() + local p = S.pick + if type(p) ~= "table" then return end + p.shape = (p.shape == "narrow") and "wide" or "narrow" + -- the wide shape is 3x6 and the narrow one 1x4, so a corner that was + -- legal for the smaller one can hang off the map with the bigger + clampPick() + evaluate() + standOnPick() + say("shape %s", p.shape) + end + + local function cycleCam() + local p = S.pick + if type(p) ~= "table" then return end + p.cam = (p.cam == nil) and "wide" or nil + evaluate() + say("lens %s", p.cam or "tele (the long default)") + end + + -- The floors of the same cave or building, for an entry that stages its + -- fight on one of them. Family is the id with its floor suffix taken off, + -- which is how these are actually named: POKEMON_TOWER_4F borrows + -- POKEMON_TOWER_3F, CERULEAN_CAVE_2F borrows CERULEAN_CAVE_B1F. + local function family(id) + return (id:gsub("_B?%d+F", "")) + end + + local function cycleHost() + local p = S.pick + if type(p) ~= "table" then return end + local id = mapId() + local kin = { id } + for other in pairs(game.data.maps) do + if type(other) == "string" and other ~= id + and family(other) == family(id) then + kin[#kin + 1] = other + end + end + table.sort(kin) + if #kin == 1 then + say("%s has no sibling floors to stage on", id) + return + end + local at = 1 + for k, other in ipairs(kin) do + if other == (p.map or id) then at = k end + end + local nextId = kin[(at % #kin) + 1] + p.map = (nextId ~= id) and nextId or nil + -- the corner is a coordinate on the HOST, so a floor change is a fresh + -- question about where on it -- clamp it in, then let the fit test speak + evaluate() + if S.host then + clampPick() + evaluate() + end + standOnPick() + say("staging on %s", p.map or "this map") + end + + local function handle(key) + if S.mode == "test" then + if key == "t" or key == "escape" then post("endtest") return true end + if key == "y" then post("shot", shifted() and "hud" or nil) return true end + if key == "f1" then S.legend = not S.legend return true end + return false + end + + if key == "up" then nudge(0, -1) return true end + if key == "down" then nudge(0, 1) return true end + if key == "left" then nudge(-1, 0) return true end + if key == "right" then nudge(1, 0) return true end + if key == "[" then post("map", -1) return true end + if key == "]" then post("map", 1) return true end + if key == "s" then cycleShape() return true end + if key == "r" then rotate(shifted() and -1 or 1) return true end + if key == "c" then cycleCam() return true end + if key == "h" then cycleHost() return true end + if key == "w" then + S.surf = not S.surf + say("the search %s water as ground", + S.surf and "counts" or "does not count") + return true + end + if key == "g" then + S.planMode = (S.planMode % #PLAN_MODES) + 1 + say("plan: %s", PLAN_MODES[S.planMode]) + return true + end + if key == "f" then post("scan") return true end + if key == "n" then stepCandidate(1) return true end + if key == "p" then stepCandidate(-1) return true end + if key == "a" then + local found = autoPick() + if found then + setPick(found) + standOnPick() + end + return true + end + -- `u` for undo, because `r` is rotate: the strongest convention on each + -- wins, and this one is only ever wanted after a change you regret + if key == "u" then + local id = mapId() + local entry = base[id] + edits[id] = nil + if entry == nil then + setPick(autoPick(true), "no entry in the config -- automatic pick") + elseif entry == false then + setPick(false, "the config refuses this map") + else + setPick({ x = entry.x, y = entry.y, shape = entry.shape or "wide", + turn = entry.turn, cam = entry.cam, map = entry.map }, + "back to the config") + end + standOnPick() + return true + end + if key == "t" then post("test") return true end + if key == "y" then post("shot", shifted() and "hud" or nil) return true end + if key == "return" or key == "kpenter" then + post("commit", shifted() and "advance" or "stay") + return true + end + if key == "backspace" then post("refuse") return true end + if key == "delete" then post("drop") return true end + if key == "e" then post("export") return true end + if key == "f1" then S.legend = not S.legend return true end + if key == "escape" then S.quit = true return true end + return false + end + + local innerKey = love.keypressed + function love.keypressed(key, scancode, isrepeat) + -- the unattended check drives handle() directly, so anything arriving + -- HERE during one is a real key from the window and worth naming + if SELFTEST then U.log("KEY " .. tostring(key)) end + local ok, claimed = pcall(handle, key) + if ok and claimed then return end + if not ok then U.log("key error: " .. tostring(claimed)) end + if innerKey then return innerKey(key, scancode, isrepeat) end + end + + -- ------- the overlay + -- + -- Drawn after the game's own frame, over it. The plan on the right is what + -- the spot is CHOSEN on -- open ground, grass, water, the walls -- and the + -- two lines drawn across it from the camera's eye are why a spot that looks + -- open gets rejected: they are the sightlines the clearance test walks, and + -- seeing them cross a building is the whole explanation. + local font = love.graphics.newFont(13) + local smallFont = love.graphics.newFont(11) + + local function drawPlan(x, y, w, h) + local plan = S.plan + if not plan then return end + local g = love.graphics + local cell = math.min(w / plan.w, h / plan.h) + if cell < 1 then cell = 1 end + local pw, ph = plan.w * cell, plan.h * cell + local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2 + g.setColor(0, 0, 0, 0.55) + g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8) + + local mode = PLAN_MODES[S.planMode or 1] + -- the map itself, from its own tiles + if S.tiles and mode ~= "grid" then + g.setColor(1, 1, 1, 1) + g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(), + ph / S.tiles:getHeight()) + end + -- and the walk grid over it: opaque with no art under it, and a wash + -- when there is -- enough to read where an arena may go without hiding + -- what it would be standing on. Open ground is never washed: it is what + -- the art is being read for. + if mode ~= "tiles" then + local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22 + for cy = 0, plan.h - 1 do + for cx = 0, plan.w - 1 do + local kind = plan[cy * plan.w + cx] + local c = CELL_COLOUR[kind] + if c and not (alpha < 1 and kind == "open") then + g.setColor(c[1], c[2], c[3], alpha) + g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell) + end + end + end + end + + -- The candidates, so a scan is a picture of where the map will take a + -- fight at all rather than a number to press n against. + -- + -- One mark at each one's middle rather than its footprint filled in: on a + -- route most open ground is a candidate, and painting all of them lays a + -- sheet of colour over the very art the spot is being chosen from. A dot + -- says "here, and here" without hiding what is there. + if S.cands then + local r = math.max(1.5, cell * 0.35) + for _, c in ipairs(S.cands) do + local fw, fh = extentOf(c.shape, c.turn) + if c.clear then + -- green for the default lens, amber for one that only reads on the + -- wide one -- the difference is a decision, not a detail + if c.cam then g.setColor(1, 0.8, 0.2, 0.85) + else g.setColor(0.3, 1, 0.4, 0.85) end + else + g.setColor(1, 0.35, 0.2, 0.35) + end + g.circle("fill", ox + (c.x + fw / 2) * cell, + oy + (c.y + fh / 2) * cell, r) + end + end + + local ow = game.overworld + if ow and ow.player then + g.setColor(1, 1, 1, 0.8) + g.circle("fill", ox + (ow.player.cellX + 0.5) * cell, + oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4)) + end + + -- the spot itself, on whichever map it is measured against + local p = S.pick + local onHost = not (S.host and S.map and S.host.id ~= S.map.id) + -- laid out through the placed record rather than from the shape's own + -- offsets, so a turned arena is drawn the way it will actually stand. + -- Built even when it does not fit -- a footprint half off the map is + -- exactly the thing you need to see to move it back on. + local shot = type(p) == "table" and Arena.at(p.x, p.y, p.shape, p.turn) + if shot and onHost then + g.setColor(S.fits and (S.clear and 0.2 or 1) or 1, + S.fits and 1 or 0.3, S.fits and (S.clear and 1 or 0.2) or 0.3, + 0.9) + g.setLineWidth(2) + g.rectangle("line", ox + shot.x * cell, oy + shot.y * cell, + shot.w * cell, shot.h * cell) + g.setLineWidth(1) + local ex, ey = shot.enemyCell[1], shot.enemyCell[2] + local px, py = shot.playerCell[1], shot.playerCell[2] + g.setColor(0.95, 0.35, 0.35, 1) + g.rectangle("fill", ox + ex * cell, oy + ey * cell, cell, cell) + g.setColor(0.4, 0.65, 1, 1) + g.rectangle("fill", ox + px * cell, oy + py * cell, cell, cell) + + -- the camera, and what it is looking through + if S.arena then + local ok, rig = pcall(BattleCam.rig, S.arena, 0, true) + if ok and rig and rig.eye then + local eyeX = ox + (rig.eye[1] / 16) * cell + local eyeY = oy + (rig.eye[3] / 16) * cell + if S.clear then g.setColor(0.3, 1, 0.5, 0.85) + else g.setColor(1, 0.35, 0.3, 0.85) end + g.line(eyeX, eyeY, ox + (ex + 0.5) * cell, oy + (ey + 0.5) * cell) + g.line(eyeX, eyeY, ox + (px + 0.5) * cell, oy + (py + 0.5) * cell) + g.circle("fill", eyeX, eyeY, 4) + end + end + end + g.setColor(1, 1, 1, 1) + end + + local LEGEND = { + "arrows move the corner (shift x5)", + "[ ] previous / next map", + "s shape: wide / narrow", + "r turn it 90 (shift: the other way)", + "c lens: tele / wide", + "h stage on a sibling floor", + "w search over water", + "g plan: tiles / walk grid / both", + "f scan the map n p step", + "a automatic pick u revert", + "t TEST a real battle here", + "y screenshot (shift: with this panel)", + "enter commit (shift: and next map)", + "bksp commit a refusal", + "del commit a removal", + "e export F1 this list", + "escape quit", + } + + local function drawOverlay() + local g = love.graphics + local W, H = g.getDimensions() + + if S.mode == "test" then + g.setFont(font) + g.setColor(0, 0, 0, 0.65) + g.rectangle("fill", 0, 0, W, 26) + g.setColor(1, 0.85, 0.3, 1) + g.print(("TEST %s %s [t] back [y] shot") + :format(mapId(), S.msg), 8, 5) + g.setColor(1, 1, 1, 1) + return + end + + local panel = math.min(340, W * 0.34) + g.setFont(font) + g.setColor(0, 0, 0, 0.68) + g.rectangle("fill", 0, 0, panel, H) + + local p = S.pick + local id = mapId() + local mark = S.done[id] and " *" or "" + local lines = {} + local function add(fmt, ...) + lines[#lines + 1] = select("#", ...) > 0 and fmt:format(...) or fmt + end + add("ARENA EDITOR %d/%d%s", S.i, #ids, mark) + add("%s", id) + if S.map then + add("%d x %d cells", S.map.widthCells, S.map.heightCells) + end + add("") + if p == false then + add("REFUSAL -- no fight is staged here") + elseif type(p) ~= "table" then + add("no spot") + else + local fw, fh = extentOf(p.shape, p.turn) + add("x %d y %d %s (%dx%d)", p.x, p.y, p.shape or "wide", fw, fh) + add("turn %d foe to the %s", p.turn or 0, + ({ [0] = "north", [90] = "east", [180] = "south", + [270] = "west" })[p.turn or 0] or "?") + add("lens %s", p.cam or "tele") + add("host %s", p.map or "this map") + add("") + add("fits %s", S.fits and "YES" or "NO -- not open ground") + add("clear %s", S.clear and "YES" + or (S.fits and "NO -- a mon is hidden" or "-")) + end + add("") + -- what the CONFIG says right now, which after a commit is what was just + -- written to it -- an export rebases on its own output, so "pending" is + -- never a state this can be left in + local held = edits[id] + if held == nil then held = base[id] end + if held == REMOVE or held == nil then + add("config no entry (the search answers)") + elseif held == false then + add("config refuses this map") + else + add("config %d,%d %s%s%s%s", held.x, held.y, held.shape or "wide", + ((held.turn or 0) ~= 0) and (" turn=" .. held.turn) or "", + held.cam and (" cam=" .. held.cam) or "", + held.map and (" on " .. held.map) or "") + end + if not sameEntry(p, held) then + add("NOT COMMITTED -- enter writes it") + elseif S.done[id] then + add("committed this session") + else + add("unchanged") + end + if S.cands then + add("scan %d fit, %d clear (%d need the wide lens)%s", + #S.cands, S.candClear or 0, S.candWide or 0, + S.ci > 0 and (" at %d"):format(S.ci) or "") + end + add("water %s", S.surf and "counts as ground" or "is not searched") + add("done %d of %d maps this session", S.doneN, #ids) + add("out %s", OUT) + + g.setColor(1, 1, 1, 1) + local y = 10 + for _, l in ipairs(lines) do + g.print(l, 10, y) + y = y + 16 + end + + y = y + 6 + g.setColor(1, 0.85, 0.35, 1) + g.setFont(smallFont) + local msg = S.msg or "" + local _, wrapped = smallFont:getWrap(msg, panel - 20) + g.printf(msg, 10, y, panel - 20) + y = y + 13 * math.max(1, #wrapped) + + -- what the marks on the plan mean, in the marks themselves + if S.cands then + y = y + 8 + local key = { { { 0.3, 1, 0.4 }, "clear" }, + { { 1, 0.8, 0.2 }, "wide lens only" }, + { { 1, 0.35, 0.2 }, "obstructed" } } + local kx = 14 + for _, entry in ipairs(key) do + g.setColor(entry[1][1], entry[1][2], entry[1][3], 0.9) + g.circle("fill", kx, y + 5, 3.5) + g.setColor(0.8, 0.8, 0.85, 1) + g.print(entry[2], kx + 9, y - 1) + kx = kx + 12 + smallFont:getWidth(entry[2]) + 10 + end + y = y + 16 + end + + if S.legend then + y = y + 10 + g.setColor(0.75, 0.8, 0.9, 1) + for _, l in ipairs(LEGEND) do + g.print(l, 10, y) + y = y + 14 + end + end + + -- the plan, in whatever is left of the window + local px = panel + 12 + drawPlan(px, 12, W - px - 12, H - 24) + g.setColor(1, 1, 1, 1) + end + + local innerDraw = love.draw + function love.draw() + if innerDraw then innerDraw() end + if S.shooting then return end + local g = love.graphics + g.push("all") + g.origin() + local ok, err = pcall(drawOverlay) + g.pop() + if not ok and not S.drawWarned then + S.drawWarned = true + U.log("overlay draw failed: " .. tostring(err)) + end + end + + -- ------- the session + + U.log("arena editor -- " .. #ids .. " maps") + for _, l in ipairs(LEGEND) do U.log(" " .. l) end + U.log("exports to " .. OUT) + + enterMap(1) + + local function dispatch(job) + if job.cmd == "map" then + enterMap(S.i + job.arg) + elseif job.cmd == "scan" then + scan() + elseif job.cmd == "test" then + beginTest() + elseif job.cmd == "endtest" then + endTest() + elseif job.cmd == "shot" then + local hud = job.arg == "hud" + shoot(shotName(hud and "sheet" or nil), hud) + elseif job.cmd == "commit" then + commit(S.pick, job.arg == "advance") + elseif job.cmd == "refuse" then + commit(false, false) + elseif job.cmd == "drop" then + commit(REMOVE, false) + elseif job.cmd == "export" then + local ok, msg = export() + say("%s", msg) + U.log((ok and "EXPORT " or "EXPORT FAILED ") .. msg) + end + end + + -- One frame of the session: the next thing a key asked for, or a frame of + -- standing still while the game runs underneath. + local function pump(frames) + for _ = 1, (frames or 1) do + local job = table.remove(queue, 1) + if job then dispatch(job) else step(1) end + if S.quit then return end + end + end + + -- ------- ARENA_SELFTEST=1: the same session, with the keys pressed for you + -- + -- Every command this tool has, driven through the SAME entry point the + -- keyboard drives -- so it exercises the real handler, the real staging and + -- the real export rather than a parallel script that can drift from them. + -- It is how the tool is checked after a change without a person sitting in + -- front of it, and it writes to its own file, never to the mod's. + if SELFTEST then + local function press(key, frames) + local ok, err = pcall(handle, key) + if not ok then U.log("SELFTEST key failed: " .. tostring(err)) end + pump(frames or 20) + U.log(("SELFTEST %-9s %s"):format(key, S.msg or "")) + end + press("f", 900) -- scan this map + press("n") -- and step through what it found + press("n") + press("s") -- both shapes + press("s") + press("r") -- all four ways round, and back to where it was + press("r") + press("r") + press("r") + press("right") -- nudge, with and without shift + press("down") + press("c") -- both lenses + press("c") + press("h") -- a sibling floor, and back off it + press("h", 40) + press("a", 60) -- the automatic pick + press("n", 60) -- back onto the scan's best answer + -- and the rest of the run happens TURNED, so what gets photographed and + -- committed is a turned staging: the projection maths is proved in the + -- suite, but whether the whole pipeline behind it -- the shadow cast, the + -- billboards, the pinned pics -- still composes at a turn is the kind of + -- thing only a picture settles. + -- + -- HALF a turn, twice, rather than a quarter: the candidate under us fits + -- the ground it was found on, and 180 keeps that footprint exactly while + -- moving the camera to the other end of it. A quarter turn swaps the + -- footprint's reach, so on a lane that is six cells one way and three the + -- other it lands on a wall -- which the tool rightly refuses to stage, and + -- which would leave this check photographing nothing. + press("r") + press("r", 60) + post("shot", "hud") -- and one with the panel in it, which is the + pump(240) -- only thing that proves the overlay draws + press("y", 240) -- a screenshot of the free-roam view + press("g") -- each way of drawing the plan + press("g") + press("g") + press("t", 1200) -- a real battle on the spot + press("y", 240) -- and one of that + press("t", 90) -- out of the test, by the key that is not also + -- the quit key -- escape is checked at the end + press("return", 120) -- commit it, which exports + press("]", 200) -- the next map + press("backspace", 120) -- commit a refusal there + press("delete", 120) -- and take the entry out again + press("e", 60) -- an explicit export + press("escape", 5) -- and the way out + U.log("SELFTEST done -- " .. tostring(S.msg)) + S.quit = true + end + + while not S.quit do pump(1) end + + -- Hand the install back the way it was found: the forced entry let go, the + -- baked plan released, the two wraps unwound and the camera rung put back. + -- The mod settings this tool moved were never persisted (see the top), so + -- there is nothing to undo there. + if S.overrideOn then Arena.setOverride(S.overrideOn, nil) end + if S.tiles then pcall(S.tiles.release, S.tiles) end + love.draw = innerDraw + love.keypressed = innerKey + pcall(Pipelines.setLevel, "voxel", wasRung) + U.log("done -- " .. OUT) +end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index a1d46e7..ee96136 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2909,6 +2909,109 @@ T.check(math.abs(cex - 124) < 1 and math.abs(cey - 56) < 1, T.check(span(closeRig, shot.player) < span(rig, shot.player), "the mons render smaller on it, which is what it trades for fitting") +-- ------- the quarter turns +-- +-- An arena may be laid down any of the four ways (BattleArena's `turn`), and +-- the whole claim of the feature is that this is a fact about the GROUND and +-- never about the shot: the footprint, the two mons and the camera turn +-- together, so the pair land on the same two anchors at the same size and +-- only what is behind them changes. +-- +-- Asserted by REPROJECTING each turn through the real rig, exactly the way +-- the unturned shot is checked above -- so a rig retune, or a sign error in +-- the rotation, says so here rather than in a screenshot nobody takes. +-- +-- In its own scope: the suite's main chunk sits at LuaJIT's 200-active-local +-- ceiling, and every local below would be one more of them. The leading +-- semicolon is the file's own convention -- without it the previous +-- statement's `)` and this `(` parse as one call. +;(function() + local corner = { shot.x, shot.y } + -- How wide a mon's own square comes out, measured ACROSS the arena's axis. + -- `span` above offsets along world X, which is across the axis only while + -- the arena is standing the way the mode was drawn; on the odd quarters the + -- axis IS X, so offsetting along it would measure the square's depth -- + -- foreshortened by a camera that is looking almost straight down it -- and + -- report a shrinking mon that is nothing of the kind. + local function acrossSpan(cam, point, turn) + local d = (turn % 180 == 0) and { 8, 0 } or { 0, 8 } + local a = project(cam, { point[1] - d[1], point[2] - d[2] }) + local b = project(cam, { point[1] + d[1], point[2] + d[2] }) + return math.abs(b - a) + end + for _, turn in ipairs({ 0, 90, 180, 270 }) do + BattleCam.reset() + local a = BattleArena.at(corner[1], corner[2], "wide", turn) + T.check(a ~= nil, ("the wide arena places at turn %d"):format(turn)) + T.eq(a.turn, turn, ("and carries the turn it was placed at (%d)"):format(turn)) + + -- the footprint swaps its reach on the odd quarters, which is the whole + -- reason a corridor takes one way round and refuses the other + local wantW = (turn % 180 == 0) and 3 or 6 + local wantH = (turn % 180 == 0) and 6 or 3 + T.eq(a.w, wantW, ("turn %d is %d cells across"):format(turn, wantW)) + T.eq(a.h, wantH, ("and %d deep"):format(wantH)) + + -- both mons stay inside the footprint they were turned within, and stay + -- three cells apart -- the gap the move animations are scaled against + T.check(a.enemyCell[1] >= a.x and a.enemyCell[1] < a.x + a.w + and a.enemyCell[2] >= a.y and a.enemyCell[2] < a.y + a.h, + ("turn %d keeps the foe inside the footprint"):format(turn)) + T.check(a.playerCell[1] >= a.x and a.playerCell[1] < a.x + a.w + and a.playerCell[2] >= a.y and a.playerCell[2] < a.y + a.h, + ("turn %d keeps the player inside it too"):format(turn)) + local gap = math.abs(a.enemyCell[1] - a.playerCell[1]) + + math.abs(a.enemyCell[2] - a.playerCell[2]) + T.eq(gap, 3, ("turn %d still stands them three cells apart"):format(turn)) + + -- and which way the foe lies from the player, which is what `turn` NAMES + local dx = a.enemyCell[1] - a.playerCell[1] + local dy = a.enemyCell[2] - a.playerCell[2] + local want = ({ [0] = { 0, -3 }, [90] = { 3, 0 }, + [180] = { 0, 3 }, [270] = { -3, 0 } })[turn] + T.check(dx == want[1] and dy == want[2], + ("turn %d puts the foe %d,%d from the player: got %d,%d") + :format(turn, want[1], want[2], dx, dy)) + + -- THE INVARIANT: the same composition, whichever way round it stands + local r = BattleCam.rig(a, 0) + local ppx, ppy = project(r, a.player) + local eex, eey = project(r, a.enemy) + T.check(ppx ~= nil and eex ~= nil, + ("turn %d keeps both marks in front of the camera"):format(turn)) + T.check(math.abs(ppx - 26) < 1 and math.abs(ppy - 96) < 1, + ("turn %d lands the player's mark on its anchor: (%.2f, %.2f)") + :format(turn, ppx, ppy)) + T.check(math.abs(eex - 124) < 1 and math.abs(eey - 56) < 1, + ("turn %d lands the enemy's on its own: (%.2f, %.2f)") + :format(turn, eex, eey)) + T.check(math.abs(acrossSpan(r, a.player, turn) - 64) < 4, + ("turn %d still makes the player's square a back pic wide (64px): got " + .. "%.2f"):format(turn, acrossSpan(r, a.player, turn))) + T.check(math.abs(acrossSpan(r, a.enemy, turn) - 56) < 4, + ("turn %d still makes the foe's square a front pic wide (56px): got " + .. "%.2f"):format(turn, acrossSpan(r, a.enemy, turn))) + + -- the eye really did move: a turn that left the camera where it was would + -- pass every anchor test above by looking at the pair from the side + if turn ~= 0 then + local base = BattleArena.at(corner[1], corner[2], "wide", 0) + BattleCam.reset() + local r0 = BattleCam.rig(base, 0) + local moved = math.abs(r.eye[1] - r0.eye[1]) + + math.abs(r.eye[3] - r0.eye[3]) + T.check(moved > 16, + ("turn %d actually moves the camera (%.1f world px)"):format(turn, moved)) + end + end + + -- degrees in, degrees out, and anything else folded onto the four + T.eq(BattleArena.quarters(360), 0, "a full turn is no turn") + T.eq(BattleArena.quarters(-90), 3, "and a negative one comes round the back") + T.eq(BattleArena.at(1, 1, "wide").turn, 0, + "an arena placed without a turn is the way the mode was drawn") +end)() + -- an arena picks its rig by name, and anything unnamed gets the default T.eq(BattleCam.rigFor({ cam = "wide" }), BattleCam.RIGS.wide, "an arena that asks for the wide lens gets it") diff --git a/tests/stadium_all_shots.lua b/tests/stadium_all_shots.lua new file mode 100644 index 0000000..1c09cc9 --- /dev/null +++ b/tests/stadium_all_shots.lua @@ -0,0 +1,157 @@ +-- Driver: one STADIUM screenshot per species, every one of the 151 standing +-- on the ENEMY mark in its standby loop. +-- +-- stadium_shots.lua photographs a handful of deliberately awkward cases; this +-- is the exhaustive sweep behind it. What it exists to catch is the class of +-- fault that is per-MODEL and invisible to the headless QA pass -- a texture +-- that comes out magenta, a mon standing in the floor or floating over it, a +-- silhouette that is subtly the wrong shape -- none of which is a number +-- stadium_anim_qa.lua can measure. +-- +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/stadium_verify \ +-- POKEPORT_SPEED=4 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_all_shots.lua \ +-- lovec.exe . +-- +-- Files land as NNN_species.png in dex order. Run from the PROJECT ROOT. +-- +-- ------- everything but the foe is nailed down +-- +-- The whole value of 151 shots is that they are comparable, so the staging is +-- identical in every one: same map, same cells, same hour (the day/night +-- cycle would otherwise relight every shot), same rung, and the same small +-- Pokemon on the player's mark -- DIGLETT, chosen because it is the shortest +-- model in the set and so hides the least of the arena behind it. Whatever +-- differs between two of these shots is the foe. +-- +-- ------- the model/sprite verdict is the other half +-- +-- A species whose pack will not load, or whose animation data the packer +-- declined, does not error: StadiumMon.setSpecies answers false and the mode +-- quietly stands the Game Boy's flat pic on the tile instead. That is the +-- correct behaviour and it is nearly invisible in a screenshot at a glance -- +-- which is exactly why it needs counting rather than looking. Stadium.showing +-- is asked for every species and the ones that decline are listed at the end. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".claude/stadium_verify" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local Battles = lib.require("OverworldBattle") + local Stadium = lib.require("Stadium") + local Pack = lib.require("StadiumPack") + local Install = lib.require("StadiumInstall") + + -- every real species the merged data carries, in dex order + local species = {} + for id, def in pairs(game.data.pokemon) do + if type(id) == "string" and type(def) == "table" + and def.dex and def.dex >= 1 and def.dex <= 151 then + species[#species + 1] = { id = id, dex = def.dex } + end + end + table.sort(species, function(a, b) return a.dex < b.dex end) + + game.save.player.name = "RED" + + -- ONTO THE MAP FIRST: the model build (if one is owed) is pushed from the + -- mod's update hook on the first frame the overworld is the top state, so + -- waiting for it before teleporting waits forever. + U.teleport(game, "ROUTE_1", 5, 8, "down") + + local waited = 0 + while (Install.pending() or Install.status.state == "building") + and waited < 5400 do + U.wait(10) + waited = waited + 10 + end + if waited > 0 then + U.log(("waited %.1fs for the model build -- %s") + :format(waited / 60, tostring(Install.status.state))) + end + + Battles.setting:setValue("stadium", game) + Battles.backSetting:setValue(false, game) + lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game) + + local have = 0 + for dex = 1, 151 do + if Pack.available(dex) then have = have + 1 end + end + U.log(("%d species, %d/151 packs on disk, rung = %s") + :format(#species, have, tostring(Battles.setting:get()))) + + -- let the neighbourhood's meshes land, so the first fight opens on the + -- real arena rather than the flat fallback + U.wait(90) + + local asPic, stuck, missed = {}, {}, {} + + for _, s in ipairs(species) do + -- the player's side is a constant (see the header), so the only thing + -- that changes between two shots is the Pokemon being verified + game.save.party = { Pokemon.new(game.data, "DIGLETT", 40) } + + local battle = BattleState.newWild(game, s.id, 30) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe and the intro chatter, then far enough past it that the + -- player's own Pokemon has been sent out -- before that the player's + -- side is legitimately the trainer's back sprite and the foe is still + -- sliding in + U.wait(70) + for _ = 1, 40 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(10) + end + if battle.phase ~= "menu" then + stuck[#stuck + 1] = s.id + U.log(("STUCK before menu: %s (phase %s)") + :format(s.id, tostring(battle.phase))) + end + -- and let the send-out beat finish so the mon is standing still in its + -- standby loop rather than mid-arrival + U.wait(45) + + -- model or flat pic? asked here, with the fight actually on screen + if not Stadium.showing("enemy") then + asPic[#asPic + 1] = ("%03d %s"):format(s.dex, s.id) + end + + local path = ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower()) + if not U.shot(game, path) then + missed[#missed + 1] = s.id + end + + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + U.log(("---- %d shots -> %s"):format(#species - #missed, DIR)) + if #asPic > 0 then + U.log(("%d species declined to a model and were shot as FLAT PICS: %s") + :format(#asPic, table.concat(asPic, ", "))) + else + U.log("all species stood as MODELS -- none fell back to a flat pic") + end + if #stuck > 0 then + U.log(("%d never reached the battle menu: %s") + :format(#stuck, table.concat(stuck, ", "))) + end + if #missed > 0 then + U.log(("%d screenshots did not reach disk: %s") + :format(#missed, table.concat(missed, ", "))) + end + U.log("done -- " .. DIR) +end From 442e9d26d56e54f98723b7a946259fa58a32ee62 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Thu, 6 Aug 2026 01:07:38 -0400 Subject: [PATCH 3/3] add fog/god rays to viridian forest --- CHANGELOG.md | 33 ++ LICENSE | 21 - README.md | 2 - data/map_atmosphere.lua | 63 +++ lib/BattleScene.lua | 11 + lib/ForestAtmos.lua | 822 ++++++++++++++++++++++++++++++++++ lib/Voxel3D.lua | 54 +++ lib/VoxelScene.lua | 14 + main.lua | 20 + manifest.json | 2 +- tests/dramatic_shape_test.lua | 103 ++++- tests/forest_fog_shots.lua | 137 ++++++ 12 files changed, 1252 insertions(+), 30 deletions(-) delete mode 100644 LICENSE create mode 100644 data/map_atmosphere.lua create mode 100644 lib/ForestAtmos.lua create mode 100644 tests/forest_fog_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d4873..f6f3a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 1.6.2 + +### Added + +- **The air of Viridian Forest: volumetric god rays, ground fog, and a + FOREST FX row.** An invisible jungle canopy now hangs above the forest's + real trees, and light comes down through it as true volumetric beams: a + per-pixel march reads the frame's own depth buffer and the sun's own + shadow map, so shafts stand exactly where light really breaks between + the tree hulls, trunks and passing characters carve dark columns + through them, and a wind-blown leaf field at the canopy plane opens and + closes the beams like foliage moving overhead. The beams are alpha zero + at the canopy and fade in as they descend -- light below the leaves, + never a lid above them -- and a forward-scattering term blooms them for + a camera looking up into the light, first person especially. + + The scene shader gains a height-and-distance fog every surface sinks + into, and the rays are that fog lit: one shared ramp off the day/night + clock colours both, gold spears of sun by day, silver moon rays after + dark, dying back through the twilights as one hands over to the other. + Pollen drifts through the day's beams and fireflies blink low over the + floor at night, all shader-animated and deterministic. A fight staged + on the forest floor sits in the same haze at half density. + + The direction never moves: a canopy map's light is pinned to noon (see + DayNight.CANOPY), so the beams always agree with the shadows on the + floor. Everything is authored per map in `data/map_atmosphere.lua` -- + a map with no entry spends nothing -- and the **FOREST FX** row (FULL / + LOW / OFF, FULL by default) governs the cost: LOW halves the march and + stands the particles down. On Android the row offers LOW / OFF only -- + no mobile driver grants the readable depth the march needs -- so the + forest keeps its haze there and loses the beams. + ## 1.6.1 ### Fixed diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 67720a0..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 DramaticShape - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index addf73c..2d20479 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,6 @@ alongside. ## Licenses -This mod is released under the **MIT License** — see [`LICENSE`](LICENSE). - It redistributes one third-party binary: - **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader diff --git a/data/map_atmosphere.lua b/data/map_atmosphere.lua new file mode 100644 index 0000000..5d6c321 --- /dev/null +++ b/data/map_atmosphere.lua @@ -0,0 +1,63 @@ +-- What hangs in the air of each map. +-- +-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader +-- folds every surface into, and volumetric god rays -- light let down +-- through an INVISIBLE canopy hanging above the map's real geometry, as +-- if the trees drawn are only the understorey of something taller. The +-- rays are not placed: a per-pixel march (see ForestAtmos) reads the +-- frame's own depth and the sun's own shadow map, so the beams stand +-- exactly where light really breaks between the tree hulls, trees and +-- characters carve dark columns through them, and a wind-blown leaf +-- field at the canopy plane opens and closes them like foliage moving +-- overhead. The light leans along the mod's fixed noon shear (the one +-- 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. +-- +-- 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 +-- map opts into by adding a line, and what a stale entry degrades to if +-- its map id ever stops existing. +-- +-- The knobs, in world pixels unless said otherwise (a map cell is 16): +-- +-- canopyY where the invisible canopy hangs. MUST clear the tallest +-- real geometry under it (Viridian's carved tree hulls top +-- at y = 32) -- a beam is alpha ZERO at this height and only +-- fades in below it, so a canopy at or under the tree tops +-- would cut every ray off before it cleared the leaves. +-- fadeTo the height by which a descending ray reaches full strength. +-- fog density how fast distance dissolves into the haze +-- (1 - exp(-density * distance-past-start)) +-- start how many pixels out the dissolve begins +-- heightK how quickly the fog thins with ALTITUDE +-- (exp(-y * heightK): 0.02 halves it by y = 35) +-- 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 +-- seed the xorshift seed the particle deal runs on +-- +-- 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; +-- and the march runs after the water's mirror copy, so beams will not +-- appear IN those reflections either. Neither can bite in a map without +-- water. + +return { + ["VIRIDIAN_FOREST"] = { + canopyY = 56, + fadeTo = 28, + fog = { density = 0.0045, start = 64, heightK = 0.02 }, + -- strength is calibrated against the march's real integral: the + -- under-canopy stretch of an orbit ray is short and thinly dense, so + -- the raw accumulation for a fully lit beam core is a few percent -- + -- this gain lands it near +0.3 on screen. Halve it for a whisper, + -- double it for cathedral light. + rays = { strength = 16, reach = 380 }, + motes = { count = 96 }, + fireflies = { count = 48 }, + seed = 0x51D, + }, +} diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 52ad143..dbaf442 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -479,6 +479,17 @@ function BattleScene.render(state, arena, textures, token) -- no glint in the arena: the drift is the shot breathing, not the player -- moving, and a shimmer on background windows would fight the mons Voxel3D.glassGlint = 0 + -- the host floor's atmosphere reaches the staged shot at HALF density -- + -- a fight in Viridian Forest sits in the same haze the walk there did, + -- thinned so neither mon goes soft -- and its god rays stay out of it: + -- this camera is low and long, and a bright blade across a combatant + -- reads as a rendering fault, not weather. nil almost everywhere. + local ForestAtmos = V.require("ForestAtmos") + local atmos = ForestAtmos.frame(host) + Voxel3D.fog = atmos and { color = atmos.fog.color, + density = atmos.fog.density * 0.5, + start = atmos.fog.start, + heightK = atmos.fog.heightK } or nil -- A B RUNG stands the fight on two carried discs against the sky, with no -- map in the shot at all (see StadiumStage). Everything below still runs -- diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua new file mode 100644 index 0000000..d3f3a4e --- /dev/null +++ b/lib/ForestAtmos.lua @@ -0,0 +1,822 @@ +-- The air under the canopy: fog, god rays, and what drifts through them. +-- +-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest +-- today, any map that adds a line tomorrow): a ground haze the scene shader +-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down +-- through an INVISIBLE canopy hanging above the map's real trees, as if the +-- carved hulls on screen were only the understorey of something taller. +-- +-- The rays are not placed geometry. A fullscreen pass marches every +-- pixel's eye ray through the air, stops at the frame's own depth buffer +-- (the same detach-and-read Water runs), and asks two questions of every +-- step of air on the way: +-- +-- * the SUN'S question -- the shadow map. Air behind a tree hull is +-- dark air; air in a real gap glows. A trunk stands in a column of +-- its own shade, a character walks through the beams and blocks +-- them, and every shaft on screen agrees with the light already on +-- the floor, because it is read from the same map. +-- +-- * the CANOPY'S question -- where this thread of sun pierced the +-- invisible leaf layer. Every step of air on one sun ray shares that +-- point, which is what makes a shaft a SHAFT, and the point samples +-- a wind-blown noise field: the dapple drifts and shivers like +-- leaves moving overhead, opening and closing the beams as it goes. +-- +-- The shafts lean along the fixed noon shear, deliberately: a canopy +-- map's rig is pinned to noon (see DayNight.CANOPY), so light that +-- followed the sun's arc would part company with every shadow on the +-- floor. What follows the clock is colour and strength -- gold spears of +-- sun by day, silver moon rays after dark, dying back through the +-- twilights -- plus the crew each shift brings: pollen adrift in the +-- day's beams, fireflies once they cool. A forward-scattering phase term +-- brightens the beams for a camera looking up into the light, which is +-- most of what makes them read as light in air rather than paint on it. +-- +-- Everything is deterministic: placement and the leaf field are dealt by +-- a seeded xorshift (StadiumFx's generator), motion is a pure function +-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame +-- exactly (see tests/forest_fog_shots). Every shader compiles lazily +-- behind pcall -- nil untried, false unavailable -- and each refusal +-- subtracts only itself: no march without readable depth, no beams +-- without a shadow map, and the fog rides the scene shader whatever +-- happens here. + +local V = ... + +local DayNight = V.require("DayNight") +local ModSetting = V.require("ModSetting") + +local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max + +local ForestAtmos = {} + +-- FULL is the point; LOW halves the march and drops the particles, for +-- hardware that minds a per-pixel loop under 4X supersampling. +-- +-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the +-- default. The march needs a depth texture it can READ, and no driver on +-- the phones this runs on has granted one (see newDepth in Voxel3D) -- +-- so FULL would be a rung with nothing behind it, which reads as a +-- broken mod rather than a missing feature. LOW there is the haze, the +-- one part of the atmosphere that rides the scene shader and works +-- everywhere. A desktop save opened on a phone stores FULL still; +-- ModSetting's unknown-value fallback lands it on LOW, and putting the +-- save back on the desktop restores the choice. +local function onAndroid() + if not (love and love.system and love.system.getOS) then return false end + local ok, os = pcall(love.system.getOS) + return ok and os == "Android" +end + +ForestAtmos.setting = onAndroid() + and ModSetting.new("atmos", "FOREST FX", { "low", "off" }, + { "LOW", "OFF" }) + or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" }, + { "FULL", "LOW", "OFF" }) + +-- the animation clock: ticked by main.lua's always-running update hook, +-- pinnable (frozen = true) so a screenshot driver can hold a frame still +ForestAtmos.time = 0 +ForestAtmos.frozen = false + +function ForestAtmos.update(dt) + if ForestAtmos.frozen then return end + ForestAtmos.time = ForestAtmos.time + (dt or 0) +end + +-- ------- the authored table +-- +-- Same shape as BattleArena's: the data file behind a pcall with a false +-- sentinel, and an overrides table a tuning driver can stage a candidate +-- through before anything is written down. `~= nil` on the override, +-- because false is meaningful -- "this map has no atmosphere, whatever +-- the file says". + +local authored = nil +local overrides = {} + +local function configFor(mapId) + if not mapId then return nil end + local forced = overrides[mapId] + if forced ~= nil then return forced or nil end + if authored == nil then + local ok, list = pcall(V.data, "map_atmosphere") + authored = (ok and type(list) == "table") and list or false + end + if not authored then return nil end + return authored[mapId] +end + +ForestAtmos.configFor = configFor + +-- ------- caches +-- +-- Particle layouts and meshes go stale with the map (map.reloaded, and +-- the pipeline's invalidate); called with no map id this also resets the +-- shader and texture sentinels, which is what a lost GL context needs. + +local layoutCache = {} +local meshCache = {} +local shaders = {} -- keyed by variant; nil untried, false refused +local leafTex = nil -- the tiling leaf-dapple field +local rayMesh = nil -- the fullscreen ray-fan quad, re-aimed per draw + +-- Every bail here is deliberate and silent on screen -- a missing piece +-- subtracts itself, never the frame -- but "the beams are off" and "the +-- beams are off BECAUSE ..." are different debugging days. Each reason +-- is said once on the console, the way VR reports a missing runtime. +local said = {} +local function say(key, msg) + if said[key] then return end + said[key] = true + print("[DRAMATIC_SHAPE] atmos: " .. msg) +end + +function ForestAtmos.invalidate(mapId) + if mapId then + layoutCache[mapId] = nil + meshCache[mapId] = nil + else + layoutCache, meshCache = {}, {} + shaders = {} + leafTex = nil + rayMesh = nil + end +end + +function ForestAtmos.setOverride(mapId, entry) + overrides[mapId] = entry + ForestAtmos.invalidate(mapId) +end + +-- ------- the hour's answer +-- +-- One ramp, authored per phase and blended with DayNight's own weights, +-- so the fog and the rays can never disagree about what hour it is. The +-- two interact three ways: the fog colour leans toward the ray colour +-- (noon warms the haze, midnight silvers it), the rays scale with the +-- fog's density (a beam IS lit fog -- less medium, less beam), and the +-- march accumulates through the same density the surfaces sink into. + +ForestAtmos.RAMP = { + day = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 }, + alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 }, + golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 }, + alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 }, + dawn = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 }, + alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 }, + dusk = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 }, + alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 }, + violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 }, + alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 }, + night = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 }, + alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 }, +} + +-- 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. +function ForestAtmos.frame(map, t) + if ForestAtmos.setting:get() == "off" then return nil end + local cfg = configFor(map and map.id) + 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 + 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 + rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w + 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 + fr = fr + (rr - fr) * LEAN + fg = fg + (rg - fg) * LEAN + fb = fb + (rb - fb) * LEAN + local base = cfg.fog or {} + local rays = cfg.rays or {} + return { + -- in exactly the shape Voxel3D.fog takes, so callers assign it whole + fog = { color = { fr, fg, fb }, + density = (base.density or 0) * dens, + start = base.start or 0, + heightK = base.heightK or 0 }, + rayColor = { rr, rg, rb }, + -- a beam is scattered fog: less medium, less beam + rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)), + rayStrength = rays.strength or 12, + rayReach = rays.reach or 380, + moteLevel = motes, + fireflyLevel = flies, + cfg = cfg, + } +end + +-- ------- deterministic noise +-- +-- The same written-out xorshift StadiumFx runs (see the note there on why +-- not LuaJIT's `bit`): the particle deal and the leaf field must come out +-- identical on every machine and every visit. + +local function bxor32(a, b) + local r, p = 0, 1 + for _ = 1, 32 do + local x, y = a % 2, b % 2 + if x ~= y then r = r + p end + a, b, p = floor(a / 2), floor(b / 2), p * 2 + end + return r +end + +local Rng = {} +Rng.__index = Rng + +local function newRng(seed) + local s = seed % 0x100000000 + if s == 0 then s = 0x9E3779B9 end + return setmetatable({ s = s }, Rng) +end + +function Rng:next() + local x = self.s + x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13) + x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17 + x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5) + self.s = x % 0x100000000 + return self.s +end + +function Rng:unit() + return self:next() / 0x100000000 +end + +-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles +local function lattice(rng, w, h) + local g = {} + for y = 1, h do + local row = {} + for x = 1, w do row[x] = rng:unit() end + g[y] = row + end + return g +end + +local function smoothstep01(t) + return t * t * (3 - 2 * t) +end + +local function torus(grid, w, h, x, y) + local x0, y0 = floor(x), floor(y) + local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0) + local x1, y1 = (x0 + 1) % w, (y0 + 1) % h + x0, y0 = x0 % w, y0 % h + local a = grid[y0 + 1][x0 + 1] + local b = grid[y0 + 1][x1 + 1] + local c = grid[y1 + 1][x0 + 1] + local d = grid[y1 + 1][x1 + 1] + return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy +end + +-- ------- the leaf field +-- +-- One small tiling texture of three-octave value noise, generated once +-- from a fixed seed: the pattern of the unseen foliage. The shader reads +-- it at two drifting, differently-scaled offsets and thresholds the sum, +-- so the pools of light between the leaves slide, open and close -- the +-- movement is the WIND's, all in the sampling; the cloth itself never +-- changes, which is what keeps a pinned frame reproducible. + +local function leafTexture() + if leafTex ~= nil then return leafTex or nil end + if not (love and love.image and love.image.newImageData + and love.graphics and love.graphics.newImage) then + leafTex = false + return nil + end + local ok, tex = pcall(function() + local N = 128 + local rng = newRng(0x1EAF) + local g1 = lattice(rng, 8, 8) + local g2 = lattice(rng, 16, 16) + local g3 = lattice(rng, 32, 32) + local img = love.image.newImageData(N, N) + for y = 0, N - 1 do + local v = y / N + for x = 0, N - 1 do + local u = x / N + local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5 + + torus(g2, 16, 16, u * 16, v * 16) * 0.3 + + torus(g3, 32, 32, u * 32, v * 32) * 0.2 + img:setPixel(x, y, n, n, n, 1) + end + end + local t = love.graphics.newImage(img) + t:setWrap("repeat", "repeat") + t:setFilter("linear", "linear") + return t + end) + leafTex = (ok and tex) or false + return leafTex or nil +end + +-- ------- placement (the particles; the light places itself) + +local MARGIN = 24 -- keep off the map's edge, world px + +function ForestAtmos.layout(cfg, w, h) + local rng = newRng((cfg.seed or 0x51D)) + local canopy = cfg.canopyY or 56 + local motes = {} + for _ = 1, (cfg.motes and cfg.motes.count) or 0 do + motes[#motes + 1] = { + x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1), + y = 3 + rng:unit() * max(canopy - 11, 8), + z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1), + phase = rng:unit() * 6.2832, + rate = 0.5 + rng:unit(), + } + end + local flies = {} + for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do + flies[#flies + 1] = { + x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1), + y = 3 + rng:unit() * 12, + z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1), + phase = rng:unit() * 6.2832, + rate = 0.5 + rng:unit(), + } + end + return { motes = motes, flies = flies } +end + +-- 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) + local hit = layoutCache[map.id] + if hit ~= nil then return hit or nil end + local cfg = configFor(map.id) + if not cfg then + layoutCache[map.id] = false + return nil + end + local def = map.def or {} + local L = ForestAtmos.layout(cfg, (def.width or 16) * 32, + (def.height or 16) * 32) + layoutCache[map.id] = L + return L +end + +ForestAtmos.layoutFor = layoutFor + +-- ------- the volumetric march +-- +-- A fullscreen quad whose four corners carry the camera's own frustum +-- rays; the varying interpolates them into a world ray per pixel. The +-- pixel stage walks that ray to the depth buffer's surface, and every +-- step of air on the way is lit or not by the shadow map and the leaf +-- field, accumulated through the same haze the surfaces sink into. +-- +-- Conventions copied from Water's march: the ray walks the FLAT world +-- (the space it is straight in) and every depth compare bends the sample +-- first, by the same displacement the vertex stage applies -- so the +-- march reads the depth buffer it actually has. The shadow lookup stays +-- flat, exactly like the scene shader's own vSun. STEPS is spliced into +-- the source rather than sent (the LOW rung is a second compile), and +-- there are no uniform arrays anywhere -- see the note in Sky about the +-- Android driver that reads them as zero. + +local RAY_SHADER = [[ + varying vec3 vRay; +#ifdef VERTEX + attribute vec3 RayDir; + vec4 position(mat4 transform_projection, vec4 vertex_position) { + vRay = RayDir; + return transform_projection * vertex_position; + } +#endif +#ifdef PIXEL + uniform Image depthTex; // the frame's own depth, detached to read + uniform Image sunMap; // the sun's answer (see ShadowMap) + uniform Image leafTex; // the unseen foliage, tiling + uniform mat4 vp; + uniform mat4 sunVP; + uniform float sunBias; + uniform vec3 eye; + uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off + uniform vec2 screen; // canvas size, for the pixel's own uv + uniform vec4 fogW; // density, heightK, canopyY, fadeTo + uniform vec3 shear; // the noon shear kx, kz; z = reach + uniform vec3 rayColor; + uniform float strength; + uniform vec3 sunward; // unit, toward the unseen sun + uniform vec2 wind; // leaf-field drift, uv per second + uniform float time; + + float sunDepth(vec2 uv) { + vec4 c = Texel(sunMap, uv); + return c.r + c.g * (1.0 / 255.0); + } + + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec2 uv = sc / screen; + float sceneD = Texel(depthTex, uv).r; + vec3 dir = normalize(vRay); + // Spend every sample where a sample can glow. Above the canopy no + // beam exists, and below the floor there is no air at all -- so the + // march runs from where this ray first dips under the leaves to + // where it would pass the ground, however long or short that + // stretch is. From the orbit camera that is the last few dozen + // pixels of a mostly-vertical ray, and dividing the WHOLE reach by + // the step count there starved the beams to nothing. + float t0 = 0.0; + if (eye.y > fogW.z) { + if (dir.y >= -0.01) return vec4(0.0); + t0 = (eye.y - fogW.z) / -dir.y; + } + float tEnd = shear.z; + if (dir.y < -0.01) { + tEnd = min(tEnd, (eye.y + 8.0) / -dir.y); + } + if (tEnd <= t0) return vec4(0.0); + // interleaved gradient noise staggers neighbouring pixels' steps, + // which is what turns 20-odd samples into a smooth volume instead + // of an onion of banded slices + float jitter = fract(52.9829189 + * fract(dot(sc, vec2(0.06711056, 0.00583715)))); + float dt = (tEnd - t0) / float(STEPS); + // HALF the fog's own extinction, on the way in and per step: the + // full rate is what the surfaces sink by, and beams that obeyed it + // too died before the orbit camera ever saw them. Half keeps the + // depth cue and leaves the light alive. + float trans = exp(-fogW.x * 0.5 * t0); + float acc = 0.0; + for (int i = 0; i < STEPS; i++) { + float t = t0 + (float(i) + jitter) * dt; + vec3 p = eye + dir * t; + // stop at the surface: bend the sample the way the geometry bent + vec2 cd = p.xz - curve.xy; + vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0); + if (c.w <= 1e-6) break; + if (c.z / c.w * 0.5 + 0.5 > sceneD) break; + if (p.y < fogW.z) { + // the sun's question: is this air behind a tree? Outside the + // frustum nothing was recorded and the air counts as lit, eased + // at the rim exactly like the scene shader's shadows + float lit = 1.0; + vec3 su = (sunVP * vec4(p, 1.0)).xyz; + if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0 + && su.z < 1.0) { + vec2 e2 = min(su.xy, 1.0 - su.xy); + float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y)); + lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge); + } + // the canopy's question: where did this thread of light pierce + // the leaves? Every step of air on one sun ray shares the + // answer -- that shared point is what makes a shaft a shaft -- + // and the two drifting reads of the field are the wind moving + // the foliage overhead, opening and closing the beams + float up = fogW.z - p.y; + vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0); + float n = Texel(leafTex, gap + wind * time).r * 0.65 + + Texel(leafTex, gap * 2.3 - wind * (time * 0.7) + + vec2(0.37, 0.61)).r * 0.35; + float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n); + // the beam fades IN below the invisible canopy, thins with + // altitude like the haze it is made of, and kisses the floor + float y = max(p.y, 0.0); + float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0); + float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0); + float dens = fogW.x * exp(-y * fogW.y); + acc += trans * lit * dapple * fadeIn * foot * dens * dt; + } + trans *= exp(-fogW.x * 0.5 * dt); + } + // forward scattering: beams bloom for a camera looking up into the + // light, which is most of what makes them read as light IN air + float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0); + return vec4(rayColor * (acc * strength * phase), 1.0) * color; + } +#endif +]] + +local function rayShaderFor(steps) + local key = "ray" .. steps + local s = shaders[key] + if s ~= nil then return s or nil end + if not (love and love.graphics and love.graphics.newShader) then + shaders[key] = false + return nil + end + local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER + local ok, sh = pcall(love.graphics.newShader, src) + if not ok then + say(key, "ray shader refused -- beams off, fog stays: " + .. tostring(sh)) + end + shaders[key] = (ok and sh) or false + return shaders[key] or nil +end + +local RAY_FORMAT = { + { "VertexPosition", "float", 2 }, + { "RayDir", "float", 3 }, +} + +-- The camera's frustum corners, from the same fields every pass sets: +-- eye, focus, fovY, and the placed camera's up when there is one (VR +-- eyes roll; the orbit never does). Interpolating a corner ray across +-- the quad IS the standard reconstruction for a perspective camera, so +-- this works identically for the orbit, first person and both eyes. +local function rayQuad(Voxel3D, w, h) + local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY + if not (e and fo and fov) then return nil end + local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3] + local fl = sqrt(fx * fx + fy * fy + fz * fz) + if fl < 1e-6 then return nil end + fx, fy, fz = fx / fl, fy / fl, fz / fl + local cam = Voxel3D.camera + local up = (cam and cam.up) or { 0, 1, 0 } + -- right = forward x up, then a true up perpendicular to both + local rx = fy * up[3] - fz * up[2] + local ry = fz * up[1] - fx * up[3] + local rz = fx * up[2] - fy * up[1] + local rl = sqrt(rx * rx + ry * ry + rz * rz) + if rl < 1e-6 then return nil end + rx, ry, rz = rx / rl, ry / rl, rz / rl + local ux = ry * fz - rz * fy + local uy = rz * fx - rx * fz + local uz = rx * fy - ry * fx + local hh = math.tan(fov * 0.5) + local hw = hh * (w / h) + -- canvas row 0 is the TOP of the frame, which is the +up corner + local function corner(su, sv) + return fx + rx * hw * su + ux * hh * sv, + fy + ry * hw * su + uy * hh * sv, + fz + rz * hw * su + uz * hh * sv + end + local x0, y0, z0 = corner(-1, 1) + local x1, y1, z1 = corner(1, 1) + local x2, y2, z2 = corner(1, -1) + local x3, y3, z3 = corner(-1, -1) + local verts = { + { 0, 0, x0, y0, z0 }, + { w, 0, x1, y1, z1 }, + { w, h, x2, y2, z2 }, + { 0, h, x3, y3, z3 }, + } + if not rayMesh then + local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts, + "fan", "stream") + rayMesh = ok and mesh or nil + return rayMesh + end + local ok = pcall(rayMesh.setVertices, rayMesh, verts) + return ok and rayMesh or nil +end + +-- ------- the particles + +local PART_SHADER = [[ + varying vec2 vCorner; + varying float vGlow; +#ifdef VERTEX + uniform mat4 vp; + uniform vec3 curve; + uniform vec3 axisR; // the camera's right, world space + uniform vec3 axisU; // and its up: the billboard's own frame + uniform float time; + uniform float size; + uniform vec2 sway; // wander amplitude: horizontal, vertical + uniform float blinky; // 0 = steady motes, 1 = blinking fireflies + attribute vec4 AtmosData; // corner x, corner y, phase, rate + vec4 position(mat4 transform_projection, vec4 vertex_position) { + float ph = AtmosData.z; + float rt = AtmosData.w; + 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( + 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); + float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0); + vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky); + vCorner = AtmosData.xy; + vec4 w = vec4(base + axisR * (AtmosData.x * size) + + axisU * (AtmosData.y * size), 1.0); + if (curve.z > 0.0) { + vec2 cd = w.xz - curve.xy; + w.y -= dot(cd, cd) * curve.z; + } + return vp * w; + } +#endif +#ifdef PIXEL + uniform vec3 dotColor; + uniform float level; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + float d = dot(vCorner, vCorner); + float glow = max(0.0, 1.0 - d); + glow *= glow; + return vec4(dotColor, glow * level * vGlow) * color; + } +#endif +]] + +local function partShader() + local s = shaders.part + if s ~= nil then return s or nil end + if not (love and love.graphics and love.graphics.newShader) then + shaders.part = false + return nil + end + local ok, sh = pcall(love.graphics.newShader, PART_SHADER) + shaders.part = (ok and sh) or false + return shaders.part or nil +end + +local PART_FORMAT = { + { "VertexPosition", "float", 3 }, + { "AtmosData", "float", 4 }, +} + +local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } } + +local function pushQuad(map, n) + local b = n * 4 + map[#map + 1] = b + 1 + map[#map + 1] = b + 2 + map[#map + 1] = b + 3 + map[#map + 1] = b + 1 + map[#map + 1] = b + 3 + map[#map + 1] = b + 4 +end + +local function buildPartMesh(points) + if #points == 0 then return nil end + local verts, indices = {}, {} + for i = 1, #points do + local p = points[i] + for c = 1, 4 do + verts[#verts + 1] = { p.x, p.y, p.z, + CORNERS[c][1], CORNERS[c][2], p.phase, p.rate } + end + pushQuad(indices, i - 1) + end + local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts, + "triangles", "static") + if not ok then return nil end + pcall(mesh.setVertexMap, mesh, indices) + return mesh +end + +local function meshesFor(map, L) + local hit = meshCache[map.id] + if hit then return hit end + local M = { + motes = buildPartMesh(L.motes), + flies = buildPartMesh(L.flies), + } + meshCache[map.id] = M + return M +end + +local MOTE_COLOR = { 1.0, 0.96, 0.78 } +local FLY_COLOR = { 0.72, 1.0, 0.45 } + +-- 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 +-- reads the eye's camera). Degenerate looks answer nil and the +-- particles sit this one out. +local function billboardAxes(Voxel3D) + local e, fo = Voxel3D.eye, Voxel3D.focus + if not (e and fo) then return nil end + local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3] + local ll = sqrt(lx * lx + ly * ly + lz * lz) + if ll < 1e-6 then return nil end + lx, ly, lz = lx / ll, ly / ll, lz / ll + local rx, rz = -lz, lx + local rl = sqrt(rx * rx + rz * rz) + if rl < 1e-4 then return nil end + rx, rz = rx / rl, rz / rl + return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly } +end + +-- ------- the draw +-- +-- Inside the scene pass, in VoxelScene's prop slot. The march borrows +-- the frame's depth through Voxel3D.beginWater -- the same detach Water +-- runs -- and hand-tests every step against it, so the pass itself needs +-- no depth attachment; the particles come after, depth-tested additive +-- 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) + local rung = ForestAtmos.setting:get() + if rung == "off" 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 + -- 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() + if not sunTex then + say("sun", "no shadow map standing -- beams off, fog stays") + end + local leaf = leafTexture() + if not leaf then + say("leaf", "leaf field would not build -- beams off, fog stays") + end + local sh = rayShaderFor(rung == "low" and 12 or 24) + local w, h = Voxel3D.size() + local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h) + if sh and sunTex and leaf and w and not quad then + say("quad", "no camera frame for the ray fan -- beams off") + end + if quad then + local _, depth = Voxel3D.beginWater(nil) + if depth then + say("on", "volumetric beams running") + local kx, kz = DayNight.shearAt(DayNight.T.day) + local kl = sqrt(kx * kx + kz * kz + 1) + love.graphics.setBlendMode("add", "alphamultiply") + love.graphics.setShader(sh) + pcall(sh.send, sh, "depthTex", depth) + pcall(sh.send, sh, "sunMap", sunTex) + pcall(sh.send, sh, "leafTex", leaf) + pcall(sh.send, sh, "vp", "row", Voxel3D.vp) + pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP) + pcall(sh.send, sh, "sunBias", ShadowMap.bias) + pcall(sh.send, sh, "eye", Voxel3D.eye) + pcall(sh.send, sh, "curve", + { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, + Voxel3D.curveK or 0 }) + pcall(sh.send, sh, "screen", { w, h }) + pcall(sh.send, sh, "fogW", + { f.fog.density, f.fog.heightK, + f.cfg.canopyY or 56, f.cfg.fadeTo or 28 }) + pcall(sh.send, sh, "shear", { kx, kz, f.rayReach }) + pcall(sh.send, sh, "rayColor", f.rayColor) + pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha) + pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl }) + pcall(sh.send, sh, "wind", { 0.016, 0.009 }) + pcall(sh.send, sh, "time", ForestAtmos.time) + pcall(love.graphics.draw, quad) + love.graphics.setShader() + love.graphics.setBlendMode("alpha") + end + Voxel3D.endWater() + end + end + + if rung == "full" then + local L = layoutFor(map) + local M = L and meshesFor(map, L) + local psh = partShader() + local axisR, axisU = billboardAxes(Voxel3D) + if M and psh and axisR then + Voxel3D.blend("add") + if Voxel3D.beginEffect(psh) then + pcall(psh.send, psh, "vp", "row", Voxel3D.vp) + pcall(psh.send, psh, "curve", + { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, + Voxel3D.curveK or 0 }) + 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, "size", 1.4) + pcall(psh.send, psh, "sway", { 5, 2.5 }) + pcall(psh.send, psh, "blinky", 0) + pcall(psh.send, psh, "dotColor", MOTE_COLOR) + 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 + 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) + end + Voxel3D.endEffect() + end + Voxel3D.blend(nil) + end + end +end + +return ForestAtmos diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index a15f265..c7e5df5 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -72,6 +72,7 @@ Voxel3D.FACE_SHADE = { local SHADER = [[ varying float vShade; varying vec3 vSun; // this fragment's place in the sun's view + varying float vFog; // how deep into the map's haze it stands #ifdef VOXEL_GRID // model space, one unit per voxel -- see VoxelGrid. Precision matters // here in a way it does not for a colour: the seam is the FRACTIONAL @@ -87,6 +88,7 @@ local SHADER = [[ uniform vec3 eye; uniform float pull; uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off + uniform vec4 fogInfo; // density, start, heightK; density 0 = clear attribute float VertexShade; vec4 position(mat4 transform_projection, vec4 vertex_position) { vShade = VertexShade; @@ -108,6 +110,19 @@ local SHADER = [[ // answered. (The pull below is excluded for the same reason: it is a // depth trick aimed at the camera's own buffer.) vSun = (sunVP * (sunModel * vertex_position)).xyz; + // THE MAP'S HAZE (see ForestAtmos): how much fog stands between the + // eye and this vertex -- distance dissolves into it, altitude climbs + // out of it. Worked out on the FLAT world like the shadow lookup + // above (the curve is a trick played on the viewer, not weather), + // and per VERTEX: on meshes built a face per voxel the interpolated + // answer is indistinguishable from per-fragment fog at a fraction of + // the cost. + vFog = 0.0; + if (fogInfo.x > 0.0) { + float fogRun = max(0.0, length(w.xyz - eye) - fogInfo.y); + vFog = (1.0 - exp(-fogInfo.x * fogRun)) + * exp(-max(w.y, 0.0) * fogInfo.z); + } // The curved world (see WorldCurve): drop every vertex by the square // of how far its column stands from the camera's focus. Applied AFTER // the shadow lookup above and clear of the wireframe's model space, so @@ -205,6 +220,7 @@ local SHADER = [[ uniform vec3 ghostColor; // the flat silhouette colour uniform float ghost; // 0 = shade normally, 1 = flatten to it uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon + uniform vec3 fogColor; // what the haze is made of (see Voxel3D.fog) uniform Image glassMask; // opaque where the atlas texel is window glass uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels uniform float glassNight; // 0 = daylight .. 1 = the lamps are on @@ -256,6 +272,11 @@ local SHADER = [[ vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine); rgb = mix(pane, lamp, glassNight * glass); } + // the haze stands between the eye and the SURFACE, so it lands after + // every surface term -- sun, seams, glass -- and before only the + // ghost, which must stay one solid readable shape whatever the + // weather (see below) + rgb = mix(rgb, fogColor, vFog); // The hidden player is a SHAPE, not a dimmed picture of itself. Tinting // through `color` could only multiply the sprite's own pixels, which // darkens each one by its own amount and keeps the character's internal @@ -687,6 +708,14 @@ end -- answers it, so a caller that never does draws exactly what it always drew. Voxel3D.tint = { 1, 1, 1 } +-- The map's haze, set the same way (VoxelScene and BattleScene ask +-- ForestAtmos, who knows which maps have weather): a table of +-- { color = {r,g,b}, density, start, heightK }, or nil for a clear day. +-- nil -- the default -- sends density 0, so a caller that never heard of +-- fog draws exactly what it always drew, and no pass can inherit the +-- last one's weather. +Voxel3D.fog = nil + -- The window-glass pass, set the same way and for the same reason: the -- MASK belongs to the map's tileset (GlassMask.texture) and how lit the -- panes are belongs to the hour and to being outdoors at all @@ -922,6 +951,12 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR) -- the hour's light, as the caller last set it (see Voxel3D.tint) pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 }) + -- and the map's haze (see Voxel3D.fog), density 0 when there is none + local fog = Voxel3D.fog + pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 }) + pcall(sh.send, sh, "fogInfo", fog and + { fog.density or 0, fog.start or 0, fog.heightK or 0, 0 } + or { 0, 0, 0, 0 }) -- the window glass: the tileset's mask (or the blank -- the sampler is -- declared either way, and unbound is a driver-dependent crash), how lit -- the panes are, and the movement-fed glint as the caller last set it @@ -1127,6 +1162,25 @@ function Voxel3D.endWater() if activeShader then love.graphics.setShader(activeShader) end end +-- A custom shader for the length of a draw, inside the pass. What makes +-- this a pair rather than a bare setShader at the call site is the way +-- BACK: the scene shader this pass bound is module-local (activeShader, +-- above), so only this file can restore it -- the same restore endWater +-- performs, without the canvas shuffle. Answers false when there is no +-- pass to come back to, and the caller skips its draw entirely. +function Voxel3D.beginEffect(shader) + if not (active and shader) then return false end + love.graphics.setShader(shader) + love.graphics.setColor(1, 1, 1, 1) + return true +end + +function Voxel3D.endEffect() + if not active then return end + love.graphics.setColor(1, 1, 1, 1) + if activeShader then love.graphics.setShader(activeShader) end +end + -- Whether a reflective water pass can run in this frame at all -- there is -- a depth texture to read. Callers use it to choose between the water -- shader and an ordinary terrain draw before they start moving canvases. diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index dd4a146..907408b 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -914,6 +914,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 local g = VoxelScene.glintStep(glint, cx, cy) Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp + -- and the map's atmosphere, if it has one (see ForestAtmos): the haze + -- the scene shader folds every surface into, in the hour's colour. + -- nil for every map without an entry -- a clear day, exactly as before. + local ForestAtmos = V.require("ForestAtmos") + local atmos = ForestAtmos.frame(state.map) + Voxel3D.fog = atmos and atmos.fog or nil local function atlasFor(map) return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) @@ -1152,6 +1158,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end + -- The map's atmosphere -- god rays down from the invisible canopy, and + -- whatever drifts through them (see ForestAtmos). Additive over the + -- 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 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 -- occludes honestly. Its frame only exists while a session is live and diff --git a/main.lua b/main.lua index 4714165..28f41b5 100644 --- a/main.lua +++ b/main.lua @@ -90,6 +90,7 @@ local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") +local ForestAtmos = V.require("ForestAtmos") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") @@ -191,6 +192,9 @@ mod.content.render_pipelines:register("voxel", { -- battles and menus, and a CYCLE evening falls mid-fight exactly as it -- would mid-walk DayNight.update(dt) + -- the atmosphere's own clock (shaft shimmer, drifting motes), on the + -- same tick so the beams keep breathing through a dialog box + ForestAtmos.update(dt) -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle -- screen the engine composites, which is not a stage the registry has. @@ -302,6 +306,7 @@ mod.content.render_pipelines:register("voxel", { OverworldBattle.invalidate() AntiAlias.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh + ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels VR.invalidate() -- the mirror, and FBO ids of dead canvases end, }) @@ -427,6 +432,18 @@ local SETTINGS = { .. "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." }, + -- `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.", + full = true }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged @@ -914,6 +931,9 @@ mod.events:on("map.reloaded", function(payload) if payload and payload.reason == "colors" then return end local mapId = payload and (payload.mapId or (payload.map and payload.map.id)) if mapId then ChunkMesher.invalidate(mapId) end + -- the atmosphere's layout stands on the same carved stamps the meshes + -- do, so it goes stale on exactly the same event + if mapId then ForestAtmos.invalidate(mapId) end end) -- ------- rows come and go, so the menu has to notice diff --git a/manifest.json b/manifest.json index 32e5c9e..8a32961 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.6.1", + "version": "1.6.2", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index ee96136..0a7487c 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -319,7 +319,7 @@ local order = {} for i, row in ipairs(grouped) do order[row.id] = i end T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 5, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") @@ -404,18 +404,23 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, -- (nothing to store, nothing for the mod manager to persist) and is offered -- on every platform, saying WHERE? rather than IMPORT where there is no file -- dialog to open -T.eq(#hookedRows, 10, "the options hook added a row per setting, plus the " +T.eq(#hookedRows, 11, "the options hook added a row per setting, plus the " .. "STADIUM ROM action row") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] -local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] --- the AA row is hookedRows[8]; it is read in its own block below, because --- this chunk is one main function and has 200 local slots to spend +local battles, backRow, daytime = hookedRows[6], hookedRows[7], hookedRows[8] +-- the FOREST FX row is hookedRows[5] and AA hookedRows[9]; both are read +-- where they are used rather than named here, because this chunk is one +-- main function and has 200 local slots to spend T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) T.eq(water.value(), "SKY", "stepping down drops the screen-space march and keeps the sky, sun and moon") +T.eq(hookedRows[5].label, "FOREST FX", "the atmosphere row carries its label") +T.eq(hookedRows[5].value(), "FULL", + "and defaults to FULL -- it only spends anything on a map with an " + .. "atmosphere entry, which is one forest today") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") T.eq(daytime.value(), "SYNC", "and defaults to SYNC -- no value set follows the clock on the wall") @@ -499,7 +504,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[8] +local aa = hookedRows[9] T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " @@ -4064,6 +4069,92 @@ T.check(not DayNight.isCanopy({ id = "PALLET_TOWN" }), T.check(not DayNight.isCanopy(nil), "no map, no canopy") end +-- ------- the air under the canopy +-- +-- ForestAtmos hangs an INVISIBLE canopy above the forest's real trees and +-- lets light down through it: fog for the scene shader, a volumetric +-- march for the beams (GPU-only, not checkable here), colour and strength +-- following the clock. Everything checkable without a GPU is checked: +-- the authored table, the hour's ramp, and the seeded particle deal -- +-- which must come out identical on every visit. +do +local ForestAtmos = + run.loader.exports.DRAMATIC_SHAPE.lib.require("ForestAtmos") +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") + +-- the authored table +local cfg = ForestAtmos.configFor("VIRIDIAN_FOREST") +T.check(cfg ~= nil, "Viridian Forest has an atmosphere entry") +T.check(cfg and (cfg.canopyY or 0) > 32, + "whose invisible canopy hangs ABOVE the carved tree hulls (y = 32) -- " + .. "a ray is alpha zero at the canopy and only fades in below it") +T.check(ForestAtmos.configFor("PALLET_TOWN") == nil, + "a map without an entry has no atmosphere at all") +T.check(ForestAtmos.configFor(nil) == nil, "and no map id does not crash") + +-- the hour's ramp: gold spears by day, silver rays by night, both dying +-- back through the twilight handover +local fmap = { id = "VIRIDIAN_FOREST" } +local day = ForestAtmos.frame(fmap, DayNight.T.day) +local night = ForestAtmos.frame(fmap, DayNight.T.night) +local dusk = ForestAtmos.frame(fmap, DayNight.T.dusk) +T.check(day and night and dusk, "the forest answers at every pin") +T.check(day.rayColor[1] > day.rayColor[3], + "the day's rays are sun-gold: more red than blue") +T.check(night.rayColor[3] > night.rayColor[1], + "the night's are moon-silver: more blue than red") +T.check(dusk.rayAlpha < day.rayAlpha and dusk.rayAlpha < night.rayAlpha, + "and the twilight is the dim handover between the two") +T.check(day.fog.density > 0 and night.fog.density > 0, + "the haze never lifts entirely, day or night") +T.check(day.moteLevel > 0.5 and day.fireflyLevel < 0.05, + "pollen drifts through the day's beams, with no fireflies out") +T.check(night.fireflyLevel > 0.5 and night.moteLevel < 0.05, + "and the night shift trades them") + +-- the seeded deal: the same particles on every visit (the beams place +-- themselves -- they are marched from the shadow map, not dealt) +local tcfg = { canopyY = 56, fadeTo = 28, seed = 77, + motes = { count = 12 }, fireflies = { count = 6 } } +local a = ForestAtmos.layout(tcfg, 320, 320) +local b = ForestAtmos.layout(tcfg, 320, 320) +T.eq(#a.motes, 12, "the pollen musters at authored strength") +T.eq(#a.flies, 6, "the fireflies too") +local same = true +for i = 1, #a.motes do + local m1, m2 = a.motes[i], b.motes[i] + same = same and m1.x == m2.x and m1.y == m2.y and m1.z == m2.z +end +T.check(same, "and the deal comes out identical on every visit") +local under = true +for _, m in ipairs(a.motes) do + if m.y >= tcfg.canopyY then under = false end +end +T.check(under, "everything drifts BELOW the canopy it lives under") + +-- the tuning override, the same handle arena_editor holds on battles +ForestAtmos.setOverride("PALLET_TOWN", { canopyY = 40, fog = {} }) +T.check(ForestAtmos.configFor("PALLET_TOWN") ~= nil, + "an override stages an atmosphere a driver can tune live") +ForestAtmos.setOverride("VIRIDIAN_FOREST", false) +T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") == nil, + "false is meaningful: this map has none, whatever the file says") +ForestAtmos.setOverride("PALLET_TOWN", nil) +ForestAtmos.setOverride("VIRIDIAN_FOREST", nil) +T.check(ForestAtmos.configFor("VIRIDIAN_FOREST") ~= nil + and ForestAtmos.configFor("PALLET_TOWN") == nil, + "and nil hands both back to the authored table") + +-- the row: OFF is the ladder's last rung and the frame answers nothing +T.eq(ForestAtmos.setting.values[1], "full", + "the atmosphere defaults to FULL -- it costs a handful of quads and " + .. "exists on one map") +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") +end + -- ------- a shadow keeps hold of the feet that throw it -- -- The depth compare forgives `slack` world pixels so lit ground does not diff --git a/tests/forest_fog_shots.lua b/tests/forest_fog_shots.lua new file mode 100644 index 0000000..d3a2e06 --- /dev/null +++ b/tests/forest_fog_shots.lua @@ -0,0 +1,137 @@ +-- Driver: screenshot the forest's atmosphere. +-- +-- Viridian Forest through the pins: gold shafts and drifting pollen by +-- day, silver moon rays and fireflies at night, the haze under both, one +-- first-person frame standing inside a beam, and the control shots -- the +-- row OFF (which must be pixel-identical to a run before the feature +-- existed) and LOW (haze, half the shafts, no particles). +-- +-- Deterministic on purpose: encounters killed, tile animation frozen, the +-- atmosphere's own clock pinned, so an AB_TAG=before/after pair diffs +-- clean (see tools/voxel-survey.md for the workflow). +-- +-- SHOT_DIR=.scratchpad/forestfog AB_TAG=after \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/forest_fog_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/forestfog") + .. "/" .. (os.getenv("AB_TAG") or "after") + + 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 -- enable it and run again") + 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") + + if not (game.data.maps and game.data.maps.VIRIDIAN_FOREST) then + U.log("no VIRIDIAN_FOREST in this dataset") + return + end + + -- ------- the determinism recipe (see round_regress_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) + -- pin the atmosphere's own clock: every shimmer and every mote is a + -- pure function of this number + 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(x, y, face, rung) + U.teleport(game, "VIRIDIAN_FOREST", x, y, face or "up") + Pipelines.setLevel("voxel", rung or 5) + Pipelines.setLevel("tiltshift", 0) -- judge the atmosphere, not the blur + settle() + end + + ForestAtmos.setting:sync("full") + + -- ------- day: gold spears and pollen, three vantages + setTime("day") + go(17, 20) + U.shot(game, ROOT .. "/10_day_17_20.png") + go(16, 20) + U.shot(game, ROOT .. "/11_day_16_20.png") + go(16, 24) + U.shot(game, ROOT .. "/12_day_16_24.png") + + -- ------- night: moon rays and fireflies, same vantages + setTime("night") + go(17, 20) + U.shot(game, ROOT .. "/20_night_17_20.png") + go(16, 20) + U.shot(game, ROOT .. "/21_night_16_20.png") + go(16, 24) + U.shot(game, ROOT .. "/22_night_16_24.png") + + -- ------- first person, standing in a beam + -- + -- The shafts are dealt by seed, so ask the layout where one landed and + -- walk into it rather than guessing a cell. + local shaft + pcall(function() + local map = game.overworld and game.overworld.map + local L = map and ForestAtmos.layoutFor(map) + shaft = L and L.shafts and L.shafts[1] + end) + if shaft then + local cx = math.floor(shaft.x / 16) + local cy = math.floor(shaft.z / 16) + setTime("day") + go(cx, cy + 1, "up", 6) -- the 1ST rung, facing the beam + U.shot(game, ROOT .. "/30_fp_in_beam_day.png") + setTime("night") + U.wait(30) + U.shot(game, ROOT .. "/31_fp_in_beam_night.png") + else + U.log("no shaft landed -- check the layout seed") + end + + -- ------- the controls + setTime("day") + ForestAtmos.setting:sync("low") + go(17, 20) + U.shot(game, ROOT .. "/40_low_day.png") + ForestAtmos.setting:sync("off") + go(17, 20) + U.shot(game, ROOT .. "/41_off_day.png") -- must match a pre-feature run + + ForestAtmos.setting:sync("full") + ForestAtmos.frozen = false + setTime("day") + U.log("done -- " .. ROOT) +end